yuqi-zheng

内存模型、缓存与流水线:并发背后的硬件


大多数并发 bug 不是逻辑错误。它们之所以出现,是因为底层的硬件在追求速度 —— 而速度意味着重排、缓冲和缓存,这些优化悄悄打破了程序员期望的顺序执行模型。本文逐步讲解导致内存重排的硬件机制、定义允许哪些重排的内存模型,以及让这一切变得必要的缓存与流水线架构。


为什么会发生内存重排

程序写出来就像每条指令按顺序执行一样。但 CPU 可以自由地对内存操作重排,只要单线程执行的结果不变。对并发程序来说,这就产生了一个鸿沟:一个线程”看到”的顺序,可能和另一个线程”写入”的顺序不一致。

三种硬件机制造成了这个鸿沟:

  1. 存储缓冲区(Store Buffer) —— 在 store 到达缓存之前先缓冲,隐藏写延迟
  2. 无效队列(Invalidate Queue) —— 延迟处理缓存无效请求,隐藏一致性延迟
  3. 乱序执行(Out-of-Order Execution) —— 尽早执行无依赖的指令,最大化流水线利用率

每一项都是让单线程代码更快的优化,但都会打破多线程的假设。下面逐一分析。


存储缓冲区:隐藏写延迟

CPU 执行 store 时,目标缓存行必须处于 Exclusive 或 Modified 状态。如果不是 —— 因为另一个核持有它 —— CPU 必须等待一次缓存一致性往返来获取所有权。在多插槽系统上,这个往返可能花数百纳秒。

为避免停顿,CPU 将 store 数据写入存储缓冲区,然后继续执行后续指令。store 稍后异步提交到缓存。从发起写的 CPU 的角度看,store 已经完成了;但从其他所有 CPU 的角度看,它还没发生。

这导致了 StoreStore 重排(两个 store 以不同的顺序被其他核看到)和 StoreLoad 重排(一个后续的 load 在更早的 store 可见之前就完成了):

// 线程 A(CPU 0)           // 线程 B(CPU 1)
a = 1;                        while (b == 0)
b = 1;                            /* 自旋 */;
                              assert(a == 1);  // 可能失败!

线程 A 先写 a,再写 b。两个 store 都进入存储缓冲区。如果 b 的缓存行在本地(命中),而 a 的不在(未命中),b 会先到达缓存。线程 B 看到 b == 1a 还是 0 —— assert 触发。


无效队列:隐藏一致性延迟

在 MESI 协议下,CPU 0 写入一个被 CPU 1 以 Shared 状态持有的缓存行时,必须发送 Invalidate 消息。CPU 1 必须确认 —— 但确认需要时间。如果 CPU 0 每次都等所有 ACK 才继续,就会停顿。

解决方案:CPU 1 将收到的 invalidate 消息放入无效队列,立即发送 ACK,稍后异步处理无效化。

这导致了 LoadLoad 重排(一个后续的 load 看到了本该被无效化的旧值)和 LoadStore 重排

// 线程 A(CPU 0)           // 线程 B(CPU 1)
a = 1;                        while (a == 0)
                                   /* 自旋 */;
                              b = 1;

// 线程 C(CPU 2)
while (b == 0)
    /* 自旋 */;
assert(a == 1);  // 可能失败!

线程 B 加载 a,看到 1,然后写 b = 1。但 a 的 invalidate 消息还在 CPU 2 的无效队列中没处理。线程 C 加载 b,看到 1,再加载 a —— 得到过时的值 0。


乱序执行与推测执行

现代 CPU 是超标量且乱序的。无依赖的指令只要操作数就绪就可以执行,不必管程序顺序。地址已经在寄存器中的 load 可以在地址还在计算中的更早的 store 之前执行。

推测执行(Speculative Execution) 进一步扩展了这一思路:CPU 预测分支方向,沿预测路径提前执行。如果预测正确,结果提交;如果预测错误,结果丢弃。

从内存模型的角度,推测执行不会引入超出存储缓冲区和无效队列已有范畴的新重排类型 —— CPU 必须表现得好像按程序顺序执行了指令。但推测执行的 load 可能导致瞬态的缓存状态变化,可通过侧信道被感知(Spectre 类攻击的基础)。


硬件内存模型

存储缓冲区、无效队列和乱序执行的组合产生了一系列可能的内存重排。硬件内存模型规定了某个架构上允许哪些重排。

load 和 store 的四种可能重排:

重排类型含义
StoreStore两个 store 被其他核以不同顺序看到
StoreLoad一个 load 在更早的 store 可见之前完成
LoadLoad两个 load 以不同顺序返回值
LoadStore一个 store 在更早的 load 完成之前变为可见

x86/x86-64:全存储序(Total Store Order, TSO)

x86 执行强模型。只允许 StoreLoad 重排:

