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_a 和 counter_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++11alignas(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_rel | lock 前缀 / xchg | dmb ish + ld/st |
memory_order_seq_cst | lock 前缀 / mfence | dmb 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
设计层面的缓存友好原则:
- 频繁写入的数据分散到不同 Cache Lines,防范伪共享。
- 读多写少的数据集中布局,提升空间局部性。
- 了解你的硬件拓扑:NUMA 节点数、每核 LLC 大小、互联带宽,必要时使用
numactl、taskset绑定核心与内存。
总结
从 SMP 到 NUMA,缓存层级从私有到共享,硬件用 MESI/MOESI 保障一致性,又用 Store Buffer 和 Invalidate Queue 释放性能。这种"先保证正确,再追求速度"的权衡,导致内存顺序不再天然与程序顺序一致,于是内存屏障成为并发编程中不可或缺的基础设施。理解这些底层机制,才能在高性能并发代码中做出正确的数据布局与同步决策,避免让伪共享吞噬你的程序性能。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。