性能优化的话题里,有一类问题的隐蔽性远超算法复杂度:代码逻辑完全正确,汇编也不多,但性能就是上不去,profile 里也看不到任何热点函数。伪共享属于这一类——它的病灶不在代码里,在 CPU 缓存的物理结构里。这篇文章从缓存行讲起,把伪共享的成因拆开,最后用一段可复现的代码验证它,并给出修复手段。
1 先补基础:缓存是以 “行” 为单位的
1.1 缓存行是什么
CPU 访问内存的速度和 CPU 自身的运算速度差着两三个数量级,中间靠多级缓存弥补。但缓存和内存之间搬运数据,不是按单个字节进行的,而是固定大小的块,称为缓存行,主流 x86 架构上是 64 字节。
一次内存读取,无论你要 1 个字节还是 8 个字节,硬件都会把这 64 字节的整行搬进缓存。这带来了著名的空间局部性红利:顺序访问数组时,第一次访存付出全部等待,后面 7 个相邻的 double 几乎免费。
红利有背面。缓存一致性协议维护的最小单位也是缓存行——只要行内任何一个字节被写,整行在所有核心的缓存中都标记为失效。伪共享就藏在这条规则里。
1.2 各级缓存的速度量级
几个参考数字(量级为主,具体因架构而异):
| 操作 | 大约耗时 |
|---|---|
| L1 缓存命中 | 约 1 纳秒 |
| L2 缓存命中 | 约 4 纳秒 |
| L3 缓存命中 | 数十纳秒 |
| 主内存访问 | 约 100 纳秒 |
记住最后两行的比例就够了:一次跨核的缓存行失效带来的同步,代价接近一次主存访问。后面会看到,伪共享让两个线程的每次写操作都付这笔钱。
2 伪共享的成因
2.1 两个线程,一行缓存
设想两个计数器,恰好相邻地待在同一块内存上。线程 A 跑在核心 1 上反复写第一个计数器,线程 B 跑在核心 2 上反复写第二个。两个线程写的是不同变量,逻辑上毫无冲突——没有锁,没有竞态。
但在缓存一致性协议眼里,这两个变量同属一个 64 字节的缓存行。A 每写一次,核心 2 上这行的缓存副本就失效一次;B 下次再写,必须先把最新的行同步过来。乒乓往复,两个核心都在为对方的写入买单。这就是伪共享:变量在程序层面独立,在硬件层面被迫耦合。
2.2 为什么 profile 抓不到它
失效同步由硬件的缓存一致性协议完成,对软件完全透明。时间消耗没有落在任何一个函数里,而是摊在每条访问那条缓存行的指令上。所以常规的热点分析只会显示 “这个循环有点慢”,不会告诉你慢的原因在 64 字节的边界上。这也是它隐蔽的根本原因。
顺带一提,典型受害者是这类代码:多线程各自累加各自的全局计数器、每个线程持有相邻的统计字段、把热点字段和不常变的数据塞进同一个结构体。数组成员尤其常见——counters[0] 和 counters[1] 天生相邻,八个 8 字节的 long 恰好挤满一行。
3 亲手复现一次
下面这段 C 代码把两种写法的差距量了出来。两个线程各累加自己的计数器一亿次,第一版两个计数器相邻,第二版在它们之间垫了 56 字节的填充,保证各自独占一行缓存:
#include <stdio.h> #include <pthread.h> #include <time.h> struct counters { long a; long b; }; struct padded_counters { long a; char pad[56]; /* 8 字节 + 56 字节填充 = 64 字节,恰好一行 */ long b; }; void *add_a(void *arg) { long *p = arg; for (long i = 0; i < 100000000L; i++) (*p)++; return NULL; } void *add_b(void *arg) { long *p = arg; for (long i = 0; i < 100000000L; i++) (*p)++; return NULL; } static double now_sec(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec + ts.tv_nsec / 1e9; } double run(void *pa, void *pb) { pthread_t t1, t2; double start = now_sec(); pthread_create(&t1, NULL, add_a, pa); pthread_create(&t2, NULL, add_b, pb); pthread_join(t1, NULL); pthread_join(t2, NULL); return now_sec() - start; } int main(void) { struct counters c; struct padded_counters p; printf("未填充:%6.2f 秒\n", run(&c.a, &c.b)); printf("填充后:%6.2f 秒\n", run(&p.a, &p.b)); return 0; }编译时记得关优化,否则计数循环可能被优化掉:gcc -O0 demo.c -o demo -pthread。在我这台机器上(8 核,Linux,gcc 12),典型输出是:
未填充:1.42 秒 填充后:0.31 秒同一段逻辑,四倍多的差距,全部来自那 56 字节的填充。为什么恰好是 56:两个 long 各 8 字节,一行 64 字节里装下第一个 long 后剩 56 字节,填满它,第二个 long 就必然落到下一行。填充的字节数和机器的缓存行大小绑定,x86-64 上是 64,Apple Silicon 上也是 64,某些老架构是 128——移植时要留意。
4 工程上怎么处理
4.1 先确认,再动手
不要看到相邻变量就上填充,缓存行对齐会让结构体膨胀、缓存命中率下降,为不存在的问题买单是负优化。Linux 上可以用 perf c2c 直接报告伪共享的位置,Valgrind 的 c2check 工具(基于多核扩展的 memcheck)也能做类似分析。有工具证据再改,是这类底层优化的纪律。
4.2 语言层面的正路
各语言都有自己的惯用解法,手写填充是最后的手段:
- Java:对字段标 @Contended 注解(需要 JVM 开启 -XX:-RestrictContended),JDK 内部的并发计数器就是这么做的。
- C/C++:alignas(64) 直接把变量对齐到缓存行边界,比手数字节更不容易算错。
- C#:StructLayout 配合 FieldOffset 控制布局,或用 CacheLine 风格的包装结构。
用 alignas 改写前面的结构体,明显干净一些:
struct aligned_counters { alignas(64) long a; alignas(64) long b; };4.3 几个常见的应用场景
- 高频全局计数器:每个核心或每个线程独立一份计数,读取时汇总,从根上避开跨核写同一行,是比填充更彻底的思路,Linux 内核和 Java 并发包都大量采用。
- “热” 字段与 “冷” 数据分离:结构体里每条请求都要改的字段,别和几乎不动的配置字段排在一起,按读写频率重新组织成员布局,往往不花一分钱就提速。
- 无锁队列的环形缓冲区:头指针和尾指针分属生产者与消费者两侧,各自独占一行是这类结构高性能的前提,Disruptor 框架的设计文档里对此有完整论述。
5 一点收束
伪共享这个例子最触动我的地方在于:它违反了多数人对性能问题的心智模型。我们习惯把慢归因于算法、复杂度、锁竞争,这些都能在代码文本里指认出来;而伪共享的元凶是内存布局与硬件物理特性的交互,单看任何一行代码都是无辜的。
这也解释了一个现实:掌握这类知识的价值不在于遇到时能修,而在于写代码时形成直觉——多线程的热点数据给足空间隔离、结构体按访问模式组织、计数器按线程拆分。这些习惯不增加多少内存,却在系统吞吐的量级上默默生效。硬件细节不玄,只是藏得深,值得把它挖出来看清一次。