重排类型x86 允许?
StoreStore
StoreLoad
LoadLoad
LoadStore

x86 上重排的唯一来源是存储缓冲区。这就是为什么大多数”显而易见正确”的并发代码在 x86 上能工作,但在 ARM 上会失败。

例外:非临时存储(non-temporal store,如 _mm_stream_si128)和未对齐访问可以打破 TSO 保证,需要显式加屏障。

ARM/PowerPC:弱序模型

ARM 和 PowerPC 允许全部四种重排:

重排类型ARM 允许?
StoreStore
StoreLoad
LoadLoad
LoadStore

硬件拥有最大的自由度来优化内存访问。软件必须使用显式内存屏障或带适当内存序的原子操作来约束重排。


C++ 内存模型:从软件到硬件的映射

不同硬件模型使可移植的并发代码变得困难。C11/C++11 通过语言内存模型解决了这个问题,提供统一的抽象。程序员指定所需的序强度,编译器为目标架构生成相应的屏障。

内存序核心保证典型用途
relaxed仅保证原子性,无序约束独立计数器
consume仅约束数据依赖序(很少使用;通常被提升为 acquire加载指针后解引用
acquire后续内存访问不能被重排到此 load 之前锁获取
release之前的内存访问不能被重排到此 store 之后锁释放
acq_rel同时具备 acquire 和 release 语义(用于读-改-写操作)原子 fetch-and-add
seq_cst所有 seq_cst 操作的全局总序分布式状态同步

到硬件屏障的映射

内存序x86 屏障ARM 屏障开销
relaxed零开销
consumedmb ishld低(仅数据依赖)
acquire无(TSO 已经保证)dmb ish
release无(TSO 已经保证)dmb ish
acq_relmfencedmb ish
seq_cstmfence + lock 前缀dmb sy最高

在 x86 上,acquirerelease 是免费的 —— 硬件已经提供了必要的保证。在 ARM 上,任何强于 relaxed 的序都需要 dmb 屏障。

选择正确的内存序

  1. 变量是否独立? 如果是(如独立计数器),用 relaxed
  2. 需要同步非原子数据吗? 如果需要,用 acquire/release 对 —— 释放线程之前的写操作对获取线程可见。
  3. 需要全局共识吗? 只有此时才用 seq_cst。这很罕见 —— 大多数同步模式只需要 acquire/release。

避免默认使用 seq_cst。C++ 原子操作因历史原因默认 seq_cst,但 90% 的场景不需要它,而在 ARM 上 seq_cstacq_rel 的差距是显著的。


内存屏障与编译器屏障

硬件内存屏障

MFENCE —— 全屏障。串行化所有内存操作(load 和 store):

mov [data], eax    ; store
mfence             ; 后续内存操作不会越过此点
mov ebx, [flag]    ; load —— 保证看到 mfence 之前的所有 store

SFENCE —— store 屏障。仅串行化 store。用于非临时存储和写组合内存:

movntps [buffer], xmm0  ; 非临时存储
sfence                  ; 所有之前的 store 在后续 store 之前可见
mov [flag], 1           ; flag 的 store —— 排在非临时存储之后

LFENCE —— load 屏障。仅串行化 load。主要用于约束推测执行(如 Spectre 缓解):

mov eax, [secret]  ; load
lfence             ; 后续指令在此 load 完成之前不会执行
mov [result], eax  ; 依赖 store

编译器屏障

编译器屏障阻止编译器跨越屏障重排内存操作,但不会阻止 CPU 这样做。

// C11/C++11
atomic_signal_fence(memory_order_seq_cst);

// GCC/Clang
__asm__ __volatile__("" ::: "memory");

// MSVC
_ReadWriteBarrier();

关键区别

编译器屏障只阻止编译时重排,不产生硬件指令。硬件内存屏障同时阻止编译时和运行时重排。在需要硬件屏障时使用了编译器屏障,是一个微妙但致命的 bug —— 它在弱序编译器上能工作,但在有激进乱序执行的硬件上会失败。


缓存一致性与伪共享

MESI 简介

MESI 协议在多个核之间维护缓存一致性。每条缓存行处于以下四种状态之一:

状态含义
Modified(已修改)本核独占此行;已被写入;内存中的副本已过时
Exclusive(独占)本核独占此行;未被写入;内存中的副本是最新的
Shared(共享)多个核持有此行的只读副本;内存是最新的
Invalid(无效)本核没有有效副本

写入需要 Exclusive 或 Modified 所有权。如果缓存行处于 Shared 状态,核必须广播 Invalidate 消息并等待 ACK 才能继续。

伪共享(False Sharing)

两个不相关的变量恰好在同一条缓存行上,即使没有逻辑数据依赖,也会争夺所有权:

struct Counters {
    std::atomic<int> a;  // 线程 1 递增
    std::atomic<int> b;  // 线程 2 递增
};

两个 atomic 各 4 字节,很可能共享同一条 64 字节的缓存行。线程 1 每次递增都会使线程 2 的缓存行失效,反之亦然 —— 一致性流量的乒乓效应。

修复:将每个变量对齐到缓存行边界:

struct alignas(64) PaddedCounter {
    std::atomic<int> value;
};

struct Counters {
    PaddedCounter a;  // 线程 1
    PaddedCounter b;  // 线程 2
};

或者使用 std::hardware_destructive_interference_size(C++17)代替硬编码 64。

真共享(True Sharing)

当多个线程真正读写同一个变量时,一致性流量是固有的 —— 不是伪共享。解决方案:

  • std::atomic 保证正确性(当性能不敏感时)
  • thread_local 副本加周期性聚合,提升吞吐量

用 Intel RDT/CAT 隔离缓存

在多核系统上,L3 缓存是共享的。后台线程 —— 日志、遥测、压缩 —— 可能逐出延迟关键路径上的热缓存行,导致不可预测的缓存未命中。

Intel 的缓存分配技术(Cache Allocation Technology, CAT),属于 Resource Director Technology (RDT) 的一部分,允许你将 L3 缓存划分为服务类(Classes of Service, COS)。每个 COS 由一个位掩码定义,指定它可以使用哪些缓存路(way)。你可以将关键核绑定到专属 COS,保证后台线程不会污染它们的缓存空间。

用 pqos 配置

# 安装工具
sudo apt install -y intel-cmt-cat

# 验证 CAT 支持
pqos -d

# 查看当前配置
pqos -s

# COS 1:分配 12 路 L3 中的高 8 路(0xff0),加上 80% 内存带宽
sudo pqos -e 'llc:1=0xff0;mba:1=80'

# COS 2:分配低 4 路(0x0f),50% 内存带宽
sudo pqos -e 'llc:2=0x0f;mba:2=50'

# 将核心 2、3 绑定到 COS 1(低延迟交易线程)
sudo pqos -a 'llc:1=2,3;mba:1=2,3'

# 监控核心 2、3 的 LLC 占用和内存带宽
sudo pqos -i 1 -m 'llc,mbl,mbr:2,3'

CAT 不是通用工具 —— 它专门用于在共享硬件上隔离延迟关键型工作负载。更广泛的系统级隔离(CPU 绑定、IRQ 路由、NUMA 绑定),参见低延迟调优指南


CPU 流水线停顿与 ILP

现代 CPU 将指令执行拆分为流水线阶段 —— 通常是取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)。理想情况下,每个时钟周期完成一条指令。

