审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
编译器和 CPU 都在重排你的代码——内存模型是"哪些重排不许做"的契约。
2026-09-26 · Linux Kernel Memory Model

第 18 关 · 内存模型:LKMM 与 happens-before

定位

你写的代码,执行时不一定是那个顺序。编译器会合并、重取、消除访存;CPU 会乱序执行、延迟写回。单线程里这些重排不可见(as-if 规则兜底),多线程里它们就是 Heisenbug 的产地。内存模型就是"哪些重排不许做"的契约:程序员用 barrier / acquire-release 标出"这里不许重排",编译器和 CPU 照办。Linux 的内存模型叫 LKMM(Linux Kernel Memory Model),它不只是一份文档——tools/memory-model/ 下是一套可执行的模型:用 cat 语言写的约束(linux-kernel.cat),配上 herd7 穷举 litmus test 的状态空间,判定某个执行"允许还是禁止"。

三个机制支点:READ_ONCE / WRITE_ONCE(先管住编译器)、smp_load_acquire / smp_store_release(单向的 happens-before)、linux-kernel.cat 里的 hb(acyclic——环即非法)与 RCU 公理。M09 的 RCU 在这里会拿到形式化语义;M15 管"最终可见",本关管"以什么顺序可见"。

核心机制深讲

3.1 两类重排:编译器和 CPU 各怀鬼胎

Documentation/memory-barriers.txt 开篇就点名两个"犯人":编译器会重排,CPU 也会重排。手法不同:

文档里最狠的一句警告:不能假定没被 READ_ONCE() / WRITE_ONCE() 保护的访问会按程序员希望的那样执行。换句话说:裸访存在并发世界里的语义是未定义的——不是"大概率按顺序",是"没定义"。

3.2 READ_ONCE / WRITE_ONCE:先管住编译器

READ_ONCE() / WRITE_ONCE() 做两件事:保证单次访问(不撕裂、不合并、不重取),以及给编译器划红线("别碰这次访存")。它们是地基:memory-barriers.txt 里所有 litmus 例子都用它们写——讨论 CPU 重排之前,得先让编译器别捣乱。

但它们不是跨 CPU ordering 的完整替代。铁证在 include/asm-generic/barrier.h:smp_load_acquire() 的 generic 实现是 READ_ONCE() 后面再加一个 barrier,smp_store_release() 是 barrier 后面再 WRITE_ONCE()。如果 READ_ONCE 自带顺序,这个 barrier 就是多余的——它的存在证明:READ_ONCE 管编译器,barrier 管 CPU,两层防御各管一层。

3.3 acquire/release:单向的 happens-before

smp_store_release(&ptr, p):release 语义——release 之前的写,不能跑到 release 之后;smp_load_acquire(&ptr):acquire 语义——acquire 之后的读,不能跑到 acquire 之前。注意"单向":release 之后的代码可以上移到 release 之前,acquire 之前的代码可以下移到 acquire 之后——反方向的重排是允许的。这就是它比 smp_mb() 便宜的原因:只禁该禁的。

发布-订阅模式:写者先初始化 p 指向的对象,再 smp_store_release(&ptr, p) 发布指针;读者 q = smp_load_acquire(&ptr); if (q) use(q->data)。如果读到了非空指针,那么"对象初始化" happens-before "指针发布" happens-before "指针读取" happens-before "数据使用"——链条不断,q->data 安全。linux-kernel.cat 里这就是 acq-po / po-rel 两条关系拼出的 hb,以及那条铁律:acyclic hb as happens-before——happens-before 出现环,这个执行就是非法的(因果不能循环)。

3.4 LKMM:可以跑起来的内存模型

tools/memory-model/README 写得直白:LKMM 用 cat 语言写成(linux-kernel.cat),由外部工具 herd7 穷举小型 litmus test 的全部执行,判定每个执行允许还是禁止——当前要求 herd7 7.58 或更高。cat 文件里定义了 coherence(缓存一致性层的顺序)、atomicity(RMW 原子性)、happens-before、propagation(写传播),以及整整一章 RCU。

这意味着内存模型不是"纸面规范 + 靠人脑验证":你写一个 litmus test(比如经典的 MP message-passing),herd7 把 cat 的约束当判定器跑一遍,告诉你"r1==1 && r2==0 这个结果 LKMM 允不允许"。模型即测试——这正是 LKMM 和文字版 memory-barriers.txt 的本质区别(文档自己也说了:它不是 specification,有疑问看 tools/memory-model/)。

3.5 RCU 与地址依赖:M09 的回响

linux-kernel.cat 里 RCU 自成一章:rcu-link、rcu-order、rcu-fence,约束"read-side critical section"和"grace period"之间的顺序——M09 的"等所有读者退出临界区再回收",在这里是形式化的公理。

