「Go语言面试题」27 - Go 内存模型与 happens-before:为什么你的并发程序会读到脏数据?
引言
「为什么这段代码开了 1000 个 goroutine 累加,结果不是 1000?」「channel 凭什么能保证发送方写的变量,接收方一定能读到?」「用了锁就一定线程安全吗?」—— 这三个问题的共同答案是:Go 内存模型,以及建立在它之上的 happens-before(先行发生)关系。
这是并发面试的理论基石:不理解它,你写出的并发代码"碰巧能跑";理解了它,你才能判断一段并发代码为什么对、为什么错。本文基于 Go 官方内存模型文档(Go 1.19 大幅修订版),把建立 happens-before 的六条主要途径一次讲清。
一、什么是内存模型,什么是 happens-before
编译器和 CPU 都会重排指令、缓存数据。单线程时代这无所谓,但多 goroutine 并发时,一个 goroutine 写入的变量,另一个 goroutine 未必立即可见——除非两者之间存在同步。
如果操作 A happens-before 操作 B,那么 A 执行的结果对 B 保证可见,且 A 的执行顺序排在 B 之前。注意是"保证",而不是"大概率"。反过来,如果两个操作之间没有任何 happens-before 关系,它们的相对顺序就是未定义的,程序产生 data race(数据竞争),行为不可预测。
还有一个重要结论(Go 官方文档明确):没有数据竞争的程序,其执行效果等价于所有 goroutine 跑在单核上(DRF-SC 性质)。也就是说,只要建立了正确的同步,你就可以按"顺序思维"推理并发程序。
二、建立 happens-before 的六条途径
1. 程序顺序:goroutine 内部天然有序
同一个 goroutine 内,按代码顺序执行的操作天然 happens-before 后续操作。跨 goroutine 才需要额外手段。
2. go 语句:启动先于执行
go f() 语句本身的执行 happens-before 新 goroutine 内任何代码的执行。所以传给 goroutine 的参数值,在新 goroutine 里一定可见。
3. channel:发送先于接收完成
对同一个 channel,第 n 次发送 happens-before 第 n 次接收完成;close 操作 happens-before 因关闭而读到零值的接收;无缓冲 channel 的接收 happens-before 发送完成。这条规则的威力见下面第一个示例。
4. Mutex / RWMutex:解锁先于下次加锁
第 n 次 Unlock() happens-before 第 m 次(m > n)Lock() 的返回。也就是说,上一个临界区写入的所有变量,下一个拿到锁的 goroutine 都能读到。
5. WaitGroup:Done 先于 Wait 返回
Done() 调用 happens-before 它唤醒的任何 Wait() 调用返回。这与第 9 期 《什么是Go中的WaitGroup》 的使用姿势直接相关。
6. sync.Once:f 执行先于 Do 返回
第一个执行 once.Do(f) 时,f() 的执行 happens-before 任何其他 Do 调用的返回。
三、示例一:channel 的可见性保证
package main
import "fmt"
var c = make(chan int)
var a string
func f() {
a = "hello, world" // 写入 a
<-c // 无缓冲 channel:接收 happens-before 发送完成
}
func main() {
go f()
c <- 0 // 发送
fmt.Println(a) // 保证打印 "hello, world"
}
推导链条:a = "hello, world" →(程序顺序)<-c →(无缓冲规则)c <- 0 完成 →(程序顺序)Println。happens-before 具有传递性,所以写入对打印必然可见。
注意如果把 channel 改成 make(chan int, 1)(带缓冲),这个保证就消失了——发送不等接收,链条断裂,打印结果未定义。这正是第 26 期 《Channel 底层实现全解析》 中"直传优化"的内存语义体现。
四、示例二:没有同步,一切免谈
package main
import (
"fmt"
"sync"
)
var count int
var mu sync.Mutex
func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
count++ // 临界区:n 次 Unlock happens-before 第 n+1 次 Lock 返回
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait() // Done happens-before Wait 返回
fmt.Println(count) // 保证输出 1000
}
这里同时用到了两条规则:Mutex 保证每次 count++ 完整可见,WaitGroup 保证全部累加完成后才打印。缺了任何一个,结果都不可信。锁的更多细节见第 10 期 《互斥锁(Mutex)和读写锁(RWMutex)的区别》。
五、实战建议
- 能讲出链条才算懂:面试时遇到并发题,先问自己"两个 goroutine 之间靠什么建立 happens-before",答不上来就是没同步。
- 不要依赖"实测没出问题":data race 程序的行为是未定义的,今天跑对不代表明天跑对。用
go run -race或go test -race常态化检测。 - 缓冲为 1 的 channel 也可能丢可见性:如示例一改缓冲后所示,缓冲 channel 的发送不需要接收方在场,链条可能断掉。
- atomic 操作也提供顺序一致性保证(Go 1.19 起官方文档明确 sync/atomic 具有顺序一致性语义),适合简单标志位;复杂临界区还是用 Mutex,参见第 15 期 《atomic 包与互斥锁怎么选》。
速查清单
| 同步手段 | happens-before 规则 |
|---|---|
| go 语句 | go 语句先于新 goroutine 内任何代码执行 |
| channel 发送/接收 | 第 n 次发送先于第 n 次接收完成 |
| channel 关闭 | close 先于因关闭读到零值的接收 |
| 无缓冲 channel | 接收先于发送完成 |
| Mutex/RWMutex | 第 n 次 Unlock 先于第 m 次(m>n)Lock 返回 |
| WaitGroup | Done 先于其唤醒的 Wait 返回 |
| sync.Once | f() 执行先于任何 Do 返回 |
结语
内存模型的考点可以浓缩成一句话:“没有 happens-before,就没有可见性保证”。面试中画出 goroutine 间的同步链条、指出链条断裂的位置,比背诵所有规则更能打动面试官。
如果觉得文章对你有帮助,请点赞、收藏、关注三连支持!
本文是「Go语言面试题」系列第 27 篇,全系列持续更新中,已发布 27 篇。