AI 资讯 知识库 后端技术操作系统内功 · 第 32 篇|内存屏障与无锁编程:为什么你的无锁队列会坏

操作系统内功 · 第 32 篇|内存屏障与无锁编程:为什么你的无锁队列会坏

重排的三个来源:编译器、CPU、缓存系统

单线程视角下「程序按代码顺序执行」是幻觉,三层优化都在重排内存操作:

  1. 编译器重排:在不改变单线程语义的前提下,编译器可以移动读写(指令调度、寄存器分配)。volatile 在 C/C++ 里只禁止编译器重排,不生成任何 CPU 屏障——这是 C/C++ 程序员最著名的误用。
  2. CPU 乱序执行:处理器把指令拆成微操作并行执行,访存指令的结果可能乱序可见(store buffer、投机执行)。x86 只重排「写-读」对(store-load),ARM/POWER 几乎全序可重排。
  3. 缓存系统的可见性延迟: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 的排查难度在于它不在逻辑里,在内存序里——逻辑复现器永远跑不出来,只有时序敏感的压测能触发。

好版本的三条纪律:

  1. 共享的每个「标志位」都过原子操作,语义选 acquire/release(单标志传递)或 seq_cst(多标志纠缠,保守但贵)。
  2. 数据结构与标志的写入顺序在代码里显式成立,靠屏障语义而非「看起来先写」。
  3. 跨平台测试: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 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?