15 内存模型与 happens-before:竞态的本质

开篇提问

先看一段代码,让你当一次”编译器”:

var a, b int
 
func f() {
    a = 1
    b = 2
}
 
func g() {
    print(b)
    print(a)
}

假设两个 goroutine 分别跑 f() 和 g(),没有任何同步。问题:g 可能打印出什么?

直觉上,你会说:可能打印 0 0(g 先跑)、2 1(f 先跑)、2 0(f 跑了一半 g 开始)……但有没有可能打印 0 1?

也就是:print(b) 读到了 b=0(f 还没写 b),但 print(a) 却读到了 a=1(f 已经写了 a)?

先别急着说”不可能”。这一章的答案,会颠覆你对”代码顺序 = 执行顺序”这个最朴素的直觉。


子主题一:内存模型在回答什么问题

Go 内存模型(Go Memory Model)要回答的核心问题是:

在一个 goroutine 里对内存的写,什么时候能被另一个 goroutine 看到?

这听起来像废话——“写了不就看到了吗?“但实际远没那么简单。因为有三样东西会打乱你看到的顺序:

1. 编译器重排。 编译器为了优化,可能把你写的语句顺序打乱。a = 1; b = 2 可能被编译成”先写 b 再写 a”,只要在单线程视角下结果不变,编译器就认为这是合法的。

2. CPU 重排。 现代 CPU 是乱序执行的,指令的执行顺序和程序顺序可以完全不同。CPU 只保证”单核视角下的结果正确”,不保证”多核看到的写顺序一致”。

3. 缓存一致性。 多核 CPU 各有各的缓存,一个核写入的值,要经过缓存一致性协议才能被别的核看到,这个过程有延迟,且不同核”看到同一组写”的顺序可能不同。

这三样东西叠加,就导致了一个残酷的事实:在没有同步的情况下,“另一个 goroutine 看到你的写”这件事,既不是即时的,也不是有序的。 开篇那个 0 1 的输出,在编译器和 CPU 的乱序面前,是完全可能的——g 可能先观察到 b 的旧值,再观察到 a 的新值(只要编译器把 f 的两条写重排成了”先 b 后 a”,或者 CPU 乱序执行了它们)。

这就是数据竞争(data race)的本质:多个 goroutine 无同步地访问同一块内存,且至少有一个是写。 一旦有数据竞争,程序的行为就是未定义的——任何输出都可能,包括那些”看起来不可能”的。


子主题二:happens-before——内存模型的基石

内存模型用来约束这种混乱的核心概念,就是 happens-before(先于关系)。

定义很简单:如果操作 A happens-before 操作 B,那么 A 的效果对 B 是可见的,且 A 和 B 的先后关系是确定的。

你不需要记住内存模型的所有细节,只需要掌握几条关键的 happens-before 规则,就足以写出正确的并发代码:

规则一:程序顺序。 在同一个 goroutine 内,前面的语句 happens-before 后面的语句。

规则二:channel 通信。 channel 的发送 happens-before 对应的接收完成(第八章讲过)。

规则三:锁。 一次 Unlock happens-before 之后的 Lock。也就是”上一个持有锁的人做的写,下一个拿到锁的人一定能看到”。

规则四:sync.Once。 once.Do(f) 里 f 的执行,happens-before 任何观察到 once 已完成的 goroutine。

规则五:go 语句。 go f() 这个 go 语句,happens-before f 的开始执行。

规则六:atomic。 atomic 操作提供内存序保证(第九章讲过,Go 的 atomic 默认顺序一致)。

这些规则的意义在于:只要你的同步走的是这些”正规渠道”(channel、锁、Once、atomic),跨 goroutine 的可见性就有保证,不需要额外操心。 反过来,任何不走这些渠道的共享内存访问,都是数据竞争,行为未定义。


子主题三:可见性与原子性——两个不同的概念

内存模型里有两个容易混淆的概念,务必分清:

可见性(visibility): 一个 goroutine 的写,另一个 goroutine 能不能看到。happens-before 管的就是这个。

原子性(atomicity): 一个操作是否不可分割。比如 64 位变量的读写,在 32 位平台上可能被拆成两次 32 位操作,另一个 goroutine 可能读到”一半新一半旧”的撕裂值。

两者是独立的:一个操作可以”可见但撕裂”(32 位平台上的 64 位读写),也可以”原子但不可见”(在错误的同步下)。 这也是为什么 64 位计数要用 atomic.AddInt64 而不是直接 counter++——既要原子性,也要可见性。

这里有个经典的坑:counter++ 不是原子操作。 它是”读-改-写”三步,两个 goroutine 同时 counter++,可能丢失一次加(都读到同一个旧值,都加一,写回同一个新值)。这就是”丢失更新”——必须用 atomic.AddInt64 或 Mutex。


子主题四:数据竞争的检测——race detector

Go 提供了一个杀手级工具:race detector(go test -race、go run -race)。

它的原理是基于 happens-before 关系做动态检测:race detector 在运行时记录每个内存访问的”逻辑时钟”,当发现”两个 goroutine 访问同一地址、至少一个是写、且它们之间没有 happens-before 关系”时,就报告一个数据竞争。

它的价值在于:数据竞争是”沉默的炸弹”,平时不炸,特定时序下才炸,极难排查。 race detector 把它变成”显式的、可定位的报错”,直接告诉你”哪两行代码、哪两个 goroutine 在竞争”。

几个使用要点:

  • race detector 只能检测”实际发生”的竞争。 如果测试没跑到那条路径,就检测不到。所以它不能证明”没有竞争”,只能证明”这次运行没发现竞争”。
  • race detector 有性能开销(通常慢 10 倍以上),所以只在测试和排查时用,不上生产。
  • “没 panic 不代表没竞争”(第六章讲过 map 并发写的例子)——race detector 才是权威裁判,别把”没崩溃”当成”没 bug”。

子主题五:正确并发的心智模型

学完这章,你应该形成一个正确的心智模型:

模型一:共享内存 + 同步原语 = 安全的并发。 要么用锁(Mutex)保护共享状态,要么用 channel 传递所有权,要么用 atomic 保护单变量。“裸奔”的共享内存(无任何同步的共享读写)永远是 bug。

模型二:happens-before 是唯一的”可见性裁判”。 判断”两个 goroutine 之间的读写是否安全”,就看它们之间有没有 happens-before 链。有,安全;没有,数据竞争。

模型三:先正确,后性能。 并发代码的正确性,永远优先于性能。加锁慢一点没关系,数据竞争错一点就是灾难。race detector 是写并发代码的标配。


子主题六:happens-before 完整规则清单——一条一条数清楚

反问第一个问题:你能一口气背出 Go 内存模型给出的全部同步边吗?如果不能,你凭什么相信自己的并发代码是对的?靠”跑了三个月没出事”?那只是三个月没轮到你而已。

在列清单之前,先纠正一个说法。Go 1.19 重写后的文档用了两个更精确的词:sequenced before(程序顺序) 和 synchronized before(同步顺序),而 happens-before 是这两者并集的传递闭包。

  • sequenced before:同一个 goroutine 内部,由 Go 语言规范的控制流结构和表达式求值顺序决定的先后关系。
  • synchronized before:如果一个同步读操作 r 观察到了某个同步写操作 w(形式化地写 W(r) = w),那么 w synchronized before r。
  • happens before = (sequenced before ∪ synchronized before)⁺,即取传递闭包。

为什么要费劲拆成两层?反问:为什么不干脆说”程序顺序就是 happens-before”?

因为它只在单个 goroutine 内有意义。跨 goroutine 的顺序没法凭空产生,只能靠”观察”建立——A 写了,B 看到了,A 才算在 B 之前。这是整个内存模型最硬的一句话:

没有观察,就没有顺序。

你写的代码里那行 x = 1,在别的 goroutine 眼里,要么通过某条同步边被”看见”,要么就什么都不是。记住这句话,下面的每一条规则都是它的具体形态。