RCU 读侧的核心机制是地址依赖:Q = READ_ONCE(P); D = READ_ONCE(*Q)——第二次读的地址依赖第一次读的结果。memory-barriers.txt 的 GUARANTEES 一节明确给出这个模式的语义:CPU 不会对存在地址依赖的两次读做"先读 D 再读 P"的重排(依赖链本身就是顺序)。rcu_dereference() 的正确性就立在这上面:发布者先初始化对象、再 release 发布指针(M09 的发布-订阅),订阅者经地址依赖拿到的是一定初始化完整的对象。链条的每一环:编译器不许动(READ_ONCE)→ CPU 不许动(地址依赖)→ 跨 CPU 的发布可见(release/acquire 或 RCU 的 grace period 保证)。少一环,链条就断。

为什么这样设计

为什么 LKMM 要做成"可执行"的(cat + herd7),而不是一份文字规范?

因为内存模型的正确性人脑穷举不了:两个线程各两条访存,交错起来就是指数级状态空间,再乘编译器和 CPU 各自的重排。文字规范只能"描述意图",herd7 能"判定执行"——你写 litmus test,它穷举所有可能,告诉你这个结果在模型下允许还是禁止。README 的定位很直白:cat 写约束,herd7 跑判定。模型和测试是同一个东西,改模型必须过 litmus 回归——这是形式化方法在内核工程里的落地。

为什么 acquire/release 只做单向顺序,而不是全屏障?

因为全屏障贵。重排是性能的来源(M16:流水线靠乱序吃饭),barrier 是按需关掉它。发布-订阅只需要单向:发布者"数据写完先于指针发布",订阅者"指针读到先于数据使用"——反方向的重排(发布者把后面的无关代码提到 release 之前)无害,禁了白付代价。smp_mb() 留给真需要双向的场景。顺序约束和性能是同一笔预算,acquire/release 是最便宜的够用解。

为什么 READ_ONCE/WRITE_ONCE 不能替代 barrier?

它们管的是编译器,不管 CPU。铁证:smp_load_acquire 的 generic 实现是 READ_ONCE() 后面再加一个 barrier——如果 READ_ONCE 自带跨 CPU 顺序,这个 barrier 就是多余的。两层防御各管一层:rwonce.h 的注释只承诺"编译器别合并/重取",barrier.h 还补了一句——和设备通信时,即使单 CPU(UP)也可能需要严格的 CPU ordering(UP 下 smp_* 退化成 compiler barrier,但 MMIO 路径自带 ordering)。"单核就不用管顺序"是错的:设备的视角是另一个"CPU"。

与已学模块的关联

本关是"顺序"的契约层,和几关拼成完整语义:

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

Q1. 编译器和 CPU 分别会做什么重排?各举一个例子。

编译器:合并相邻访问、重新取值、凭空消除访存——如 while (!p->ready); 被优化成只读一次 ready(编译器"证明"了循环体内它不变)。CPU:乱序执行、store buffer 延迟写回——如一个 CPU 写 x = 1; y = 1;,另一个 CPU 可能先看到 y == 1 再看到 x == 1。前者是"代码被改写",后者是"执行顺序被打乱",治法不同(3.2 / 3.3)。

Q2. int a = p->x; int b = p->x; ——编译器可能怎么"优化"?READ_ONCE 如何阻止?

编译器可能把两次取值合并成一次(a = b = p->x 一次访存),或把第二次当成第一次的复用直接删掉——单线程语义下这完全合法。但如果另一个 CPU 在两次读之间改了 p->x,合并后 a == b 恒成立,程序观察到的行为就变了。READ_ONCE() 给编译器划红线:include/asm-generic/rwonce.h 的注释明确说它防止编译器做合并、重取这类变换——每次 READ_ONCE(p->x) 都是一次真实的、独立的访存。

Q3. smp_store_release 保证什么?它的"单向"体现在哪里?举一个 release 允许、smp_mb 不允许的重排。

smp_store_release(&ptr, p) 保证:release 之前的写,不能跑到 release 之后。"单向"指反方向不管:release 之后的代码可以上移到 release 之前(只要不破坏单线程语义)。例子:smp_store_release(&flag, 1); x = 42;——x = 42 被提到 release 之前执行,release 允许(它只管"之前的写不许下移"),smp_mb() 不允许(全屏障双向都禁)。发布-订阅只需要"数据先于指针",反方向的重排无害——只禁该禁的,所以比 smp_mb 便宜。

Q4. 发布-订阅:写者 smp_store_release(&ptr, p),读者 q = smp_load_acquire(&ptr)。若 q 非空,读 q->data 安全吗?用 happens-before 链解释。

安全。链条四环:(1) 写者初始化对象 p->data ——在 release 之前,release 语义保证它不能跑到发布之后;(2) smp_store_release(&ptr, p) 发布;(3) 读者 smp_load_acquire(&ptr) 读到该值——release-store 被 acquire-load 读到,建立跨线程 happens-before;(4) use(q->data) 在 acquire 之后,acquire 语义保证它不能跑到 acquire 之前。四环相扣:初始化 happens-before 发布 happens-before 读取 happens-before 使用。linux-kernel.cat 里这就是 po-rel + acq-po 拼出的 hb。断任何一环(比如读者用裸读),链条就断。

Q5. LKMM 由哪两部分组成?herd7 拿来干什么?

