审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
多核的第一性原理是缓存行——两个核"各写各的变量",可能只是在互相 invalidate。
2026-09-26 · Cache Coherence, MESI and False Sharing

第 15 关 · 缓存一致性:MESI 与伪共享

定位

这一关回答多核编程里最反直觉的问题:"共享内存"到底是什么。答案比直觉更激进:每个核看到的从来不是内存,而是自己的缓存——"所有核看到同一份内存"是一个由 MESI 协议时刻维持的幻觉。而幻觉的最小记账单位不是字节,是 64 字节的缓存行:你写 1 个字节,硬件按 64 字节跟你算账。

三个机制支点:MESI 四态与核间 invalidate(一性致如何维持)、伪共享(各写各的变量为什么互相拖慢)、解法三件套(cacheline 对齐、__read_mostly、per-CPU)。READ_ONCE/WRITE_ONCE 的边界留给 M18:它们防的是编译器,不是缓存一致性。M04 的 TLB shootdown、M07 的上下文切换、M09 的锁,都是这一关的下游。

核心机制深讲

15.1 MESI:四态,一本"谁有最新数据"的账

每个缓存行在每个核的缓存里都有一个状态,四个字母就是全部可能:

读缺失时发 BusRd:如果别的核手里有 M 态的行,它先写回内存、转 S,我拿到 S;如果没人有,我直接拿到 E。写命中 S 态行时发 invalidate:别人的副本全转 I,我转 M。写缺失时先 RFO(Read For Ownership)——把整行读进来再写,而不是直接写内存:因为写 1 个字节也得先拥有整行。

E 态存在的理由值得品味:"独占读进来、随后就写"是极常见的模式(比如刚 malloc 的内存第一次写入)。没有 E 态,每次"读后写"都要发一次 invalidate;有了 E 态,静默转 M,省一次核间消息。MESI 的每个状态都是为"省一次总线事务"而存在的。

15.2 缓存行:64B 是 x86_64 的记账单位

arch/x86/include/asm/cache.h:L1_CACHE_BYTES = (1 << L1_CACHE_SHIFT),x86_64 上就是 64 字节。一致性协议不认识字节——写 1 个字节的效果是:该行进 M 态,其他核的副本全进 I 态。硬件没有"字节级一致性"这个概念。

为什么是行而不是字节?两条硬理由:(1) DRAM 的 burst 传输本来就是按行搬运的,字节级传输在物理上不存在;(2) 状态位开销——每字节维护 2bit MESI 状态是 25% 的标签存储开销,按 64B 行摊薄到可忽略。一致性粒度是物理现实决定的,不是协议设计师拍脑袋定的。

量化是这一关的灵魂:L1 命中约 1ns(几个周期),行被 invalidate 后从内存重取约 100ns——两个数量级。伪共享的全部恐怖就在于:它把你的每次写,都变成了 100ns。

15.3 伪共享:各写各的变量,互相 invalidate

经典案发现场:

struct { long cnt_a; long cnt_b; } s;
// 核0 循环 s.cnt_a++,核1 循环 s.cnt_b++

两个 8 字节变量落在同一个 64B 缓存行里。核0 写 cnt_a:本核行转 M,核1 的副本转 I;核1 写 cnt_b:RFO 把行抢回来,核0 的副本转 I……缓存行在核间乒乓,两个核"各写各的",吞吐量却塌到内存带宽量级。诊断铁证:单核跑飞快,双核跑反而慢,perf 里 coherence miss / HITM(hit-modified)计数飙高。

