两个线程改互不相关的变量,性能掉了 7 倍
先看一段没有任何共享状态、没有锁、没有原子操作的代码。两个线程各自累加自己的计数器:
c
arduino
typedef struct { volatile long v; } packed_t; // 紧挨着
typedef struct { volatile long v; char pad[56]; } padded_t; // 填充到 64 字节
static packed_t packed[2];
static padded_t padded[2];
// 线程 id 只碰 arr[id].v,两个线程的目标变量完全不重叠
for (long i = 0; i < 200000000L; i++) arr[id].v++;
packed 和 padded 的逻辑完全等价。唯一区别是两个计数器在内存里的距离:8 字节 vs 64 字节。
实测(Xeon 2.1GHz,2 核,gcc 13 -O2):
ini
sizeof(packed elem) = 8, 两个计数器地址差 = 8 字节
sizeof(padded elem) = 64, 两个计数器地址差 = 64 字节
round 1: 同一 cache line = 0.544s padding 隔开 = 0.074s 慢 7.32x
round 2: 同一 cache line = 0.547s padding 隔开 = 0.076s 慢 7.17x
round 3: 同一 cache line = 0.543s padding 隔开 = 0.073s 慢 7.47x
56 个字节的空气,换来 7 倍性能。 这就是伪共享(false sharing)。
为什么
因为CPU 不认识「变量」,它只认识 cache line。
x86-64 的 cache line 是 64 字节(getconf LEVEL1_DCACHE_LINESIZE 可以确认),它是缓存一致性协议(MESI)的最小操作单位。核心机制是:
一个核要写某个地址,必须先把这条 cache line 拿到 Exclusive/Modified 状态,也就是向其他核发起 RFO(Read For Ownership),把别人手里这条 line 的副本全部置为 Invalid。
于是 packed 版本发生的事情是:
- 核 0 写
packed[0].v→ 抢走 line,核 1 的副本失效 - 核 1 要写
packed[1].v(跟核 0 的变量毫无关系)→ 但它需要的字节就在那条被抢走的 line 里 → 重新 RFO,核 0 的副本失效 - 回到第 1 步
两亿次循环,就是两亿次 cache line 在两个核之间来回弹。这叫 cache line ping-pong。一次核间转发(HITM)大约几十个周期,而本该命中的 L1 只需要 4 个周期左右------差距就是这么来的。
「伪」的含义就在这里:逻辑上没有任何共享,物理上共享了一条缓存行。
这不是玩具代码
伪共享最常见的三种真实形态:
1. 分片计数器 / per-thread 槽位。 为了避免锁竞争,很多人会写 long counters[NUM_THREADS],每个线程只碰自己那格。听起来很聪明,但 8 个 long 刚好塞进一条 cache line------你把锁竞争换成了缓存竞争,往往更慢。
2. 结构体字段布局。 一个高频自增的 head/tail、一个自旋锁标志、一个 refcount,如果挨着声明,它们就在同一条 line 上。生产者写 head、消费者写 tail,队列吞吐直接腰斩------这是无锁队列最经典的坑。
3. 数组元素的边界。 线程按下标分段处理数组,段的交界处那条 cache line 被两个线程同时写。
各家标准库都在认真处理这件事,可以当作交叉验证:
- JDK :
LongAdder的父类Striped64里的Cell打了@Contended注解(JDK 8+ 用户代码要生效需加-XX:-RestrictContended) - Go :
sync.Pool的 per-P 结构体尾部就挂着一段pad - Linux 内核 :满地都是
____cacheline_aligned - C++17 :专门给了个
std::hardware_destructive_interference_size - Disruptor:靠 padding 出名的那半个框架
怎么定位,怎么修
定位 用 perf c2c(Linux 专门为此设计的工具):
bash
bash
perf c2c record -- ./your_app
perf c2c report
它会直接告诉你哪条 cache line 在被多少个核抢,以及是哪几行代码在抢。粗筛也可以先看 perf stat -e cache-misses,cycles------伪共享的特征是 cache-misses 高得离谱,但工作集明明很小。
修有三条路,按优先级:
- 消除共享本身 (最优)。让每个线程在自己的局部变量 里累加,退出前才合并一次。局部变量在寄存器/栈上,根本不产生一致性流量。上面那个 benchmark 如果去掉
volatile,编译器就会自动帮你做这件事------所以真实代码里首先该问的是「这个值真的需要每次都写回内存吗」。 - 对齐/填充 。
alignas(64)、@Contended、手写char pad[]。 - 重排字段,把「一个线程写、其他线程只读」的冷数据和高频写字段分开。
顺便一个细节 :Intel 有 adjacent cache line prefetch,会成对预取相邻的两条 line,所以某些场景要填到 128 字节才彻底干净------这也是 Go 标准库用 128 而不是 64 的原因。
最后
padding 不是免费的,它拿内存和 cache 容量换一致性流量。只对被多线程高频写入的热点字段做,别整个项目撒糖。
判断标准很简单一句话:如果两个线程写的东西在 64 字节以内,它们就是在竞争,哪怕你觉得它们毫无关系。