两部分:tools/memory-model/linux-kernel.cat(用 cat 语言写的约束:coherence、atomicity、happens-before、propagation、RCU),和外部工具 herd7(当前要求 7.58+)。herd7 穷举小型 litmus test 的全部执行,用 cat 的约束当判定器,告诉你每个执行"允许还是禁止"。所以 LKMM 是可执行的模型,不是纸面规范——模型即测试。

Q6. Q = READ_ONCE(P); D = READ_ONCE(*Q) ——为什么 D 一定能看到 P 指向的新对象的初始化写入?

靠地址依赖:第二次读的地址(*Q)依赖第一次读的结果,CPU 不会对存在地址依赖的两次读做"先读 D 再读 P"的重排——依赖链本身就是顺序(memory-barriers.txt GUARANTEES 一节的明确语义)。再配发布侧的 release(先初始化对象、再发布指针),链条是:初始化 happens-before 指针发布 happens-before Q 的读取 happens-before D 的读取。注意两个 READ_ONCE 也缺一不可:没有它们,编译器可能把 *Q 的取值提到 Q 之前,地址依赖就被编译器拆了。

Q7. 单 CPU(UP)系统还需要 smp_store_release 吗?READ_ONCE 呢?

READ_ONCE 照样需要:编译器重排和 CPU 核数无关,UP 下编译器一样会合并/重取访存。smp_store_release 在 UP 配置下退化成 compiler barrier(include/asm-generic/barrier.h:UP 仍保留 compiler barrier 语义)——CPU 侧的重排在单核上不可见(中断处理与被中断代码在同一核上按程序顺序观察),但编译器侧仍要管。还有一个例外,barrier.h 注释点名了:和设备通信时,即使 UP 也可能需要严格的 CPU ordering——设备的视角是另一个"CPU",单核不等于单观察者。

Q8. acyclic hb as happens-before ——如果某个执行的 hb 出现环,意味着什么?

意味着这个执行被 LKMM 禁止。happens-before 是"因果"关系:A happens-before B 表示 A 的效果必须对 B 可见。如果出现环(A happens-before B,B 又 happens-before A),因果循环了——A 要在 B 之前、又要在 B 之后,这在物理上不可能对应任何真实执行。linux-kernel.cat 用 acyclic 把这类执行一票否决:环不是"罕见",是"非法"。

Q9. litmus MP:P0 执行 WRITE_ONCE(x,1); smp_store_release(&y,1); P1 执行 r1=smp_load_acquire(&y); r2=READ_ONCE(x); —— r1==1 且 r2==0 的结果允许吗?推导。

不允许。推导:P0 内 WRITE_ONCE(x,1) 在 release 之前,release 语义 ⇒ 它 happens-before smp_store_release(&y,1)(po-rel);P1 的 smp_load_acquire(&y) 读到 1,即读到了 P0 的 release-store ⇒ release-store happens-before acquire-load(跨线程);r2=READ_ONCE(x) 在 acquire 之后,acquire 语义 ⇒ acquire-load happens-before 它(acq-po)。传递闭包:x=1 happens-before r2 的读取,所以 r2 必须看到 1,r2==0 与 hb 矛盾——herd7 会把这个执行判为禁止。如果把 release/acquire 换成裸访存,链条断裂,r2==0 就允许了。

Q10. 设计题:为 RCU 的 grace period 论证——为什么读临界区内的访问不能被重排到临界区外?LKMM 用什么约束表达?如果允许重排,grace period 的保证会怎么被打破?

grace period 的保证是:"等所有读临界区结束,再回收旧对象"。如果临界区内的读被重排到 rcu_read_unlock 之后,回收者看到"临界区已结束"时,读者实际还在访问旧对象——use-after-free。linux-kernel.cat 用 rcu-order / rcu-fence 约束 read-side critical section 与 grace period 的顺序:临界区内的访问必须"在"临界区内,grace period 必须"在"所有临界区之后。打破重排约束 = 打破"临界区"的边界定义 = grace period 等了个寂寞。这就是 M09 的工程直觉("等读者走完")在 LKMM 里的形式化形态。

延伸阅读

两类重排,两套治法:
编译器重排(合并/重取/消除访存)
  → READ_ONCE / WRITE_ONCE(单次访问+编译器红线)
  → barrier()(compiler barrier,纯编译器栅栏)
CPU 重排(乱序执行/store buffer/失效队列)
  → smp_mb / smp_rmb / smp_wmb(全屏障,按需付费)
  → smp_load_acquire / smp_store_release(单向,最便宜的够用解)
  → 地址依赖(依赖链本身就是顺序,RCU 读侧的基石)
acquire/release 的单向性(以 release 为例):
  之前的写 ──┐
             ▼  不许下移 ═══ smp_store_release(&p, v) ═══
  之后的代码 ┘  可以上移(无害的重排,允许)
对比 smp_mb():双向都不许动 —— 更贵,留给真需要的场景
LKMM 工作流(模型即测试):
litmus test(C 语言小测试,如 MP)
    → herd7 穷举全部执行
    → linux-kernel.cat 当判定器(hb 无环?coherence?RCU?)
    → 每个执行:允许 / 禁止
改模型 = 改 cat = 必须过 litmus 回归

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。