停顿打破了这个理想。两类指令级依赖导致停顿:

  1. 数据依赖 —— 后一条指令需要前一条指令的结果:
a = b + c;   // 写 a
d = a + e;   // 读 a —— 必须等前一条指令写回

d = a + e 无法进入执行阶段,直到 a = b + c 完成写回。这是一个流水线气泡。

  1. 控制依赖 —— 分支指令的目标地址在分支执行前未知:
if (x > 0)        // 分支 —— 取指阶段不知道目标
    y = a + b;     // 可能执行也可能不执行

分支解析之前,流水线无法取正确的下一条指令。分支预测缓解了这个问题,但一次预测错误会刷掉整个流水线 —— 在现代 CPU 上代价约 15–20 个周期。

流水线友好代码的三原则

  1. 拆分长依赖链。 不要把依赖操作串成一条长链,而是重构为可重叠的独立计算。例如,N 笔订单的”价格 × 数量”并行计算,而非累加一个运行总和。

  2. 热数据放寄存器。 每次内存访问都是潜在的缓存未命中。反复使用的值应该留在寄存器中 —— 编译器能做时会自动做,但指针别名、函数调用和 volatile 访问会迫使它溢出。

  3. 混合指令类型。 现代 CPU 有多个执行单元 —— 整数 ALU、FPU、load/store 单元、向量单元。一连串同类型指令会饱和一个单元,其他单元闲置。交错整数、浮点和内存操作可以让多个单元并行工作,最大化指令级并行性(ILP)。

对于系统中复杂的依赖图,拓扑排序可以识别关键路径,暴露并行化的机会。


结论

内存序、缓存一致性和流水线效率不是独立的主题 —— 它们是同一硬件现实的三个视角。存储缓冲区存在,因为写缓存慢。无效队列存在,因为缓存一致性慢。乱序执行存在,因为流水线必须保持填满。内存模型定义了游戏规则;缓存一致性实现了规则;流水线依赖这一切。

实操建议:同步用 acquire/release,独立计数器用 relaxed,只有当你能证明需要全局一致性时才用 seq_cst。用 perf 测量。如果你在 x86 上,算你幸运 —— 硬件替你做了大部分重活。