1. 为什么软件工程师需要关心CPU缓存一致性大多数写业务代码的人日常打交道的是语言运行时、框架和数据库很少会主动去想CPU内部发生了什么。但只要你碰过并发编程就一定遇到过一些反直觉的现象两个线程各自修改一个独立的变量性能却比单线程慢好几倍明明代码里写了volatile结果还是读到了旧值用无锁队列替换掉互斥锁吞吐量不升反降。这些问题往下挖最终都会落到同一个地方——多核CPU的共享内存模型。我刚开始做后端服务的时候对这块完全是黑盒认知。直到有一次排查一个诡异的性能问题一个统计计数器在多线程下累加用了原子操作逻辑上没有任何问题但在16核机器上跑出来的吞吐量只有单核的3倍。后来用perf工具一看缓存未命中率高得离谱问题出在多个核心反复争抢同一条缓存行。那次经历让我意识到不理解硬件层面的内存模型写并发代码基本靠运气。这篇文章就是把我这些年从软件工程师视角理解多核共享内存模型的过程整理出来。不追求体系结构教材那种完备性而是聚焦在写代码时真正会遇到什么这个角度。适合有基本并发编程经验、想搞清楚底层原理的后端或系统方向开发者。读完之后你应该能解释清楚伪共享为什么慢、内存屏障到底在挡什么、以及为什么有些看起来安全的代码在多核上会出问题。2. 从单核到多核共享内存模型到底共享了什么2.1 多核CPU的真实物理结构很多人脑子里的多核CPU模型是这样的多个核心连到一根总线上总线再连到一块统一的内存。这个模型在早期SMP对称多处理时代大致成立但现代CPU早就不是这样了。真实的物理结构是分层的。每个核心有自己的L1指令缓存、L1数据缓存和L2缓存这些是核心私有的其他核心访问不到。然后多个核心共享一个L3缓存有些架构叫LLCLast Level CacheL3再通过内存控制器连接到DRAM。核心之间通过片上互连网络通信比如环形总线或者网格互连。这个结构带来的直接后果是核心A写了一个变量这个写操作首先落在A自己的L1缓存里不会立刻写到主存。核心B要读这个变量如果B的L1里没有它会去L3找L3里如果有因为A的写最终会传播到L3B就能拿到。但如果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::atomiclong 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输出里会列出HITMHit 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的六种内存序怎么选C11引入了六种内存序从弱到强内存序语义典型用途relaxed只保证原子性不保证顺序计数器、统计consume依赖顺序实践中多被当作acquire指针发布acquire之后的读写不能重排到之前读锁、读标志位release之前的读写不能重排到之后写锁、写标志位acq_relacquirerelease读改写操作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的话可以用JFRJava 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而是用语言或库提供的常量。C17可以用std::hardware_destructive_interference_sizeJava可以用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的统计再跑第二个版本对比。这样你就能把理论、代码和实测数据串起来了。这个实验我推荐每个写并发代码的人都做一遍。亲眼看到伪共享的代价之后你对数据布局的敏感度会完全不一样。之后再写并发代码脑子里会自然浮现出缓存行的概念而不是等到性能出问题了才回头排查。