跳转至

写了不等于看得见:内存屏障与 Go 内存模型

前一篇的 SPSC 队列里有这么两行:

q.buf[head&(int64(len(q.buf))-1)] = e // 先写数据
atomic.StoreInt64(&q.head, head+1)    // 后发布

注释写着"先""后",但落到汇编层面,真的先写数据后写 head 吗?消费者凭什么保证:看到 head 前进之后,读槽位就一定能读到刚写的数据?这两行代码的全部信用,抵押在一个源码里看不见的东西上—— 内存模型。这篇把它赎回来。

1. 两个重排者:编译器与 CPU

单线程世界有一份温柔的契约:as-if-serial——编译器和 CPU 随便重排指令,只要最终结果等价于顺序执行。乱序执行(谁的操作数就绪谁先走)、编译器指令调度,都在这份契约下合法营业。

多线程把契约撕了:一个执行流开始观察另一个执行流的 中间状态。经典双线程例子:

var data  int  // 初始 0
var ready bool // 初始 false

// goroutine A:先备数据,再插旗
data = 42       // ① 写数据
ready = true    // ② 插旗

// goroutine B:看到旗子,再去取数
if ready {          // ③ 读旗
    println(data)   // ④ 取数
}

直觉的推理链有三环:② 排在 ① 之后(A 自己的顺序)→ ③ 能读到 ②(跨核的可见性)→ ④ 因此读到 ①(顺序的传递)。链条不断,B 打印 42。实际上打印 0 的路径至少有三条——前两条断在第一环,第三条断在第二环:

  1. 编译器重排:①② 互换。两行没有数据依赖,交换后单线程结果完全等价,as-if-serial 契约下合法
  2. CPU 重排:ARM 这类弱序架构只尊重显式依赖,② 的 store 可以先于 ① 提交
  3. 传播乱序:①② 在 A 核上确实按序执行了,但落在两条缓存行上,传播到 B 核的先后无保证——B 先看见旗子、后看见数据(x86 的 TSO 保证同核 store 按序可见,这条路在 x86 上堵死,ARM 上敞开)

殊途同归:③ 读到的是新旗子,④ 摸到的是旧数据——B 打印 0。

第 3 条的元凶是 store buffer:每个核有一条自己的写队列,写操作进队列就立刻返回,核心接着跑后面的指令。自己读自己刚写的值有 store forwarding 兜底,别的核则要等它刷进缓存体系——"写了"和"别人看得见"之间隔着一道传播距离。

Tip

一句话概括:单线程的正确性由 as-if-serial 兜底,多线程的正确性没人兜底——除非你自己建立同步点。

2. 硬件的两副面孔:x86 TSO 与 ARM 弱序

重排不是法外之地,每个架构有自己的宪法。x86 是商用主流里最严格的:TSO(Total Store Order),四类基本重排只放行一类——StoreLoad,一条读可以越过自己前面的写(因为写还躺在 store buffer 里没落地)。Sewell 等人的 x86-TSO 论文给了这套模型的形式化描述。

ARM 和 POWER 是弱序架构:四类基本全放,只尊重数据依赖、地址依赖这类显式链条。同一份 Go 代码,两种硬件给出的"合法执行"集合天差地别。

对写无锁代码的人,这带来一个阴险的事实:x86 上 plain store 天然带 release 语义、plain load 天然带 acquire 语义——这两样在 x86 上是免费的。于是"忘了加同步"的代码在 Intel 机器上常常恰好正确,迁到 ARM(Graviton、鲲鹏、手机)才炸。想知道弱序硬件到底怎么坑你,Paul McKenney 的 Memory Barriers: a Hardware View for Software Hackers 是最佳入门。

Warning

在 x86 上跑对的无锁代码不算对——它可能只是被 TSO 顺手保了。编译器重排和 ARM 弱序随时收回这份运气。

3. Go 怎么说:内存模型与 sync/atomic

Go 内存模型回答的正是"什么时候允许读到什么"。Go 1.19 把它重写了一遍(2009 年以来头一次),语义对齐 C++20 的 seq_cst 和 Java 的 volatile。两条核心规则:

  1. 没有 happens-before 排序的读写,读到什么不作保证——读到旧值是合法的
  2. sync/atomic 的操作是顺序一致的,并且是同步事件,建立 happens-before

