Skip to content

内存屏障

内存屏障(Memory Barrier)是并发编程中最反直觉的机制——它的存在源于一个事实:代码的执行顺序和编写顺序可以不同。编译器会重排指令(在保证单线程语义的前提下),CPU 会乱序执行(在保证单核可见一致的前提下)。单线程视角下这些重排无害;多线程视角下,另一个线程观察到的事件顺序可能与编写顺序矛盾。内存屏障的作用就是在指定的点上禁止这种重排——它告诉编译器和 CPU:"这条线不能越过去"。

为什么需要内存屏障

一个经典的失败案例是双检锁(Double-Checked Locking):

c
// 线程 A:初始化
obj = malloc(sizeof(Obj));
obj->data = 42;          // ① 写 data
initialized = true;      // ② 写标志

// 线程 B:使用
if (initialized) {       // ③ 读标志
    use(obj->data);      // ④ 读 data——期望看到 42
}

单线程视角下 ①②③④ 的顺序天经地义。但在没有屏障的并发环境中,两个层面都可能破坏它:编译器可能把 ② 重排到 ① 之前(它不知道其他线程依赖这个顺序——单线程语义中两行互不相关);CPU 的存储缓冲可能让 ② 先于 ① 对其他核心可见(store buffer 是异步的,不同地址的写可以乱序到达内存系统)。线程 B 于是可能看到 initialized == truedata != 42——这就是著名的 DCLP 问题。

内存屏障解决的正是这个问题:在 ① 和 ② 之间放一个 release 屏障(保证之前的写对之后的写可见性有序),在 ③ 和 ④ 之间放一个 acquire 屏障(保证之后的读不被重排到之前的读前面)。两个屏障配合,① 先于 ② 可见、③ 先于 ④ 读取——顺序得到保证。

屏障的语义分类

内存屏障按约束强度分为几类,理解它们需要先理解"重排"的两种来源和"可见性"的两个方向。

编译器屏障(Compiler Barrier):只约束编译器的指令重排——asm volatile("" ::: "memory") 告诉编译器"内存可能被修改了,不要跨越这行做重排和缓存优化"。它不产生任何 CPU 指令——对 CPU 的乱序执行零约束。编译器屏障的典型用途是内核中"关闭抢占后访问共享数据"的场景。

CPU 屏障:约束 CPU 的乱序执行和存储缓冲的提交顺序。x86 上主要是三种:sfence(store fence,之前的写必须先完成)、lfence(load fence,之后的读必须等待之前的读完成)、mfence(全屏障,读写都约束)。ARM 上对应 DMB(Data Memory Barrier)指令族。

语义屏障(Acquire/Release):高级语言和内存模型层面的抽象——"acquire 操作后的所有读写不会被重排到 acquire 之前"、"release 操作前的所有读写不会被重排到 release 之后"。语义屏障不指定具体的 CPU 指令——编译器根据目标架构选择合适的指令(x86 上 acquire/release 可能零成本,因为 x86 的硬件模型本身较强;ARM 上需要显式的 DMB)。Rust 的 Ordering::Acquire/Release、C++ 的 memory_order_acquire/release 都是语义屏障。

x86 与 ARM 的内存模型差异

理解"什么时候需要显式屏障"的关键是理解各平台的内存模型强弱。

x86 是强内存模型(TSO,Total Store Order):写写不乱序(store buffer 先进先出)、读读不乱序、读写不乱序。唯一的重排是"写之后的读可以提前"——读操作可以越过之前的写(因为读可以直接从 store buffer 取数)。工程推论:x86 上 release 语义免费(写写有序是硬件保证),acquire 语义也基本免费(读读有序是硬件保证),只有"写读"之间的顺序需要 mfence(如 seqlock 的读端)。因此很多在 x86 上"跑着没问题"的无锁代码,在 ARM 上会出问题。

ARM 是弱内存模型:所有类型的重排都可能发生,硬件只保证依赖关系(数据依赖、地址依赖)不破坏。工程推论:ARM 上 acquire/release 需要显式的 DMB 指令——语义屏障在 x86 上编译为零指令,在 ARM 上编译为 DMB ISH。跨平台并发代码必须显式使用语义屏障——依赖 x86 的强模型是"在我机器上没问题"类 bug 的高发源头。

什么时候需要内存屏障

一个实用的判断清单:

发布/消费模式——线程 A 写入数据后写标志,线程 B 读标志后读数据(DCLP、无锁初始化的正确写法):A 侧 release,B 侧 acquire。

无锁数据结构的节点发布——一个线程把新节点链接到结构中(release),其他线程通过指针发现它(acquire)。无锁队列、无锁栈的 push/pop 都遵循这个模式。

跨线程标志与状态机——"停止标志"、"就绪标志"这类简单同步:标志写 release,标志读 acquire。很多"偶尔不生效"的 stop flag bug 都是缺了这对屏障。

seqlock 的读端——顺序锁在读完成时需要一个屏障确认"数据读取先于序号读取"(x86 上就是那个特殊的写读屏障)。

不需要屏障的场景:整个临界区由互斥锁保护(锁的 acquire/release 语义自动覆盖了临界区内访问的顺序需求);原子变量用于单纯计数(不需要与其他数据的顺序关系);单线程内的一切访问。屏障是"跨线程顺序关系"的工具——没有跨线程顺序需求就没有屏障需求。

屏障与锁的关系

锁是屏障的高级封装。mutex 的 lock 操作包含 acquire 语义(临界区内的访问不会重排到 lock 之前),unlock 操作包含 release 语义(临界区内的访问不会重排到 unlock 之后)。这意味着"加了锁就不需要考虑屏障"——锁的语义边界就是屏障的覆盖范围。

这个关系解释了锁的代价来源之一:锁不仅付出上下文切换的代价(争用时),还付出屏障的代价(即使不争用)。无锁编程省掉的正是这两者,但把正确性责任转移给了开发者——每个跨线程顺序关系都要显式用屏障标注。这就是"无锁代码难写"的精确含义:不是原子指令难,而是顺序关系的推理和标注难。

工程建议:默认用锁(正确性由锁的语义保证),性能瓶颈确认在锁争用时,才考虑无锁改造——而且改造时优先用现成的无锁库(其作者已经完成了屏障推理),自己写无锁结构是最后的选择。