★ Go 内存模型:happens-before 是什么,同步原语保证了什么?
一句话结论:Go 只保证“同一个 goroutine 内部”的顺序,跨 goroutine 的共享读写如果没有 happens-before(HB)关系,就是数据竞争,行为不可预期;mutex、channel、atomic、WaitGroup 等同步原语的作用就是建立 HB 关系,让“写”对“读”可见。
为什么需要 happens-before
CPU 缓存、指令乱序执行与编译器优化,都会让代码看上去的执行顺序和实际效果不一致,而这些问题只在“跨 goroutine 共享数据”时才暴露。单 goroutine 内无论怎么重排,程序语义都不变,因此编译器可以放心优化;一旦跨 goroutine,就必须用同步手段给编译器与 CPU 画一条“界线”。
happens-before 是一种偏序关系:如果事件 A happens-before 事件 B,那么 A 及其之前的所有写操作,对执行 B 的 goroutine 都可见,且不会被重排到 B 之后。
Go 内存模型中的主要规则
| 场景 | 同步保证 |
|---|---|
| 同一 goroutine | 语句按程序顺序构成 HB |
| sync.Mutex | 某 goroutine 的 Unlock happens-before 另一个 goroutine 后续的 Lock |
| channel | 发送 happens-before 对应的接收完成(无缓冲与有缓冲均成立) |
| atomic | 对同一变量的原子操作按修改顺序串行化,Load-Acquire / Store-Release 可建立 HB |
| sync.WaitGroup | Wait 返回 happens-before 之前所有 Done 的调用点之后 |
| sync.Once | Do 返回 happens-before f 的执行完成 |
| goroutine 启动 | go 语句 happens-before 新 goroutine 开始执行 |
注意:channel 的“发送 HB 接收”保证的是配对的收发;用有缓冲 channel 传数据时,真正要依赖的是“第一次接收 HB 第二次发送”这类链条,别把容量大小当成同步依据。
最小同步示例
var counter int
var mu sync.Mutex
func inc() {
mu.Lock()
counter++ // 读-改-写全程在锁内
mu.Unlock()
}
func read() int {
mu.Lock()
defer mu.Unlock()
return counter // Lock 保证了能看到之前的写
}
去掉锁之后,counter++ 由多条指令组成,两个 goroutine 同时执行会丢更新;而且即使操作本身原子,没有同步,另一个 goroutine 也可能读到旧值。
常见追问 / 记忆点
- 追问:两个 goroutine 只是“一个写、一个读”,不加锁会怎样?——仍是数据竞争,读到旧值、撕裂值都是合法结果。
- 追问:close 一个 channel 算同步吗?——算,close 也 happens-before 从该 channel 收到关闭信号的接收方。
- 记忆点:没有同步就没有跨 goroutine 可见性;判断并发代码是否安全,就问一句“这个写在 happens-before 这条读吗”。