重排的三个来源:编译器、CPU、缓存系统
单线程视角下「程序按代码顺序执行」是幻觉,三层优化都在重排内存操作:
- 编译器重排:在不改变单线程语义的前提下,编译器可以移动读写(指令调度、寄存器分配)。
volatile在 C/C++ 里只禁止编译器重排,不生成任何 CPU 屏障——这是 C/C++ 程序员最著名的误用。 - CPU 乱序执行:处理器把指令拆成微操作并行执行,访存指令的结果可能乱序可见(store buffer、投机执行)。x86 只重排「写-读」对(store-load),ARM/POWER 几乎全序可重排。
- 缓存系统的可见性延迟:MESI 协议(第 18 篇)保证最终一致,但一个核的写入到达另一个核是有延迟的——store buffer 让「我写了」和「别人看到」之间存在时间窗。
MESI 不解决顺序问题是理解本篇的钥匙:MESI 保证的是缓存行的所有权一致(不会两个核同时持有 Modified),而「A 核先写 x 后写 y,B 核是否可能看到 y 更新了而 x 还没更新」——答案是可能(写进 store buffer 的顺序、失效消息的到达顺序都不受 MESI 约束)。顺序保证需要额外的指令:内存屏障。
屏障的四种类型与 x86/ARM 的差异
屏障按「禁止哪种重排」分类:
| 类型 | 语义 | x86 对应 | ARM 对应 |
|---|---|---|---|
| StoreStore | 前面的写先于后面的写对其他核可见 | 通常无需 | dmb ishst |
| LoadLoad | 前面的读先于后面的读完成 | 通常无需 | dmb ishld |
| LoadStore | 前面的读先于后面的写 | 通常无需 | dmb ish |
| StoreLoad(全屏障) | 前面的写全局可见后才执行后面的读 | mfence |
dmb ish |
x86 的强内存模型(TSO)免费提供前三类,只缺 StoreLoad——所以 x86 上无锁代码往往「恰好能跑」(DPDK 老代码常见这种幸运),移植到 ARM(含 Apple Silicon、AWS Graviton、国产 ARM 服务器)立刻出问题。「x86 上测过的无锁代码,不代表正确」是跨平台并发编程的第一戒律。Linux 内核的 smp_mb()/smp_rmb()/smp_wmb() 宏按架构展开成对应指令,就是为这个问题统一语义。
acquire/release:比全屏障便宜的定向保证
全屏障(mfence)贵(几十个周期 + 阻塞流水线),大多数场景只需要定向保证,这就是 C++11 引入的 memory_order 体系:
- release 写(
memory_order_release):本次写之前的所有读写,不得重排到本次写之后。语义:「发布」——先把货备齐(数据),再举牌宣布(标志)。 - acquire 读(
memory_order_acquire):本次读之后的所有读写,不得重排到本次读之前。语义:「接收」——看到举牌(标志)后,保证能拿到备好的货。
acquire-release 配对构成「同步于」(synchronizes-with)关系:release 写与读到该值的 acquire 读之间,发布者屏障之前的所有写入,对接收者可见。这是无锁数据结构正确性的标准配方,比全屏障省一半以上的开销。
// SPSC 环形队列:屏障该放哪的最小演示
// 生产者
buffer[head] = data; // ① 写数据
atomic_store_explicit(&seq[head], 1,
memory_order_release); // ② release:数据先于标志可见
head++;
// 消费者
if (atomic_load_explicit(&seq[tail],
memory_order_acquire)) { // ③ acquire:看到标志才读数据
use(buffer[tail]); // ④ 保证能读到 ① 的数据
tail++;
}
把 ② 写成普通写(或 C 的 volatile),在 ARM 上 ① 可能被重排到 ② 之后——消费者看到标志位翻转、读到的却是垃圾数据。屏障的位置不是装饰,是「数据先行于标志」这条契约的执行机制。
正反对照:同一个队列,两种命运
坏版本(x86 上侥幸能跑,ARM 上必坏):所有标志用普通读写,注释「MESI 会保证一致」——犯了本篇开篇的错误。症状是概率性的:压测几小时偶发校验和错误、数据错位,且复现依赖流量形态。这类 bug 的排查难度在于它不在逻辑里,在内存序里——逻辑复现器永远跑不出来,只有时序敏感的压测能触发。
好版本的三条纪律:
- 共享的每个「标志位」都过原子操作,语义选 acquire/release(单标志传递)或 seq_cst(多标志纠缠,保守但贵)。
- 数据结构与标志的写入顺序在代码里显式成立,靠屏障语义而非「看起来先写」。
- 跨平台测试:ARM 机器(或 QEMU 用户态模拟)跑并发压测——x86 绿灯不等于发布绿灯。
工程上的务实建议依然成立(第 16 篇说过):无锁是最后手段。互斥锁的 uncontended 路径已经只有几十纳秒(futex 用户态快路径),先确认锁竞争真实存在(perf lock/futex 系统调用占比),再考虑无锁——把「消灭锁」当信仰的团队,最后都在内存序的坑里把省下的性能赔了回去。
各语言/平台的对应速查
| 层级 | release/acquire | 全屏障 | 备注 |
|---|---|---|---|
| C++/C11 | memory_order_release/acquire |
memory_order_seq_cst / atomic_thread_fence |
volatile ≠ 原子 |
| Java | volatile 写/读(JMM 定义 acquire/release 语义) |
VarHandle.fullFence() |
volatile 还保证可见性 |
| Go | sync/atomic 各操作默认 seq_cst |
atomic.Store 家族 |
Go 1.19+ atomic.Int64 类型化 |
| Linux 内核 | smp_store_release/smp_load_acquire |
smp_mb() |
WRITE_ONCE/READ_ONCE 防编译器 |
| Rust | Ordering::Release/Acquire |
Ordering::SeqCst |
与 C++ 同源 |
Java 的 volatile 值得单独说明:它是 JMM(Java 内存模型)定义的 acquire/release + 可见性保证,比 C 的 volatile 强得多——Java 程序员没有「volatile 只是防编译器」的坑,但有「volatile 数组只保证引用不保证元素」的新坑(元素要用 AtomicIntegerArray/VarHandle)。
一个真实的并发 bug:双重检查锁定的内存序
用本篇的知识解剖一个跨语言流传最广的并发 bug:双重检查锁定(double-checked locking)的懒加载单例。Java 5 之前的错误写法:先无锁读引用判空,为空则加锁、再判空、构造并赋值。看似严谨,坏在构造的写与引用赋值的写可能被重排——另一个线程在第一次无锁读时看到「引用非空」,但对象字段还没构造完(部分初始化),用到一个半成品对象。x86 上因强内存模型(只重排 store-load)这个 bug 恰好不容易触发,老项目里大量潜伏;ARM 机器普及后(手机、服务器)开始成批爆雷。Java 5 的修复有两条正路:volatile 声明引用(JMM 定义 volatile 写为 release、读为 acquire,部分初始化的可见性被屏障封死)或类初始化机制(JVM 的类加载锁自带内存序,holder 模式零成本)。C++11 的修复是 static 局部变量的线程安全初始化(语言标准保证内存序,编译器帮你插屏障)或 atomic 加 acquire/release 的显式版本。这个 bug 的教学价值在于三点:其一,「看起来正确的顺序」不是执行顺序,重排发生在编译器与 CPU 两个看不见的层面;其二,平台迁移是并发 bug 的显影液,x86 的侥幸不等于正确,跨平台测试是并发代码的必选项;其三,最贵的修复是免费的抽象——语言级保证(Java holder、C++ static)比手工屏障又快又对,并发正确性的最优解常常是「让语言运行时替你做」,手写无锁永远排在最后。
与本系列其他章节的勾连
本篇是第 18 篇(MESI 与伪共享)的正确性续篇:伪共享是性能病,重排是正确性病,同根于缓存体系。第 16 篇共享内存「同步自建」的警示,答案就是本篇的 acquire/release。第 26 篇 NAPI 的环形缓冲、第 27 篇 skb 的队列、第 5 篇 epoll 的就绪链表,内核里到处是本篇配方的实例(内核用 smp_store_release 写、smp_load_acquire 读的环形生产消费指针)。理解了它,第 13 篇 io_uring 的提交/收割环为什么能安全地无锁工作,也就有了完整的地基。下一篇转向系统的起点:从按下电源到 systemd 就绪的完整启动链路。
内存序与 GC 的交叉:为什么托管语言也要懂屏障
托管语言的运行时把内存屏障埋在 GC 与同步原语里,应用开发者以为不需要懂——三个反例说明为什么仍然要懂。反例一:volatile 数组的误解(正文提过)在生产代码里的变体是「final 数组」——Java 的 final 字段有安全发布保证,但 final 数组只保证引用的可见性,数组元素的修改没有任何屏障语义,跨线程改元素照样需要 AtomicIntegerArray 或 VarHandle——「final 加上了所以线程安全」是真实出过线上事故的误读。反例二:GC 的写屏障成本——分代 GC(G1、ZGC)在引用赋值处插入写屏障(记录跨代引用),这段屏障代码对所有引用写生效:高频写引用的循环里,GC 写屏障可能吃掉 10-20% 的 CPU——perf 火焰图里出现 G1 的 write barrier 模板时,优化方向不是去动屏障(动不了),而是减少引用写(对象池、值类型/数组的 struct 化)。反例三:Unsafe 与 VarHandle 的手工屏障——某些高性能库(Netty、Agrona)用 VarHandle 的 getAndAdd/setRelease 手工编排内存序,它们的正确性依赖使用者理解 acquire/release(正文的配方),这些库的 javadoc 里的内存序注释是使用契约的一部分,跳过注释的「能跑就行」用法在 ARM 上会炸。三个反例的共同点:托管语言把屏障的正确性默认值调好了,但性能敏感路径的每一步都还在与内存序打交道——懂屏障不是为了手写它,是为了读懂运行时与库的性能模型,在正确的层面做优化。
给写代码的人:三条内存序的日常纪律
把本篇收成三条可执行的日常纪律。纪律一:共享可变状态的每一次跨线程访问都要能回答「谁为它付了屏障钱」——是语言关键字(volatile/atomic)、是锁、还是显式的 memory_order;回答不出来就当它没同步,bug 只是还没排期。纪律二:跨平台发布前,并发密集模块必须在弱内存序架构(ARM)上跑过并发压测——x86 的绿灯只证明「这台机器上暂时没炸」,CI 里加一台 ARM runner 的成本远低于一次线上偶发数据损坏的排查。纪律三:新代码先锁后优化—— uncontended 锁的路径已经只有几十纳秒,先用锁写对,用 perf 锁分析(perf lock contended)确认竞争真实存在且集中在哪个结构,再对该结构做无锁化;顺序反了的团队,通常同时收获两个版本的 bug。内存序知识的最优用法不是天天写屏障,而是在 code review 里认出「这个共享变量没有任何同步护航」的瞬间——那一眼就是本篇全部内容的价值变现。
小结
内存操作的重排来自编译器、CPU 乱序与缓存可见性延迟三层,MESI 保证所有权一致但不保证顺序——顺序必须由屏障指令显式购买。x86 的强模型免费提供大部分顺序(只缺 StoreLoad),ARM 的弱模型让「x86 上侥幸能跑」的代码跨平台爆雷,这是无锁代码必须跨平台测试的根本原因。acquire/release 是性价比最高的定向保证:「数据先于标志可见」的发布-接收契约是无锁数据结构的标准配方。seqlock 与 RCU 把读侧成本压到零,是内核读多写少场景的两大范式。纪律总结:先确认锁竞争真实存在,再考虑无锁;无锁代码的每个共享标志位都要过原子操作,内存序语义显式声明。
读者留言
COMMENTS 暂无还没有留言,来说第一句?