规则 R0:初始化

  • 如果包 p 导入包 q,那么 q 的 init 函数完成,happens-before p 的任何 init 函数开始。
  • 所有 init 函数完成,synchronized before main.main 开始。

所以包级变量的初始化天然是安全的——前提是你别在 init 里 go func() { 写包级变量 }()。反问:这个坑你踩过吗?init 里起的 goroutine,没有任何东西保证它在 main 开始之前写完。

规则 R1:go 语句(goroutine 创建)

启动新 goroutine 的 go 语句,synchronized before 该 goroutine 的开始执行。

var a string
 
func f() { print(a) }
 
func hello() {
    a = "hello, world"
    go f()
}

这段代码保证打印 “hello, world”。链条是:a 的写 sequenced before go f()(程序顺序)→ go f() synchronized before f 开始(R1)→ 传递闭包得出:写 happens-before 读。

这条规则的工程价值:go 语句之前的所有写,对新 goroutine 可见。 所以正确的启动姿势永远是”先把共享数据准备好,再 go”。反过来说,把初始化挪到 go 之后,就是在赌运气。

规则 R2:goroutine 退出——不保证任何同步

这条是被忽略最多的一条,原文说得很直白:goroutine 的退出不保证 synchronized before 程序中的任何事件。

var a string
 
func hello() {
    go func() { a = "hello" }()
    print(a)
}

这段代码的 print 不保证看到 “hello”。文档甚至补了一刀:一个激进的编译器可以删掉整个 go 语句。

反问:你写过多少次 go func() { ... }() 然后什么都不等?如果没人观察它的结果,那它在内存模型里等于不存在。想观察它的效果,就必须上锁、channel 或 WaitGroup 之类的同步机制。

这一条是”goroutine 泄漏”之外更隐蔽的一类 bug:goroutine 跑了,但没人接得住它的结果。

规则 R3:channel 通信(四条,别记漏)

(a)一次发送 synchronized before 对应接收的完成。 这是主规则,buffered 和 unbuffered 都成立。

(b)close(ch) synchronized before 那个”因 channel 已关闭而返回零值”的接收。 这就是为什么 close 是广播”我做完了 / 没了”的正规手段——它自带同步语义,不是”顺手关一下”。

(c)对无缓冲 channel,一次接收 synchronized before 对应发送的完成。 注意方向:这是一条反向边。无缓冲 channel 是一次 rendezvous(会合),双方都确认了对方在场,才各自放行。所以无缓冲 channel 给的是两条边:发送→接收完成,接收→发送完成。

(d)容量为 C 的 channel,第 k 次接收 synchronized before 第 k+C 次发送的完成。

(d)是(c)的推广:当 C = 0 时,k + 0 = k,即”第 k 次接收 synchronized before 第 k 次发送完成”,恰好退化成(c)。漂亮吧——一条公式统一了两种 channel。

这里有个必须记住的坑:把(c)那个例子换成 make(chan int, 1),保证就消失了。因为发送可以直接塞进缓冲区然后立刻返回,根本不会和接收方会合。所以它”可能打印空串、可能崩溃、也可能干点别的”。

(d)这条规则直接解释了 Go 里最经典的并发限流惯用法为什么是对的:

var limit = make(chan int, 3)
 
func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1   // 占一个名额
            w()          // 干活
            <-limit      // 还名额
        }(w)
    }
    select{}
}

反问:为什么它能把并发限制在 3?因为第 k 次 <-limit synchronized before 第 k+3 次 limit <- 1 完成——第 4 个 goroutine 想进来,必须等前面某一次接收腾出位置。而且,这一同步边还顺带保证了:上一个用名额的 goroutine 在 w() 里做的所有写,对下一个拿到名额的 goroutine 可见。限流的同时白送了可见性,这就是”用 channel 建模信号量”比”用计数器 + if”高明的根本原因。

规则 R4:锁

对任意 sync.Mutex 或 sync.RWMutex 变量 l,n < m:第 n 次 Unlock synchronized before 第 m 次 Lock 的返回。

注意措辞的每一个字:是 Unlock synchronized before Lock 的返回(不是 Lock 的调用)。锁建立的边是”上一位释放者 → 下一位获得者”,这是临界区能保护数据的全部依据。

RWMutex 的读锁规则更绕一点:存在某个 n,使得第 n 次 Unlock synchronized before 该次 RLock 的返回;并且配对的 RUnlock synchronized before 第 n+1 次 Lock 的返回。 用人话说:写锁释放后,之后的读锁能看到;读锁释放后,之后的写锁能看到。读写之间也形成链,不是”读锁之间随便玩”。

TryLock / TryRLock 的坑(必考):成功的 TryLock 等价于 Lock;不成功的调用完全没有同步效果。文档还补了一句很损的:就内存模型而言,l.TryLock 甚至在锁根本没被持有的情况下也允许返回 false。

反问:你是不是写过这种代码?

if !mu.TryLock() {
    return cachedValue   // 拿不到锁就读缓存
}

如果 cachedValue 是被锁保护的共享变量,那这条快路径上一丁点可见性保证都没有。TryLock 失败意味着”我没有获得任何排序承诺”,你在没有任何承诺的情况下读了数据——教科书级竞争。

规则 R5:sync.Once

once.Do(f) 中那唯一一次 f 的完成,synchronized before 任何 once.Do(f) 调用的返回。

再读一遍”任何”两个字。它包含两拨人:跑 f 的那个 goroutine 自己,以及中途阻塞等待、最后直接返回的那些 goroutine。所有人在 once.Do 返回后,都能看到 f 里的写。

这是 Go 里唯一推荐的惰性初始化写法,没有之一。为什么它对、手写双重检查锁却错?子主题十一会给完整解剖。

规则 R6:sync/atomic

文档只有两句话,但信息量极大:

  1. 如果 atomic 操作 A 的效果被 atomic 操作 B 观察到,那么 A synchronized before B。
  2. 程序中所有 atomic 操作,表现得好像按某个全局一致的顺序执行(顺序一致,sequentially consistent)。

文档还明确标注了等价物:这个语义等同于 C++ 的 sequentially consistent atomics,以及 Java 的 volatile 变量。

第一句告诉你:atomic 不只是”不可分割的加减”,它还是同步边的来源——只要 B 读到了 A 写的值,边就建立了。第二句告诉你:Go 把所有 atomic 焊死在最强档 seq_cst,没有 relaxed、没有 acquire/release 可选(子主题十展开代价)。

规则 R7:finalizer——只有一条边,其余一概不管

runtime.SetFinalizer(x, f) 的调用,synchronized before 终结语调用 f(x)。

就这一条。什么时候跑?不知道。会不会跑?不保证(程序退出时可能直接不跑)。在哪个 goroutine 跑?Go 运行时自己的 goroutine,跟你的代码没有第二条同步边。

这条规则的含义是:f 能看到 x 对象在 SetFinalizer 时刻之前的内容,但不能假设能看到别的 goroutine 之后写的任何东西。所以 finalizer 里读共享状态是纯粹的赌博。它的正确用途只有一个量级:释放 cgo 分配的、GC 管不到的资源(C malloc 的内存、文件描述符)。把它当析构器做业务(比如”对象被回收时写回数据库”),是把”不确定性的来源”引进了核心链路。反问:一个”可能永远不执行、可能在任意时刻执行”的函数,你敢把业务正确性押上去吗?

规则 R8:其他同步机制——契约式保证

sync.Cond、sync.Map、sync.Pool、sync.WaitGroup 的同步保证,由它们各自的文档规定。内存模型原文说得很清楚:其他提供同步抽象的包,也应当自己写清保证。

反问:wg.Wait() 凭什么能保证看到所有 goroutine 的写?不是因为它在内存模型清单里(它不在),而是因为 WaitGroup 的文档承诺了这件事。这是 Go 内存模型一个很不一样的设计:它不是一份穷举清单,而是一套”契约机制”——核心原语写进规范,其余的由各自文档声明。所以第三方并发库如果没写清同步保证,你无权替它假设。