什么算"不作保证"?看官方文档里那个著名的死循环:

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
}

func main() {
    go setup()
    for !done { // 编译器有权只读一次:done 在循环里只读不写,值"不会变"
    }
    print(a)
}

编译器把 done 提升出循环、缓存进寄存器,循环被优化成"判断一次,然后原地转"。结果 main 永远出不来。这不是编译器 bug——普通读在存在竞争时,就是这个语义。加了同步点,一切改观:

var a string
var done atomic.Bool

func setup() {
    a = "hello, world"
    done.Store(true) // release:a 的写不许沉到这之后
}

func main() {
    go setup()
    for !done.Load() { // acquire:同步点,禁止缓存、禁止提升
    }
    print(a) // 必然打印 "hello, world"
}

Load 不只是"原子地读一个 bool":它是同步事件,编译器被禁止缓存它,硬件被强制在它周围插屏障,语义上它和 Store 建立 happens-before,并且靠传递性把前面的 a = "hello, world" 一起锚定。

Tip

在 Go 里,atomic 的本质不是"读写不出错",而是跨 goroutine 的 同步点:编译器层面禁缓存禁重排,硬件层面保证传播,语义层面建立 happens-before。

4. 回到 SPSC:逐行找屏障

现在可以逐行标注 SPSC 那篇的代码了(mask 为省写):

// 生产者
q.buf[head&mask] = e                // ① 普通写
atomic.StoreInt64(&q.head, head+1)  // ② release:① 不许沉到 ② 之后

// 消费者
if tail == atomic.LoadInt64(&q.head) { // ③ acquire:④ 不许提到 ③ 之前
    ...
}
e := q.buf[tail&mask]               // ④ 普通读

配对效果:消费者一旦在 ③ 看到 head 前进,④ 读到的 必然 是 ① 写入的数据——不是"大概率",是内存模型层面的保证。反向同理:消费者读完才 Store tail+1,生产者判满时 Load tail,同一套 release/acquire 再演一遍,保住"槽位释放晚于数据读走"。

把 atomic 换成普通访问,三个翻车现场立刻就位:

  1. 死循环:Pop 的重试循环里普通读 head,编译器提升出循环——队列明明有数据,消费者永远报空。§3 那个例子的队列版
  2. 读到垃圾:ARM 上 ①② 重排,head 先走、数据后到——消费者读到一个"已发布"的空槽
  3. 撕裂读:32 位平台 int64 的普通读写是两条指令,可能读到高位新值、低位旧值拼成的怪物

顺手回答一个隐藏问题:为什么 x86 上"忘了 atomic"的 SPSC 经常也能跑?因为 TSO 顺手保了 ② 的顺序与传播,编译器恰好又没做致命优化。但这是运气不是保证——换编译器版本、换平台,随时收回。

5. 性能的账:屏障代价与 false sharing

同步不是免费的,价目表大致是:普通 load/store 一个周期上下;x86 的 release/acquire 就是普通指令,免费;全屏障(mfence、dmb)几十个周期;带 lock 前缀的 CAS 二十个周期上下,还要独占缓存行。SPSC 在 x86 上快,正因为它的同步全部落在免费区。

但屏障之外还有一笔账:false sharing。MESI 缓存一致性协议保证所有核最终看到同一份内存,可它的粒度是 64 字节的缓存行,不是变量:

type Counters struct {
    a int64
    b int64 // 和 a 挤在同一缓存行
}

// 两个 goroutine 分别原子自增 c.a 和 c.b
// 数据层面无竞争,物理层面同一行在两个核之间弹跳

修复是补齐:

type Padded struct {
    v int64
    _  [56]byte // 补齐到 64B 独立缓存行
}

或者直接用 x/sys/cpu 的 CacheLinePad。实测差距常见 2~4 倍,核越多越惨。修复那篇的 cell 结构里那坨 [56]byte 干的就是这件事——当时只说了"要做",这里补上了"为什么"。

6. -race 是怎么抓到你的:vector clock

go test -race 底层是 ThreadSanitizer v2,它给内存模型做运行时记账:

  1. 每个 goroutine 持有一只 vector clock(向量时钟),自己那个分量随同步事件递增
  2. 每个内存字有影子内存,记录它最后一次读、写各自的时钟
  3. mutex / channel / atomic 这些同步点上时钟互相合并——release 把自己的时钟写出去,acquire 把对方的时钟并进来
  4. 一次访问若发现:同一地址的两次访问时钟互不包含(谁也没 happens-before 谁),且至少一次是写——报告竞争

