资讯动态

多核缓存一致性实战:内存可见性、内存屏障与伪共享

发布时间:2026/10/28 3:31:27 来源:尧图企业网站定制
多核时代写代码最让人后背发凉的一类 bug 是逻辑看起来天衣无缝单核跑一万遍都对一上多核机器某个线程就是读不到另一个线程刚写进去的值。你把打印语句加上它又好了把优化等级从-O2降到-O0它又好了。这类问题的根子八成落在高速缓存和缓存一致性上。上一讲我们把缓存的组织结构、局部性原理、替换策略捋了一遍这一讲专门回答那个要命的问题你确定你的数据更新了么换句话说当一个核心改了数据另一个核心凭什么、以及什么时候能看到这个改动。这篇内容适合已经写过并发程序、被“可见性”坑过、或者想搞明白volatile、内存屏障、伪共享到底在干什么的开发者看不管你做的是后端服务、嵌入式、还是高性能计算只要碰多线程这一关绕不过去。1. 先复现一次“数据没更新”的现场1.1 一个跑不出错但确实错了的例子我习惯先用最小可复现的例子把问题钉死不然讨论全是空话。下面这段代码两个线程共享一个标志位一个写一个读// 编译gcc -O2 -pthread demo.c -o demo #include pthread.h #include stdio.h int flag 0; int data 0; void *writer(void *arg) { data 42; // 步骤 A flag 1; // 步骤 B return NULL; } void *reader(void *arg) { while (flag 0) { /* 自旋等待 */ } printf(data %d\n, data); // 步骤 C return NULL; }单核机器上这代码几乎必出data 42因为线程切换有完整的上下文保存编译器也不会乱序到把 B 提到 A 前面。但在多核机器上-O2编译后你可能会看到data 0。原因有两层第一层是编译器看到reader里的while (flag 0)循环体没有任何副作用直接把flag读到寄存器里循环再也去内存取值第二层更深就算编译器老老实实每次都读内存写线程的flag 1可能还躺在**写缓冲区Store Buffer**里没落到缓存读线程所在的核自然看不见。我第一次遇到这个问题是在一个采集程序里主线程设了个stop标志让工作线程退出结果工作线程像没听见一样继续跑日志里stop一直是 0。当时我以为是编译器优化加了volatile就好了——后来才知道volatile只是碰巧把第一层问题解决了第二层问题它压根没管只是那次运气好没触发。这个认知差是很多人踩坑的起点。1.2 为什么单核时代没有这个问题要理解一致性先得想明白单核为什么不需要一致性。单核只有一个执行流在跑任何时刻只有一个核心在读写缓存缓存里存的就是“最新”的写回内存的时机对程序语义没有影响——因为没人跟它抢。缓存对单核而言纯粹是性能加速器对正确性是透明的。多核就不一样了。现代处理器每个核心都有自己的私有 L1通常还有私有 L2L3 才是多核共享的。这带来一个非常现实的问题同一块内存地址可能在四个核心的 L1 里各有一份副本在 L3 里有一份在主存里还有一份。当核心 0 执行data 42时它只改了自己 L1 里那份核心 1 的 L1 里那份还是老的。如果不管程序就会读到“过期数据”。这就是**缓存一致性Cache Coherence**要解决的问题让所有核心对同一地址的读看到一个统一的、符合程序顺序的值。这里要区分两个容易混的概念。**一致性Coherence**管的是单个地址保证不会出现两个核心同时读到不同值**连贯性Consistency**管的是多个地址的读写顺序比如 A 写 x 再写 yB 读到 y 的新值后能不能保证读到 x 的新值。前者靠硬件协议后者靠内存模型加屏障。标题里那句“你确定你的数据更新了么”问的其实两层都有。1.3 缓存的写策略写回还是写直达在讲一致性协议之前得先把“写”这件事本身说清楚因为不同写策略直接决定了一致性协议要处理多少麻烦。缓存写命中时有两条路策略行为优点缺点写直达 Write Through每次写都同时更新缓存和主存实现简单内存永远最新写开销大总线压力高写回 Write Back只改缓存标记脏被替换时才写回写性能高减少总线流量一致性维护复杂脏数据可能滞留写未命中时还有两种分配策略**写分配Write Allocate**先把块读到缓存再改**非写分配No Write Allocate**直接写到内存、不占缓存。主流处理器基本是“写回 写分配”的组合因为绝大多数写操作后面还会跟着读或再次写同一块把块拿到本地改最划算。代价就是脏数据散落在各个核心的私有缓存里一致性协议必须能追踪“谁手里有最新版本”并且在别人要读的时候把最新版本交出去。这正是下一节 MESI 要干的事。2. MESI 协议到底在忙什么2.1 四种状态与它们代表的“所有权”MESI 是目前最广为人知的缓存一致性协议名字来自四个状态Modified、Exclusive、Shared、Invalid。不要把它当成四个抽象字母去背而要理解成“这个缓存行现在归谁、干不干净”。状态本核有无副本与主存是否一致其他核有无副本通俗理解M 修改有不一致脏无我是唯一持有者且我改过别人要得找我要E 独占有一致无只有我有且和内存一样我可以随时改S 共享有一致有大家都有只读副本谁想写先打招呼I 无效无或已作废————我手里这份不能用了关键在于M 状态是“脏且独占”。一个缓存行处于 M意味着整个系统里只有这个核心有最新数据主存里的是旧值。这时候如果别的核心来读不能让它直接读内存必须由 M 状态的核把数据交出来或者先写回内存再让它读。这个“交出来”的动作就是一致性协议里最昂贵的部分也是后面性能问题的源头。顺带提一句有些架构用的是 MOESI多了一个 OOwned状态表示“我这份是脏的但我允许别人也持有只读副本写回的责任在我”。这个状态能减少“脏数据在不同核心间搬运”的次数对多核共享读多的场景有好处。理解了 M 和 S 的张力O 状态的存在就很好理解了它是为了在“脏数据必须唯一”和“只读副本可以多处存在”之间找一个折中。2.2 状态是怎么迁移的状态迁移不用死记表格抓住几条主干就行。我一般按“读未命中”和“写命中”两类场景去推读未命中时本核发一个读请求到总线或者片上互联网络。如果其他核都没有这个块本核拿到数据后进入 E如果其他核有干净副本大家一起去读都进入 S如果其他核有 M 状态的脏副本那个核必须先把自己的数据提供出来同时可能写回然后两个核都进入 S。写命中时如果本核是 E直接改成 M不需要通知任何人——因为只有我有改就完了。如果本核是 S就必须发一个“我要写请你们作废”的请求Invalidate等到所有持有副本的核回 ACK 之后本核才能进 M 并真正写。如果本核是 I那要么先读进来写分配要么直接发写请求让别人作废具体看实现。这里有个细节很多人忽略写 S 状态的行需要等待所有 ACK。这个等待是同步的如果每次都等写性能会被互联网络的延迟拖垮。真实处理器不会傻等它引入了 Store Buffer把“等”这件事挪到后面代价就是出现了新的可见性问题——这个下面专门讲。2.3 总线嗅探与目录协议一致性消息怎么传播有两种主流做法。小规模多核常用总线嗅探Snooping所有核心挂在同一条总线上谁发请求大家都听得见各自检查自己缓存里有没有对应的块有就按协议响应。这种方式简单直接但扩展性差——核心一多总线就是瓶颈广播风暴能把带宽吃光。大规模多核更倾向目录协议Directory维护一张目录表记录每个内存块被哪些核心持有、处于什么状态。请求来了先查目录只通知相关的核心避免全网广播。目录本身也有开销和存储成本所以真实芯片里常常是混合方案片内小簇用嗅探跨簇用目录。这个选择对写代码的人有实际影响。嗅探型架构下一个核的写操作会“惊动”所有核的缓存控制器哪怕它们没这个块也要检查一下所以缓存行越少被共享、访问模式越局部性能越好目录型架构下跨 NUMA 节点的访问延迟差异明显绑核、控制数据亲和性就更重要。这也解释了为什么同样的伪共享代码在不同拓扑的机器上性能下降幅度不一样。2.4 一个容易被问到的边界缓存行对齐一致性协议的操作粒度是缓存行主流实现是 64 字节也有 128 字节的。这意味着哪怕你只改一个字节整个 64 字节的行都要走一遍状态迁移。这个粒度设计是权衡的结果太小了协议消息太频繁太大了浪费带宽和空间。理解这一点后面所有关于伪共享、关于“两个不相干变量放一起导致性能暴跌”的问题就都好解释了。我见过有人在结构体里把两个高频写的计数器挨着放单线程测性能很好多线程一跑直接掉到原来的五分之一查了两天才发现是缓存行在打架。3. Store Buffer 和 Invalidate Queue看不见的延迟3.1 写操作为什么不能立刻生效如果严格按 MESI 执行每次写一个 S 状态的行核心都要停下来等所有其他核心回 ACK。这个延迟在几十个周期到上百个周期不等对流水线是灾难。于是硬件加了两级缓冲Store Buffer写操作先塞进本核的 Store Buffer核心继续往下执行等 ACK 回来再把数据真正写进缓存。这样写操作对核心来说几乎是“瞬间完成”的。问题是如果核心紧接着读自己刚写的地址缓存里还是旧值必须去 Store Buffer 里查这叫 Store Forwarding查不到才去缓存。Invalidate Queue收到别人的“请作废”请求时不立刻作废而是先塞进队列并马上回 ACK等空闲了再真正处理。这样发请求的核能快速拿到 ACK但副作用是本核手里可能还留着一份“已经答应作废、实际还没作废”的副本短时间内读它还能读到旧值。这两个缓冲合起来就制造了内存重排序程序里先写 A 再写 B别的核可能先看到 B 再看到 A程序里先读 X 再读 Y实际可能先读 Y。这不是编译器在捣乱是硬件为了性能主动做的重排。所有弱内存模型架构ARM、Power都有这个问题x86 的 TSO 模型相对严格只允许 StoreLoad 重排但也不是完全顺序的。注意调试并发 bug 时加printf往往会“治好”问题因为printf涉及系统调用或锁操作顺带插入了屏障把重排序挡住了。这恰恰是最坑的地方——你加了日志就复现不了删了日志又出现。3.2 内存屏障给重排画一条线既然硬件会重排软件就得有手段叫停。内存屏障Memory Barrier / Fence就是干这个的它告诉处理器和编译器这条线之前的访存不能挪到线之后线之后的不能挪到线之前。屏障类型约束典型用途LoadLoad前面的读先于后面的读读标志位后再读数据StoreStore前面的写先于后面的写先写数据再写标志位LoadStore前面的读先于后面的写读锁后再写共享量StoreLoad前面的写先于后面的读最重全屏障锁的释放-获取StoreLoad是最贵的因为它要清空 Store Buffer让前面的写真正可见。x86 上mfence就是这个ARM 上对应的指令更细有dmb ish之类的变体。写代码时不用手写汇编各语言都封装好了C11 的atomic_thread_fence、Java 的volatile读写、C 的std::atomic内存序底层都会翻译成对应的屏障指令。3.3 回到开头那段代码现在可以解释开头那个例子了。要让它正确flag必须是原子类型并带正确的内存序data也要配合#include stdatomic.h atomic_int flag 0; int data 0; void *writer(void *arg) { data 42; atomic_store_explicit(flag, 1, memory_order_release); // 保证 data 先可见 return NULL; } void *reader(void *arg) { while (atomic_load_explicit(flag, memory_order_acquire) 0) {} printf(data %d\n, data); // release-acquire 配对保证读到 42 return NULL; }memory_order_release保证它之前的所有写包括data 42在flag 1之前对其他核可见memory_order_acquire保证读到flag 1之后的所有读都能看到 release 之前的写。这一对语义就是所谓的release-acquire 语义是写无锁数据结构最常用的工具。如果只写volatileJava 里能保证可见性和禁止重排效果接近 acquire/release但 C/C 里的volatile只保证编译器不优化掉访问不提供任何跨核顺序保证拿它做线程同步是错的。4. 伪共享缓存行打架的典型现场4.1 一个性能掉五倍的例子缓存行 64 字节这个粒度最反直觉的后果就是伪共享False Sharing。两个线程各自写自己独立的变量逻辑上毫无关系但因为这两个变量落在同一个缓存行里缓存行会在两个核心之间来回弹——一个核要写就得把对方的副本作废对方再写又得作废回来来回拉锯。// 伪共享版本 struct Counters { long a; // 线程 0 写 long b; // 线程 1 写和 a 同一缓存行 };两个线程各跑一千万次自增实测下来这个版本比“每个变量独占一个缓存行”的版本慢 4 到 6 倍具体倍数取决于核心数、互联拓扑和自增的访存密度。我在一台 8 核机器上测过从大约 1.2 亿次/秒掉到 2000 万次/秒出头差距非常直观。4.2 怎么消掉它最直接的办法是填充Padding把变量撑到各自独占缓存行struct PaddedCounter { long value; char pad[64 - sizeof(long)]; // 补齐到 64 字节 };C11 之后有更干净的做法用_Alignas(64)或alignas(64)指定对齐Java 里可以用Contended注解需要开启对应参数或者手动加几个long填充字段Go 里没有标准手段通常靠结构体里塞[56]byte之类的填充数组。但填充不是无脑加的有两个坑要说清楚。第一填充会浪费内存如果一个结构体有几百万个实例每个都多占 56 字节内存占用会显著上升缓存有效容量也会下降。第二填充要对齐到缓存行边界才有意义只加 padding 不对齐变量还是可能跨行或者和别人共享行尾。我一般会先确认结构体起始地址的对齐方式再决定填多少。提示不要凭感觉判断有没有伪共享。先在代码里埋点做对照实验——同一份逻辑跑“紧凑布局”和“填充布局”两版比较吞吐。数据差距不明显就说明瓶颈在别处别浪费时间。4.3 用工具把问题揪出来靠猜效率太低我一般用两类手段。第一类是硬件计数器Linux 上用perf stat看cache-misses、cache-references或者perf c2c直接定位到具体哪两行地址在跨核争抢。perf c2c record加perf c2c report能把 HITMHit Modified事件对应的调用栈打出来伪共享几乎一抓一个准。第二类是架构级模拟在开发阶段用模拟器跑小规模负载观察一致性流量适合没有真机或者想验证极端拓扑的场景。工具给的是线索最终还是要回到代码上看数据布局。我的经验是只要看到某个热点函数在多线程下性能随核心数增加不升反降先怀疑伪共享再看是不是锁竞争最后才怀疑算法本身。5. 常见问题与排查实录5.1 一张速查表实际排查时现象和根因往往对不上号我把这些年遇到的典型情况整理成表方便对照着查现象可能原因排查动作加了日志就正常删了就复现日志调用引入了屏障用原子变量替代标志位单核正常多核偶发读到旧值Store Buffer 未刷新检查是否有 release/acquire 配对核心数增加吞吐反而下降伪共享或锁竞争perf c2c定位争抢地址优化等级一改行为就变编译器重排或缓存变量到寄存器用atomic或编译器屏障偶发死循环在自旋等待标志位未加可见性语义自旋变量必须用原子类型跨 NUMA 节点延迟异常高数据不在本地节点检查内存亲和性与绑核策略5.2 几个不写在文档里的经验第一个经验别用volatile当同步原语。它在 Java 里语义够强可见性加禁止重排但在 C/C 里只是个“别优化掉”的提示跨核顺序一概不管。我见过有人拿 C 的volatile写自旋锁在 x86 上跑得好好的换到 ARM 平台就挂因为 ARM 的弱内存模型下编译器重排和硬件重排都会发生。第二个经验自旋等待一定要加退避。纯粹的while (!flag)在等不到的时候会把流水线和缓存带宽全吃光还会不断触发一致性流量把写线程也拖慢。加个pause指令x86 的_mm_pauseARM 的yield或者短暂让出对整体吞吐帮助很大尤其是等锁时间较长的时候。第三个经验先量再改。缓存一致性问题最大的坑是“看起来像”你以为是伪共享改了半天填充结果瓶颈其实在别的地方。我现在的习惯是先用性能计数器确认一致性流量确实异常再动代码。没有数据支撑的优化改对了是运气改错了是常态。第四个经验跨平台代码要把内存序显式写出来。不要依赖“x86 上能跑”这个事实因为 x86 的 TSO 模型帮你挡掉了大部分重排换到弱内存模型平台同样的代码就是另一个故事。写atomic的时候把memory_order显式标上虽然啰嗦但它是可移植的正确性保证比事后 debug 便宜太多。第五个经验关于调试本身并发 bug 的复现环境要尽量“裸”。关掉日志、关掉调试器、用真实的优化等级、多跑几轮。带调试器跑会改变时序很多重排序问题在调试器下根本不复现。我一般会写一个压力脚本循环跑几千次配合随机绑核和随机负载把概率性问题的触发率提上去再去定位。5.3 一个值得记住的判断顺序遇到“数据没更新”的问题我现在的判断顺序是固定的先看这个共享变量有没有正确的同步语义原子类型或锁没有就是它有的话看内存序配对对不对release 配 acquire别配错再对的话看是不是伪共享或者缓存行对齐问题导致性能异常而不是正确性异常最后才怀疑硬件或者工具链的极端情况。这个顺序覆盖了我遇到的九成以上场景剩下的一成往往是多个问题叠在一起需要分开验证。顺着这个顺序走你会发现大部分“玄学”问题都能落到具体的机制上要么是可见性没保证要么是顺序没保证要么是缓存行在打架。把 MESI 的状态迁移、Store Buffer 的延迟、屏障的约束这三件事想透剩下的就是熟练度问题了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