前言
在编写 Linux 内核并发代码时,READ_ONCE、WRITE_ONCE、smp_rmb、smp_wmb 等是最常见的基础设施。但很多开发者只是"照着抄",并不真正理解它们的原理。本文从最底层出发,彻底讲清楚 编译器指令重排 和 CPU 指令重排 这两个核心概念。
一、编译器指令重排
1.1 什么是编译器重排
编译器的工作是把 C 源码翻译成汇编指令。但编译器 不保证 生成的汇编顺序和你写的 C 代码顺序一致。
编译器的目标是:在不改变单线程语义的前提下,生成尽可能快的代码。
1.2 具体例子
你写的 C 代码:
c
int a = 1; // 第1行
int b = 2; // 第2行
int c = a + b; // 第3行
编译器看到这段代码后,发现第1行和第2行之间没有依赖关系,可能会重排成:
asm
mov [b], 2 ; 先写 b
mov [a], 1 ; 再写 a
mov r1, [a]
mov r2, [b]
add [c], r1, r2
单线程下,c 的结果还是 3,没有任何问题。编译器认为只要单线程最终结果对就行。
1.3 编译器的其他优化
合并冗余读写:
c
*p = 0;
*p = 0;
// 编译器优化后:直接删掉第一次写,只保留一次
*p = 0;
拆分读写(tearing):
c
// 你写的:写 4 字节
*(int *)p = 1234;
// 编译器可能拆成两次 2 字节写
*(short *)p = 1234;
*(short *)(p+2) = 0;
在并发场景下,另一个 CPU 可能看到半写入的值。
1.4 READ_ONCE / WRITE_ONCE 的原理
c
#define READ_ONCE(x) (*(volatile typeof(x) *)&(x))
#define WRITE_ONCE(x, val) \
(*(volatile typeof(x) *)&(x) = (val))
本质就是 volatile 访问。volatile 告诉编译器:
- 每次都必须真的发一条 load/store 指令去内存,不许用寄存器缓存值
- 不许合并、不许拆分、不许删除多余的读写
- 不许推测执行
注意: volatile 只约束编译器,不约束 CPU。它生成的还是一条普通的 CPU load/store 指令,和不加 volatile 生成的指令码一模一样(在 x86 上)。区别仅在于编译器不会对它做优化。
二、CPU 指令重排
2.1 编译器重排完了,CPU 还会再重排
假设编译器没有重排,生成了正确的汇编顺序:
asm
; 线程 A --- 编译器没重排
mov [data], 42 ; 指令1:写 data
mov [flag], 1 ; 指令2:写 flag
你以为指令1一定在指令2之前执行完毕。但在现代 CPU 上不是这样的。
2.2 为什么 CPU 要重排
现代 CPU 有很多加速机制,导致指令的实际执行/可见顺序和程序顺序不一致。
原因一:Store Buffer(写缓冲区)
CPU 核心 主内存
│ │
├── 指令1: 写 data=42 ──────→ [Store Buffer] ──→ (慢慢刷到内存)
├── 指令2: 写 flag=1 ──────→ [Store Buffer] ──→ (慢慢刷到内存)
CPU 把写操作先放进 Store Buffer,立刻就去执行下一条指令了(不用等数据真正写入内存)。Store Buffer 再以自己的节奏把数据刷到缓存/内存。
这意味着:CPU 0 执行了指令1和指令2,但对 CPU 1 来说,可能先看到 flag=1,后看到 data=42。
原因二:Out-of-Order Execution(乱序执行)
CPU 的执行引擎可以乱序执行指令。只要对当前线程的最终结果没影响,CPU 可以先执行后面的指令。
原因三:Invalidation Queue(失效队列)
当其他 CPU 修改了某个缓存行,本 CPU 会收到 invalidate 消息,但不会立刻处理,而是放进队列里延迟处理。这导致本 CPU 可能还在读旧值。
2.3 具体例子
时间线:
CPU 0 执行线程 A: CPU 1 执行线程 B:
───────────────── ─────────────────
写 data=42
读 flag → 看到 0(缓存还是旧的)
写 flag=1
读 flag → 看到 1(invalidate 消息终于处理了)
读 data → 看到 ???
CPU 0 虽然先写 data 再写 flag,但由于 Store Buffer 刷出的顺序不确定,CPU 1 可能在看到 flag=1 的时候,data=42 还没传播到 CPU 1 的缓存。
结果就是线程 B 打印出 data=0,而不是期望的 42。
三、内存屏障(Memory Barrier)
3.1 什么是内存屏障
内存屏障是一条 CPU 硬件指令,它约束 CPU 和缓存系统对内存操作的重排序行为。
3.2 屏障的分类
| 屏障类型 | 指令 | 作用 |
|---|---|---|
| 写屏障 | smp_wmb() |
保证屏障之前 的 store 操作在屏障之后的 store 操作之前,对其他 CPU 可见 |
| 读屏障 | smp_rmb() |
保证屏障之前 的 load 操作在屏障之后的 load 操作之前完成 |
| 全屏障 | smp_mb() |
保证屏障之前的所有 load/store 在屏障之后的所有 load/store 之前完成 |
3.3 写屏障解决什么问题
c
// 线程 A(生产者)
data = 42;
smp_wmb(); // ← 写屏障
flag = 1;
smp_wmb() 告诉 CPU:Store Buffer 里,屏障之前的写(data=42)必须在屏障之后的写(flag=1)之前,刷到其他 CPU 能看到的地方。
效果:CPU 1 一定先看到 data=42,然后才看到 flag=1。
3.4 读屏障解决什么问题
c
// 线程 B(消费者)
if (READ_ONCE(flag)) {
smp_rmb(); // ← 读屏障
v = data;
}
smp_rmb() 告诉 CPU:在屏障之前的读(读 flag)的结果确定之前,不要执行屏障之后的读(读 data)。
效果:CPU 不会因为推测执行等原因,跳过读 flag 直接读 data。
四、两者的关系
你写的代码: data = 42; smp_wmb(); flag = 1;
│ │
┌────┘ └────┐
▼ ▼
编译器可能重排: flag = 1; data = 42; ← 编译器把顺序改了
│ │
WRITE_ONCE 会阻止这种重排 ──┘
│ │
▼ ▼
CPU 可能重排: store [flag]=1 在前 store [data]=42 在后 ← Store Buffer 乱序
│ │
smp_wmb() 会阻止这种重排 ──┘
总结对比
| 维度 | READ_ONCE / WRITE_ONCE | 内存屏障 (smp_rmb/smp_wmb/smp_mb) |
|---|---|---|
| 作用层面 | 编译器层面 | CPU 硬件层面 |
| 解决的问题 | 防止编译器优化:合并、拆分、缓存、删除读写 | 防止 CPU 硬件对内存操作做重排序 |
| 是否影响指令顺序 | 不影响。生成的汇编指令和不加 volatile 一样 | 影响。在 CPU 流水线中插入屏障 |
| x86 上的表现 | 编译成普通 mov 指令 | smp_rmb/smp_wmb 编译成空操作(x86 硬件保证了),smp_mb 编译成 mfence 或 lock 前缀 |
| ARM 上的表现 | 编译成普通 ldr/str | smp_rmb → dmb ishld,smp_wmb → dmb ishst,smp_mb → dmb ish |
| 性能开销 | 几乎为零 | 有实际开销(flush store buffer、stall 流水线) |
简单记忆: READ_ONCE → 告诉编译器:"你别自作聪明优化这次读";smp_rmb() → 告诉 CPU:"你别自作聪明重排这次读"。两者解决的是不同层面的问题,实际代码中必须配合使用。
五、完整的正确写法
生产者-消费者模型
c
// 全局变量
int data = 0;
int flag = 0;
// 线程 A(生产者)
data = 42;
smp_wmb(); // 硬件层面:data 的写一定先于 flag 的写对其他 CPU 可见
WRITE_ONCE(flag, 1); // 编译器层面:保证 flag 确实写入内存
// 线程 B(消费者)
if (READ_ONCE(flag)) { // 编译器层面:保证从内存读 flag
smp_rmb(); // 硬件层面:flag 的读一定先于 data 的读
int v = data; // 安全,v 一定是 42
}
内核中的实际例子
在 Linux 内核的 QoS 继承代码中,可以看到这种模式的实际运用:
c
// qi.c 中的 get_dyn_qos_lvl
if (atomic_read(&task_qi->flag) == 0) // READ_ONCE 语义
return 0;
/* ensure dyn_qos read ops after flag */
smp_rmb(); // 读屏障
return atomic_read(&task_qi->dyn_qos) & QOS_LVL_MASK;
含义:如果看到了 flag != 0,那么 smp_rmb() 保证后续读到的 dyn_qos 一定是 flag 被设置之前写入的值。
c
// qi_utils.c 中的 do_qos_inherit
list_add(&node->list, &task_qi->inherit[type].list);
/* Add memory barrier to ensure list adding has finished
* before flag has been modified. */
smp_wmb();
atomic_or((1 << type), &task_qi->flag);
含义:smp_wmb() 保证链表插入操作对其他 CPU 可见之后,才去设置 flag。这样其他 CPU 看到 flag 被设置时,链表一定已经是完整的。
六、常见误区
误区1:"READ_ONCE 就是 volatile"
不完全是。READ_ONCE 确实基于 volatile,但内核的 READ_ONCE 还会处理一些特殊情况(比如 volatile 对 struct 类型的特殊语义问题)。不要直接用 volatile 替代 READ_ONCE。
误区2:"x86 是强内存模型,不需要内存屏障"
x86 保证了 store-store 不重排、load-load 不重排、load 不会和更早的 store 重排。但 x86 不保证 store 不会和更早的 load 重排 (即 Load-Store 重排)。所以 smp_mb() 在 x86 上仍然需要 mfence 或 lock 前缀。而且即使在 x86 上,READ_ONCE / WRITE_ONCE 仍然是必要的(防止编译器重排)。
误区3:"用了 atomic 就不需要内存屏障"
atomic_read 就是 READ_ONCE,atomic_set 就是 WRITE_ONCE。它们只是防止编译器优化,不提供内存屏障语义 。如果你需要原子读加屏障,应该用 smp_load_acquire() / smp_store_release()。
总结
┌──────────────────────────────────────────────┐
│ 你写的 C 代码 │
└──────────────┬───────────────────────────────┘
│ 编译器可能重排
▼
┌──────────────────────────────────────────────┐
│ READ_ONCE / WRITE_ONCE │
│ 阻止编译器重排(volatile 语义) │
└──────────────┬───────────────────────────────┘
│ 生成普通汇编指令
▼
┌──────────────────────────────────────────────┐
│ 汇编指令序列 │
└──────────────┬───────────────────────────────┘
│ CPU 可能重排(Store Buffer 等)
▼
┌──────────────────────────────────────────────┐
│ smp_rmb / smp_wmb / smp_mb │
│ 阻止 CPU 硬件重排(屏障指令) │
└──────────────┬───────────────────────────────┘
│ 实际内存操作顺序
▼
┌──────────────────────────────────────────────┐
│ 主内存 │
└──────────────────────────────────────────────┘
一句话总结:READ_ONCE 管编译器,内存屏障管 CPU,两者各管一层,缺一不可。