看到 done=true,就说明前面的写入都可见吗?

0 阅读9分钟

看到 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。
  • donecfg 都被多个 goroutine 并发访问了,但没有加锁。
  • 正确做法应该是用 Mutex、channel、sync.Once,或者至少用 atomic。

这个回答不能算错。

如果候选人能主动提到 data race,说明他已经知道“程序跑通”不等于“并发正确”。这在面试里已经比“我觉得大概率没事”强很多了。

但如果回答停在这里,我一般不会满意。因为这仍然像是在背规范结论,而不是在解释为什么错。

3. 为什么这个答案不够

不够的地方有三层。

第一,它只说了“有 race”,但没有说清 race 在这里具体破坏了什么。
真正被破坏的不是“线程切换顺序”,而是“可见性保证”。

第二,它把“看到标志位变了”和“相关状态已经可见”混成了一件事。
这恰好是很多并发 bug 最容易伪装自己的地方。代码不一定立刻 panic,测试甚至可能经常通过,但语义上它没有任何保证。

第三,它没有区分同步原语之间的职责。
Mutex、channel、sync.Oncesync/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. 面试官继续追问

如果候选人在这里答得还不错,我通常会继续往下追几个方向:

  1. 你刚才说“有 race”,那先精确定义一下:这里是 read-write race,还是 write-write race?
  2. 如果我把 done 改成 atomic,是不是问题就自动解决了?你能说清同步边界究竟覆盖了什么吗?
  3. 为什么 go f() 只能保证新 goroutine 开始执行的先后关系,却不能自动保证 goroutine 结束后的写入被别人看到?
  4. 如果我把这个例子改成 channel、sync.OnceMutex 三种写法,它们各自更适合什么场景?
  5. 为什么这类 bug 落到 mapslicestringinterface 上时,风险会比“读到旧值”更大?

这组追问的目的很明确:

从“你知道这个例子不安全”,一路追到“你能不能把 Go 并发里的可见性、同步边界、发布协议和工程取舍讲成一个完整故事”。

6. 工程场景

这道题不是课堂题,它在工程里非常常见。

  1. 配置热更新
    一个 goroutine 生成了新的配置快照,另一个 goroutine 根据一个 readyversion 标志决定是否切换。如果发布协议没建好,你读到的可能不是“新配置”,而是“部分可见的新配置”。

  2. 单例和懒加载
    很多人为了省一点锁,会自己写“先判断是否初始化,再决定是否进入慢路径”的代码。问题是,只要第一次观察发生在没有同步保护的路径上,这个优化就可能先把正确性优化掉。

  3. 后台任务完成通知
    有人喜欢写一个 done bool,让其他 goroutine 自旋等待。这个写法最大的问题不是浪费 CPU,而是它把“任务完成”错误地等同于“相关结果已经安全可见”。

  4. 缓存快照和全局 client 发布
    你上线后最难排查的不是稳定复现的 bug,而是那种“偶发 nil”“偶发旧值”“偶发字段不一致”。很多时候,根因根本不在业务逻辑,而在发布协议没有建立起来。

7. 面试官点评

这道题的合格线并不高,但优秀回答和普通回答的差距非常大。

合格回答,是能明确说出这段代码不安全,并知道要用同步原语。
好回答,是能进一步说清:问题不只是“并发访问了共享变量”,而是“没有建立 happens-before,所以读到标志位不等于看到了相关状态”。
强回答,则会把这件事继续落到工程上:什么时候该用 Mutex,什么时候该用 channel,什么时候该用 sync.Once,什么时候 atomic 其实是在放大风险,而不是减少开销。

如果一个候选人把这道题答成“Go 调度不可预测,所以最好加锁”,我会觉得他见过问题,但还没真正掌握判断标准。
如果他能主动把答案收敛到“安全发布”和“同步边界”,那我会认为他已经从背概念,走到了会做并发设计。

这也是资深面试官爱问这道题的原因:它看起来像一个布尔标志位的小坑,实际上考的是你能不能用 Go 官方语义解释真实工程代码。

注:本文主要依据 Go 官方文档《The Go Memory Model》整理。