补一条必须知道的:含竞争程序的”实现限制”

除了以上规则,Go 还对已经存在数据竞争的程序给了实现层面的限制——这决定了竞争最坏会坏到什么程度:

  • 不超过一个机器字的读:必须观察到某个真实写过的(先前的或并发的)值,不允许出现”凭空(out of thin air)“的值。这点 Go 比 C++ 温和:单个字的竞争,你至少能拿到一个”存在过的值”,不是彻底未定义。
  • 大于一个机器字的读:规范鼓励但不要求它像单字那样只观察单次写。实现可以出于性能考虑,把它拆成多个机器字大小的操作,顺序未指定。这意味着多字数据结构上的竞争,可能读到”不对应任何单次写”的组合值。
  • 最狠的一句在最后:当这些值内部依赖 (指针, 长度) 或 (指针, 类型) 的配对一致性时——interface、map、slice、string 在绝大多数 Go 实现里正是如此——这种竞争进而可以导致任意内存破坏(arbitrary memory corruption)。

反问:现在你还会说”并发读写最多是读到脏数据”吗?读到脏数据是最好的结局。


子主题七:可见性、原子性、有序性——三个维度,别再混为一谈

反问开场:atomic.AddInt64(&x, 1) 到底保证了下面三件事里的哪几件?

  • 原子性(atomicity):这个操作对外界看是不可分割的。要么没发生,要么全发生,中间状态不可见,也不会和别人的操作互相覆盖。
  • 可见性(visibility):一个 goroutine 的写,能不能被另一个 goroutine 的读观察到。
  • 有序性(ordering):两个操作在程序里的先后,在别的观察者眼里是否保持。

谁保证谁(一张表说清)

手段原子性可见性有序性备注
普通读写(无同步)单字内”够用”(能读到一个真实写过的值);多字不保证无无竞争,行为不受保护
Mutex / RWMutex是(整个临界区对外原子)是(Unlock→Lock)是最省心,代价是串行
channel是(一次交接)是是附带所有权转移的语义
sync.Once是是是惰性初始化专用
atomic是(仅针对那一个变量)是(被观察到即同步)是(seq_cst 全局单序)不解决多变量一致性
无同步的共享读写可能撕裂无无唯一结论:错

三者是独立的维度——举三个反直觉的组合

“原子,但不解决可见性”——只有当你把 atomic 用错一半时。

// goroutine A
data = buildBigThing()
atomic.StoreInt64(&ready, 1)
 
// goroutine B
if atomic.LoadInt64(&ready) == 1 {
    use(data)   // 安全:B 观察到了 A 的 atomic 写,边成立
}

这个是对的:seq_cst + “被观察到即 synchronized before”,两个普通变量的可见性问题被一个 atomic 变量”捎带”解决了。这是非常有用、也非常常用的发布模式。

但如果把它改成一半裸奔:

// goroutine A
data = buildBigThing()
ready = 1            // 普通写!
 
// goroutine B
if atomic.LoadInt64(&ready) == 1 {   // atomic 读
    use(data)        // 依然是竞争
}

照样是竞争。 为什么?因为 synchronized before 的定义是”一个同步读观察到一个同步写”——两端都得是同步操作。一边 synchronized 一边裸奔,等于没有边。

可见性是一条双向匹配的边,不是单方面的声明。这一点,一半的并发 bug 都栽在上面。

“可见,但不原子”: 用一个普通指针变量去发布一个正在被逐字段写的 struct。你确实看到了指针(可见性有了),但对象内部的字段可能是”一半新一半旧”——原子性指的是对象整体的不可分割,而普通发布没给你这个。

“有序,但不可见”:不存在。 有序本身就蕴含了观察者之间的共识。所以这是三个维度里唯一不能单独缺失的一环——它是前两者的前提。

关键结论:happens-before 管的是哪两个?

happens-before = 可见性 + 有序性的合体,它不直接管原子性。 原子性由 atomic / 锁 / channel 自己提供。

于是判断一处并发访问安不安全,你要问两个问题,而不是一个:

  1. 这个操作本身是原子的吗?(不是 → 必须上 atomic 或锁;记住 counter++ 不是)
  2. 它和其他 goroutine 的访问之间有 happens-before 链吗?(没有 → 竞争,行为不受保护)

只答对一题,不算安全。

一个狠一点的类比

  • 原子性:这段路能不能被拦腰截断——你过马路时会不会被从中间劈成两半。
  • 可见性:你写的信有没有真的寄出去——不是”你投进邮筒了”就算数。
  • 有序性:收信人能不能保证先收到第一封再收到第二封。

你写了两封信,每封都很完整(原子性 OK),但邮局可能把第二封先送到(有序性崩),甚至根本没送(可见性崩)。

  • atomic = 挂号信 + 强制按序投递 + 签收回执。
  • Mutex = 整条街戒严,一次只放一个人进去办事。
  • channel = 不当邮差了,直接当面把钥匙交到对方手里。
  • 裸奔的共享变量 = 你写在便利贴上,贴在自己脑门上,然后指望别人看见。

子主题八:数据竞争的三种死法——丢失更新、撕裂读、半初始化对象

反问:如果数据竞争最坏只是”偶尔读到一个旧值”,Go 官方为什么要写出”对含竞争的程序不提供任何保证”这种话?因为下面三种死法里,只有第一种还算温和。

死法一:丢失更新(lost update)

var counter int64
 
func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); counter++ }()
    }
    wg.Wait()
    fmt.Println(counter)   // 大概率不是 1000
}

counter++ 是 读-改-写 三步:读 counter → 加一 → 写回。两个 goroutine 同时读到 100,都算成 101,都写回 101——一次加法凭空消失。

类比:两个人同时瞟一眼白板上的数字、同时加一、同时擦掉重写,白板最终只涨了 1。一千个人挤在同一个白板前,涨出来的数远小于一千。

特征: 结果偏小、不可复现、负载越高偏差越大。这是最”温和”的死法,也是线上最常见的(QPS 统计、限流计数、指标上报、库存扣减)。

修法: atomic.AddInt64,或者分片后求和,或者 channel 汇总串行化。

死法二:撕裂读(torn read)——两个层面

层面一:32 位平台上的 64 位变量。

一条 64 位的读写,在 32 位平台上可能被编译成两条 32 位指令。另一个 goroutine 正好插在中间,读到”低 32 位是新值、高 32 位是旧值”——一个从来没有被任何人写过的数。

还有个连带的坑:对齐。Go 只保证”一个变量,或者一个已分配的 struct / array / slice 的第一个字,是 64 位对齐的”。所以:

type Stats struct {
    name  string
    total int64   // 在 32 位平台上不保证 8 字节对齐 → 原子操作可能 panic
}

Go 1.19 引入的类型化原子 atomic.Int64(以及 atomic.Pointer[T] 等)内部带了 64 位对齐字段,就是替你消掉这个坑。所以在 32 位目标平台上,优先用类型化原子,或者把 64 位字段放在 struct 的第一个位置。反问:你在 x86-64 上跑得好好的代码,部署到 32 位 ARM 设备上突然 panic,你想过是这个原因吗?

层面二:多字结构(这才是真正的大杀器)。

struct、interface、slice、string、map,都是多字的。按子主题六末尾引的规范原文:大于机器字的操作,实现可以拆成多个机器字操作,顺序未指定。于是:

  • interface 值可能读到”新的类型指针 + 旧的数据指针”——下一步按新类型去解释旧数据,直接是类型混淆。
  • slice 可能读到”新的底层数组指针 + 旧的 len/cap”,或者”新 len + 旧指针”——紧接着就是越界读写。
  • string 同理,读到一半甚至可能指向已经释放的内存。

规范原文对这些情况给的词是:任意内存破坏(arbitrary memory corruption)。

