Go 内存模型
Go 内存模型定义跨 goroutine 的读写可见性与排序必须依赖哪些同步事件;区分语言规范保证与编译器/CPU 重排的实现现实。
[!info] 关联笔记
Go 内存模型
这个概念为什么会出现
单线程里“先写后读”的直觉来自程序顺序。多 goroutine 共享变量时:
- 编译器可能重排无数据依赖的访问
- CPU 缓存使写入不立即对其他核可见
- 没有同步时,读可能看到陈旧值、部分更新,或与 data race 一起进入未定义般危险的行为
Go 内存模型回答:在什么条件下,goroutine A 的写入保证被 goroutine B 的读取看到?它是并发正确性的语义底座,不是 GC 教程,也不是“把变量设为 volatile”的清单。
[!abstract] 一句话理解 除非通过内存模型承认的同步操作建立 happens-before,否则一个 goroutine 的写不保证被另一个 goroutine 的读看到;程序顺序只约束同一 goroutine 内。
最小可运行示例
先把示例放进可观察实验,再看代码:
场景:配置热更新「发布新开关」必须让工作协程看见
特性开关服务在后台 goroutine 写好 featureOn,再通知请求路径读取。
若只用普通变量 + 忙等/flag,规范不保证读者一定看到新值,且常伴随 data race。
正确做法:用 channel(或锁/atomic)建立 happens-before,把「写配置」发布给「读配置」。
下面先给错误示范(不要当生产模板),再给 channel 发布的正确最小模型。
package main
import (
"fmt"
"time"
)
// 错误示范:无同步发布特性开关(实验用,勿照抄上线)。
var featureOn, ready int
func badPublish() {
go func() {
featureOn = 1 // 想发布「开关打开」
ready = 1 // 想当「发布完成」标志
}()
for ready == 0 {
// 忙等:既可能 data race,也不构成内存模型承认的同步
}
// 即使碰巧退出循环,规范仍不保证一定读到 featureOn==1
fmt.Println("bad featureOn=", featureOn)
time.Sleep(0) // sleep 不能修复可见性语义
}
package main
import "fmt"
// publishFeatureFlag 正确:写开关后 close(done) 做发布。
//
// 业务意图:热更新完成后,所有等到 done 的请求路径必见新值。
// 教学点:close 完成 HB 因关闭而完成的接收 → 接收之后的读可见之前的写。
func publishFeatureFlag() {
var featureOn int
done := make(chan struct{})
go func() {
featureOn = 1 // (1) 写共享配置
close(done) // (2) 发布:关闭 happens-before 接收完成
}()
<-done // (3) 等发布完成
fmt.Println(featureOn) // (4) 保证看到 1
}
func main() {
publishFeatureFlag()
}
建议运行(只跑正确示例文件时):
go run .
# 错误示范可用 go run -race 观察 data race 报警(若合并进同一程序)
期望输出(正确示例):
1
结合场景再看三个关注点
-
程序顺序只约束当前 goroutine
后台「先写 featureOn 再写 ready」不自动让另一 goroutine 有序看见。 -
忙等 / sleep 不是同步原语
配置热更新用它们只会得到偶发正确,规范与-race都不认。 -
发布要靠模型承认的同步
channel、Mutex、atomic、Once等建立 HB;细节见 go-happens-before-and-synchronization。
核心概念与准确模型
规范文档位置
权威文本:The Go Memory Model。它属于语言语义规范向文档,描述程序员可依赖的保证。
核心词汇
| 术语 | 含义 |
|---|---|
| 程序顺序 | 单 goroutine 内语句顺序带来的 happens-before |
| Happens-before | 偏序:若事件 e1 HB e2,则 e1 对 e2 “可见”意义上的排序 |
| 同步 | 创建跨 goroutine HB 边的操作(锁、channel 等) |
| Data race | 冲突访问且无 HB 排序——必须消除 |
更细的同步列表见 go-happens-before-and-synchronization。
同一 goroutine 内
在单个 goroutine 中,内存模型保证行为如同按程序顺序执行(对数据依赖与语言规则而言)。你不必为单线程函数插入屏障。
跨 goroutine
没有同步时:
- 读可能看到旧值
- 多个写的可见顺序可能出乎“源码行号”直觉
- 存在 data race 时,整个程序行为不再有可靠讨论基础(应用
-race抓)
同步建立可见性
写共享状态 ──HB──► 同步事件 S ──HB──► 另一 goroutine 读
典型 S:
Unlock→ 之后的Lock- channel 发送与对应接收
Once.Do中函数完成- goroutine 启动的
go语句相对新 goroutine 开始 - 某些 atomic 操作(见文档与
sync/atomic语义)
与实现的关系
| 层面 | 内容 |
|---|---|
| 规范 | 哪些操作建立 HB;race 的定义;程序员模型 |
| 实现 | 编译器重排如何遵守 As-if;CPU 内存序;具体屏障插入 |
| 工具 | race detector 用插桩近似发现 race(实现工具) |
实现必须在可观测行为上遵守内存模型;你不能依赖“某 CPU 上碰巧总是看见”。
与 GC 的区别
- 内存模型:并发可见性/排序
- GC:对象何时可回收
二者都谈“内存”,问题域不同。
边界情况与反直觉行为
1. 双检锁与发布
错误地用普通 bool flag 发布大结构,无同步:其他 goroutine 可能看到 flag 为真但结构字段未初始化完整。
2. 切片头与底层数组
复制 slice 描述符是值拷贝;底层数组并发写仍需同步。模型看的是内存位置,不是变量名。
3. range 与共享游标
多 goroutine 共游标无锁 → race。
4. “读多写少”仍要同步
无写冲突不代表不要同步;一写多读无 HB 仍是 data race(在 Go 模型下)。
5. atomic 不是万能锁
单个字的 atomic 有其顺序保证;复合不变量仍可能需要 Mutex。
常见误区
[!warning] 常见误区:代码行序 = 全局时间序 错误:以为 A 函数写在 B 启动前就全局先发生。
正确:跨 goroutine 只认 HB 边。
[!warning] 常见误区:用 sleep 当同步 错误:测试里 sleep 让 race “消失”。
正确:真实原语;测试加-race。
[!warning] 常见误区:没崩溃就是对的 错误:x86 上强模型“看起来对”。
正确:按规范推理 + race detector + 审查。
[!warning] 常见误区:内存模型=手动管理内存 错误:与 C++ allocator 混淆。
正确:这里是并发语义,不是 free。
工程实践
- 所有权优先:数据尽量单 goroutine 拥有,channel 交接。
- 共享则同步:Mutex、RWMutex、atomic、channel。
- 文档并发契约:谁可并发调用、是否线程安全。
- CI
-race:见 go-race-detector。 - 不要发明屏障:勿用无文档保证的“技巧 flag”。
- 读官方 mem 文档再读实现博客,顺序勿反。
type Config struct {
mu sync.RWMutex
data map[string]string
}
func (c *Config) Get(k string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[k]
return v, ok
}
可验证实验
实验 1:channel 发布
对比无同步忙等与 close(done) 两种发布,后者在模型下正确。
实验 2:-race
对无锁共享计数跑 go test -race。
实验 3:Mutex 可见性
Unlock 前写字段,另一 goroutine Lock 后读,确认稳定。
实验 4:错误 flag
用普通 bool 做“数据已就绪”无锁发布,在 -race 下观察(勿当生产模式)。
本节总结
- 问题:跨 goroutine 写何时可见。
- 答案:依赖 happens-before 同步,而非行号或 sleep。
- 规范 vs 实现:mem 文档给保证;编译器/CPU/检测器是实现与工具。
- 下一步:把同步事件清单吃透——go-happens-before-and-synchronization。
自测题
概念题
- 程序顺序保证跨 goroutine 可见性吗?
- data race 与“逻辑结果偶发错误”关系?
- 内存模型与 GC 各管什么?
代码推理题
var done bool
var msg string
go func() { msg = "hi"; done = true }()
for !done {}
fmt.Println(msg)
为何不安全?
工程思考题
库要在初始化完成后只读共享配置。可选哪些发布手段?
参考答案
展开
- 不保证。
- data race 是未同步冲突访问;即使“看起来对”仍属严重缺陷。
- 模型管并发可见性;GC 管回收。
代码题:done/msg存在 data race;即使看见 done 为 true 也不保证看见 msg 的写(无 HB)。
工程题:sync.Once、init 完成后 channel 关闭、Mutex 保护、不可变快照指针的 atomic 发布等——均需符合文档语义。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| The Go Memory Model | 规范向 | HB、同步、race |
| Package sync | 标准库 | 锁与 Once 等 |
| Package atomic | 标准库 | 原子操作语义入口 |
| Data Race Detector | 工具文档 | 动态检测 |
| Share Memory By Communicating | 博客 | 所有权风格 |
笔记元信息
- 建议文件名:
go-memory-model.md - 所属阶段:阶段六 / 并发深水
- 学习顺序:会用 channel/mutex 之后读规范
- 建议下一篇:happens-before 与同步
- 本篇状态:已深化(规范 vs 实现分明;最小正反例)