前文聚焦于读------地址翻译如何影响访存延迟。本部分转向写。Store 指令看似简单,但其路径从流水线的 retire 阶段到最终写入 L1d 缓存行,中间经过了一个对多核程序正确性至关重要的结构:Store Buffer。理解 Store Buffer 的工作方式,是理解内存屏障(fence)和内存排序模型(memory ordering)的硬件前提。
写策略
缓存对程序员应为透明的------修改一个缓存行应当等同于修改主存中的对应数据。实现此透明性有两种基本策略:直写与写回。
直写(write-through):缓存行被写后立即同步到主存。实现简单,缓存与主存始终一致。但其性能开销过大:若程序反复修改同一缓存行,每次修改均需经总线传递至主存。当前仅极少数硬件边缘场景(如部分嵌入式系统的寄存器映射)使用直写。
写回(write-back):现代 99% 的处理器采用此策略。写入仅修改缓存,缓存行被标记为 dirty 即告完成。dirty 数据仅在该缓存行被驱逐时写回主存。此外,CPU 可在总线空闲时提前将 dirty 行写回主存并清除 dirty 标记,不等待驱逐时刻。
写回策略降低了写入的带宽压力,但它引入了一个关键问题:若一条 store 指令的目标缓存行当前不在 L1d 中------例如该行被核心 B 持有为 Modified 状态,须等 RFO 完成后才能写入------CPU 将被阻塞在等待缓存行到位的数百周期中。写回策略本身不解决此问题;解决它的是 Store Buffer。
Store Buffer
假设 CPU 每次执行 store 指令时均需等待目标缓存行在 L1d 中就位并完成写入,流水线将被严重阻塞。若目标缓存行不在 L1d 中(需从 L2、L3 甚至主存追回,或需等待 MESI RFO 广播),中间数百周期的延迟将使流水线完全空转。为解决此问题,现代 CPU 在流水线执行单元与 L1d 之间插入一个 Store Buffer。
store 指令从流水线 retire 时,数据并非直接写入 L1d,而是先进入 store buffer 排队。对流水线而言,该 store 指令视为已完成------指令可 retire,后续指令可继续发射。store buffer 中的数据由硬件在后台异步写入 L1d。写入时机为:目标缓存行在 L1d 中获得相应的 MESI 状态(E 或 M),且总线不繁忙时,store buffer 依次将数据写回。
Store Buffer 同时提供 Store-to-Load Forwarding 机制:若一条 load 指令的目标地址与 store buffer 中尚未写回 L1d 的某条 store 指令地址匹配,硬件直接从 store buffer 将最新值转发给 load 指令,而不等待写入 L1d 完成。对程序而言,此值与"已经写进缓存"语义等价。
Store Buffer 容量有限。现代高性能处理器的 store buffer 通常在数十条到上百条之间,具体数字随微架构代际变化(Intel 客户端核心近年多在 56--64 条范围,AMD Zen 系列约 44--48 条,Apple M 系列据逆向分析估算在 80 条以上)。这意味着若某段时间大量 store 指令堆积在流水线中,store buffer 将被填满。一旦填满,后续 store 指令无法 retire------流水线停止,乱序执行能力失效,CPU 只能等待 store buffer 腾出空位。此即多线程密集写入、总线带宽被挤爆时 L1d "仿佛消失"的内部因果链:下游 DDR 总线堵塞导致 L1d 写不出去 → store buffer 无法排空 → 流水线 stall。瓶颈不在缓存本身的速度,而在存储通路的整体吞吐。
不同平台对同样密集写入的容忍度差异,部分解释了为何同一套生产代码在 x86 服务器和 Apple Silicon 上的"可写性"体感不同:更大的 store buffer 可以在流量峰值中吸收更多的突发写入,推迟流水线 stall 的临界点。
内存排序与屏障
Store Buffer 带来了一个根本性的副作用:一条 store 指令对外部核心(其他 CPU 核心)的可见时间,晚于它对本地核心(执行该 store 的核心)的可见时间。原因是 store-to-load forwarding 使本地核心可以"提前"看到尚未写入 L1d 的数据,而其他核心必须在数据实际进入 L1d 并通过缓存一致性协议扩散后才能观察到。
考虑以下场景:核心 0 执行
cpp
x = 1;
y = 2;
核心 1 同时执行
cpp
if (y == 2) {
assert(x == 1); // 这个断言会成功吗?
}
在 x86 的 TSO(Total Store Order,全存储排序)模型下,断言一定成功------因为 TSO 保证同一核心的 store 在其他核心眼中保持程序顺序。但在 ARM 等弱排序模型下,核心 1 可能先观察到 y = 2,而稍后才观察到 x = 1。原因并非 store buffer 必然倒序写出,而是 ARM 不要求不同地址上的 store 对其他核心以程序顺序可见------即使核心 0 的流水线按顺序 retire 了这两条 store,它们在缓存一致性网络上的可见性传播并不遵循统一的全局顺序。除非在两条 store 之间插入屏障指令。
消除此类不确定性的是内存屏障(memory barrier,或 fence)。x86 提供三条主要屏障指令:
SFENCE(Store Fence):保证 SFENCE 之前的所有 store 指令已经离开程序顺序上的待执行状态------后续的 store 不可越过它提前执行。SFENCE 不要求数据已写入 L1d 或对其他核心可见,也不约束 load 指令的行为。在 WC 内存类型下,SFENCE 还会强制刷新写合并缓冲区,使设备能够观察到先前写入的数据(详后)。MFENCE(Memory Fence):最强屏障。确保 MFENCE 之前的所有 load 和 store 指令均已全局可见,之后的所有 load 和 store 指令才开始执行。x86 的 TSO 默认保证 LoadLoad、LoadStore 和 StoreStore 排序,但允许 StoreLoad 重排------即 store 可以暂时停留在 store buffer 中,而后续不相关的 load 可以先执行。MFENCE 正是用于恢复 StoreLoad 顺序的唯一指令。LFENCE(Load Fence):串行化指令流------LFENCE 之前的所有指令必须完成本地执行(retired),之后的所有指令才开始发射。LFENCE 不排空 store buffer,不控制缓存写回,仅用于防止指令的推测执行越过边界(如RDTSC前后的序列化需求)。在 Spectre 缓解中,LFENCE 被用于阻止条件分支的推测执行污染。
x86 的 TSO 模型赋予程序一个相对强的默认保证:同一核心的 store 在其他核心眼中保持程序顺序(StoreStore)。但 TSO 允许本核心的后续 load 先于之前尚未排空的 store 执行------此即 StoreLoad 重排,是 TSO 区别于顺序一致性的唯一缺口。因为 store 在 store buffer 中排队时,后续完全不相关的 load 可以先发射,读取的是当前 L1d 乃至其他缓存的"旧"值。MFENCE 正是为填补这一缺口而设立的。
ARM 的弱排序模型则不同:在 ARM 上,store 之间、load 之间、乃至 load 与 store 之间的顺序均不被默认保证。ARMv8 引入了专用的获取/释放指令(LDAR------Load-Acquire,STLR------Store-Release)来提供单向屏障:LDAR 确保该 load 之后的所有 load 和 store 不重排到它之前;STLR 确保该 store 之前的所有 load 和 store 不重排到它之后。对于那些同时需要两个方向的场景,ARM 的 DMB(Data Memory Barrier)指令相当于 MFENCE。ARM 弱排序模型的代价是:一份在 x86 上正确运行的无锁代码,移植到 ARM 后可能因缺少屏障而出现 heisenbug------极低概率、极难以复现的排序违规。
原子操作与缓存
C++ 的 std::atomic 提供了四个标准的内存排序标记,每个标记映射到不同的硬件行为:
memory_order_relaxed :不提供任何排序约束。唯一保证是原子性------修改变量的 RMW(Read-Modify-Write)操作不会被其他核心的并发访问撕裂。在 x86 上,relaxed 原子变量的 load 和 store 编译为普通的 mov 指令(得益于 TSO 的默认 load/store 有序性),仅 RMW 操作需要 lock 前缀(如 lock cmpxchg)。在 ARM 上,relaxed load/store 编译为普通 ldr/str,RMW 通过 LDREX/STREX 循环实现,不含额外屏障。
memory_order_acquire :获取语义。该 load 之后的所有 load 和 store 不可重排到它之前。确保读取一个 flag 后,被该 flag 保护的所有数据都已"获取"到正确的可见性。在 x86 上,TSO 已提供 LoadLoad 和 LoadStore 排序,所以 acquire load 编译为普通 mov,不产生额外的屏障指令。在 ARM 上,acquire load 编译为 LDAR 指令,隐含单向获取屏障。
memory_order_release :释放语义。该 store 之前的所有 load 和 store 不可重排到它之后。确保所有数据准备完毕后再设置 flag,使其他核心的 acquire 能观测到完整状态。在 x86 上,TSO 已提供 LoadStore 和 StoreStore 排序,release store 编译为普通 mov。在 ARM 上,release store 编译为 STLR 指令。
memory_order_seq_cst :顺序一致性。提供全局唯一的全序(total order)------所有核心看到的 seq_cst 操作顺序一致。这是性能最昂贵的排序约束。在 x86 上,seq_cst 往往可以利用 TSO 的天然排序保证实现,不一定对应显式的 MFENCE;而 seq_cst 的原子 RMW 操作通常会借助带 lock 前缀的指令建立这一全序关系。具体实现因编译器版本和上下文而异,不应假定必定生成特定指令序列。在 ARM 上,seq_cst 需要在前后插入 DMB 屏障。
理解上述映射关系后,一个关键结论浮现:在 x86 上正确使用 acquire/release 完全免费(在指令层面),只有 seq_cst 需要付出全屏障的代价。这一事实使得许多性能敏感的并发代码将需要全序同步的路径缩紧到最小------例如,仅在 flag 的设置或读取上使用 seq_cst,而将大量内部数据交换使用 acquire/release 实现。在 ARM 上,acquire/release 即使指令开销也远低于 seq_cst 的全屏障,但相比于 x86 的"免费",ARM 程序员需要更审慎地选择排序约束。
lock 前缀(用于 x86 原子 RMW 指令,如 lock add、lock cmpxchg)在现代缓存一致性系统中通常不会锁住整个总线,而是先取得目标缓存行的独占所有权(MESI 的 M 或 E 状态),在该缓存行上完成不可分割的读-改-写序列。只有极少数无法缓存的访问才会退化为真正的总线锁定(bus lock)。lock 前缀同时隐式包含全内存屏障(MFENCE 的排空效果),因此 lock 前缀的指令也等于通知硬件:执行前将所有 store buffer 排空,确保全局可见的原子 RMW 在其前后建立了完整的 StoreLoad 顺序。
WC 与 UC:写合并与不可缓存
除 store buffer 外,CPU 还有两种特殊的写入通道。
写合并 (Write-Combining,WC)针对设备内存。以游戏渲染为例,CPU 需向显卡帧缓冲写入 100 MB 像素数据。若每 4 字节像素写入均通过 PCIe 总线单独发送一次写事务,总线的有效带宽将被细碎写入消耗殆尽。WC 缓冲区将若干次连续的小写入合并为一个较大的传输块------典型为 64 字节(一条 cache line 大小),填满后一次性通过总线发送至设备。Intel 处理器通常拥有 4--6 个 WC 缓冲区,每个缓冲区为 64 字节。non-temporal store(_mm_stream_si128 等)正是将数据定向写入 WC 缓冲区,而非污染 L1--L3 缓存。值得注意的是,non-temporal store 并不等于直接写入 DRAM------对于 WB(write-back)内存类型,CPU 仍可能通过写合并缓冲区聚合数据后再写出;其核心目标是避免将数据填入缓存层次,而不是保证立刻落入主存。
不可缓存(Uncacheable,UC)是 OS 对特定物理页面设置的标记。此类页面的访问完全穿透 L1、L2、L3 缓存------读操作每次都从设备总线上取值,写操作每次都直接上总线。UC 确保 CPU 对设备寄存器的写入在指令 retire 后立即可被设备观测到,不经过任何缓存层次的中间滞留。OS 通过页表属性和 PAT(Page Attribute Table)/ MTRR(Memory Type Range Register)等机制将特定物理页面标记为 UC 或 WC。
一个容易混淆的点:SFENCE 在 x86 参考手册中明确排空 WC buffer 中的数据------也就是说,SFENCE 不仅保证 store buffer 中的常规写回数据已进入缓存层次,还强制 WC buffer 中的数据真正发出到总线/设备。因此,在 GPU 驱动或 RDMA 栈中,在向设备写入一系列命令/描述符后跟一条 SFENCE,是保证设备能观测到完整写入序列的标准做法。MFENCE 的效果包含 SFENCE 的全部功能------所以如果已经使用了 MFENCE,不需要额外的 SFENCE。
回到第 I 部分提出的内存墙命题------Load 的延迟隐藏依赖于 OOO 与 MLP,Store 的延迟隐藏依赖于 Store Buffer。两者的本质相同:都是用有限容量的硬件队列,将长达数百周期的内存访问与前端流水线解耦。一旦队列耗尽,CPU 便重新撞上内存墙。
下一部分将进入 Store Buffer 排空后数据进入的战场------多核缓存一致性:MESI 协议如何维持多个核心之间缓存行的状态同步、RFO 在所有核心间做闭环握手的代价、以及伪共享在工程中的排查与修复。