反问:现在回头看第六章讲的并发写 map——“最坏是读到脏数据”这个想法,是不是该扔了?最坏是把进程内存搞烂,而崩溃栈会指向 runtime.mapassign 或某个八竿子打不着的第三方库,离真正的 bug 有十万八千里。

死法三:读到半初始化对象(unsafe publication)

var cfg *Config
 
func initCfg() {
    c := &Config{}
    c.Timeout = 30
    c.Endpoint = "https://api.example.com"
    cfg = c            // 无同步发布
}
 
func use() {
    if cfg != nil {
        fmt.Println(cfg.Timeout, cfg.Endpoint)   // 可能打印 0 和 ""
    }
}

先说一个好消息(也是 Go 比 C++ 温和的地方):指针写本身是单字写,所以你读到的 cfg 一定是某个真实写过的指针值,不会撕裂成一个野地址——你不会莫名 segfault。

再说坏消息,而且坏得很彻底:

  1. 你看到 cfg != nil,靠的是”碰巧看到了另一个 goroutine 的写”,没有任何 happens-before 边。于是 c.Timeout = 30 这些字段写,对你不保证可见,你完全可能看到 cfg != nil && cfg.Timeout == 0。
  2. 更狠的是编译器可以在单线程视角下把 c.Timeout = 30; c.Endpoint = ...; cfg = c 重排成 cfg = c; c.Timeout = 30; c.Endpoint = ...。只要它认为没人从外面看,这就是合法优化。于是你拿到的 cfg 指向的,是一个此刻真的还没装修的房子。

类比:你把钥匙快递给了朋友,快递单号(指针)是真的、能查到,但房子(对象)还是毛坯——他开门进去,只看到水泥地。

正确发布方式只有一类:让”对象内部的字段写” happens-before “指针的读”。 具体手段:

  • 用 sync.Once 发布(推荐);
  • 在持有锁时构造并发布,读也在锁里;
  • 通过 channel 把指针传过去(传递所有权,天然有边);
  • 用 atomic.Pointer[Config] / atomic.Value 两端都用 atomic。

一句话总结这三种死法

  • 丢失更新:算错了(数量不对)。
  • 撕裂读:看错了,甚至看崩了(值不存在 / 内存被破坏)。
  • 半初始化:看得太早了(看到了,但还没准备好)。

三种的根,是同一个东西:两个 goroutine 之间,缺一条 happens-before 边。


子主题九:race detector 是怎么抓到你的——向量时钟、性能开销、天然盲区

反问:race detector 凭什么能”看见” happens-before?happens-before 又不是贴在内存上的标签。

答案朴素而粗暴:它给每个内存位置和每个 goroutine 都贴了标签,代价是整个程序慢一个数量级。

原理:在运行时增量维护 happens-before 偏序

Go 的 -race 背后是 ThreadSanitizer(TSan),核心是三样东西:

(1)每个 goroutine 维护一个逻辑时钟 + 一个向量时钟。

  • 自己的时钟(epoch)每次同步事件后自增。
  • 向量时钟的第 i 位记录”我知道 goroutine i 的时钟已经走到了 n”。

每当发生同步(channel 收发、Unlock/Lock、Once、atomic 操作、WaitGroup 等),检测器就把双方的向量时钟按分量取最大值合并,然后本地 epoch 自增。

这一步的效果是:happens-before 偏序被在运行时增量地算了出来。goroutine A 现在”知道”B 走到了哪一步——这就是”我看到了你的写”的可判定形式。

(2)每块内存有对应的 shadow cell。

程序内存被映射到一块 shadow memory,每 8 个字节的应用内存,配若干条(默认 4 条)“最近访问记录”,每条记录是:(goroutine id, 该 goroutine 当时的 epoch, 读还是写, 访问的字节范围)。

(3)每次内存访问都做一次检测。

读或写发生时,遍历这块内存对应的 shadow cell。如果找到一条属于别的 goroutine 的记录,且同时满足:

  • 两次访问中至少一个是写;
  • 访问的字节范围有重叠;
  • 并且记录的那个时刻不 happens-before 当前时刻(用向量时钟比较即可判定);

那就报一个数据竞争,并打印双方的完整调用栈。报完之后把这条记录替换掉,避免同一对重复刷屏。

反问:它为什么能精确告诉你”哪两行代码、哪两个 goroutine”?因为两边的记录里都存了栈。这也顺带解释了它为什么那么吃内存。

性能开销从哪来(官方口径:典型程序内存涨 5–10 倍,时间涨 2–20 倍)

  1. 每条内存访问都被插桩。 一次 x++ 从一两条指令变成几十条(查 shadow、比向量时钟、可能还要记录栈)。这是数量级的放大,不是百分比。
  2. shadow memory 本身是应用内存的数倍。 每 8 个字节配多条访问记录。
  3. 每次 goroutine 创建和每次同步操作都要维护向量时钟。
  4. 它抑制内联、禁用一批编译器优化,代码本身的执行效率也下降。

结论很直接:-race 用在测试、CI、压测灰度、线上小流量旁路,不要挂在生产主链路上。想要”生产也检测”的团队,通常的做法是灰度一台机器开 -race,或者只在预发环境全量开。

盲区:比”慢”重要一百倍的问题

反问:跑完 -race 全绿,你能说”我的代码没有竞争”吗?不能。 你只能说”这次运行没发现竞争”。盲区至少有五条:

  1. 只检测跑到的路径。 没被执行的分支、没被触发的错误重试逻辑、只在某个配置开关下才走的代码、只在特定错误码下才走的兜底路径——一概测不到。这是最大、最常被忽视的盲区。
  2. 只检测实际交错出来的竞争。 两处访问即使存在竞争,如果这次运行中它们相隔很远、或者调度器没把它们交错放在一起,也不会被记录到一起。所以并发测试必须”够并发”:GOMAXPROCS > 1、goroutine 数量要足、循环次数要足。
  3. shadow cell 数量有限。 每 8 字节只保留最近 4 条记录(Go/TSan 的默认量级),更早的那次访问被挤掉之后,就漏报。
  4. 不是所有代码都被插桩。 cgo 里的 C 代码、手写汇编、以及没用 -race 重新编译的依赖(比如预编译的静态库)不在监控范围。
  5. 可追踪的 goroutine 数量有上限。 超大规模 goroutine 程序可能跑不起来,或者行为退化。

正确使用姿势(硬规矩)

  • 单元测试和集成测试全量开 -race;关键模块补专门的并发测试:起 N 个 goroutine 反复读写同一份状态,把交错概率拉满。
  • 用 go test -race -count=10 -cpu=1,4,16 ./... 提高交错覆盖。
  • race detector 报的每一个竞争都要查到根因。“看起来无害”的竞争也要修——今天的无害,换一个编译器版本、换一个 CPU 架构,就是内存破坏。
  • 最后再问一次:你愿意拿线上的内存破坏,去赌一个”概率很低”吗?“跑了很多次没崩”从来不是证据,它只是说明你还没跑到那条路。

子主题十:内存序深入——CPU 为什么敢乱序,屏障在拦什么,Go 的 seq_cst 到底给了什么

反问:既然硬件有缓存一致性协议(MESI 那套),多核看到的内存不就是一致的吗?那还要内存屏障干什么?

先把三个常被混为一谈的概念劈开:

  1. 缓存一致性(cache coherence):MESI 之类保证的是”对同一个地址的写,所有核最终看到一个值,且这些写是被串行化的”。它管的是单地址。
  2. 内存一致性(memory consistency / ordering):管的是”跨地址的写,被其他核看到的相对顺序”。
  3. MESI 不保证第 2 条。

所以:核心 A 先写 x = 1 再写 y = 1,核心 B 完全有可能先看到 y == 1 再看到 x == 1。缓存一致 ≠ 内存有序,这是并发领域最经典的误解之一。

为什么会这样?两个硬件结构是直接原因

  • Store buffer:核写完并不直接进缓存,而是先塞进 store buffer,异步刷出。于是”我自己写完了”和”别人看得见”之间,隔了一段不确定的时间。
  • Invalidate queue:收到别人的缓存行失效消息时,先排进队列、先回 ACK,稍后才真正失效自己的副本。于是”别人写了”和”我不再看旧值”之间,也隔了一段不确定的时间。

