Go 内存模型:happens-before 与并发安全
goroutine 和 channel 语法谁都会,但并发正确性要靠内存模型理解。为什么一个 goroutine 改了变量,另一个 goroutine 读到的还是旧值?为什么明明"顺序执行"的代码会出诡异结果?——答案都在 Go 内存模型里。
一、问题场景
|
|
原因:没有同步机制时,两个 goroutine 之间没有 happens-before 关系——编译器/CPU 可能重排,B 的缓存可能看不到 A 的写入。
二、happens-before:核心概念
定义:事件 E1 happens-before E2,当且仅当 E1 的影响对 E2 可见(且顺序保证)。
Go 官方内存模型规定了哪些操作建立 happens-before 关系:
| 同步操作 | 建立的规则 |
|---|---|
| channel | 向 channel 发送 happens-before 从该 channel 接收完成 |
| sync.Mutex | Unlock happens-before 后续的 Lock |
| sync.WaitGroup | Wait 返回 happens-before 所有 Add 的 Done 之后 |
| sync.Once | doOnce 内函数 happens-before 所有 Once.Do 返回 |
| goroutine 创建 | go 语句 happens-before 新 goroutine 开始执行 |
| goroutine 退出 | goroutine 退出 happens-before 它被 Join(如有) |
修复上面的例子:用 channel 建立顺序:
|
|
三、为什么互斥锁能"顺便"解决可见性
|
|
锁的价值不只是互斥,还建立内存屏障:A 的 Unlock 之前的所有写入,对 B 的 Lock 之后的所有读取可见。这就是为什么"加锁的代码天然无可见性问题"——锁顺带完成了内存同步。
四、数据竞争(Data Race)
定义:两个 goroutine 同时访问同一变量,且至少一个是写操作,且没有同步机制 → 数据竞争。
数据竞争的危害(Go 官方原话):未定义行为——可能读到撕裂值、可能死循环、可能 panic,行为不可预测。
|
|
五、检测:-race 是必须的
|
|
race detector 是 Go 最强的并发检测工具——它不能保证 100% 抓全(只在测试覆盖到的执行路径生效),但跑了它没报,比不跑强一百倍。
CI 必开:
|
|
六、Go 1.22 起 memory model 的增强
Go 1.22 更新了内存模型文档(官方),明确了:
- sync/atomic 的更强保证:
atomic.Int32等类型有明确语义 - channel 关闭的 happen-before 保证更清晰
- 跨 package 的 sync 原语保证:第三方并发原语(如 errgroup)基于官方原语,保证可推导
结论:写并发代码用官方同步原语(channel/mutex/atomic),语义有保证;自己造轮子(unsafe 指针、手动内存序)容易踩未定义行为。
七、实用规范
|
|
总结
Go 内存模型的三大认知:
- 没有 happens-before 就没有可见性:两 goroutine 之间要有同步操作才保证"看见"
- 锁 = 互斥 + 内存屏障:加锁代码天然解决可见性
- 数据竞争是未定义行为:
-race是底线,不是可选项
并发编程的正确姿势:用官方原语建立 happens-before,别靠"直觉顺序"。 goroutine 语法 10 分钟学会,内存模型是让你 10 年不踩坑的地基。