Go 的 happens-before 是语义保证而非内存屏障指令,由同步事件(如 channel 收发、Mutex 操作等)推导;goroutine 启动本身不建立 happens-before,channel 发送仅对其对应接收操作成立。

Go 的 happens-before 不是内存屏障指令,而是语义保证
Go 语言没有提供 atomic_thread_fence 这类显式内存屏障,它的 happens-before 关系完全由语言规范定义的同步事件推导而来。写代码时如果误以为“只要用了 go 就自动有序”,很容易掉进数据竞争陷阱。
常见错误现象:data race 检测器报出读写冲突,但程序在本地跑十次都“刚好”没崩;或者加了 time.Sleep 后看似正常,一删就出错。
- 真正起作用的同步原语只有:channel 收发、
sync.Mutex/sync.RWMutex的Lock/Unlock、sync.WaitGroup的Done+Wait、sync.Once.Do -
atomic.LoadUint64和atomic.StoreUint64之间能建立happens-before,但atomic.LoadUint64和普通变量读取之间不能 -
goroutine 启动(
go f())本身不构成同步点——它只保证f()内部代码“之后执行”,但不保证对其他 goroutine 可见
channel 发送与接收是最常用也最容易误用的 happens-before 场景
很多人记成“发送先于接收”,其实准确说法是:**一个 channel 的发送操作在该 channel 的对应接收操作 happens-before**。关键在“对应”——必须是同一个 channel 实例,且接收确实等到了这次发送。
使用场景:跨 goroutine 传递信号或数据,比如 worker 等待任务、主 goroutine 等待结果。
立即学习“go语言免费学习笔记(深入)”;
- 无缓冲 channel:发送和接收必须配对阻塞,天然构成强顺序,适合做信号同步
- 有缓冲 channel:
ch 在缓冲未满时立即返回,此时不保证接收端已读——除非你紧接着调用或用select等待 - 关闭 channel 后的接收会立即返回零值,但关闭操作本身只对后续接收可见,不构成对之前发送的排序约束
示例:ch := make(chan int, 1); go func() { ch —— 这里 ch happens-before ,所以 val 一定是 42;但如果把 ch 换成无缓冲且没配对接收,就会死锁。
sync.Mutex 的 Lock/Unlock 是最可靠的临界区边界
只要共享变量的读写都包裹在同一个 mu.Lock()/mu.Unlock() 块内,Go 运行时就能保证这些访问不会被重排,且对其他 goroutine 可见。但它不解决“锁粒度”问题——锁太粗,性能差;锁太细,容易漏。
常见错误现象:用两个不同 sync.Mutex 实例保护同一块数据;或在锁外读取变量后,以为“刚读的值还新鲜”,实际已被其他 goroutine 修改。
-
mu.Lock()操作 happens-before 该锁后续任意mu.Unlock(),而该mu.Unlock()又 happens-before 下一次mu.Lock() - 不要在持有锁期间调用可能阻塞或耗时的函数(如 HTTP 请求、数据库查询),否则会拖慢所有争抢该锁的 goroutine
-
sync.RWMutex的RUnlock不构成写同步点——多个读 goroutine 之间无序,读和写之间才靠RLock/RUnlock与Lock/Unlock交叉约束
Go 的 happens-before 图不是运行时生成的,得靠人脑建模
没有工具能自动画出某段并发代码的完整 happens-before 图。你得手动标出所有同步事件(channel 操作、锁进出、WaitGroup 等),再根据规则连边。一旦漏掉一个,模型就失效。
最容易被忽略的是:**编译器和 CPU 都可能重排普通变量访问,只要不违反 happens-before 规则**。这意味着,即使逻辑上“先 A 后 B”,若中间没同步点,B 仍可能在 A 之前被其他 goroutine 观察到。
所以别依赖“看起来顺序对”——检查每处共享变量访问,确认它是否落在某个明确的同步原语保护范围内。不然 -race 不报错,也不代表安全。











