TL;DR
- 伪共享不是“线程写同一个变量”,而是不同线程写不同变量却落在同一条 Cache Line(通常 64B)里,导致缓存一致性协议频繁失效/争用。
- 在撮合引擎、实时风控、行情聚合这类高频写路径中,伪共享会把缓存行在多核间来回“乒乓”,表现为核数越多吞吐越差、L3 争用/Cache Miss 飙升。
- 落地优化的核心是:让被不同线程高频写的字段物理上隔离(对齐/填充、拆结构、AoS→SoA),再用基准测试 + perf/PMC 计数器验证收益。
- 优化有 trade-off:填充会增加内存占用、降低缓存密度;因此只对热点做“精准手术”,不要全局无脑 padding。
1. 为什么“逻辑上独立”的计数器也会互相拖慢?
交易系统里常见的统计结构(买单数、卖单数、成交额等)看起来互不相关,线程 A/B/C 各写各的。
但 CPU 缓存是按缓存行搬运数据的:你写 8 字节,CPU 可能在缓存里拿的是 64 字节。
如果两个线程写的字段恰好位于同一缓存行,那么每次写入都可能触发 MESI 的 RFO(Request For Ownership),
让其他核的该行副本失效,缓存行被迫在核之间转移——这就是伪共享的成本来源。
关键要点 / 常见坑(工程视角)
- “加 volatile/加原子”不等于解决:它只改变可见性/原子性,不改变缓存行的物理布局;伪共享依然会发生(甚至更明显)。
- 症状很像“锁争用”:吞吐下降、CPU 似乎很忙,但锁很少;perf 里 cache-misses、LLC-load-misses、互连流量异常高。
- 结构体字段顺序会害人:两个热点计数器并排放在 struct 里,极易落到同一 cache line;多线程写时就开始“打乒乓”。
- 填充会带来反噬:padding 把对象膨胀,数组遍历时缓存命中率下降;热点写隔离了,但读取密集的路径可能变慢。
- 别盲猜缓存行大小:x86-64 常见是 64B,但不同平台/编译器布局不完全一致;要以实测与工具验证为准。
2. 定位路径:先用数据证明“是它”,再开刀
- 建立可复现 benchmark:模拟真实并发写入比例与数据分布(否则线上收益很容易对不上)。
- 用 perf/性能计数器找信号:重点看 cache miss、LLC 争用、跨核同步相关事件;结合热点函数/热点数据结构定位。
- 验证“拆开就好”:把两个字段临时分离到不同对象/不同数组,若吞吐明显回升,基本坐实伪共享。
3. 解决方案:从“对齐填充”到“数据结构重构”
目标只有一个:让不同线程高频写的变量不要共享同一缓存行。
常见落地方式可按侵入性从低到高排列:
- C/C++:alignas(64) / 手动 padding:让关键字段独占 cache line(适合计数器、状态位等高频写小字段)。
- Java:@Contended(需 JVM 参数启用):由 JVM 负责在字段周围插入填充,避免手写 padding 污染业务结构。
- AoS → SoA:把“结构体数组”改为“数组结构体”,让线程按列写入,天然隔离写热点;同时也更利于 SIMD/批处理。
- 封装抽象:用 CacheLineAligned 之类小工具把对齐细节收口,避免全项目散落“丑 padding”。
4. 适用场景:哪些团队值得立刻排查伪共享
- 撮合引擎 / 实时风控 / 行情聚合:高频更新计数器、盘口状态、聚合指标的路径。
- 多核扩展不线性:从 8 核到 16 核吞吐提升很小甚至下降,但业务锁不明显。
- 低延迟追到“纳秒级尾部”:平均值还行,但 tail latency 居高不下,且与并发写热点强相关。