伪共享(False Sharing)—— MESI 的头号性能坑
承 mesi(一致性协议族主文档/总纲)和 mesi-protocol(MESI 四态、状态机、总线事务)。本篇把"多线程各写各的变量、没加锁、却比单线程还慢"这个诡异现象拆到底:什么是伪共享、为什么 64 字节的 cache line 是元凶、怎么解决、怎么用 perf 定位。
一、什么是伪共享
两个核各自频繁写"逻辑上毫不相干"的两个变量,但这两个变量恰好落在同一个 cache line 里。 硬件不认识"变量",只认 cache line——于是每个核写自己的变量,都会把另一个核的整行打成 Invalid,触发 mesi-protocol 第四节 那套昂贵的失效+Flush+重载。明明没有共享数据,却像在抢同一把锁,故名"伪"共享。

// 经典伪共享:两线程各写各的,却在同一 64B 行里
struct {
long a; // 线程1 疯狂写 a
long b; // 线程2 疯狂写 b —— a、b 相邻,同属一个 cache line
} shared;现象:多线程反而比单线程慢,CPU 忙但没干有用活,perf stat 里 cache-misses 极高、IPC 极低。
二、机理拆解:为什么 64 字节就够"误伤"
伪共享成立的三个前提,缺一不可:
- 一致性以 cache line 为粒度:MESI 失效、Flush、重载的最小单位是一条 64B 的 line,不是变量。两个变量只要 Tag+Index 相同(落在同一条 line),就共享这条 line 的同一个 MESI 状态(见 cache-organization-addressing 的 line 元数据表)。
- 写之前必须独占:MESI 的 S 态不能直接写,必须先发
BusUpgr让所有其他副本失效、升到 M。核 0 写 a 时核 1 的整行被 invalidate;核 1 要写 b 又得把所有权抢回来——line 在两核间来回"乒乓"。 - 乒乓的每一步都走互联:每次易手都要付 mesi-protocol 第四节 那笔"失效广播 + 等 ACK + Flush + 重载"的互联往返,几十到上百拍。高频交替写时,两核几乎全程在等,真正干活的时间趋近于零。

