性能优化

CPU缓存行和伪共享(false sharing)

2026-08-15 #CPU#缓存#性能优化#并发

先看一个真实的性能怪象:多线程程序里,两个线程各自只写自己的变量,没有锁、没有共享数据,理论上应该随核数线性加速。实测却是核数越多越慢,甚至跑不过单线程。代码没有 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
2
3
4
5
Cache Line(64B)
+--------+--------+--------------------------------------+
| x | y | 其余空间 |
+--------+--------+--------------------------------------+
偏移0~3 偏移4~7

它们就落进了同一条缓存行。对访问局部性好的程序,这是好事——顺带把邻居也搬进来,下次访问邻居时不用再跑一趟内存。但这个细节,也是后面所有麻烦的来源。

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
2
Core0 L1: [x, y]  M
Core1 L1: (还没有这条缓存行)

第二步,Core1 要修改 y:

  1. Core1 本地缓存没有这行数据,向总线发出写请求:”我要写 y 所在的缓存行”;
  2. 写请求在总线上广播,所有核都听得见,这套机制叫嗅探(snoop);
  3. Core0 嗅探到这个地址,和本地缓存行的 Tag 一比对,发现这行在自己手上,还是 M 态;
  4. 按协议,M 态的持有者要先把最新数据写回内存,再把本地副本置为 Invalid,最后回应总线;
  5. Core1 拿到这行数据,放进自己的 L1,改完 y 后状态也变成 M。
1
2
Core0 L1: [x, y]  I   ← 本地副本作废
Core1 L1: [x, y] M

第三步,Core0 又要写 x。它本地这行是 Invalid,不能直接写,只能再来一轮:让 Core1 失效,Core1 回写内存,自己重新拉取整行,然后才能改。

于是两个核陷入循环:

1
2
3
Core0 写 x → Core1 的整行缓存失效
Core1 写 y → Core0 的整行缓存失效
Core0 写 x → ……

x 和 y 明明互不相干,两个核却在硬件层面反复争夺同一条缓存行:失效、回写、重新拉取,循环往复。这就是伪共享——逻辑上没有共享,物理上共享了缓存行。

4. 追问:Core1 缓存里没有 y,凭什么能踢掉 Core0 的缓存?

这是整个流程里最容易想不通的一步,单独拆出来讲。

失效的触发点不在 Core1 的缓存里,而在总线上:

  1. Core1 按内存地址发起写请求;
  2. 请求广播到总线(核间互连链路),所有核都能监听到;
  3. Core0 嗅探到这个地址,与本地缓存行的 Tag(内存高位地址)匹配成功;
  4. Core0 发现这行归自己持有、还是 M 态,于是按协议执行”回写 + 失效”。

换句话说,Core1 从头到尾没有”找到” y,是它的写请求被广播出去之后,Core0 自己认出了这个地址。

两个常见追问:

为什么 Core1 不能只拿 y 的 4 个字节?
缓存的传输粒度和协议状态都以整条缓存行为单位,硬件不支持更细的传输。

为什么这么设计?
整行传输浪费一点空间,但换来了简单的状态管理和不错的传输效率,是硬件层面权衡后的结果。

5. 真共享与伪共享

真共享(True Sharing) 伪共享(False Sharing)
场景 多个核读写同一个变量 多个核读写完全无关的变量
竞争来源 业务本身就要共享数据 只是恰好挤在同一条缓存行
本质 同步是必需的开销 同步是多余的、纯硬件层面的意外
处理 用锁或原子操作,开销合理 用 padding / 对齐隔离,可以彻底消除

6. 伪共享的代价

  • 缓存一致性流量暴涨:每个核写一次,都伴随广播失效、回写内存、重新拉取整行;
  • 缓存命中率下降:本地副本刚拿到手就作废,缓存形同虚设;
  • 多核扩展性差:核越多互相踩得越厉害,甚至跑不过单核;
  • 高并发计数器、按线程统计的指标、多线程状态结构体,是重灾区。

7. 解决思路:让热变量各占一条缓存行

思路很直接:经常被不同核分别写入的热变量,别让它们挤进同一条缓存行。

方法一:手动填充(padding)

1
2
3
4
5
struct Data {
int a;
char pad[60]; // 补齐 64 字节,把 b 推到下一条缓存行
int b;
};

int a 占 4 字节,填充 60 字节后,b 就从下一条缓存行开始了。

方法二:C++17 标准对齐(推荐,可移植)

1
2
3
4
5
6
7
#include <cstdint>
#include <new>

struct Stats {
alignas(std::hardware_destructive_interference_size) uint64_t a;
alignas(std::hardware_destructive_interference_size) uint64_t b;
};
  • std::hardware_destructive_interference_size 是标准库给出的建议对齐值,通常等于缓存行大小(64),名字里的 destructive interference 指的就是”主动破坏共享”;
  • alignas 强制变量的起始地址按这个值对齐,让 a、b 各自独占一条缓存行;
  • 标准库还配套提供了 std::hardware_constructive_interference_size,用途相反:把希望一起访问的数据放进同一条缓存行,对预取友好。

方法三:调整数据布局

多个线程独立修改的变量,不要塞在同一个结构体里。可以拆成独立分配的对象,或者按线程分组存放。

8. 编译器和语言会自动处理吗?

不会。这是个高频误区,值得展开讲。

编译器确实会加 padding,但目的完全不同。

1
2
3
4
struct S {
char c; // 1 字节
int i; // 要求 4 字节对齐
};

编译器会在 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 里暴露,也不产生任何错误,只在性能数据里露头。理解了”缓存行是缓存失效的最小单位”这一点,判断、排查、修复就都有了抓手。

评论
分享