多处理器与缓存一致性:MESI 协议与内存屏障

SMP 与 NUMA:两种多处理器架构 多核处理器从架构上可以分为 SMP(Symmetric Multi-Processing,对称多处理)和 NUMA(Non-Uniform Memory Access,非统一内存访问)两大类。

SMP 与 NUMA:两种多处理器架构

多核处理器从架构上可以分为 SMP(Symmetric Multi-Processing,对称多处理)和 NUMA(Non-Uniform Memory Access,非统一内存访问)两大类。

在 SMP 架构中,所有 CPU 核心共享同一条内存总线(Front Side Bus,FSB)。每个核心访问任意内存地址的延迟是相同的,因此称为"对称"。这种设计的优点是实现简单,缺点是总线带宽成为瓶颈,处理器数量扩大(通常超过 8 路)时性能急剧下降。

现代服务器几乎全部采用 NUMA 架构。NUMA 将物理内存按插槽(Socket)或节点(Node)划分,每个 CPU 核心有一条本地内存总线连接到本地内存。当处理器访问远程节点的内存时,需要通过 CPU 间的互连链路(如 Intel UPI、AMD Infinity Fabric)进行跨节点通信,延迟显著高于本地访问(通常远程访问延迟是本地的 1.2-1.5 倍)。

Linux 提供了 numactl 工具用于 NUMA 感知的内存分配。numactl --hardware 可以查看系统 NUMA 拓扑,numactl --cpunodebind=0 --membind=0 ./program 可将程序绑死在节点 0 上执行。Linux 默认采用 First-Touch 分配策略——内存页分配给第一次访问它的 CPU 所在的 NUMA 节点。如果数据由线程 A 创建但被线程 B(位于另一节点)密集访问,就会产生大量远程访问。

多核缓存层级与一致性问题

现代多核处理器的缓存层级通常如下:每个核心拥有私有的 L1(数据 + 指令)和 L2 缓存,而同一插槽内的所有核心共享一个 L3 缓存(Last Level Cache)。以 Intel Ice Lake 为例,L1 为 48KB + 32KB,L2 为 1.25MB,L3 可达数十 MB。

缓存一致性(Cache Coherency)的核心问题是:同一个内存地址的数据可能同时存在于多个核心的缓存中,当某个核心修改了它,其他核心必须看到这个修改。缺少一致性保证的话,Core A 可能永远读到自己 L1 中的旧值,而 Core B 已经修改了内存。

缓存的最小传输单位是 Cache Line,通常为 64 字节(x86、ARM 皆如此)。无论访问 1 字节还是 4 字节,整个 Cache Line 都会被加载到缓存中。这意味着两个变量若恰好落在同一个 Cache Line 内,它们的命运就被绑在了一起——这正是"伪共享"的根源。

MESI 协议:四状态缓存一致性

MESI 协议是现代 CPU 实现缓存一致性的基石,定义了每个 Cache Line 的四种状态:

  • M(Modified):数据已被修改,与内存不一致,该缓存是此数据的唯一拥有者。必须写回(Writeback)到内存后才能被替换。
  • E(Exclusive):数据与内存一致,且该缓存是此数据的唯一拥有者。此时尚未修改,但修改无需向其他核心发通知。
  • S(Shared):数据与内存一致,且可能被多个核心同时持有。只能读,不能直接写入。
  • I(Invalid):数据无效,不可使用。

状态转换通过总线事务(Bus Transactions)完成,包括:

  • Read:读取某个地址的数据。
  • Read Exclusive / RFO(Read For Ownership):以独占方式读取,用于写入意图,迫使其他核心将该行置为 Invalid。
  • Writeback:将写脏的 Cache Line 写回内存。
  • Invalidate:通知其他核心使其缓存行失效。

举一个简单的例子:Core 0 和 Core 1 最初都在读变量 x(各自状态为 S)。Core 0 要写入 x,必须先发送 RFO,使 Core 1 的缓存行变为 I,然后 Core 0 从 S 转为 M。Core 1 之后再读 x,必须向总线发 Read,Core 0 检测到请求后,将数据从 M 写回(或转发)并降为 S,Core 1 获得数据也转为 S。

AMD 在 MESI 基础上增加了 O(Owned) 状态,称为 MOESI 协议。O 状态表示"我持有数据,但数据是脏的,且其他核心可能也在读取"。引入 O 后,脏数据可以直接从一个缓存转发到另一个缓存,而无需先写回内存,减少了总线带宽消耗和多跳延迟。

对于大型 NUMA 系统,总线广播的 MESI/Snooping 方案扩展性不足(每修改一次内存可能涉及广播)。此时采用 Directory-based Coherency——内存控制器维护一张目录表,记录每个缓存行被哪些缓存持有。需要一致性动作时只向相关节点发送点对点消息,避免了广播风暴。

伪共享:肉眼不可见的性能杀手

伪共享(False Sharing)指的是两个线程访问两个完全不相关的变量,但它们恰好被编译器布局在同一个 64 字节 Cache Line 中。由于缓存一致性以 Cache Line 为单位,一方的写入会导致另一方的缓存行失效,引发大量 RFO 和重新加载。

考虑这个经典场景:

struct data {
    int64_t counter_a;   // 被线程 A 频繁写入
    int64_t counter_b;   // 被线程 B 频繁写入
};

counter_acounter_b 共占 16 字节,极有可能位于同一 Cache Line。线程 A 每写一次 counter_a,线程 B 的整个缓存行就被置为 I;线程 B 每写一次 counter_b,线程 A 也惨遭波及。这种情况下,程序性能可能暴跌 10-100 倍。

