「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)的区别》。

五、实战建议

  1. 能讲出链条才算懂:面试时遇到并发题,先问自己"两个 goroutine 之间靠什么建立 happens-before",答不上来就是没同步。
  2. 不要依赖"实测没出问题":data race 程序的行为是未定义的,今天跑对不代表明天跑对。用 go run -race 或 go test -race 常态化检测。
  3. 缓冲为 1 的 channel 也可能丢可见性:如示例一改缓冲后所示,缓冲 channel 的发送不需要接收方在场,链条可能断掉。
  4. 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 篇。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo