从 happens-before 真正理解 Go 内存模型
并发代码里最危险的一句话是:“另一个 goroutine 应该已经执行完了。”内存模型不讨论“应该”,它只回答一件事:某次读是否被保证能观察到某次写。
在 Go 中,只要多个 goroutine 并发访问同一内存,且至少一个是写,就必须通过 channel、锁、atomic 等同步手段建立顺序。没有同步,就不仅是“可能读到旧值”,而是数据竞争。
两种顺序组成 happens-before
可以先用非形式化的方式理解:
- sequenced-before:同一个 goroutine 内,按语言规定的执行顺序发生;
- synchronized-before:由 channel、锁、atomic 等同步操作在 goroutine 之间建立。
happens-before 是这两种关系合并后的传递闭包。
flowchart LR
W["G1: data = ready"] -->|程序顺序| S["G1: close(done)"]
S -->|同步关系| R["G2: <-done"]
R -->|程序顺序| P["G2: print(data)"]
因为写入 happens-before 打印,G2 保证看到 ready。
启动 Goroutine 只保证一个方向
go f() 的启动发生在 f 开始执行之前,因此下面的参数准备是安全的:
value := buildValue()
go consume(value)但 goroutine 的退出不会自动同步回启动方:
var message string
func main() {
go func() {
message = "ready"
}()
fmt.Println(message) // 数据竞争,不保证输出 ready
}加 time.Sleep 也没有建立内存模型关系。调度上“通常足够久”不是同步。应使用 channel 或 WaitGroup:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
message = "ready"
}()
wg.Wait()
fmt.Println(message)Done 同步先于它解除阻塞的 Wait 返回。
Channel 的同步规则
发送先于对应接收完成
data = "ready"
ch <- struct{}{}如果另一个 goroutine 完成 <-ch,它之后读取 data 能看到此前写入。
close 先于观察到关闭的接收
这使关闭 channel 能作为广播屏障:
config = loaded
close(ready)
// 任意 goroutine
<-ready
use(config)无缓冲 Channel 的反向保证
对无缓冲 channel,接收发生在对应发送完成之前。发送方返回时,接收方已经到达会合点。
缓冲 Channel 还有一条容量规则
容量为 C 的 channel 上,第 k 次接收 happens-before 第 k+C 次发送完成。这条规则让缓冲 channel 可以实现计数信号量:释放一个槽位发生在后续任务成功占用该槽位之前。
注意:缓冲 channel 的“发送完成”不代表接收方已经处理了数据。容量允许发送方提前继续,这正是缓冲的意义。
Mutex 的可见性保证
对同一把 Mutex,一次 Unlock happens-before 后续成功的 Lock。因此锁不仅防止同时进入临界区,也负责发布写入:
mu.Lock()
config = next
mu.Unlock()
// 另一个 goroutine
mu.Lock()
current := config
mu.Unlock()两个 goroutine 使用不同的锁保护同一个变量没有意义。同步关系必须通过同一个原语建立。
TryLock 失败不会建立任何同步关系。不能因为“尝试过锁”就读取受保护状态。
Once 发布初始化结果
once.Do(f) 中 f 的完成 happens-before 任意一次 once.Do 返回。调用方不需要在 Once 外再加锁读取初始化结果:
var (
once sync.Once
config *Config
)
func getConfig() *Config {
once.Do(func() {
config = loadConfig()
})
return config
}自己用一个普通 bool 做双重检查,如果没有正确的 atomic 或锁,会产生数据竞争,也可能看到“ready 为 true,但对象字段还没安全发布”的状态。
Atomic 提供顺序一致性
Go 的 atomic 操作表现得像按某个全局的顺序一致次序执行。如果 atomic 操作 A 的效果被 B 观察到,A synchronized-before B。
var ready atomic.Bool
var config *Config
// 发布方
config = loadConfig()
ready.Store(true)
// 读取方
if ready.Load() {
use(config)
}这段代码在内存可见性上可以成立,但设计上通常不如 atomic.Pointer[Config] 或 Once 直接。内存模型正确不代表 API 清楚。
无数据竞争程序的关键保证
Go 提供 DRF-SC:没有数据竞争的程序,其行为可以解释为多个 goroutine 操作按某种顺序一致的方式交错执行。
这就是为什么官方内存模型反复强调“不要聪明过头”。与其推演某个 CPU 是否会重排,不如使用明确同步原语,让程序进入无数据竞争的世界。
如果存在数据竞争,不能拿某次实验结果证明代码安全:
- 编译器优化会改变访问方式;
- CPU 和架构的内存行为不同;
- 增加日志可能恰好改变调度;
- 竞态检测器没有在某次运行中报告,不代表所有路径都覆盖。
常见的伪同步
下面这些都不建立可靠的 happens-before:
time.Sleep;- 观察 goroutine 数量;
- 认为单字长读写“在机器上是原子的”;
- 两边各用一把不同的锁;
- 轮询普通 bool;
- 依赖日志或 fmt 调用造成的偶然调度。
业务代码需要的是语言保证,不是当前实现的运气。
用 race detector 佐证,而不是替代设计
go test -race ./...竞态检测器会观察实际运行路径中的冲突访问。它非常有价值,但只能发现被执行到的竞态。测试覆盖、压力测试和正确的同步设计缺一不可。
读并发代码的方法
审查一段并发代码时,可以沿着数据而不是 goroutine 读:
- 哪些内存会被多个 goroutine 访问?
- 哪些访问包含写?
- 它们通过哪个具体同步操作建立顺序?
- 这个顺序是否覆盖所有退出、超时和错误路径?
如果第三个问题的答案是“它们执行得很快”或“通常先跑这里”,代码就还没有同步完成。