这两个队列,加上编译器的重排(寄存器缓存、循环不变式外提、指令调度),三样叠加,就是你开篇看到的那个 0 1。

不同架构”敢乱序”的程度完全不同

  • x86 / x86-64(TSO,全序存储):只允许一种重排——StoreLoad(自己写完的内容,不能被自己后续的读立刻看到,因为还躺在 store buffer 里)。另外三种(LoadLoad、StoreStore、LoadStore)都不允许。所以 x86 上 acquire 读、release 写常常是免费的,只需要一个”编译屏障”阻止编译器乱来。
  • ARM / POWER / RISC-V(弱序):四种重排都可能发生。所以一段在 x86 上”永远测不出来”的代码,跑到 ARM 服务器或苹果 M 系列上就原形毕露。

反问:你只在 x86 笔记本上跑过测试,就说这段并发代码没问题?——这就是为什么永远不能靠测试来证明并发正确性。测试只能证伪,不能证明。

内存屏障:按”要拦住哪种重排”命名

屏障拦住什么
LoadLoad后面的读不能跑到前面的读之前
StoreStore后面的写不能跑到前面的写之前
LoadStore后面的写不能跑到前面的读之前
StoreLoad后面的读不能跑到前面的写之前——最贵,通常要排空 store buffer(x86 上的 mfence 或带 lock 前缀的指令)

用它们可以搭出熟悉的语义:

  • release 写 = StoreStore + LoadStore(我之前的读写,不许跑到我后面去)
  • acquire 读 = LoadLoad + LoadStore(我之后的读写,不许跑到我前面去)
  • seq_cst = 在 acquire/release 之上,再加一个全局单一总序(通常还要一条 StoreLoad 屏障)

Go 给的是什么?一句话:seq_cst,焊死

文档的原话是:程序中所有 atomic 操作,表现得好像按某个全局一致的顺序执行,语义等同于 C++ 的 seq_cst atomics 和 Java 的 volatile 变量。

这意味着:

  • 你不需要、也不能选择更弱的内存序。Go 没有暴露 relaxed / acquire / release 档位。
  • 代价:在 ARM/POWER 上,每次 atomic 写都可能要一条昂贵的屏障;在 x86 上,atomic 的读-改-写用 lock 前缀,StoreLoad 也逃不掉。也就是说,你为一个纯计数器付出了”发布-订阅”级别的同步成本。
  • 收益:你几乎不可能因为选错内存序而写出 bug。

反问:Go 把最锋利的工具收起来了,换来”用 atomic 就基本不会错”——对一个多人协作的工程语言,这笔交易值不值?Go 官方的答案是明确的:值。这是 Go 一以贯之的设计取向:宁可牺牲性能上限,也要把错误空间压缩。

Go 里用 atomic 的心法(四条)

  1. 把 atomic 当”同步边”用,不只是当”不可分割的加减”用。 它的真正威力是能捎带传播可见性(见子主题七那个发布模式)。
  2. 两端必须都是 atomic(或同一把锁)。 一边 atomic 写、另一边普通读 = 没有边。
  3. 传大对象用 atomic.Value / atomic.Pointer[T],别自己拿 atomic.StoreUintptr 裸奔,更别发布之后还去改对象内容(发布即冻结,改就竞争)。
  4. atomic 只保护一个变量。 多个变量之间的一致性(比如”余额和流水号必须同时更新”),atomic 救不了你,上锁。

子主题十一:三个经典竞态案的完整解剖

案一:双重检查锁(DCL)——在 Go 里为什么也错

var (
    instance *Config
    mu       sync.Mutex
)
 
func Get() *Config {
    if instance == nil {              // ← 快路径:无保护的读
        mu.Lock()
        if instance == nil {
            instance = newConfig()    // ← 构造 + 发布
        }
        mu.Unlock()
    }
    return instance
}

这是从 C++/Java 那边抄过来的经典写法。它在 Go 里错得一模一样,但错的原因和很多人以为的不一样。

第一刀:快路径上那次 if instance == nil,和另一个 goroutine 的 instance = newConfig() 之间,有 happens-before 边吗?

没有。一个在读,一个在写,中间没有任何同步操作。这是教科书级的数据竞争,-race 一跑一个准。

第二刀(更致命):就算运气好,快路径读到了非 nil,就安全了吗?

不安全。这是绝大多数人想不通的地方。你拿到这个非 nil 的指针,靠的是”碰巧看到了另一个 goroutine 的写”,而不是一条同步边。同步边(这里的 Unlock → Lock)只在慢路径上被建立过,而你走的这条快路径根本没碰过那把锁。

于是:

  • newConfig() 里对各字段的写,对你不保证可见——你可能看到 instance != nil 但 instance.Timeout == 0。
  • 编译器/CPU 重排可以让 instance = c 发生在字段初始化之前,你拿到的是真正的毛坯房(就是子主题八的死法三)。
  • 唯一的安慰:Go 里指针是单字写,不会撕裂成野地址,所以你不会 segfault——你只会静默地使用一份错误的配置。 反问:静默错误和崩溃,哪个更可怕?

第三刀:那我用 atomic 包一层指针,就够了吗?

够——但前提是你两端都用 atomic。用 atomic.Pointer[Config] 做发布、用它的 Load() 做读取,边就成立了。只包一半(atomic 写 + 普通读),等于没包。

Go 里的正确答案,只有一个推荐项:

var (
    instance *Config
    once     sync.Once
)
 
func Get() *Config {
    once.Do(func() { instance = newConfig() })
    return instance
}

为什么它对?因为 Once 的规则是”那次 f 的完成,synchronized before 任何 once.Do 的返回”——注意”任何”,包括那些走快路径直接返回的 goroutine。

Once 的内部实现,正是用 atomic 做快路径探测 + Mutex 做慢路径互斥。它和手写 DCL 的唯一区别就在于:快路径上那个”探测”是有同步语义的 atomic 读,而不是裸读。就这一个字之差,快路径也接上了边。

一句话结论:DCL 的错误不是”少加了一层判空”,而是”快路径上不存在同步事件,因而不存在 happens-before 边”。

Java 在 JDK 5 之后靠把单例字段声明为 volatile 修好了它;Go 的答案是 sync.Once。在 Go 里手写 DCL,一律视为错误。

案二:并发 map——不是”可能读到旧值”,是”可能把内存搞烂”

先把现象摆出来:并发读写 map,Go 运行时会抛出 fatal error: concurrent map read and map write,直接把进程干掉。注意是 fatal,不是 panic——recover 救不了,defer 也来不及优雅收尾。

反问:那是不是说 Go 帮我兜底了,最坏就是崩一下?

想得美。这个检查是**尽力而为(best-effort)**的:map 的 header 里有个 hashWriting 标志位,写操作时置位,某些读路径上发现标志被置位就 throw。它抓不到所有交错:

  • 两个 goroutine 的读写时序可以完美错过这个检查窗口;
  • 最糟的是扩容(growing)期间:map 正在把 bucket 从旧数组搬迁到新数组,此时另一个 goroutine 拿着旧 bucket 的指针在读,而旧 bucket 已经被复用或释放——读到的是垃圾,甚至会顺着野指针继续走下去。这就属于规范原文说的”任意内存破坏”。

于是它的表现形态是:崩溃栈完全不在你的业务代码里(在 runtime.mapassign / mapaccess 里)、死循环、莫名 OOM、或者数据静默丢失。排查难度是灾难级的,因为现场和原因相距十万八千里。

