ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CPU多核共享内存模型:从缓存一致性到内存屏障的工程实践

CPU多核共享内存模型:从缓存一致性到内存屏障的工程实践 1. 从一个日常困惑说起为什么多核编程这么难写对刚入行那会儿我对多核 CPU 的理解基本停留在“核多就是快”这个层面。直到有一次写了一个多线程的计数器跑出来的结果跟单线程对不上我才意识到事情没那么简单。后来查资料、翻手册、看内核源码慢慢把 CPU 多核共享内存模型这块拼图补全了。这篇文章就是把我这些年踩过的坑、想明白的道理用软件工程师能听懂的语言整理出来。如果你写过 Java 的synchronized、C 的std::atomic、Go 的sync.Mutex或者被volatile关键字折磨过那你其实已经在跟多核共享内存模型打交道了。只不过大多数时候我们是在语言层面跟它博弈很少往下看一眼 CPU 到底在干什么。这篇文章想做的就是把这层窗户纸捅破。核心关键词就三个CPU、多核、共享内存模型。我会从硬件结构讲到缓存一致性协议再讲到内存屏障和实际编码中的注意事项。适合有基本编程经验、想搞清楚并发底层原理的工程师阅读。不需要你懂 Verilog 或者数字电路但需要你对指针、缓存、指令执行有基本概念。2. 多核共享内存的硬件底座先搞清楚数据存在哪2.1 从单核到多核共享的到底是什么单核时代CPU 和内存之间只有一条路CPU 发出地址内存返回数据。程序看到的就是一块连续的、读写一致的内存空间。到了多核时代事情变了。每个核都有自己的寄存器和 L1/L2 缓存但它们共享同一条内存总线和同一块物理内存。这里的关键在于“共享”二字。共享意味着多个核可以同时访问同一块内存地址但“同时”在硬件层面是有代价的。如果两个核同时写同一个地址谁先谁后如果一个核写了另一个核什么时候能看到这些问题在单核时代根本不存在因为只有一个执行流。我习惯用一个类比来理解把内存想象成一栋楼的公共仓库每个核是楼里的一个住户。每个住户自己家里有个小储物间L1 缓存楼道里有个共享储物柜L2/L3 缓存仓库在地下室主存。住户取东西时先看自己家有没有没有就去楼道找再没有才去地下室。问题来了如果住户 A 改了仓库里的东西住户 B 怎么知道这就是缓存一致性问题。2.2 缓存层级与访问延迟为什么缓存这么重要现代 CPU 的存储层级大致是这样的层级典型容量访问延迟周期是否核私有寄存器几百字节0-1是L1 缓存32-64 KB4-5是L2 缓存256 KB-1 MB10-15通常是L3 缓存几 MB-几十 MB30-50共享主存几 GB-几百 GB100-300共享这张表说明一个残酷的事实从主存读一次数据够 CPU 执行几百条指令。所以 CPU 拼命做缓存把最近用过的数据留在离自己近的地方。但缓存带来了一个新问题同一个地址的数据可能同时存在于多个核的 L1 缓存里。如果核 A 改了核 B 的缓存里还是旧值程序就会读到脏数据。注意缓存一致性不是软件能完全控制的它由硬件协议保证。但硬件只保证“最终一致”不保证“顺序一致”。这就是为什么你需要内存屏障。2.3 缓存行共享内存的最小单位CPU 不是按字节从内存读数据的而是按“缓存行”读的。典型缓存行大小是 64 字节。这意味着你访问一个int4 字节CPU 实际上会把周围 64 字节都拉进缓存。这个设计带来一个经典问题伪共享。假设两个线程分别操作两个相邻的int变量这两个变量在同一个缓存行里。线程 A 改了变量 1线程 B 改了变量 2。虽然它们操作的是不同变量但硬件层面这个缓存行在两个核之间来回弹跳导致性能急剧下降。我实测过一个例子两个线程各自对一个数组的相邻元素做自增一亿次耗时是操作相隔 64 字节元素的 3 倍以上。解决办法很简单给变量加 padding让它们落在不同缓存行。Java 的Contended注解、C 的alignas(64)都是干这个的。3. 缓存一致性协议MESI 到底在做什么3.1 MESI 的四种状态MESI 是最常见的缓存一致性协议名字来自四个状态MModified这个缓存行被当前核修改过与主存不一致其他核没有这个数据。EExclusive这个缓存行只存在于当前核与主存一致。SShared这个缓存行存在于多个核与主存一致。IInvalid这个缓存行无效不能直接用。状态转换的核心规则是写操作需要独占。如果一个核想写一个处于 S 状态的缓存行它必须先发消息让其他核把这个行置为 I然后自己变成 M。这个过程叫“RFORead For Ownership”。3.2 一次写操作发生了什么假设核 A 要写地址 X核 A 检查自己的缓存发现 X 处于 S 状态。核 A 发送 RFO 消息到总线。其他持有 X 的核收到消息把自己的缓存行置为 I。核 A 把 X 置为 M执行写入。如果其他核之后要读 X核 A 需要把数据写回主存或直接转发。这个过程对软件工程师的启示是写共享数据比写私有数据慢得多。因为写私有数据E 状态不需要跟其他核通信直接改就行。写共享数据要经历一轮总线通信延迟可能几十到几百个周期。3.3 Store Buffer 与失效队列硬件为什么要“作弊”如果每次写都等 RFO 完成CPU 会慢得没法看。所以硬件加了两级缓冲Store Buffer核 A 写数据时先写进自己的 Store Buffer然后继续执行后面的指令。等 RFO 完成后再把数据写入缓存。Invalidate Queue核 B 收到失效消息时先放进队列回一个 ACK等有空再处理。这两个缓冲让 CPU 跑得更快但也让内存模型变得更复杂。因为从其他核的角度看核 A 的写可能还没真正生效但核 A 自己已经认为写完了。这就是内存重排序的硬件根源。提示Store Buffer 和 Invalidate Queue 是理解内存屏障的关键。屏障的作用就是强制刷新这两个缓冲让写操作对其他核可见。4. 内存模型与内存屏障软件工程师的必修课4.1 什么是内存模型内存模型是一份契约规定了多线程程序在共享内存上运行时读写操作看起来应该是什么顺序。硬件提供一种模型语言提供一种模型两者不一定一致。常见的内存模型从强到弱顺序一致SC所有操作按程序顺序执行全局只有一个顺序。最好理解但性能最差。TSOTotal Store Order允许 Store-Load 重排序x86 基本是这个模型。弱内存模型允许更多重排序ARM、RISC-V、Power 属于这类。x86 的 TSO 意味着你写了一个变量再读另一个变量硬件可能先执行读再执行写。这在单线程里没问题但多线程里会导致意想不到的结果。4.2 内存屏障的四种类型以 Linux 内核为例常见屏障有屏障类型作用smp_mb()全屏障读写都不能越过smp_rmb()读屏障读不能越过smp_wmb()写屏障写不能越过smp_read_barrier_depends()数据依赖屏障弱模型需要在高级语言里这些屏障被封装成了更友好的形式。Java 的volatile写对应 StoreLoad 屏障C 的std::atomic可以指定memory_order。4.3 一个经典例子Dekker 算法与屏障Dekker 算法是两个线程互斥进入临界区的经典方案。在 TSO 模型下如果不加屏障可能两个线程都进不去也可能都进去。原因就是 Store Buffer 导致写操作延迟可见。// 线程 1 flag1 1; smp_mb(); if (flag2 0) { // 进入临界区 } // 线程 2 flag2 1; smp_mb(); if (flag1 0) { // 进入临界区 }没有smp_mb()两个线程可能都读到对方的 flag 为 0然后都进入临界区。加了屏障硬件保证屏障前的写对其他核可见后才执行屏障后的读。5. 从语言到硬件实际编码中的映射关系5.1 Java 内存模型与 CPU 的对应Java 的volatile关键字做了两件事保证可见性、禁止重排序。在 x86 上volatile写编译成带lock前缀的指令lock会刷新 Store Buffer 并等待 Invalidate Queue 处理完。volatile读则基本是普通读因为 x86 不允许 Load-Load 重排序。Java 的synchronized除了互斥还隐含了内存屏障。进入同步块相当于读屏障退出相当于写屏障。所以你在同步块里改的变量退出后对其他线程可见。5.2 C 的 memory_order 怎么选C 给了你更细粒度的控制memory_order_relaxed只保证原子性不保证顺序。适合计数器。memory_order_acquire读操作后面的读写不能提到前面。memory_order_release写操作前面的读写不能沉到后面。memory_order_acq_rel读写都不能越。memory_order_seq_cst全序最安全也最慢。我的经验是不确定就用 seq_cst。性能敏感的地方再考虑降级。降级前一定要用压力测试验证因为弱内存序的 bug 极难复现。5.3 Go 的 sync 包与 channelGo 的sync.Mutex和sync.RWMutex底层用了原子操作和屏障。channel的发送和接收也隐含了内存同步发送前的写对接收方可见。Go 的内存模型文档写得很清楚不要通过共享内存来通信而要通过通信来共享内存。这句话背后的硬件含义是channel 帮你处理了屏障你不需要自己操心。6. 常见问题与排查技巧实录6.1 多核数据不一致的典型症状症状可能原因排查方法偶发读到旧值缺少屏障或 volatile加屏障后压测计数器结果偏小非原子自增用原子操作或锁性能随核数增加下降伪共享检查缓存行对齐死循环无法退出标志位未同步用 volatile 或 atomic压测才复现弱内存序重排序用 seq_cst 验证6.2 伪共享的排查与解决伪共享的排查比较麻烦因为逻辑上没问题。我一般用两个办法性能计数器Linux 的perf可以看 cache miss 率。如果 miss 率异常高怀疑伪共享。手动 padding把可能被多线程访问的变量按 64 字节对齐。struct padded_counter { alignas(64) atomic_int value; char padding[64 - sizeof(atomic_int)]; };Java 里可以用sun.misc.Contended但需要加 JVM 参数-XX:-RestrictContended。6.3 内存屏障加多了会怎样屏障不是免费的。smp_mb()在 x86 上是一条mfence指令延迟大概几十个周期。如果在一个热循环里加屏障性能会断崖式下跌。我的原则是只在必要的地方加屏障并且尽量用 acquire/release 代替全屏障。比如生产者-消费者队列生产者用 release 写 tail消费者用 acquire 读 tail就够了。注意不要凭感觉加屏障。先用最严格的 seq_cst 保证正确再用 perf 找热点逐步放宽。7. 我个人在实际操作中的体会这些年下来我对多核共享内存模型最大的体会是不要跟硬件对着干。硬件为了性能做了大量重排序和缓冲软件要做的不是消除这些优化而是理解它们、利用它们。具体来说我有几个习惯第一写并发代码时先问自己“这个数据会被几个核访问”。如果只有一个核写、多个核读用 release/acquire 就够了。如果多个核写老老实实加锁或原子操作。第二性能问题先怀疑伪共享再怀疑屏障过多。这两个是实践中最常见的坑。第三不要迷信“无锁编程”。无锁队列写起来很酷但调试起来很痛苦。大多数场景下一把粗粒度的锁加合理的分片性能已经够用。最后分享一个小技巧如果你不确定一段并发代码在弱内存模型上是否正确把它放到 ARM 机器上跑。ARM 的重排序比 x86 激进得多很多在 x86 上“看起来没问题”的代码在 ARM 上会立刻暴露问题。这个办法帮我省了很多调试时间。
返回列表