1. 为什么软件工程师需要关心CPU缓存一致性
大多数写业务代码的人,日常打交道的是语言运行时、框架和数据库,很少会主动去想CPU内部发生了什么。但只要你碰过并发编程,就一定遇到过一些"反直觉"的现象:两个线程各自修改一个独立的变量,性能却比单线程慢好几倍;明明代码里写了volatile,结果还是读到了旧值;用无锁队列替换掉互斥锁,吞吐量不升反降。这些问题往下挖,最终都会落到同一个地方——多核CPU的共享内存模型。
我刚开始做后端服务的时候,对这块完全是黑盒认知。直到有一次排查一个诡异的性能问题:一个统计计数器在多线程下累加,用了原子操作,逻辑上没有任何问题,但在16核机器上跑出来的吞吐量只有单核的3倍。后来用perf工具一看,缓存未命中率高得离谱,问题出在多个核心反复争抢同一条缓存行。那次经历让我意识到,不理解硬件层面的内存模型,写并发代码基本靠运气。
这篇文章就是把我这些年从软件工程师视角理解多核共享内存模型的过程整理出来。不追求体系结构教材那种完备性,而是聚焦在"写代码时真正会遇到什么"这个角度。适合有基本并发编程经验、想搞清楚底层原理的后端或系统方向开发者。读完之后,你应该能解释清楚伪共享为什么慢、内存屏障到底在挡什么、以及为什么有些看起来安全的代码在多核上会出问题。
2. 从单核到多核:共享内存模型到底"共享"了什么
2.1 多核CPU的真实物理结构
很多人脑子里的多核CPU模型是这样的:多个核心连到一根总线上,总线再连到一块统一的内存。这个模型在早期SMP(对称多处理)时代大致成立,但现代CPU早就不是这样了。
真实的物理结构是分层的。每个核心有自己的L1指令缓存、L1数据缓存和L2缓存,这些是核心私有的,其他核心访问不到。然后多个核心共享一个L3缓存(有些架构叫LLC,Last Level Cache),L3再通过内存控制器连接到DRAM。核心之间通过片上互连网络通信,比如环形总线或者网格互连。
这个结构带来的直接后果是:核心A写了一个变量,这个写操作首先落在A自己的L1缓存里,不会立刻写到主存。核心B要读这个变量,如果B的L1里没有,它会去L3找,L3里如果有(因为A的写最终会传播到L3),B就能拿到。但如果B的L1里恰好有一份旧的副本,那就麻烦了——B可能读到过期的数据。
这就是共享内存模型要解决的核心问题:多个核心各自有缓存,如何保证它们看到的内存状态是一致的。
2.2 缓存行:数据搬运的最小单位
理解共享内存模型,必须先理解缓存行(Cache Line)。CPU和内存之间不是按字节传输数据的,而是按固定大小的块传输,这个块就是缓存行。x86架构上缓存行通常是64字节,ARM架构上也多为64字节。
为什么是64字节而不是更小?因为空间局部性原理。程序访问了一个变量,大概率接下来会访问它附近的变量。一次搬64字节,比每次搬1字节效率高得多。这个设计在单核时代非常合理,但在多核时代带来了一个副作用:如果两个核心分别修改同一缓存行里的不同变量,硬件仍然会认为它们在争抢同一份数据。
举个例子。假设有两个变量a和b,它们在内存里紧挨着,落在同一条64字节的缓存行里。核心1只修改a,核心2只修改b。逻辑上它们互不干扰,但硬件层面,核心1修改a时需要独占这条缓存行,核心2修改b时也需要独占同一条缓存行。于是两个核心就会反复争夺这条缓存行的所有权,缓存行在两个核心的L1之间来回弹跳。这个现象叫伪共享(False Sharing),是并发程序性能杀手之一。
2.3 缓存一致性协议在做什么
为了保证多个核心看到一致的内存视图,硬件实现了缓存一致性协议。最经典的是MESI协议,它的名字来自四种缓存行状态:
| 状态 | 含义 | 其他核心是否有副本 |
|---|---|---|
| Modified | 本核心修改过,与主存不一致 | 无 |
| Exclusive | 本核心独占,与主存一致 | 无 |
| Shared | 多个核心共享,与主存一致 | 有 |
| Invalid | 缓存行无效 | 不确定 |
当一个核心要写某个缓存行时,它必须先让其他核心中该缓存行的副本失效。这个过程叫"缓存行失效广播"。其他核心收到失效消息后,把自己L1里对应的缓存行标记为Invalid。然后写核心才能把缓存行改成Modified状态并写入。
读操作相对简单:如果缓存行是Shared或Exclusive状态,直接读;如果是Invalid,就需要从其他核心或L3或主存拉取。
这套协议保证了单个变量的读写一致性,但它不保证多个变量之间的顺序。也就是说,核心1先写a再写b,核心2可能先看到b的新值再看到a的新值。这就是内存重排序问题,也是内存屏障要解决的事情。
3. 内存重排序:编译器和你开的玩笑
3.1 三种重排序的来源
写并发代码时,你写的代码顺序和实际执行顺序可能完全不同。重排序有三个来源:
第一是编译器优化。编译器在保证单线程语义不变的前提下,会调整指令顺序。比如把循环里不变的读取提到循环外,或者把两个独立的写操作调换顺序以减少寄存器压力。
第二是CPU的乱序执行。现代CPU都是超标量的,一个周期可以发射多条指令。为了填满执行单元,CPU会在指令窗口内动态调度,只要数据依赖满足,后面的指令可以先执行。
第三是存储缓冲(Store Buffer)。核心写数据时,不是直接写到缓存,而是先写到存储缓冲,然后再异步刷到缓存。这个缓冲的存在让写操作对当前核心来说"立刻完成",但对其他核心来说"还没发生"。
这三种重排序叠加在一起,导致一个核心上的写操作,在另一个核心看来顺序可能是乱的。
3.2 一个具体的重排序案例
考虑这段伪代码,两个线程分别执行:
// 线程1 x = 1; flag = 1; // 线程2 while (flag == 0) {} print(x);直觉上,线程2看到flag变成1之后,x一定已经是1了。但在弱内存模型下,线程1的两次写可能被重排序,flag先于x对其他核心可见。线程2看到flag为1,跳出循环,打印x却得到0。
这个例子在x86上通常不会出问题,因为x86是强内存模型(TSO),只允许写读重排序,不允许写写重排序。但在ARM和RISC-V这类弱内存模型架构上,这个重排序是真实存在的。这也是为什么同样的并发代码,从x86迁移到ARM服务器上可能出bug。
3.3 内存屏障:给重排序画一条线
内存屏障(Memory Barrier)是解决重排序的手段。它告诉编译器和CPU:屏障之前的操作不能排到屏障之后,屏障之后的操作不能排到屏障之前。
常见的内存屏障类型:
- 写屏障(Store Barrier):保证屏障前的所有写操作,在屏障后的写操作之前对其他核心可见。
- 读屏障(Load Barrier):保证屏障前的所有读操作,在屏障后的读操作之前完成。
- 全屏障(Full Barrier):读写都不能跨越。
在高级语言里,这些屏障通常被封装成原子操作的内存序参数。C++的std::atomic提供了memory_order_acquire、memory_order_release、memory_order_seq_cst等选项。Java的volatile关键字在写时插入写屏障,读时插入读屏障。Go的sync/atomic包也提供了类似的语义。
理解这些内存序的关键是:它们不是"刷新缓存"这种物理动作,而是限制重排序的约束。memory_order_release保证之前的写操作不会被重排到release之后,memory_order_acquire保证之后的读操作不会被重排到acquire之前。两者配对使用,就能建立起跨线程的同步关系。
4. 伪共享的识别与消除:一个真实的优化案例
4.1 伪共享为什么比想象中更常见
伪共享的隐蔽性在于,代码逻辑上完全正确,没有任何数据竞争,但性能就是上不去。而且它不会报错,不会崩溃,只是慢。很多开发者遇到性能问题会先怀疑锁、怀疑算法、怀疑GC,最后才想到缓存行。
我见过最典型的场景是计数器数组。比如一个线程池,每个工作线程有自己的任务计数,存在一个数组里。数组元素是long类型,8字节,8个元素就占满一条64字节缓存行。如果两个线程的计数恰好落在同一条缓存行里,它们每次更新计数都会导致缓存行在核心间弹跳。
还有一个常见场景是队列的head和tail指针。如果head和tail定义在同一个结构体里且相邻,生产者更新tail、消费者更新head,两者就会互相干扰。这也是为什么高性能队列(比如Disruptor)会把head和tail用填充字节隔开。
4.2 用填充消除伪共享
消除伪共享的基本思路是让每个被频繁独立修改的变量独占一条缓存行。做法是在变量前后填充无用的字节,把缓存行撑满。
struct padded_counter { long value; char padding[64 - sizeof(long)]; };这样每个padded_counter占64字节,两个计数器不会落在同一条缓存行里。C++里可以用alignas(64)更优雅地实现:
struct alignas(64) PaddedCounter { std::atomic<long> value; };Java里没有直接的alignas,但可以通过继承一个填充类或者用@Contended注解(需要开启JVM参数-XX:-RestrictContended)来实现。Go里可以在结构体里加_ [64]byte填充字段。
4.3 实测数据与验证方法
光说理论不够,得能验证。Linux上可以用perf c2c工具来检测伪共享。它会统计缓存行的争抢情况,直接告诉你哪些变量导致了跨核的缓存行弹跳。
perf c2c record -a -- sleep 10 perf c2c report --stdio输出里会列出HITM(Hit Modified)次数高的缓存行,这些就是伪共享的热点。我第一次用这个工具的时候,发现一个看似无关紧要的统计变量HITM次数占了全程序的40%,加上填充之后整体吞吐量提升了将近一倍。
另一个验证方法是直接对比。写一个简单的benchmark,两个线程各自累加一个计数器,分别测试相邻和填充两种情况。在16核机器上,相邻版本的耗时通常是填充版本的5到10倍。这个差距在核心数越多、争抢越激烈的时候越明显。
注意:填充不是越多越好。过度填充会浪费缓存空间,降低缓存命中率。原则是只对确认存在争抢的热点变量做填充,不要全局滥用。
5. 从语言内存模型到硬件内存模型的映射
5.1 Java内存模型与硬件的对应关系
Java内存模型(JMM)是软件工程师最常接触的内存模型之一。它定义了happens-before关系,规定了哪些操作对其他线程可见。但JMM是抽象的,它需要映射到具体硬件上。
volatile写对应硬件上的release语义,volatile读对应acquire语义。在x86上,release写通常编译成普通写加上一个mfence或者lock前缀指令,acquire读通常就是普通读(因为x86的TSO已经保证了读不会重排到读之后)。在ARM上,release和acquire会编译成dmb ish之类的屏障指令。
synchronized块在JMM里是monitorenter和monitorexit,对应硬件上是原子操作加屏障。JVM在实现时会在monitor进入时插入acquire屏障,退出时插入release屏障。
理解这层映射的意义在于:当你在Java里写volatile时,你知道它不只是"从主存读"这么简单,而是一组重排序约束。这能帮你判断什么时候需要volatile,什么时候不需要。
5.2 C++的六种内存序怎么选
C++11引入了六种内存序,从弱到强:
| 内存序 | 语义 | 典型用途 |
|---|---|---|
| relaxed | 只保证原子性,不保证顺序 | 计数器、统计 |
| consume | 依赖顺序(实践中多被当作acquire) | 指针发布 |
| acquire | 之后的读写不能重排到之前 | 读锁、读标志位 |
| release | 之前的读写不能重排到之后 | 写锁、写标志位 |
| acq_rel | acquire+release | 读改写操作 |
| seq_cst | 全局顺序一致 | 默认,最安全但最慢 |
选择原则很简单:如果只是计数,用relaxed;如果是发布-订阅模式,写端用release,读端用acquire;如果拿不准,用seq_cst,它最安全,性能损失在大多数场景下可以接受。
我见过不少人为了"性能"把所有原子操作都改成relaxed,结果引入了极难排查的bug。除非你确实理解每个操作之间的依赖关系,否则不要轻易降级内存序。
5.3 Go的sync/atomic与channel的内存语义
Go的内存模型相对简单。sync/atomic包提供原子操作,但没有暴露内存序参数,所有操作都是seq_cst语义。channel的发送和接收隐含了acquire-release语义:发送操作在接收操作之前发生。
Go的哲学是"不要通过共享内存来通信,而要通过通信来共享内存"。channel的设计就是为了避免手动管理内存屏障。但在性能敏感的场景下,channel的开销可能成为瓶颈,这时候还是得回到atomic操作。
一个容易踩的坑是:Go里用atomic操作保护一个复杂数据结构时,atomic只保证指针本身的读写是原子的,不保证指针指向的数据的可见性。你需要确保数据在发布之前已经完全初始化,并且用release语义发布指针。
6. 写并发代码时真正该记住的几条经验
6.1 先测量再优化,不要凭直觉
多核性能问题最忌讳凭直觉猜。你觉得是锁的问题,可能是伪共享;你觉得是算法的问题,可能是缓存未命中。一定要用工具测量。
常用的工具组合:perf stat看整体指标(缓存未命中率、上下文切换次数),perf c2c看缓存行争抢,perf record加perf report看热点函数。Java的话可以用JFR(Java Flight Recorder)或者async-profiler。
我自己的习惯是,任何并发相关的性能改动,前后都要跑一遍perf stat,对比cache-misses和context-switches两个指标。如果cache-misses没降下来,说明优化方向可能错了。
6.2 数据布局比算法选择更重要
在单核时代,算法复杂度是性能的主导因素。但在多核时代,数据布局的影响可能更大。一个O(n)的算法,如果数据布局对缓存友好,可能比O(log n)但缓存不友好的算法更快。
具体来说:把频繁一起访问的数据放在一起(提高空间局部性),把被不同核心独立修改的数据分开(避免伪共享),把只读数据集中放置(提高缓存命中率)。这些原则比选什么排序算法、用什么数据结构更影响多核性能。
6.3 不要假设x86的行为就是通用行为
很多在x86上"看起来没问题"的并发代码,在ARM上会出问题。因为x86是强内存模型,很多重排序被硬件禁止了,代码里的bug被掩盖了。一旦迁移到ARM服务器(现在云厂商的ARM实例越来越多),这些bug就会暴露。
写并发代码时,要么使用语言提供的高级同步原语(它们会正确处理内存序),要么在写底层原子操作时严格按内存模型来,不要依赖"在x86上能跑"这个假设。
6.4 缓存行大小不是永远64字节
虽然x86和大多数ARM是64字节,但这不是绝对的。有些架构是128字节,有些是32字节。写可移植代码时,不要硬编码64,而是用语言或库提供的常量。C++17可以用std::hardware_destructive_interference_size,Java可以用jdk.internal.vm.annotation.Contended让JVM自动处理。
6.5 原子操作不是免费的
原子操作比普通操作慢,因为要处理缓存一致性。在热点路径上频繁使用原子操作,性能可能比加锁还差。一个常见的优化是批量处理:把多次原子累加合并成一次,或者用线程本地存储先攒着,最后再合并。
我在一个日志统计模块里做过这个优化:原来每条日志都做一次原子累加,改成每个线程维护本地计数,每1000条合并一次到全局计数器。吞吐量提升了将近4倍,代价是统计值有轻微延迟。
7. 一个可以动手验证的小实验
理论说了这么多,不如自己动手跑一遍。下面这个实验不需要特殊硬件,普通的多核笔记本就能做。
准备两个C程序。第一个版本,两个线程各自累加一个全局数组里相邻的两个元素:
#include <pthread.h> #include <stdio.h> long counters[2] = {0, 0}; void* worker(void* arg) { int idx = *(int*)arg; for (long i = 0; i < 100000000L; i++) { counters[idx]++; } return NULL; } int main() { pthread_t t1, t2; int i1 = 0, i2 = 1; pthread_create(&t1, NULL, worker, &i1); pthread_create(&t2, NULL, worker, &i2); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("%ld %ld\n", counters[0], counters[1]); return 0; }第二个版本,把两个计数器用填充隔开:
#include <pthread.h> #include <stdio.h> struct padded { long value; char pad[64 - sizeof(long)]; }; struct padded counters[2] = {{0}, {0}}; void* worker(void* arg) { int idx = *(int*)arg; for (long i = 0; i < 100000000L; i++) { counters[idx].value++; } return NULL; } int main() { pthread_t t1, t2; int i1 = 0, i2 = 1; pthread_create(&t1, NULL, worker, &i1); pthread_create(&t2, NULL, worker, &i2); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("%ld %ld\n", counters[0].value, counters[1].value); return 0; }编译时记得加-O2和-lpthread。在两个版本上分别跑time,你会看到明显的差距。在我的机器上(8核),第一个版本大约需要2.5秒,第二个版本大约0.6秒。这个差距就是伪共享的代价。
更进一步,你可以用perf c2c record跑第一个版本,看看HITM的统计,再跑第二个版本对比。这样你就能把理论、代码和实测数据串起来了。
这个实验我推荐每个写并发代码的人都做一遍。亲眼看到伪共享的代价之后,你对数据布局的敏感度会完全不一样。之后再写并发代码,脑子里会自然浮现出缓存行的概念,而不是等到性能出问题了才回头排查。