3. 缓存行与伪共享:CPU缓存行的概念,多线程编程中的伪共享问题及解决方案

3.1 先聊聊CPU缓存行

咱们写代码这么多年,有没有想过一个问题:为什么有时候多线程程序反而比单线程还慢?

我第一次遇到这情况,是在优化一个高并发计数器的时候。8个线程一起跑,按理说应该快8倍,结果性能直接腰斩。我当时就懵了——这不对啊!

后来一查,问题出在CPU缓存上。嗯,这里要好好讲讲。

CPU从内存读数据,不是一次读一个字节。它一次读一整块,这块就叫缓存行。在大多数x86处理器上,一个缓存行是64字节。

说白了,你读一个int,CPU顺手把旁边的63个字节也捎上了。为啥?因为程序访问内存有空间局部性——你访问了地址A,大概率马上要访问A+1、A+2。

核心概念:缓存行是CPU和内存之间数据传输的最小单位。大小通常为64字节(x86架构)或32字节(ARM部分架构)。

3.2 伪共享——多线程的隐形杀手

好,现在想象一个场景。

两个线程,分别操作两个不同的变量。变量A在线程1里频繁修改,变量B在线程2里频繁修改。看起来互不干扰,对吧?

但如果这两个变量恰好在同一个缓存行里呢?

线程1改了A,整个缓存行就失效了。线程2想读B,发现缓存行没了,得重新从内存加载。反过来也一样。两个线程就这么互相踢来踢去,性能直接崩了。

这就是伪共享——明明操作的是不同数据,却因为共享了同一个缓存行,导致缓存不断失效。

注意:伪共享不是数据竞争,也不是线程安全问题。它是纯粹的性能问题。你的程序逻辑完全正确,但就是跑得慢。

3.3 我在项目中踩过的坑

我曾经优化过一个消息队列。队列头由生产者线程写,队列尾由消费者线程读。两个变量紧挨着定义在结构体里。

结果呢?生产者和消费者互相拖累。吞吐量只有预期的30%。

我当时排查了很久,perf一看,L1缓存缺失率高达40%。这才意识到是伪共享在作祟。

3.4 解决方案:缓存行填充

解决伪共享的思路很简单:让不同线程操作的数据,不要落在同一个缓存行里。

最经典的做法就是缓存行填充——在变量之间插入足够的填充字节,让它们隔开。

// 错误示例:两个变量可能在同一缓存行
struct Counter {
    long producer_count;
    long consumer_count;
};

// 正确示例:缓存行填充
struct Counter {
    long producer_count;
    char padding1[56];  // 填充到64字节
    long consumer_count;
    char padding2[56];  // 填充到64字节
};

在C++11及以后,标准库提供了std::hardware_destructive_interference_size,可以直接获取缓存行大小。

#include <new>

struct alignas(std::hardware_destructive_interference_size) Counter {
    long producer_count;
    long consumer_count;
};

Java里也有类似的做法。JDK 8引入了@Contended注解,可以自动填充缓存行。

// Java示例
@sun.misc.Contended
public class Counter {
    volatile long producerCount;
    volatile long consumerCount;
}

个人建议:不要滥用缓存行填充。它增加了内存占用,而且只在多线程高频访问不同变量时才有必要。我一般只在性能热点上使用。

3.5 其他解决方案

除了填充,还有几种思路:

  • 线程本地存储:把数据拆到每个线程自己的空间里,彻底避免共享。比如用ThreadLocal。
  • 数据分拆:把结构体拆开,让不同线程操作不同的结构体实例。
  • 读写分离:读多写少的场景,可以用读写锁或者RCU。

3.6 知识体系图

下面这张图,把缓存行和伪共享的核心逻辑串起来了。你一看就明白。

缓存行与伪共享知识体系 CPU核心1 CPU核心2 L1缓存 L1缓存 缓存行(64字节) 缓存行(64字节) 变量A 变量B 变量C 变量D ⚠ 伪共享:A和B在同一缓存行 ✓ 正常:C和D在不同缓存行 解决方案:缓存行填充 | 线程本地存储 | 数据分拆

3.7 避坑指南

最后,分享几个我踩过的坑:

  • 别过度优化:不是所有多线程程序都有伪共享问题。先profile,再动手。
  • 注意对齐:填充要保证变量按缓存行对齐。用alignas或者__attribute__((aligned(64)))。
  • 数组场景更隐蔽:多个线程操作同一个数组的不同元素,也可能触发伪共享。比如多线程累加数组元素。
  • Java里小心:JVM可能会重新排列字段顺序。用@Contended更靠谱。

一句话总结:伪共享的本质是缓存行级别的资源竞争。解决思路就两条——要么让数据别挨着,要么让线程别抢着。

好了,这一章就到这儿。记住,下次写多线程代码的时候,多想想缓存行。你想想看,一个64字节的缓存行,可能就是你程序性能的瓶颈。

想要更多资料,联系可可