修法三条,按场景选:

  1. sync.Mutex / sync.RWMutex 包一层。 读多写少用 RWMutex。语义最清楚,作为默认推荐。
  2. sync.Map。 它专为两种场景优化:① 写一次读多次(比如缓存配置);② 多个 goroutine 各自读写不相交的 key 集合。为此它内部用了 read/dirty 双 map + 无锁读路径。反过来:写很频繁、key 高度重叠的场景,sync.Map 可能比 Mutex + map 还慢。反问:你是不是默认”它是 sync 包里的,所以一定更快”?这是最常见的误用来源。
  3. 分片(sharding)。 按 key 哈希分 N 个桶,每桶一把锁,把锁竞争降到 1/N。

案三:共享计数器——从”跑得不对”到”跑得不快”的三层进阶

第一层:正确性。 counter++ 丢失更新(死法一)。修法:atomic.AddInt64(&counter, 1)。这一层大部分人都知道。

第二层:别在函数式 atomic 上踩对齐坑。 32 位平台上,64 位原子操作要求 8 字节对齐,而 Go 只保证”变量、或已分配的 struct / array / slice 的第一个字”是 64 位对齐的。

type Stats struct {
    total int64   // 在 32 位平台上不保证对齐
    ok    int64
}

正确做法:用 Go 1.19 的类型化原子 atomic.Int64(内部带了 64 位对齐字段,替你消掉这个坑),或者把 64 位字段放在 struct 第一个位置。

第三层:性能。 atomic 的代价不只是”慢一点点”,而是缓存行争用:多个核反复对同一个 8 字节做读-改-写,每一次都会把那一行缓存(64 字节)在各个核之间来回搬——cache line ping-pong,核越多越惨,20 核机器的单 atomic 计数器可能比单核还慢。再叠加 Go 的 seq_cst 语义(每次都要完整屏障),这就是高并发下计数器的真实成本。

优化路径(按需递进,别一上来就上最复杂的):

  1. 分片 + padding。 每个 P / 每个 worker 一个 atomic.Int64,并用 padding 隔开到不同缓存行(避免 false sharing),读的时候求和:
    const shards = 64
    type Counters [shards]struct {
        v atomic.Int64
        _ [56]byte   // 补齐到 64 字节,占满一个缓存行
    }
    (sync.Pool 和 runtime 内部都有类似手法,这不是炫技,是标准做法。)
  2. 换算法。 真要精确计数且写极多:用 channel 把增量汇总到单一 goroutine(把共享变成串行),用所有权转移消灭竞争。
  3. 接受近似。 限流和指标场景下,允许”近似计数”(分片采样、滑动窗口近似),用精度换吞吐。反问:一个 QPS 大盘,你需要的是 99.99% 精确,还是 1 毫秒内返回?

三个案子的共性

反问收尾:这三个案子的根因,是不是同一个?

是。都在回答同一个问题:“这条执行路径上,有没有一条 happens-before 边?”

  • DCL 错在快路径没边;
  • 并发 map 错在压根没有边;
  • 计数器错在用了没边的操作,却指望它有原子性。

子主题十二:业界对照补强——Java JMM、C++ 多档内存序、Rust 编译期保证

反问开场:为什么 Java 和 C++ 的内存模型要写得那么厚,Go 却能把内存模型压成”几页规范 + 一个 race detector”?

因为三家的错误发现时机不一样。而”什么时候发现错误”,直接决定了”规范要写多细、工具要做多重”。

Java(JSR-133,2004 年定稿)

  • 同样是 happens-before,规则清单包括:程序顺序、监视器锁(unlock → lock)、volatile 写 → volatile 读、Thread.start()、Thread.join()、以及传递性。外加一套 final 字段的 freeze 语义。
  • volatile 给的是可见性 + 顺序性(写相当于 release、读相当于 acquire),但不给原子性:volatile int i; i++ 照样丢失更新。所以 Java 里计数要用 AtomicInteger,高竞争下还要换 LongAdder(就是 Go 里”分片计数”那一套)。
  • final 是 Java 独有的一张牌:JMM 保证”正确构造的对象”被发布后,别的线程看到的 final 字段一定是初始化后的值。
  • 反问:是不是第一次意识到,Go 的构造函数比 Java 的”弱”?Go 没有 final,也没有这套语义——Go 里”正确构造完成”这件事本身不提供任何保证,你必须靠同步边。同样是 new 出来的对象,Java 至少保证 final 字段安全,Go 一个字段都不保证。
  • DCL 在 JDK 5 之后靠 volatile 修饰单例字段变正确了(JDK 1.4 及以前是坏的)。Go 的对应物是 atomic.Pointer / sync.Once。

C++(C++11 起)

  • 六档 memory_order:relaxed、consume(基本被弃用)、acquire、release、acq_rel、seq_cst。
  • relaxed 只保证原子性,不提供任何同步或顺序保证。 典型用途就是纯粹的计数器:只关心最终不丢,不关心它和其他变量的相对顺序。这是 C++ 能榨出极致性能的原因,也是它被称为”专家专用”的原因。
  • release / acquire 成对使用,形成一条单向同步边。比 seq_cst 便宜得多:x86 上几乎免费(只需编译屏障),ARM 上只需单向屏障。
  • seq_cst 在 acquire/release 之上再加一个全局单一总序——这正是 Go atomic 的语义。
  • 关键差异:C++ 把选择权交给了你。代价是选错就是最难查的 bug,而且往往只在弱序架构上暴露,在 x86 开发机上永远测不出来。
  • 一句话对照:C++ 给你六档变速箱和离合器,Go 给你一辆自动挡。 上限低了,但绝大多数人开自动挡更不容易出事。

Rust

  • 思路完全不同:在类型系统里消灭竞争。Send(所有权可以跨线程转移)和 Sync(&T 可以跨线程共享)是两个 auto trait,编译器自动推导。
  • 结果:safe Rust 里不可能写出数据竞争。 想要共享可变状态,必须显式套 Arc<Mutex<T>> / RwLock / channel(crossbeam)/ Atomic*,编译器替你记账,漏了就编译不过。
  • 代价:借用检查 + 生命周期的心智成本、Arc<Mutex<T>> 的写法噪音、以及和”自引用结构 / 图结构”搏斗时的痛苦。
  • unsafe 是逃生舱。一旦进去手写并发原语,C++ 的所有坑全部回归。
  • 反问:Rust 把正确性证明挪到了编译期,Go 挪到了运行期(race detector),Java 挪到了规范(靠人读)。你所在的团队,哪一种成本更低?

一张对照表:谁保证、何时发现、代价是什么

语言正确性由谁保证错误何时被发现主要代价
Gohappens-before 规则 + race detector运行期(测试时)编译期不拦,依赖测试覆盖
JavaJMM + volatile/final/synchronized运行期(通常更晚)规范复杂,靠人理解
C++memory_order 手动选择运行期(最晚、最难复现)心智负担极高
Rust类型系统 Send/Sync编译期写法噪音、学习曲线陡
Erlang / Actor根本不共享内存竞争这个概念不存在建模方式受限

结论

Go 的选择是:规范够用就行,把力气花在工具上。

它的赌注是——只要 race detector 足够好用、跑起来足够便宜,绝大多数团队就能写出正确的并发代码,而不需要人人都是内存模型专家。

所以对你这个 Go 程序员来说,真正的”精通”不是背下内存模型的每一句话,而是两件具体的事:

  1. 把 -race 变成默认动作(测试、CI、压测、灰度)。
  2. 看到竞争报告时,能立刻画出那条缺失的 happens-before 边,并且知道该用 channel、锁、Once 还是 atomic 去补上它。

子主题十三:把 happens-before 变成肌肉记忆——一份 code review 自检清单

