先看一个真实的性能怪象:多线程程序里,两个线程各自只写自己的变量,没有锁、没有共享数据,理论上应该随核数线性加速。实测却是核数越多越慢,甚至跑不过单线程。代码没有 bug,结果也完全正确。
这类”看不见的性能损耗”,多半是伪共享(False Sharing)在作怪。这篇文章从 CPU 缓存的工作方式讲起,把它讲透。
1. 缓存行:CPU 读写内存的最小单位
CPU 访问内存比自己的运算速度慢得多,所以中间垫了几层高速缓存(L1 / L2 / L3)。CPU 优先从缓存里拿数据,拿不到才去内存。
这里有个容易被忽略的细节:CPU 和缓存之间交换数据的最小单位不是变量,而是缓存行(Cache Line)。主流 x86 和 ARM 上,一条缓存行是 64 字节。
也就是说,CPU 不会只搬一个 4 字节的 int。代码只要碰一下这个 int,它所在的整条 64 字节就会被拉进缓存。
假设两个变量 x、y 在内存里紧挨着放:
1 | Cache Line(64B) |
它们就落进了同一条缓存行。对访问局部性好的程序,这是好事——顺带把邻居也搬进来,下次访问邻居时不用再跑一趟内存。但这个细节,也是后面所有麻烦的来源。
2. 多核缓存一致性:MESI 协议
单核时代没有一致性问题。现代 CPU 每个核都有独立的 L1 缓存,同一个地址的数据可能同时存在于多个核的缓存里,只要有核写入,其他核手里的就是旧数据。硬件必须保证所有核看到的数据一致,这就是缓存一致性协议要做的事。主流方案是 MESI,它给每条缓存行标了四种状态:
| 状态 | 含义 | 说明 |
|---|---|---|
| M(Modified) | 已修改 | 本核修改过这行数据,其他核的副本全部失效 |
| E(Exclusive) | 独占 | 只有本核持有这行,且与内存一致 |
| S(Shared) | 共享 | 多个核都持有这行只读副本 |
| I(Invalid) | 无效 | 本核的副本已作废,不能使用 |
MESI 对”写”有一条硬性规定:
一个核要写某条缓存行里的任意一个字节,必须先让其他所有核上对应的缓存行副本失效(Invalid)。
注意失效的粒度:是整条缓存行,不是单个变量。哪怕只改里面 4 字节的 x,整条 64 字节都会作废。这条规定就是伪共享的根源。
3. 场景还原:Core0 写 x,Core1 写 y
把问题放到具体场景里。两个线程分别跑在两个核上:
- Core 0 上的线程只写
x,从不碰y; - Core 1 上的线程只写
y,从不碰x。
x 和 y 逻辑上毫无关系,没有数据竞争,也不需要任何同步。问题在于:它们恰好落在同一条缓存行里。
整个过程走一遍。
第一步,Core0 读取并修改 x。Core0 读 x 时,整条缓存行(连同 y)进入它的 L1,状态是 E(独占);修改 x 之后,状态变成 M(已修改)。
1 | Core0 L1: [x, y] M |
第二步,Core1 要修改 y:
- Core1 本地缓存没有这行数据,向总线发出写请求:”我要写
y所在的缓存行”; - 写请求在总线上广播,所有核都听得见,这套机制叫嗅探(snoop);
- Core0 嗅探到这个地址,和本地缓存行的 Tag 一比对,发现这行在自己手上,还是 M 态;
- 按协议,M 态的持有者要先把最新数据写回内存,再把本地副本置为 Invalid,最后回应总线;
- Core1 拿到这行数据,放进自己的 L1,改完
y后状态也变成 M。
1 | Core0 L1: [x, y] I ← 本地副本作废 |
第三步,Core0 又要写 x。它本地这行是 Invalid,不能直接写,只能再来一轮:让 Core1 失效,Core1 回写内存,自己重新拉取整行,然后才能改。
于是两个核陷入循环:
1 | Core0 写 x → Core1 的整行缓存失效 |
x 和 y 明明互不相干,两个核却在硬件层面反复争夺同一条缓存行:失效、回写、重新拉取,循环往复。这就是伪共享——逻辑上没有共享,物理上共享了缓存行。
4. 追问:Core1 缓存里没有 y,凭什么能踢掉 Core0 的缓存?
这是整个流程里最容易想不通的一步,单独拆出来讲。
失效的触发点不在 Core1 的缓存里,而在总线上:
- Core1 按内存地址发起写请求;
- 请求广播到总线(核间互连链路),所有核都能监听到;
- Core0 嗅探到这个地址,与本地缓存行的 Tag(内存高位地址)匹配成功;
- Core0 发现这行归自己持有、还是 M 态,于是按协议执行”回写 + 失效”。
换句话说,Core1 从头到尾没有”找到” y,是它的写请求被广播出去之后,Core0 自己认出了这个地址。
两个常见追问:
为什么 Core1 不能只拿 y 的 4 个字节?
缓存的传输粒度和协议状态都以整条缓存行为单位,硬件不支持更细的传输。
为什么这么设计?
整行传输浪费一点空间,但换来了简单的状态管理和不错的传输效率,是硬件层面权衡后的结果。
5. 真共享与伪共享
| 真共享(True Sharing) | 伪共享(False Sharing) | |
|---|---|---|
| 场景 | 多个核读写同一个变量 | 多个核读写完全无关的变量 |
| 竞争来源 | 业务本身就要共享数据 | 只是恰好挤在同一条缓存行 |
| 本质 | 同步是必需的开销 | 同步是多余的、纯硬件层面的意外 |
| 处理 | 用锁或原子操作,开销合理 | 用 padding / 对齐隔离,可以彻底消除 |
6. 伪共享的代价
- 缓存一致性流量暴涨:每个核写一次,都伴随广播失效、回写内存、重新拉取整行;
- 缓存命中率下降:本地副本刚拿到手就作废,缓存形同虚设;
- 多核扩展性差:核越多互相踩得越厉害,甚至跑不过单核;
- 高并发计数器、按线程统计的指标、多线程状态结构体,是重灾区。
7. 解决思路:让热变量各占一条缓存行
思路很直接:经常被不同核分别写入的热变量,别让它们挤进同一条缓存行。
方法一:手动填充(padding)
1 | struct Data { |
int a 占 4 字节,填充 60 字节后,b 就从下一条缓存行开始了。
方法二:C++17 标准对齐(推荐,可移植)
1 |
|
std::hardware_destructive_interference_size是标准库给出的建议对齐值,通常等于缓存行大小(64),名字里的 destructive interference 指的就是”主动破坏共享”;alignas强制变量的起始地址按这个值对齐,让a、b各自独占一条缓存行;- 标准库还配套提供了
std::hardware_constructive_interference_size,用途相反:把希望一起访问的数据放进同一条缓存行,对预取友好。
方法三:调整数据布局
多个线程独立修改的变量,不要塞在同一个结构体里。可以拆成独立分配的对象,或者按线程分组存放。
8. 编译器和语言会自动处理吗?
不会。这是个高频误区,值得展开讲。
编译器确实会加 padding,但目的完全不同。
1 | struct S { |
编译器会在 c 后面自动塞 3 字节,让 i 的地址按 4 对齐。这是为了满足 CPU 对齐访问的要求和 ABI 布局规则,和缓存行没有关系——c 和 i 大概率还在同一条缓存行里。
hardware_destructive_interference_size 也不是自动开关。
它只是一个常量,告诉你推荐的对齐值是多少,不会改变任何结构体的默认布局。编译器不知道哪些变量会被不同线程写——它看得到代码,看不到业务语义,无法判断相邻的两个变量是该隔离还是该放一起。如果编译器擅自加 padding,还会带来内存浪费、破坏 ABI、破坏既有序列化布局等副作用。所以这件事只能由程序员显式声明。
thread_local 同样不能幸免。
它让每个线程各持一份副本,但多份副本在内存里可能紧挨着,落在同一条缓存行里照样伪共享。
小结一下:
| 想要的效果 | 怎么做 |
|---|---|
| 默认布局 | 编译器只做类型对齐的 padding,不做缓存行隔离 |
| 规避伪共享 | 程序员显式声明 alignas(...) 或手动 padding |
| 检测问题 | 靠 perf / VTune 等性能工具,编译器不会自动检测 |
9. 常见误区
误区一:”伪共享是数据竞争,会导致内存错乱。”
不会。MESI 保证了数据的正确性,伪共享只影响速度,不影响结果。程序跑得慢,但输出没有任何问题,是纯粹的性能问题。
误区二:”结构体里有 padding,就不会伪共享。”
默认 padding 只对齐到类型边界(比如 int 的 4 字节),远小于一条缓存行的 64 字节,变量照样可能挤在同一行。防伪共享需要的是按缓存行对齐的 padding,两者不是一回事。
误区三:”thread_local 天然避免伪共享。”
见上一节:副本之间可能紧挨着。
误区四:”只读变量也会伪共享。”
不会。多个核同时读时,缓存行处于 S(共享)态,谁都不需要失效。伪共享只在多个核分别写同一条缓存行的不同变量时发生。
判断标准可以记成一条:写、同一缓存行、写的不是同一个变量,三个条件同时满足,就是伪共享。
10. 如何确认程序里有伪共享
几类典型信号:
- 线程数增加,加速比反而下降,甚至变慢;
- 各线程操作的数据完全独立、无锁无竞争,性能却依然上不去;
- perf / VTune 里 cache-miss 和缓存一致性相关事件异常偏高。
Linux 上可以直接用 perf 观察缓存相关事件:
1 | perf stat -e cache-misses,cache-references ./your_program |
最直接的验证办法是做对照实验:给怀疑的变量加上 alignas(64) 重新跑一遍,性能明显提升的话,基本就能坐实了。
小结
伪共享一句话概括:变量在逻辑上不共享,在缓存行上共享了。它不会在 code review 里暴露,也不产生任何错误,只在性能数据里露头。理解了”缓存行是缓存失效的最小单位”这一点,判断、排查、修复就都有了抓手。