解法一:____cacheline_aligned_in_smp(include/linux/cache.hSMP_CACHE_BYTES 对齐并独占一整行。注意它的 #else 分支:非 SMP 下展开为空——单核不需要一致性,对齐是纯浪费。内核连"要不要对齐"都按配置条件编译。

解法二:__read_mostly——cache.h 注释原话:"keep rarely changing variables out of frequently updated cachelines"。它防的不是"写乒乓",是"读污染":把热路径频繁读取、极少写入的变量打包进只读缓存行,搬离写者的战场。

代价是显式的:对齐拿空间换时间,每个变量吃掉 64B。内核里 ____cacheline_aligned 的结构体不少——这是深思熟虑后的挥霍,不是粗心。

15.4 per-CPU:不共享,就没有一致性问题

DEFINE_PER_CPU(include/linux/percpu.hthis_cpu_read/this_cpu_write——关抢占上下文里直接读写本 CPU 副本,无锁。

铁证:arch/x86/kernel/cpu/bugs.c 里,SPEC_CTRL 的当前值就是 DEFINE_PER_CPU(u64, x86_spec_ctrl_current)——连 Spectre 缓解代码自己都用 per-CPU 存状态。理由很硬:MSR 本来就是 per-CPU 的,切换上下文时(M07)本来就要按 CPU 写,per-CPU 是唯一自然的选择。

代价有三:内存开销 ×NR_CPUS;汇总时需要跨 CPU 求和(同步点,M09 会展开);以及——per-CPU 变量在进程上下文里不能随意 this_cpu_*(抢占可能把你换到别的 CPU),要配 get_cpu()/put_cpu() 或在已关抢占区使用。

15.5 READ_ONCE/WRITE_ONCE:防编译器,不防硬件

include/asm-generic/rwonce.h 注释原话:"Prevent the compiler from merging or refetching reads or writes"——防的是编译器把两次读合并成一次、把没必要的读优化掉。它的两个官方用例:(1) 同一 CPU 上进程上下文与 irq/NMI handler 之间的通信;(2) 与显式 barrier/原子指令配合、不需要排序的访问。

关键边界,一句话:"读到的是某次完整写入的值"和"按什么顺序看到多次写入"是两回事。READ_ONCE 只约束编译器,不约束 CPU 的乱序执行和投机,也不提供核间的顺序保证。后者是 M18 内存模型 + smp_mb() 的地盘——本关在这里给 M18 埋钩子。

为什么这样设计

为什么一致性粒度是缓存行而不是字节?

因为物理层就没有字节级搬运:DRAM burst、总线事务都是按行;再加上每字节 2bit 状态位的存储开销。字节级一致性在物理和经济上都不成立——MESI 是"向物理现实妥协"的最优解,不是"向程序员妥协"的。

为什么是写失效(invalidate)而不是写更新(update)?

写更新要求每次写都把数据广播给所有共享者——"多核读、频繁写"的场景下总线直接爆炸。写失效只在第一次写时发一次 invalidate,之后本核的写零流量。"一次写、长期独占"是更常见的模式,协议为常见模式优化。

为什么内核里到处是 per-CPU,而不是"更好的锁"?

因为锁解决的是正确性,per-CPU 消除的是竞争本身。M09 会展开这个鄙视链:不共享 > 共享+无锁 > 共享+锁。但 per-CPU 把复杂度推给了"汇总"时刻——求和的那一刻,一致性账单还是要付。

与已学模块的关联

自测

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

Q1. MESI 四个字母各代表什么状态?M 态的缓存行,内存里的数据是新的还是旧的?

Modified(独占脏)、Exclusive(独占干净)、Shared(共享干净)、Invalid(无效)。M 态时内存里是旧的——最新数据只在持有 M 态行的那个核的缓存里,内存的拷贝已经过时,直到该行被写回(writeback)或逐出。

Q2. E 态存在的意义是什么?从 E 态写和从 S 态写,总线事务有什么区别?

E 态为"独占读进来、随后就写"的常见模式省一次总线事务。从 E 写:静默转 M,零总线事务(本来就独占,没人需要通知);从 S 写:必须先发 invalidate,把其他核的 S 副本打成 I,自己转 M。E 态是 MESI 里"为常见模式省钱"的设计。

Q3. 场景:struct { long a; long b; },线程0循环写 a、线程1循环写 b,单核跑飞快,双核跑反而慢,perf 显示 HITM 计数极高。诊断并给出两种修复。

诊断:伪共享。a 和 b 落在同一 64B 缓存行,每次写都 invalidate 对方核的副本,缓存行在核间乒乓(HITM = 命中了别的核 Modified 的行,是伪共享的铁证)。修复:(1) 给 a、b 各加 ____cacheline_aligned_in_smp,让它们独占不同缓存行;(2) 改用 per-CPU 变量/每线程局部变量 + 最后汇总,彻底消除共享。

Q4. ____cacheline_aligned_in_smp 在 CONFIG_SMP=n 时展开成什么?为什么单核下不需要它?

展开成空(cache.h 的 #else 分支直接 #define ____cacheline_aligned_in_smp)。单核下没有第二个核来 invalidate 你的缓存行,一致性协议根本没有工作对象——对齐是纯粹的内存浪费,所以内核用条件编译把它裁掉。

Q5. __read_mostly 的语义是什么?它防的是伪共享的"写乒乓",还是别的什么?

语义(cache.h 注释原话):把极少变化、但在热路径被频繁读取的变量,搬出频繁被更新的缓存行、打包进只读缓存行。它防的不是"写乒乓"(那是 ____cacheline_aligned 的活),而是"读污染":别让只读的热数据和别人写的热数据挤在同一行里,被连带 invalidate。

Q6. 高频计数器场景:方案 (a) 全局 atomic 计数器,方案 (b) per-CPU 计数器 + 定期汇总。从一致性流量和汇总代价两方面权衡。

选 (b)。(a) 的每次 atomic_inc 都要把缓存行抢到 M 态——N 个核就是 N 路 invalidate 风暴,一致性流量随核数线性增长;(b) 每次写都是本 CPU 副本的 L1 命中,零核间流量。代价是 (b) 读总数时要跨 CPU 求和——但"读总数"是低频操作,把高频的竞争换成低频的汇总,总体稳赚。教训:把竞争从热路径上删掉,而不是在热路径上更好地竞争。

Q7. 为什么 READ_ONCE 不能替代 smp_mb()?"读到完整值"和"按序看到多次写入"的区别是什么?

READ_ONCE 只保证"这次读是单次、不被编译器合并/撕裂的完整访问",不保证它和别的读写之间的顺序。多核场景下,CPU 的乱序执行和 store buffer 可能让"先看到新值 B、后看到旧值 A"——读到的每个值都完整,但顺序是错的。smp_mb() 约束的是顺序。M18 会展开:单次访问的原子性 vs 多次访问的顺序,是两个正交的问题。

Q8. cache.h 里还有 ____cacheline_internodealigned_in_smp,INTERNODE_CACHE_SHIFT 可能比 L1_CACHE_SHIFT 大。为什么跨 NUMA node 需要更大的对齐?

因为 NUMA 下一致性域更大:跨 node 的缓存行乒乓要走 interconnect(如 UPI),延迟比同 node 跨核高一个数量级,且可能按更大的传输单元搬运。更大的对齐保证"逻辑上无关"的数据在物理上离得更远,避免一次跨 node 传输拖走无辜邻居。对齐粒度跟着"犯错的代价"走——代价越大,对齐越宽。

Q9. RFO(Read For Ownership):写缺失时为什么要先"读"整行?直接写内存不行吗?

因为写 1 个字节不等于写整行——行里其余 63 字节的数据还得保留。RFO 先把整行读进来(获得 M 态所有权),再在缓存里修改那 1 个字节,最后统一写回。直接写内存会丢掉同行的其他字节。RFO 是"字节级语义"和"行级一致性"之间的翻译层——伪共享的写放大,根子就在这里。

Q10. 设计题:8 核机器,8 个线程各写各的 8B 计数器。(a) 给出引发伪共享的布局和避免伪共享的布局;(b) 估算伪共享布局下,每次写触发的一致性流量(按"消息数 × 行大小"估);(c) 若计数器极少被读取,对比"cacheline 对齐数组"与"per-CPU 变量"两种方案的内存开销。

(a) 伪共享布局:long cnt[8] 紧密数组——8×8=64B 恰好挤在一行里;避免:struct { long v; } cnt[8] ____cacheline_aligned_in_smp 或每线程局部变量。(b) 每次写:1 次 RFO(64B 读)+ 1 次 invalidate 广播(7 个核的副本转 I)≈ 2 个核间消息 × 64B 行——而有效载荷只有 8B,放大 16 倍,还没算对方下次写的抢回。(c) 对齐数组:8×64B=512B 常驻;per-CPU:同样 8×64B 量级(percpu 按 unit 分配,实际按 CPU 数×对齐后大小)。内存开销接近,差异在访问模式:per-CPU 用 this_cpu_* 无锁且天然关抢占安全,对齐数组仍需处理"谁写哪一项"的索引——开销接近时,选语义更干净的。

延伸阅读

MESI 状态迁移(核0视角,初始行不在缓存 = I)
                    读缺失                 读缺失
                 (别人无此行)           (别人有M态行)
        I ─────────────────────► E      I ──────────► S
        │  写缺失(RFO)                  ▲              │ 写(S→M)
        │                               │ BusRd        │ 发invalidate
        ▼                               │              ▼
        M ◄──────── 静默转M ────────── E      别人的S ──► I
        │  (独占,无需总线事务)
        │  写回内存/被逐出
        ▼
  (M→S:响应别人的BusRd,先写回)

一句话:E 是"读进来就知道没人和我抢",S 是"读进来就知道有人和我分"。 
伪共享:两个核"各写各的",硬件看到的却是同一行

  缓存行 (64B): [ cnt_a (8B) | cnt_b (8B) | ............ 48B ............ ]
                    ▲                  ▲
                 核0只写a           核1只写b

  t0: 核0写a → 行在核0进M态,核1副本→I
  t1: 核1写b → RFO抢回,行在核1进M态,核0副本→I
  t2: 核0写a → RFO抢回 ………… 乒乓,吞吐量塌到内存带宽

  修复后:[ cnt_a + 56B填充 ] [ cnt_b + 56B填充 ]
          各占一行,写a不再invalidate核1的行。 

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

作答汇总

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