反问收尾:学完这一章,你在 review 别人的并发代码时,具体该看什么?给你一张能直接用的清单。

  1. 先把所有被多个 goroutine 触碰的变量圈出来。 别急着看同步,先找共享状态。圈不出来,就等于没审。
  2. 对每一处访问问两个问题:它是读还是写?保护它的是什么(Mutex / RWMutex / channel / Once / atomic / 只读不变)?答不上来的,就是嫌疑犯。
  3. 重点检查”快路径”。 惰性初始化、缓存命中直接返回、if x != nil 提前返回——这些路径最容易绕过同步。想想 DCL 是怎么死的。
  4. 检查”发布端”和”消费端”是否成对。 一边 atomic 写、另一边普通读 = 没有同步。同步边必须两端同时存在,单方面的声明不算数。
  5. 检查 goroutine 的退出。 go func() { ... }() 之后,谁在观察它的结果?如果没人观察,那这段代码在内存模型里可能根本不存在(编译器有权删掉)。
  6. 特别关注 map / slice / interface / string 这类多字值的并发写。 它们的竞争不是”读到脏值”,是”内存破坏”。优先级最高。
  7. 检查 32 位平台。 64 位变量用的是 atomic.Int64(带对齐保证)吗?是不是被放在 struct 中间了?
  8. 跑 -race,而且跑得够狠:go test -race -count=10 -cpu=1,4,16 ./...;关键模块另外补专门的并发测试(起 N 个 goroutine 反复读写同一份状态)。
  9. -race 报出的”看起来无害”的竞争,一律修。 今天的无害,就是明天的内存破坏——编译器换版本、CPU 换架构,结论就会翻转。
  10. 最后、也是最高级的一问:能不能用”不共享”让这个问题消失? channel 传所有权、每个 goroutine 独占状态、只读数据一次构造到处读。最好的同步,是不需要同步。

最后一句狠的:并发 bug 的可怕,从来不在于它难修,而在于它在你机器上永远不会出现,在客户那里每次都出现。happens-before 是唯一能让你在”还没出事之前”就知道自己对不对的工具。用它,而不是用运气。


业界对照

Java 内存模型(JMM): 也有 happens-before 关系,规则和 Go 类似(锁、volatile、线程启动/join)。Java 的 volatile 提供可见性和顺序性,但没有原子性(volatile int i; i++ 依然不是原子)。Java 有完整的内存模型规范,比 Go 的表述更学术化。

C++ 内存模型: 最底层、最强大,也最难。C++ 的 memory_order(relaxed/acquire/release/seq_cst)让你精确控制每一条原子指令的内存序,性能上限最高,但用错就是最难查的 bug。Go 的 atomic 默认 seq_cst,是”用性能上限换简单安全”。

Rust: 靠所有权 + 借用检查,在编译期就禁止”无同步的共享可变访问”——Send/Sync trait 决定了类型能否跨线程共享。所以 Rust 的”数据竞争”大部分在编译期就被掐死了,这是比 Go 更彻底的路线(Go 要靠 race detector 运行时检测)。但 Rust 的 unsafe 仍可能引入竞争,且借用检查的心智成本更高。

Erlang: 进程间无共享内存,只有消息传递,所以”数据竞争”这个概念在 Erlang 的常规代码里根本不存在。这是”用无共享消灭竞争”的最彻底路线。


版本演进小结

  • 早期:Go 就有内存模型文档,但表述不够严谨。
  • 2022(Go 1.19):重写了内存模型规范,更精确地定义了 happens-before 关系,尤其细化了 atomic、sync 原语的语义。
  • 持续:race detector 不断增强检测能力。

主线:Go 的内存模型,用”happens-before”这一个核心概念,统一了 channel、锁、Once、atomic 等所有同步原语的可见性保证。它的哲学是”给你几条简单的规则,遵守了就能写对,不遵守就靠 race detector 抓你”。 理解 happens-before,是理解一切 Go 并发正确性的钥匙。


本章思考题

【思考题】

  1. 开篇那个例子,为什么 g 可能打印出 0 1(读到了 b=0 却读到 a=1)?是什么导致的?

  2. “可见性”和”原子性”有什么区别?为什么 64 位计数在 32 位平台上要用 atomic?

  3. race detector 的原理是什么?它为什么不能证明”没有竞争”?

  4. Go 1.19 之后的文档把 happens-before 拆成 sequenced before 和 synchronized before 两层。这两层分别指什么?happens-before 与它们是什 么关系?为什么要这样拆?

  5. 下面这段代码能保证打印 “hello” 吗?为什么?如果不能,该怎么改?

var a string
func hello() {
    go func() { a = "hello" }()
    print(a)
}
  1. “容量为 C 的 channel,第 k 次接收 synchronized before 第 k+C 次发送完成”——这条规则在 C = 0 时退化成什么?它如何解释 limit := make(chan int, 3) 限流惯用法的正确性?

  2. Go 里手写的双重检查锁(快路径无同步判空)为什么是错的?就算快路径读到了非 nil 指针,为什么仍可能看到未初始化的字段?为什么 sync.Once 就是对的?

  3. Go 的 atomic 操作默认是什么内存序?它同时保证了哪三件事?如果”一边用 atomic 写、另一边用普通读”,同步边还成立吗?为什么?

  4. 撕裂读在 Go 里有哪两个来源?为什么 map / slice / interface / string 上的竞争,比”读到一个旧值”严重得多?规范原文用了什么措辞?

  5. race detector 的性能开销主要来自哪几处?它有哪些固有盲区(至少列三条)?为什么”跑完 -race 全绿”不能证明没有竞争?

  6. 对比 Java 的 volatile、C++ 的 memory_order_relaxed、Rust 的 Send/Sync:它们分别在什么时机阻止数据竞争?Go 的选择又是什么?

