🔑 伪共享:你的并发程序为什么比单线程还慢

多核 CPU 的 cache 不是按单个变量管理的,而是按 cache line(通常 64 字节)为单位加载。两个变量如果恰好在内存里相邻,落在同一条 cache line 上,哪怕线程 A 只写变量 x、线程 B 只写变量 y,它们也会互相把对方的 cache line 弈无效——这就是 false sharing,中文叫伪共享。

问题在于它完全静默。你的代码看起来每个线程操作独立变量,逻辑上无竞争,连 race detector 都不会报警,但性能可能比单线程还差好几倍。原因是一条 cache line 在两个核心之间反复弹来弹去,每次写操作都让对方核心的 cache 副本失效,触发 MESI 协议的 invalidate + reload 往返。

什么时候会踩坑?最经典的是计数器场景。你写一个 struct,里面放 N 个线程各自的计数 int,以为这样每个线程只碰自己的字段就安全了。但 N 个 int 紧挨着,全挤在一条 64 字节的 cache line 里,对齐得越整齐,伪共享越严重。

解法叫 padding:在每个变量后面补足无用字节,人为撑开到 cache line 对齐。C/C++ 里用 alignas(64),Java 里在字段后面塞 6 个 long 占位,Go 从 1.19 起也支持 //go:alignas 注解。看起来浪费内存,实际上换回来的是几倍的吞吐。

更高级的做法是把只读数据放在一个 cache line、写数据放另一个,利用读写分离天然避免竞争。Linux 内核里很多 per-CPU 变量都做了 padding,不是洁癖,是被坑过才加的。

现代编译器和运行时也在帮你。比如 Java 的 @Contended 注解、C++ 的 std::hardware_destructive_interference_size(C++17 起),都是在语言层面把这个问题显式化。

判断你是否中招:如果一段并发代码的 profile 显示大量 cache miss 且性能随核心数增加不升反降,先怀疑伪共享。

🧪 假设 cache line 为 64 字节,以下 Go 结构体在 8 核上跑,每个 goroutine 只原子递增自己的 Counter 字段,哪个会触发伪共享?

// 版本 A
type Stats struct {
    Counters [8]int64
}

// 版本 B
type Stats struct {
    Counters [8]struct {
        n    int64
        pad  [56]byte
    }
}


答案:版本 A 会触发伪共享。8 个 int64 各 8 字节,共 64 字节,恰好填满一条 cache line,所有核互相失效。版本 B 每个元素被 pad 撑到 64 字节对齐,各自独占一条 cache line,无伪共享。

💡 易混淆点:伪共享和 data race 是两回事。data race 是正确性问题,多线程同时写同一变量导致未定义行为;伪共享是性能问题,各写各的变量但刚好共享 cache line。你的代码可以是完全 race-free 的,仍然被伪共享拖垮。

#CS