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 知识体系图
下面这张图,把缓存行和伪共享的核心逻辑串起来了。你一看就明白。
3.7 避坑指南
最后,分享几个我踩过的坑:
- 别过度优化:不是所有多线程程序都有伪共享问题。先profile,再动手。
- 注意对齐:填充要保证变量按缓存行对齐。用
alignas或者__attribute__((aligned(64)))。 - 数组场景更隐蔽:多个线程操作同一个数组的不同元素,也可能触发伪共享。比如多线程累加数组元素。
- Java里小心:JVM可能会重新排列字段顺序。用
@Contended更靠谱。
一句话总结:伪共享的本质是缓存行级别的资源竞争。解决思路就两条——要么让数据别挨着,要么让线程别抢着。
好了,这一章就到这儿。记住,下次写多线程代码的时候,多想想缓存行。你想想看,一个64字节的缓存行,可能就是你程序性能的瓶颈。
想要更多资料,联系可可