【参考答案】

  1. 因为编译器和 CPU 都会重排指令。a = 1; b = 2 在单线程视角下结果不变,编译器或 CPU 就可能把两条写重排成”先 b 后 a”;而 g 的两次读也可能乱序。于是 g 可能先观察到 b 的旧值 0,再观察到 a 的新值 1,打印 0 1。无同步的共享内存访问,行为是未定义的,任何输出都可能。

  2. 可见性管”写能不能被另一个 goroutine 看到”(happens-before 保证);原子性管”操作是否不可分割”。它们是独立的。32 位平台上,64 位变量的读写可能被拆成两次 32 位操作,另一个 goroutine 可能读到”一半新一半旧”的撕裂值——所以 64 位计数要用 atomic.AddInt64,既保证原子(不撕裂),又保证可见(正确内存序)。

  3. race detector 在运行时记录每个内存访问的逻辑时钟(基于 happens-before),当发现”两个 goroutine 访问同一地址、至少一个写、且无 happens-before 关系”时报告竞争。它只能检测”实际执行到的路径”,如果测试没跑到某条路径就检测不到,所以它不能证明”没有竞争”,只能证明”这次运行没发现竞争”。因此要配足够的并发测试覆盖。

  4. 终极标准是:看这两个访问之间是否存在 happens-before 链。如果存在(比如通过同一个锁、同一个 channel、同一个 Once 建立了顺序),就安全;如果不存在(两个 goroutine 无同步地访问同一地址、至少一个写),就是数据竞争、行为未定义。happens-before 是唯一的”可见性裁判”。

  5. sequenced before 是单个 goroutine 内部、由 Go 语言规范的控制流结构与表达式求值顺序决定的先后关系(程序顺序);synchronized before 是”一个同步读操作 r 观察到了某个同步写操作 w”时,w 与 r 之间的关系(W(r) = w)。happens-before 是这两者并集的传递闭包。

    拆成两层的意义在于:程序顺序只在单个 goroutine 内有意义,跨 goroutine 的顺序无法凭空产生,只能靠”观察”建立——A 写了、B 看到了,A 才算在 B 之前。一句话:没有观察,就没有顺序。 拆开表述,才能精确地把”语言规范给的局部顺序”和”同步原语给的跨 goroutine 顺序”区分开,再取闭包得到全局偏序。

  6. 不能保证。 因为规则明确写着:goroutine 的退出不保证 synchronized before 程序中的任何事件。go 语句只保证”启动前的东西对子 goroutine 可见”,反方向(子 goroutine 的写对父 goroutine 可见)没有任何保证;文档甚至说激进的编译器可以直接删掉整个 go 语句。

    要观察子 goroutine 的结果,必须显式建立同步边,比如:

    • channel:done := make(chan struct{}); go func(){ a = "hello"; close(done) }(); <-done; print(a)(close synchronized before 那个返回零值的接收);
    • WaitGroup:wg.Wait()(它的同步保证写在自己的文档里);
    • Mutex:mu.Lock() / mu.Unlock() 建立 Unlock → Lock 的边。
  7. C = 0 时退化为”第 k 次接收 synchronized before 第 k 次发送完成”——也就是无缓冲 channel 的反向边(接收同步于发送完成)。无缓冲 channel 是一次 rendezvous,双方会合后才各自放行,所以发送→接收完成、接收→发送完成两条边都有;而缓冲 channel 只有发送→接收完成这一条,反向边要”等缓冲区腾出位置”才成立,于是就有了 k+C 这个偏移。

    限流惯用法的正确性:limit <- 1 占名额、w() 干活、<-limit 还名额。因为第 k 次 <-limit synchronized before 第 k+3 次 limit <- 1 完成,所以第 4 个 goroutine 必须等到前面某一次接收腾出位置——并发被限制在 3。同时,这条边还顺带保证了上一个占用者在 w() 里的所有写,对下一个拿到名额的 goroutine 可见。限流的同时白送了可见性,这就是用 channel 建模信号量比”计数器 + if”高明的根本原因。

  8. 两层错误:

    • 第一层:快路径的 if instance == nil 是无保护的读,它和另一个 goroutine 的 instance = newConfig() 之间没有任何同步操作,不存在 happens-before 边——这就是数据竞争,-race 必然报。
    • 第二层(更隐蔽):就算读到了非 nil,也不安全。因为这个非 nil 是”碰巧看到”,不是同步边带来的。Unlock → Lock 这条边只在慢路径上建立过,而你这个 goroutine 走的是快路径,根本没碰过那把锁。于是 newConfig() 里对各字段的写对你不保证可见(可能 instance != nil 但 Timeout == 0),而且编译器/CPU 完全可能把 instance = c 重排到字段初始化之前,让你拿到一个未初始化的对象。Go 里指针是单字写不会撕裂成野地址(所以不会 segfault),但你会静默地使用一份错误配置——这比崩溃更糟。

    sync.Once 为什么对?因为规则是”once.Do(f) 中那次 f 的完成,synchronized before 任何 once.Do(f) 的返回”——“任何”包括那些走快路径直接返回的 goroutine。Once 内部正是用 atomic 做快路径探测 + Mutex 做慢路径互斥:它和手写 DCL 的唯一区别,就是快路径上那个”探测”是带同步语义的 atomic 读而不是裸读。就这一个差别,快路径也接上了同步边。

    一句话:DCL 的错误不是”少加了一层判空”,而是”快路径上不存在同步事件,因而不存在 happens-before 边”。 Go 里惰性初始化一律用 sync.Once(或两端都用 atomic.Pointer[T]),手写 DCL 视为错误。

  9. Go 的 atomic 是 sequentially consistent(顺序一致,seq_cst):程序中所有 atomic 操作表现得好像按某个全局一致的顺序执行;并且如果 A 的效果被 B 观察到,A synchronized before B。文档明确说这个语义等同于 C++ 的 seq_cst atomics 和 Java 的 volatile 变量。

    它同时保证了三件事:原子性(对那一个变量不可分割,不撕裂、不丢失更新)、可见性(被观察到即建立同步边)、有序性(seq_cst 全局单序)。

    “一边 atomic 写、另一边普通读”——边不成立。 因为 synchronized before 的定义是”一个同步读观察到一个同步写”,两端都必须是同步操作。一端裸奔,就没有任何排序承诺,竞争依旧。同理,反过来 atomic 读 + 普通写也不成立。同步边是双向匹配的,不是单方面的声明。

  10. 两个来源:

    • 32 位平台上的 64 位变量:一次 64 位读写可能被拆成两条 32 位指令,另一个 goroutine 读到”一半新一半旧”的值(一个从未被写过的数)。连带还有对齐问题:Go 只保证”变量、或已分配的 struct/array/slice 的第一个字”是 64 位对齐的,所以要用 Go 1.19 的 atomic.Int64(内部带对齐字段)或把 64 位字段放首位。
    • 多字数据结构(struct / interface / slice / string / map):规范明确说,大于机器字的读写,实现可以出于性能考虑拆成多个机器字操作,顺序未指定,于是可能读到”不对应任何单次写”的组合值——比如 interface 的新类型指针 + 旧数据指针、slice 的新指针 + 旧长度。

    严重性在于:这些值内部依赖 (指针, 长度) 或 (指针, 类型) 的配对一致性,一旦配对错位,紧接的就是越界读写、类型混淆、顺着野指针访问已释放内存。规范原文用的措辞是:这种竞争”可以进而导致任意内存破坏(arbitrary memory corruption)“。所以”读到旧值”已经是最好的结局——真实后果是崩溃栈出现在 runtime.mapassign 里、死循环、莫名 OOM,而现场离真正的 bug 有十万八千里。

  11. 开销来源:① 每条内存访问都被插桩(查 shadow cell、比较向量时钟、可能记栈),一次 x++ 从一两条指令膨胀到几十条;② shadow memory 本身是应用内存的数倍(每 8 字节配若干条访问记录);③ 每次 goroutine 创建和同步操作都要维护/合并向量时钟;④ 抑制内联并禁用一批编译器优化。官方口径:典型程序内存涨 5–10 倍、时间涨 2–20 倍。

    盲区(任举三条即可):① 只检测跑到的路径——没执行的分支、没触发的重试/兜底逻辑、特定配置才走的代码都测不到;② 只检测实际交错出来的竞争——两处访问即使存在竞争,若本次运行没交错到一起也记录不到,所以要靠 -count=N、-cpu=1,4,16、足够多的 goroutine 和循环来提高概率;③ shadow cell 数量有限(每 8 字节只保留最近若干条记录),更早的访问被挤掉就漏报;④ cgo 的 C 代码、汇编、未用 -race 重编的依赖不在插桩范围;⑤ 可追踪的 goroutine 数量有上限。

    为什么全绿不能证明没竞争:因为 -race 是动态检测,它只能证明”这次运行、这些路径、这些交错下没发现竞争”,无法穷举所有路径和时序。它是证伪工具,不是证明工具。

  12. 语言阻止时机具体机制
    Java运行期(靠规范 + 人正确理解)volatile 提供可见性 + 顺序性(写 release / 读 acquire)但不给原子性(volatile int i; i++ 照样丢更新,要用 AtomicInteger / LongAdder);另有 final 字段的 freeze 语义,保证正确构造对象的 final 字段对其他线程可见
    C++运行期(最晚、最难复现)六档 memory_order:relaxed 只保原子性、不保任何顺序(纯计数器),acquire/release 成对形成单向边(x86 上几乎免费),seq_cst 再加全局单序。选错就是最难查的 bug,常只在弱序架构暴露
    Rust编译期Send(所有权可跨线程转移)+ Sync(&T 可跨线程共享)是 auto trait,编译器自动推导;safe Rust 里数据竞争不可能存在,共享可变必须显式 Arc<Mutex<T>> / channel / Atomic*。unsafe 里所有 C++ 的坑回归
    Go运行期(测试时,靠工具)happens-before 规则(程序顺序 / go 语句 / channel / 锁 / Once / atomic / finalizer)+ race detector

    Go 的选择:把内存序档位焊死在 seq_cst(不暴露 relaxed/acquire-release),用性能上限换”用 atomic 基本不会错”;同时把正确性发现机制从编译期/规范挪到工具——赌 race detector 足够好用、足够便宜,绝大多数团队就能写对并发代码,而不需要人人都是内存模型专家。代价是编译期不拦任何东西,必须靠测试覆盖。

    对你的实际意义只有两条:把 -race 变成默认动作;看到竞争报告时能立刻画出那条缺失的 happens-before 边,并知道该用 channel、锁、Once 还是 atomic 补上它。