这一关回答多核编程里最反直觉的问题:"共享内存"到底是什么。答案比直觉更激进:每个核看到的从来不是内存,而是自己的缓存——"所有核看到同一份内存"是一个由 MESI 协议时刻维持的幻觉。而幻觉的最小记账单位不是字节,是 64 字节的缓存行:你写 1 个字节,硬件按 64 字节跟你算账。
三个机制支点:MESI 四态与核间 invalidate(一性致如何维持)、伪共享(各写各的变量为什么互相拖慢)、解法三件套(cacheline 对齐、__read_mostly、per-CPU)。READ_ONCE/WRITE_ONCE 的边界留给 M18:它们防的是编译器,不是缓存一致性。M04 的 TLB shootdown、M07 的上下文切换、M09 的锁,都是这一关的下游。
每个缓存行在每个核的缓存里都有一个状态,四个字母就是全部可能:
读缺失时发 BusRd:如果别的核手里有 M 态的行,它先写回内存、转 S,我拿到 S;如果没人有,我直接拿到 E。写命中 S 态行时发 invalidate:别人的副本全转 I,我转 M。写缺失时先 RFO(Read For Ownership)——把整行读进来再写,而不是直接写内存:因为写 1 个字节也得先拥有整行。
E 态存在的理由值得品味:"独占读进来、随后就写"是极常见的模式(比如刚 malloc 的内存第一次写入)。没有 E 态,每次"读后写"都要发一次 invalidate;有了 E 态,静默转 M,省一次核间消息。MESI 的每个状态都是为"省一次总线事务"而存在的。
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。
经典案发现场:
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 的结构体不少——这是深思熟虑后的挥霍,不是粗心。
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() 或在已关抢占区使用。
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 把复杂度推给了"汇总"时刻——求和的那一刻,一致性账单还是要付。
task_struct 缓存行跟着"变冷"——调度器的 cache-hot 逻辑就是这一关的消费者switch_mm/switch_to 路径上的 SPEC_CTRL(per-CPU)读写,M16 会算它的 Spectre 账READ_ONCE 不保证顺序,smp_mb() 才保证——本关埋的钩子,M18 来收出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。
Modified(独占脏)、Exclusive(独占干净)、Shared(共享干净)、Invalid(无效)。M 态时内存里是旧的——最新数据只在持有 M 态行的那个核的缓存里,内存的拷贝已经过时,直到该行被写回(writeback)或逐出。
E 态为"独占读进来、随后就写"的常见模式省一次总线事务。从 E 写:静默转 M,零总线事务(本来就独占,没人需要通知);从 S 写:必须先发 invalidate,把其他核的 S 副本打成 I,自己转 M。E 态是 MESI 里"为常见模式省钱"的设计。
诊断:伪共享。a 和 b 落在同一 64B 缓存行,每次写都 invalidate 对方核的副本,缓存行在核间乒乓(HITM = 命中了别的核 Modified 的行,是伪共享的铁证)。修复:(1) 给 a、b 各加 ____cacheline_aligned_in_smp,让它们独占不同缓存行;(2) 改用 per-CPU 变量/每线程局部变量 + 最后汇总,彻底消除共享。
展开成空(cache.h 的 #else 分支直接 #define ____cacheline_aligned_in_smp)。单核下没有第二个核来 invalidate 你的缓存行,一致性协议根本没有工作对象——对齐是纯粹的内存浪费,所以内核用条件编译把它裁掉。
语义(cache.h 注释原话):把极少变化、但在热路径被频繁读取的变量,搬出频繁被更新的缓存行、打包进只读缓存行。它防的不是"写乒乓"(那是 ____cacheline_aligned 的活),而是"读污染":别让只读的热数据和别人写的热数据挤在同一行里,被连带 invalidate。
选 (b)。(a) 的每次 atomic_inc 都要把缓存行抢到 M 态——N 个核就是 N 路 invalidate 风暴,一致性流量随核数线性增长;(b) 每次写都是本 CPU 副本的 L1 命中,零核间流量。代价是 (b) 读总数时要跨 CPU 求和——但"读总数"是低频操作,把高频的竞争换成低频的汇总,总体稳赚。教训:把竞争从热路径上删掉,而不是在热路径上更好地竞争。
READ_ONCE 只保证"这次读是单次、不被编译器合并/撕裂的完整访问",不保证它和别的读写之间的顺序。多核场景下,CPU 的乱序执行和 store buffer 可能让"先看到新值 B、后看到旧值 A"——读到的每个值都完整,但顺序是错的。smp_mb() 约束的是顺序。M18 会展开:单次访问的原子性 vs 多次访问的顺序,是两个正交的问题。
因为 NUMA 下一致性域更大:跨 node 的缓存行乒乓要走 interconnect(如 UPI),延迟比同 node 跨核高一个数量级,且可能按更大的传输单元搬运。更大的对齐保证"逻辑上无关"的数据在物理上离得更远,避免一次跨 node 传输拖走无辜邻居。对齐粒度跟着"犯错的代价"走——代价越大,对齐越宽。
因为写 1 个字节不等于写整行——行里其余 63 字节的数据还得保留。RFO 先把整行读进来(获得 M 态所有权),再在缓存里修改那 1 个字节,最后统一写回。直接写内存会丢掉同行的其他字节。RFO 是"字节级语义"和"行级一致性"之间的翻译层——伪共享的写放大,根子就在这里。
(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_* 无锁且天然关抢占安全,对齐数组仍需处理"谁写哪一项"的索引——开销接近时,选语义更干净的。
____cacheline_aligned_in_smp、__read_mostly、CACHELINE_PADDING 的定义与注释DEFINE_PER_CPU、alloc_percpu、this_cpu_* 家族READ_ONCE/WRITE_ONCE,"防编译器合并/重取"的原话出处L1_CACHE_SHIFT/L1_CACHE_BYTES,x86_64 的 64B 来源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的行。
在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。