两个线程改互不相关的变量,性能掉了 7 倍

两个线程改互不相关的变量,性能掉了 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++;

packedpadded 的逻辑完全等价。唯一区别是两个计数器在内存里的距离: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 版本发生的事情是:

  1. 核 0 写 packed[0].v → 抢走 line,核 1 的副本失效
  2. 核 1 要写 packed[1].v(跟核 0 的变量毫无关系)→ 但它需要的字节就在那条被抢走的 line 里 → 重新 RFO,核 0 的副本失效
  3. 回到第 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 被两个线程同时写。

各家标准库都在认真处理这件事,可以当作交叉验证:

  • JDKLongAdder 的父类 Striped64 里的 Cell 打了 @Contended 注解(JDK 8+ 用户代码要生效需加 -XX:-RestrictContended
  • Gosync.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 高得离谱,但工作集明明很小。

有三条路,按优先级:

  1. 消除共享本身 (最优)。让每个线程在自己的局部变量 里累加,退出前才合并一次。局部变量在寄存器/栈上,根本不产生一致性流量。上面那个 benchmark 如果去掉 volatile,编译器就会自动帮你做这件事------所以真实代码里首先该问的是「这个值真的需要每次都写回内存吗」。
  2. 对齐/填充alignas(64)@Contended、手写 char pad[]
  3. 重排字段,把「一个线程写、其他线程只读」的冷数据和高频写字段分开。

顺便一个细节 :Intel 有 adjacent cache line prefetch,会成对预取相邻的两条 line,所以某些场景要填到 128 字节才彻底干净------这也是 Go 标准库用 128 而不是 64 的原因。

最后

padding 不是免费的,它拿内存和 cache 容量换一致性流量。只对被多线程高频写入的热点字段做,别整个项目撒糖。

判断标准很简单一句话:如果两个线程写的东西在 64 字节以内,它们就是在竞争,哪怕你觉得它们毫无关系。

相关推荐
程序员清风1 小时前
为什么不推荐走Agent开发?
java·后端·面试
Rain的Java大神之路1 小时前
高并发场景下MySQL数据库性能优化实战
java·后端·mysql
明月_清风2 小时前
分治、贪心、回溯、动态规划:四种经典算法思想
后端·算法
Johnston_man2 小时前
golang 安装 protoc、protoc-gen-go、protoc-gen-go-grpc、protoc-gen-grpc-gateway
后端·golang
ttwuai2 小时前
Go 后台接入单点登录后,JWT、菜单权限和接口 403 怎么验?
开发语言·后端·golang
落木萧萧8252 小时前
MyBatis 启动的时候都在干什么:从 MappedStatement 说起
java·数据库·后端
国奉2 小时前
iOS 如何处理 GB 级大文件?从 FileHandle、分块读写到进度、取消与异常恢复
后端
Json____2 小时前
基于 FastAPI + Vue3 的在线拍卖系统技术解析
spring boot·后端·fastapi·wwwoop.com
创新技术阁2 小时前
FastapiAdmin 实战:二次开发前的准备(环境配置与项目启动)
前端·后端·fastapi