一个残酷的细节:无论写的是 a 还是 b,被失效的永远是整条 64B line——即使 b 在物理上离 a 只差 8 字节、即使内核把两个变量隔得很远,只要还在同一行内就躲不掉。这就是为什么解法必须是把它们挤出同一行(见下节),而不是"隔开一点"。
为什么偏偏是 64 字节
这个数字是"空间局部性"和"传输成本"的妥协:行太小(如 8B)顺序访问时频繁拉取,tag 等元数据占比也高;行太大(如 128B)一次多取数据、顺序读更友好,但伪共享的误伤面翻倍、跨核传输也变慢。x86 从 32B 一路演进,主流稳定在 64B;ARM 同样常见 64B(部分核心支持 128B 行)。Linux 下可用 cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size 直接查当前机器的行大小。记"64B"就够了——它正是协议与硬件反复博弈后定下的粒度,也是对齐解法里"对齐到哪"的直接答案。 顺带一提:64 也是 8 的倍数,alignas(64) 对任意结构都天然满足成员对齐,不会引入撕裂。
三、解决:cache line 对齐 / 填充
让两个热点变量分处不同 cache line:
// 方法1:C++ alignas 强制每个变量独占一行
struct alignas(64) PaddedLong { long v; };
PaddedLong a, b;
// 方法2:手动填充
struct {
long a;
char pad[64 - sizeof(long)]; // 把 b 挤到下一行
long b;
} shared;
// 方法3:C++17 标准常量(跨平台,通常 = 64)
#include <new>
alignas(std::hardware_destructive_interference_size) long a;
alignas(std::hardware_destructive_interference_size) long b;对齐后各占一行,两核写彼此不再互相失效,多线程才真正并行加速。反面提醒:不要把"多个线程各自频繁更新的计数器/状态"塞进同一个数组或结构体的相邻位置。
对齐不是白拿的:alignas(64) 隔离后每条变量独占一整行,相邻字段被挤到下一行留下空洞,数组或对象池规模一大,内存开销肉眼可见。取舍口诀——只有"多核高频写"的字段才值得隔离;冷字段、读多写少字段铺 padding 纯属浪费,把缓存有效容量稀释了反而伤局部性。工程上更常见的是"把少数热字段挪到一起、整体对齐一行",而不是对每个成员盲目 padding。
这里是从多核伪共享角度讲"cache line 隔离"。内存对齐更广的性能影响——单次访存的对齐代价、未对齐撕裂/总线锁、结构体 padding、SIMD 对齐——见 memory-alignment。
四、如何观测(数据来源)
MESI 是硬件行为,不在 /proc 里,靠 CPU 的 PMU 硬件计数器,用 perf 采:
# 整体缓存未命中与 IPC —— 伪共享会让 cache-misses 高、IPC 低
perf stat -e cycles,instructions,cache-references,cache-misses ./app
# 缓存一致性相关事件(事件名因 CPU 型号而异,先 perf list 查)
perf list | grep -iE 'l1d|llc|hitm|coheren|snoop'
# Intel 上定位伪共享的利器
perf c2c record ./app # c2c = cache-to-cache
perf c2c report # 报告直接指出 HITM 高的 cache line + 变量偏移关键指标:
cache-misses高 +IPC(instructions/cycles) 低 → CPU 大量周期耗在等缓存/内存,是伪共享或访存模式差的信号。perf c2c专抓伪共享:报告指出哪个 cache line、哪两个变量、被哪些核争抢。HITM(Hit Modified,跨核命中对方 M 态行) 就是伪共享的指纹——对应 mesi-protocol 第四节 M→S/I 的 Flush。
perf c2c report 打开后重点看 HITM 列和按 cache line 聚合的争抢分布。伪共享行的特征很典型:某一行 HITM 明显偏高,展开后能看到两个不同偏移分别被两组不同 CPU 高频写。对照着读:同一偏移被多核读写是真共享;不同偏移、各核只写自己那份才是伪共享——报告按偏移展开,正好把这两种情况拆开,别一看到高 HITM 就扣伪共享的帽子。
五、实验:用本仓库写个最小对照
cpu_demo 是单线程程序,触发不了伪共享(需多核同时写相邻变量)。要体会的话写个最小对照实验:两线程各写一个 long,对比"挤同结构体" vs "各自 64B 对齐"的耗时——后者常快数倍。仓库里已有完整可跑的版本:
cd demos/false-sharing && make run # 对比 未对齐 vs 对齐 两版耗时一次典型结果(四核 x86、Linux 5.x、-O2)大致长这样,数值随 CPU 与频率浮动,看相对差距即可:
| 版本 | 两线程各写 1 亿次 long | 耗时 | 相对 |
|---|---|---|---|
| 伪共享版(a、b 同结构体) | 两线程跑完 | ~2.1 s | 1× |
| 对齐版(各自 64B 对齐) | 两线程跑完 | ~0.4 s | 约 5× 快 |
差距的来源就是机理拆解那张时序图:伪共享版每写一次都要付一次"失效广播 + 等 ACK"的互联往返,而 L1 命中的写只要几个周期——一次跨核往返顶得上上千次本地写,数量级差距就是这么来的。
perf stat -e cache-misses,cycles,instructions ./your_mt_app # 先看有无一致性开销
perf c2c record ./your_mt_app && perf c2c report # cache-miss 高就用 c2c 定位⚠️ 平台提醒:
perf c2c和精确的一致性事件需 Linux + 对应 CPU 的 PMU 支持(Intel/AMD 事件名不同),常需 root 或调低kernel.perf_event_paranoid;虚拟机里 PMU 可能被屏蔽。
六、伪共享 vs 原子争用 vs 真共享
三者都表现为"多线程抢同一条 line 很慢",但病因与对策不同:
| 数据关系 | 是否加锁/原子 | 典型场景 | 对策 | |
|---|---|---|---|---|
| 伪共享 | 各写各的,逻辑无关 | 无 | 线程私有计数器挤进同一结构体 | cache line 对齐/填充 |
| 原子争用 | 同一变量,逻辑共享 | atomic/锁 | 全局计数器 count++ | 无锁化、分片计数(per-thread counter) |
| 真共享 | 同一数据被多核读写 | 需同步 | 生产者-消费者队列 | 锁/无锁队列、减少锁持有时间 |
判定口诀:被争抢的如果是"两个不同变量",多半是伪共享(对齐就能治);如果是"同一个变量",那是真共享或原子争用(对齐治不了,得换算法)。
现实世界的伪共享
大型系统里伪共享从不缺席。教科书级例子是 Linux 内核的 struct rq(每 CPU 运行队列):nr_running、cpu_load[] 这类高频更新的热字段曾挤进同一条 cache line,调度器在不同 CPU 上更新它们时就在核间乒乓,内核后来靠字段重排和 ____cacheline_aligned 注解把热字段隔开。应用层同样常见:per-thread 计数器数组、连接池里每连接的状态位、日志缓冲区的每线程槽位——只要"按线程拆分"的结构字段挨得近就可能踩中。排查经验:多线程代码、锁很少、CPU 占用高但吞吐上不去,先怀疑伪共享,再怀疑原子争用,最后才是算法本身。
一句话总结
伪共享 = 变量不共享、但 64B 的 cache line 共享——两核各写各的却互相把对方整行失效,在互联上乒乓;解法是把热点变量对齐到不同 cache line(alignas(64)/padding),定位用 perf c2c 的 HITM。