Linux 提供了 perf c2c(cache-to-cache)工具用于检测伪共享,它可以追踪哪些 Cache Line 在核心之间被频繁争抢。

解决方案包括:

  • Padding(填充):手动将结构体填充至 Cache Line 大小,确保两个变量分属不同行。
  • Per-CPU 变量:在 Linux 内核中,DEFINE_PER_CPU(type, name) 会把变量按 CPU 隔离到不同 Cache Lines,根除了跨核冲突。
  • 按行对齐__attribute__((aligned(64))) 或 C++11 alignas(64)

Store Buffer 与 Invalidate Queue:乱序执行的温床

MESI 协议保证了缓存一致性,但如果严格按照协议串行执行,CPU 性能会严重受损。考虑 Core 0 写入一个变量,必须等待 RFO 完成、其他核心确认 Invalidate 才能提交写入——期间 CPU 处于空等状态。

为了解决这个问题,硬件引入了两个关键缓冲:

Store Buffer(存储缓冲):核心可以将写入操作暂存入 Store Buffer,不必等到全局一致性完成就能继续执行后续指令。Store Buffer 中的数据在后台异步刷入缓存。这带来了两个后果:一、本核心对自己的写入是有序可见的;二、其他核心可能暂时看不到这个写入(写入尚未全局可见)。

Invalidate Queue(失效队列):收到 Invalidate 请求的核心不必立即将缓存行置 I,可以先把请求加入队列、快速回复 Ack,稍后异步处理。这减轻了响应延迟压力,但也意味着其他核心可能在"已答应失效"之后的一段时间内仍然读到旧值。

Store Buffer + Invalidate Queue 的组合使 CPU 获得了极大的指令重排自由,但也导致内存操作的可见顺序与程序顺序可能不一致。这正是我们需要内存屏障的根本原因。

内存屏障:驯服乱序的缰绳

内存屏障(Memory Barrier / Memory Fence)是一种指令,强制保证其前后的内存操作在全局可见性上满足某种顺序。

x86 体系

x86 采用强一致性模型(TSO,Total Store Order),天然保证单核视角的 Load-Load、Load-Store、Store-Store 顺序。但仍需要以下屏障:

  • mfence:全屏障,阻止其前后的 Load 和 Store 跨越屏障重排。
  • sfence:Store Fence,仅阻止 Store-Store 重排。
  • lfence:Load Fence,仅阻止 Load-Load 重排(主要用于配合 SSE/AVX 指令序列化,而非并发场景)。

x86 中真正的并发挑战在于 Store-Load 重排:Store Buffer 可能导致先 Store 后 Load 的指令在全局被观察到相反顺序。xchg(隐含 lock 前缀)和 lock 前缀指令本身就是全屏障。

ARM 体系

ARM 采用弱一致性模型(Weak Memory Model),所有类型的重排都有可能发生,因此屏障更为丰富:

  • dmb(Data Memory Barrier):保证 dmb 之前的内存指令都在其后序指令开始执行前完成。
  • dsb(Data Synchronization Barrier):比 dmb 更强,还会等待缓存维护、TLB 无效等操作完成。
  • isb(Instruction Synchronization Barrier):刷新流水线,确保屏障后的指令重新从缓存取指。

编译器屏障与硬件屏障

编译器屏障(如 C/C++ 的 asm volatile("" ::: "memory"))只阻止编译器重排,不保证 CPU 执行顺序。硬件屏障才是真正的指令级顺序保障。实际编程中,两者常常结合使用。

C++ std::atomic 的映射

内存序x86 实现ARM 典型实现
memory_order_relaxed普通 mov普通 ldr/str
memory_order_acquire无额外指令ldr; dmb ishld
memory_order_release无额外指令dmb ish; str
memory_order_acq_rellock 前缀 / xchgdmb ish + ld/st
memory_order_seq_cstlock 前缀 / mfencedmb ish

实践启示

为什么 volatile 不能替代原子操作? volatile 只保证编译器不会把变量优化到寄存器,每次都从内存读取。但它不提供任何缓存一致性保障,也不阻止 CPU 乱序执行,更不具备原子性。多线程中用 volatile 做同步是典型的错误。

为什么应该使用 std::atomic std::atomic 是 C++11 提供的正确抽象,它同时约束编译器优化和生成恰当的硬件屏障,保证操作的原子性和可见顺序。不要试图手动内联汇编做同步,99% 的情况下你写不过编译器后端。

如何获取运行时 Cache Line 大小? 在 Linux 上:

#include <unistd.h>
long cache_line = sysconf(_SC_LEVEL1_DCACHE_LINESIZE);  // 通常返回 64

设计层面的缓存友好原则

  1. 频繁写入的数据分散到不同 Cache Lines,防范伪共享。
  2. 读多写少的数据集中布局,提升空间局部性。
  3. 了解你的硬件拓扑:NUMA 节点数、每核 LLC 大小、互联带宽,必要时使用 numactltaskset 绑定核心与内存。

总结

从 SMP 到 NUMA,缓存层级从私有到共享,硬件用 MESI/MOESI 保障一致性,又用 Store Buffer 和 Invalidate Queue 释放性能。这种"先保证正确,再追求速度"的权衡,导致内存顺序不再天然与程序顺序一致,于是内存屏障成为并发编程中不可或缺的基础设施。理解这些底层机制,才能在高性能并发代码中做出正确的数据布局与同步决策,避免让伪共享吞噬你的程序性能。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace