★ 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.WaitGroupWait 返回 happens-before 之前所有 Done 的调用点之后
sync.OnceDo 返回 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 这条读吗”。
笔记加载中…