看到 done=true,就说明前面的写入都可见吗?
先给结论:
读到
done == true,不代表前面对cfg的写入已经对当前 goroutine 可见。真正要看的不是“源码顺序像不像对”,而是“有没有同步边界建立
happens-before”。
1. 题目
面试里我很喜欢问这道题,因为它能迅速区分两类人。
第一类人会说:“这就是个并发问题,加锁就好了。”
第二类人会先停一下,然后问一句更关键的话:
“你这里到底有没有建立同步边界?”
先看代码:
var cfg *Config
var done bool
func initConfig() {
cfg = &Config{Timeout: 3}
done = true
}
func useConfig() int {
if done {
return cfg.Timeout
}
return 0
}
如果一个 goroutine 执行 initConfig(),另一个 goroutine 在 useConfig() 里读到 done == true,它能不能据此认为 cfg 已经完整初始化好了?
这道题表面上在问一个布尔标志位,实际上在问你对 Go memory model 的理解到底停留在“知道有 happens-before”这层,还是已经能把它用到真实并发代码里。
2. 常规答案
常规答案通常是这样的:
- 不能这样写,因为有 data race。
done和cfg都被多个 goroutine 并发访问了,但没有加锁。- 正确做法应该是用
Mutex、channel、sync.Once,或者至少用 atomic。
这个回答不能算错。
如果候选人能主动提到 data race,说明他已经知道“程序跑通”不等于“并发正确”。这在面试里已经比“我觉得大概率没事”强很多了。
但如果回答停在这里,我一般不会满意。因为这仍然像是在背规范结论,而不是在解释为什么错。
3. 为什么这个答案不够
不够的地方有三层。
第一,它只说了“有 race”,但没有说清 race 在这里具体破坏了什么。
真正被破坏的不是“线程切换顺序”,而是“可见性保证”。
第二,它把“看到标志位变了”和“相关状态已经可见”混成了一件事。
这恰好是很多并发 bug 最容易伪装自己的地方。代码不一定立刻 panic,测试甚至可能经常通过,但语义上它没有任何保证。
第三,它没有区分同步原语之间的职责。
Mutex、channel、sync.Once、sync/atomic 都能参与建立顺序,但它们解决的问题并不一样。不会区分这一点,后面写并发代码就很容易开始“凭感觉同步”。
所以这道题真正要追的不是“你知不知道该加锁”,而是:
你能不能证明另一个 goroutine 看到 done == true 时,也一定能看到 cfg 对应的写入。
4. 机制深挖
第一层:读到某个值,不等于看到了相关写入
很多人默认有一个危险直觉:
“既然 done 都已经是 true 了,那前面的 cfg = ... 肯定也早就好了。”
这句话的问题在于,它把“程序员眼里的源码顺序”误当成了“跨 goroutine 的可见性保证”。
Go memory model 关心的核心不是“代码看起来先写了什么”,而是:
一个 goroutine 的写,是否有保证地能被另一个 goroutine 观察到。
如果没有同步关系,那么另一个 goroutine 即使读到了 done == true,也不能据此推出和它相关的其他写入已经对自己可见。换句话说,读到一个值,不自动附赠一整组相关状态的可见性。
这也是为什么这类代码最容易制造“偶现 bug”。它不是必错在每一次执行上,而是从语义上根本没有被证明是对的。
第二层:安全发布依赖同步边界,不依赖“写入顺序看起来合理”
这道题真正的关键词是安全发布。
想把一个对象、一个配置快照、一个初始化结果安全地交给其他 goroutine 使用,你需要的不是“我先写数据,再写标志位”,而是一个正式的同步边界。
在 Go 里,这个边界通常来自两类关系:
- 单 goroutine 内部的执行顺序
- 同步原语建立的跨 goroutine 顺序
这两者通过 happens before 串起来,才构成你能依赖的可见性保证。
如果这条链条不存在,那么“我代码明明是先初始化 cfg,后写 done”这件事,对另一个 goroutine 并没有足够的证明力。
很多人把这类错误写法称为“简单粗暴但一般能跑”。面试官真正关心的是:你是不是在拿“经常能跑”冒充“语义正确”。
图示:为什么读到 done == true 仍然不构成安全发布
sequenceDiagram
participant W as Init goroutine
participant R as Use goroutine
W->>W: cfg = &Config{Timeout: 3}
W->>W: done = true
R->>R: read done == true
R->>R: read cfg.Timeout
Note over W,R: 没有 channel / Mutex / Once / atomic 等同步原语
Note over W,R: 因此不存在可证明的 happens-before 链条
第三层:channel、Mutex、Once、atomic 各自保证什么
接下来要看候选人会不会区分同步原语。
Mutex 的作用是最直观的。Unlock 与后续成功的 Lock 之间可以形成清晰的顺序保证,所以它既能保护互斥访问,也能承担可见性边界。
channel 的价值在于,它不只是通信工具,也是同步工具。尤其是无缓冲 channel,发送和接收之间天然就是一条明确的交接边界。你不是“猜对方应该已经看到了”,而是通过同步操作把这个顺序建出来。
sync.Once 适合一次性初始化。它不是“少写点锁”的语法糖,而是把“只执行一次”和“执行结果对后来调用者可见”这两个要求一起打包了。
sync/atomic 则最容易被误用。很多人把它理解成“更快的普通读写”,这恰好是危险的开始。atomic 能参与建立同步,但前提是你真的在用一套完整的原子访问协议,而不是只把某一个布尔标志换成原子变量,然后默认其他普通字段也自动安全了。
所以“至少用 atomic”这句话在面试里通常不算答案,只算提示。真正的答案是:你要解释清楚,你建立的同步边界到底覆盖了哪些状态。
图示:安全发布到底靠什么建立同步边界
flowchart LR
A[共享状态准备完成] --> B{如何建立同步边界}
B --> C[Mutex<br/>Unlock -> Lock]
B --> D[Channel<br/>send -> recv]
B --> E[sync.Once<br/>Do -> later callers]
B --> F[atomic<br/>仅在完整原子协议下]
C --> G[读方可见]
D --> G
E --> G
F --> G
第四层:为什么 busy wait、双重检查、标志位发布最容易出错
这道题最后通常会追到错误模式。
最典型的错误模式就是 busy wait:
for !done {
}
return cfg.Timeout
它的问题不是“写得丑”,而是这个循环本身并没有建立任何同步语义。你只是在反复读一个共享变量,然后假设它顺手把相关状态也带过来了。
双重检查锁也类似。很多人以为“外面先 if x != nil,里面再加锁检查一次”天然安全,实际上只要第一次读发生在没有同步保护的路径上,就很容易落回“看到了指针,但没看到完整对象状态”的老问题。
所以资深面试官问这道题,不是为了听一句“加锁就好”,而是为了看你有没有一个稳定的判断标准:
不是看代码像不像初始化完成了,而是看有没有正式的 happens-before 链条把状态发布出去。
5. 面试官继续追问
如果候选人在这里答得还不错,我通常会继续往下追几个方向:
- 你刚才说“有 race”,那先精确定义一下:这里是 read-write race,还是 write-write race?
- 如果我把
done改成 atomic,是不是问题就自动解决了?你能说清同步边界究竟覆盖了什么吗? - 为什么
go f()只能保证新 goroutine 开始执行的先后关系,却不能自动保证 goroutine 结束后的写入被别人看到? - 如果我把这个例子改成 channel、
sync.Once、Mutex三种写法,它们各自更适合什么场景? - 为什么这类 bug 落到
map、slice、string、interface上时,风险会比“读到旧值”更大?
这组追问的目的很明确:
从“你知道这个例子不安全”,一路追到“你能不能把 Go 并发里的可见性、同步边界、发布协议和工程取舍讲成一个完整故事”。
6. 工程场景
这道题不是课堂题,它在工程里非常常见。
-
配置热更新
一个 goroutine 生成了新的配置快照,另一个 goroutine 根据一个ready或version标志决定是否切换。如果发布协议没建好,你读到的可能不是“新配置”,而是“部分可见的新配置”。 -
单例和懒加载
很多人为了省一点锁,会自己写“先判断是否初始化,再决定是否进入慢路径”的代码。问题是,只要第一次观察发生在没有同步保护的路径上,这个优化就可能先把正确性优化掉。 -
后台任务完成通知
有人喜欢写一个done bool,让其他 goroutine 自旋等待。这个写法最大的问题不是浪费 CPU,而是它把“任务完成”错误地等同于“相关结果已经安全可见”。 -
缓存快照和全局 client 发布
你上线后最难排查的不是稳定复现的 bug,而是那种“偶发 nil”“偶发旧值”“偶发字段不一致”。很多时候,根因根本不在业务逻辑,而在发布协议没有建立起来。
7. 面试官点评
这道题的合格线并不高,但优秀回答和普通回答的差距非常大。
合格回答,是能明确说出这段代码不安全,并知道要用同步原语。
好回答,是能进一步说清:问题不只是“并发访问了共享变量”,而是“没有建立 happens-before,所以读到标志位不等于看到了相关状态”。
强回答,则会把这件事继续落到工程上:什么时候该用 Mutex,什么时候该用 channel,什么时候该用 sync.Once,什么时候 atomic 其实是在放大风险,而不是减少开销。
如果一个候选人把这道题答成“Go 调度不可预测,所以最好加锁”,我会觉得他见过问题,但还没真正掌握判断标准。
如果他能主动把答案收敛到“安全发布”和“同步边界”,那我会认为他已经从背概念,走到了会做并发设计。
这也是资深面试官爱问这道题的原因:它看起来像一个布尔标志位的小坑,实际上考的是你能不能用 Go 官方语义解释真实工程代码。
注:本文主要依据 Go 官方文档《The Go Memory Model》整理。