这也解释了 07-1 里那句"路线一、二都能过 -race":原子访问本身就是同步事件,天然建立了 happens-before,不会误报。反过来,锁罩住的普通读写同样不报——锁的获取释放合并了两边的时钟。

代价:内存 5~10 倍,CPU 2~20 倍。实践策略是 CI 全量常开,生产按需灰度(race 版二进制可以直接上线采样低流量实例)。

Warning

-race 只报 跑到的交错。测试没覆盖的路径,对它来说不存在——它是查漏的探照灯,不是正确性的证明。

7. 开发者的定心丸:独占,或看守

读到这里容易心慌:编译器会重排、CPU 会重排、传播还会乱序——难道每次读写共享变量,都要在脑内过一遍这三层?不用。你执行的是 纪律,不是 机制;机制是纪律背后的信用背书。

基本原则只有一条:一个变量只有两种合法状态——独占(只有一个 goroutine 碰它),或看守(所有访问都过同一个同步原语)。没有第三态。

"所有地方都可能埋着屏障"的恐惧,来自"任意两条指令都可能重排"的直觉——那是天文数字级的检查量。但内存模型的存在意义,恰恰是把这片无限空间压缩成有限的几条同步边:

  1. 同步边围出的区间内:as-if-serial 兜底,单线程思维,一个重排都不用想
  2. 同步边上:一条 release→acquire 是 批发 担保——边之前的全部写,对边之后的全部读可见,一条边一次性定序无穷多指令对

思考量等于你设计的同步边条数(个位数),不是指令对数(天文数字)。你推理的对象是 happens-before 这层契约,屏障只是它的实现——就像调 HTTP 不用考虑底下 TCP 的拥塞控制。

落到实操,所有共享访问只做一道判定题:

这次写和那次读之间,有没有同步边?(Lock/Unlock、send/recv、atomic 的 Store/Load、WaitGroup 的 Done/Wait)

有 → 有序;没有 → 数据竞争,改到有为止。这道题是局部的、二值的、可判定的,不需要全局推理。-race 干的就是把这道题自动化——§6 的 vector clock,算的就是这条边存不存在。

看守三选一,选定后全程序只用这一种:

  • Mutex:罩住一个不变式——一组必须一起变的变量。锁罩的是不变式,不是一条语句
  • atomic:变量自己是看守。只做标志位、计数器、指针发布
  • channel:传所有权,发出去之后原 goroutine 不再碰这块内存
// 独占:channel 传的是所有权,发出去之后不再碰
ch <- result

// 看守:Mutex 罩住不变式——balance 和 entries 必须一起变
mu.Lock()
acc.balance -= amt
acc.entries = append(acc.entries, entry)
mu.Unlock()

// 看守:atomic,变量自己是看守
atomic.AddInt64(&count, 1) // 计数器、标志位、指针发布

初始化后只读的共享,连看守都不用——发布那一刻过一次同步点,之后随便读。

顺带一个安慰:这已经是被 Go 削过的难度。C++ 要在 memory_order_relaxed / acquire / release / seq_cst 里五选一,选错照样炸;Go 砍成了"用不用 atomic"的二选一。

Tip

一句话概括:独占,或看守。无锁代码是这条原则的严格版——唯一写者 + 同步点锚定顺序;日常开发是宽松版。原则从没变过,变的只是你愿意为性能把多少区域划成"独占"。

小结

至此系列公式补全:无锁正确性 = 唯一写者 + 同步点锚定顺序 + 缓存行隔离。第一篇给了纪律,第二篇给了修复,这篇给了地基——前两篇里每一行 atomic,编译器和硬件都在按这份契约干活。

一句话概括:原子操作不是"让一次读写不出错"的指令,它是两个执行流之间唯一合法的通信语言——既传值,也传顺序。你在 goroutine 之间说的每一句话,都得用它。

下一篇预告:标准库的 channel 反其道而行——一把 hchan.lock 罩住整个通道。Go 团队为什么不追求无锁?拆源码见分晓。


最后更新:2026-09-08

评论