缓存写入策略与 MESI 一致性协议

13 分钟

写缓存,比读缓存更复杂

在《CPU cache 原理》里讲的是"读缓存":CPU 如何靠局部性原理、地址拆分、组相联映射来加速内存读取。这一篇讲"写缓存"和它带来的连锁问题——多核一致性。

读缓存是单向的:缓存 miss 就去内存搬数据,不会破坏别处的数据。写缓存则不同,一旦写入,缓存和内存就出现了两份可能不一致的数据;到了多核,各核心之间还会互相不一致。所以写缓存需要两套策略分别回答两个问题:

  1. 缓存和内存怎么保持一致——什么时候把写回内存(写直达 vs 写回)。
  2. 核心和核心怎么保持一致——多个核心缓存了同一块内存怎么办(MESI 一致性协议)。

下面先讲第一套策略,再讲第二套协议。

缓存写入策略

写数据时,缓存和内存两份数据可能不一致,需要策略约定。核心问题有两个:什么时候写内存写未命中时要不要先加载

写直达 vs 写回

  • 写直达(Write-Through):每次写缓存的同时,同步写入内存。实现简单、一致性好,但每次写都要等内存,性能差。
  • 写回(Write-Back):只写缓存,把这一行标记为 dirty(脏),等到这行被替换时再写回内存。性能好,但实现复杂,还要维护 dirty 位。

现代 CPU 普遍用写回,因为内存写太慢,频繁写直达会拖垮性能。而写回带来的"脏数据"正是后面多核一致性要处理的核心对象。

写分配 vs 写不分配

针对"写未命中"(要写的数据不在缓存里):

  • 写分配(Write-Allocate):先把内存块加载进缓存,再写。利用空间局部性,后续读写更快。
  • 写不分配(No-Write-Allocate):直接写内存,不加载进缓存。

常见组合是 写回 + 写分配:写 miss 时加载进缓存再写,标记 dirty,替换时写回。这是大多数 CPU 的默认行为。

多核时代,缓存为什么需要"一致"

写入策略解决了"缓存和内存"之间的一致性。但现代 CPU 都是多核的,每个核心有自己独立的 L1/L2 缓存,同一块内存可能同时被多个核心缓存。这时候"缓存和内存一致"还不够,还得保证"各核心之间的缓存也一致"。

问题场景很直接:核心 A 和核心 B 都缓存了变量 x = 0。A 把 x 改成 1(写回策略下,A 的 L1 里是 1,内存里可能还是 0)。B 再去读 x,如果读自己的缓存会读到 0,就拿到了一份过期数据。

要解决这个问题,光靠"写回内存"不够,因为 B 的缓存里还躺着旧值,它根本不会去内存读。必须在多个核心的缓存之间建立一套协议,让它们知道"某个缓存行已经失效了"。

MESI 协议

MESI 是经典的缓存一致性协议(Cache Coherence Protocol),用四个状态标记每个缓存行,四个字母就是状态的缩写:

状态全称含义
MModified(已修改)本缓存行的数据被改过,与内存不一致,且只有本核心有最新副本
EExclusive(独占)数据与内存一致,且只有本核心持有这份副本
SShared(共享)数据与内存一致,多个核心都持有这份副本
IInvalid(无效)该缓存行失效,不能使用

读、写、被读时的状态变化

围绕这四个状态,MESI 规定了三种核心场景下的行为:

  • :本核心缓存行是 I(无效)时,需要从内存或其他核心加载。如果其他核心有 M 状态的最新副本,需要先拿到那份数据。
  • :本核心要写一个 S(共享)状态的缓存行时,必须先让其他核心的副本失效(发 Invalidate 消息,把它们置为 I),再升级为自己的 M 状态。
  • M 状态被读:其他核心要读 M 状态的数据时,持有 M 的核心要把数据写回内存(或直接转发给请求方),然后两者都变为 S。

这套协议保证了一个核心不变量:任意时刻,最多只有一个核心持有某个缓存行的 M 状态。这是多核程序正确性的基石。

用变量 x(初始内存值为 0)串一遍状态流转,建立直觉:

  1. A 读 x:缓存行加载进 A,此时只有 A 有副本,A 持有 E
  2. B 读 x:B 也加载,A、B 都变成 S
  3. A 写 x = 1:A 先发 Invalidate 让 B 的副本失效(B 变 I),A 自己变 M
  4. B 再读 x:B 是 I,需要拿最新值;A 把数据写回内存(或直接转发给 B),A、B 都变 S,B 读到 1。

整个过程保证了 B 在任何时候都不会读到过期数据。

伪共享:缓存行粒度的副作用

MESI 的一致性粒度是缓存行(64 字节),不是单个变量。这带来一个隐蔽的性能问题:伪共享(False Sharing)

看一个多线程计数器的例子:

// Java 示例
class Counter {
    volatile long a = 0;  // 线程 1 反复 a++
    volatile long b = 0;  // 线程 2 反复 b++
}

ab 各占 8 字节,但它们在内存里紧挨着,落在同一个 64 字节缓存行里。线程 1 写 a 时,会把整行置为 M 并让线程 2 的缓存行失效;线程 2 写 b 时同样让线程 1 失效。两个线程逻辑上互不相关,却因为共享同一缓存行而反复互相失效,导致性能严重下降——这就是"伪"共享:没有真正的数据共享,却有共享的代价。

排查思路:多线程程序里,两个"看起来应该并行无冲突"的变量,如果频繁被不同线程写且性能异常,先怀疑是否落在同一缓存行。

解决办法是缓存行填充(Cache Line Padding):在变量之间填充无用字节,让它们各自独占一个缓存行:

class Counter {
    volatile long a = 0;
    long p1, p2, p3, p4, p5, p6, p7;  // 填充 56 字节,把 b 推到下一个缓存行
    volatile long b = 0;
}

Java 8 还提供了 @Contended 注解(需加 JVM 参数 -XX:-RestrictContended),由 JVM 自动完成填充;C/C++ 里用 alignas(64) 或手写填充结构体。

缓存一致性与 Java 内存模型

缓存一致性是硬件层的机制,Java 的 volatile 等语言层保证,底层就是靠它落地。

volatile 保证两点:可见性(一个线程的写对另一个线程可见)和禁止重排。写 volatile 变量后,会插入内存屏障,把对应的缓存行刷到内存并失效其他核心的副本;读 volatile 变量时,保证读到最新值。这正是 MESI 协议在语言层的体现。

二者是"语言层保证"和"硬件层机制"的配合关系:JMM(Java 内存模型)定义了 volatile 的语义,硬件靠 MESI 把这份语义变成真实的缓存行为。理解这层关系,能解释很多并发编程里"为什么加了这个关键字就对了"的现象。

面试高频追问

Q1:为什么写回策略比写直达更常用? 内存写延迟高(200+ 周期),写直达每次写都要等内存,性能差。写回把写操作"延迟"到替换时批量处理,配合 dirty 位,是性能优先的选择。代价是实现复杂,且断电可能丢数据,所以需要缓存的刷新机制。

Q2:MESI 里 E(独占)和 S(共享)状态有什么区别? 两者数据都与内存一致,区别在于副本数量:E 表示只有本核心持有这份副本,S 表示多个核心都持有。E 状态的好处是,本核心从 E 写数据时,因为只有自己有副本,无需通知其他核心失效,可以直接升级为 M,省一次 Invalidate。

Q3:为什么有了 MESI,Java 还需要 volatile / 锁? MESI 解决的是"缓存数据一致",但不能解决"代码执行顺序"和"复合操作的原子性"。比如 i++ 是读-改-写三步,MESI 保证每个核心看到的数据一致,但保证不了这三步不被其他核心交叉。所以语言层还需要 volatile(禁止重排)和锁(保证原子性)来补上更高的抽象。

Q4:伪共享为什么这么隐蔽,怎么定位? 隐蔽在它"看起来正确"——逻辑上没有共享任何数据,程序结果也正确,只是慢。定位靠 perf 等工具观察 cache miss 和缓存行争抢,或直接审视代码里"不同线程高频写相邻变量"。解决靠缓存行填充或 @Contended

总结

  • 写缓存比读缓存复杂,需要两套策略分别解决缓存与内存核心与核心的一致性问题。
  • 写入策略:写直达 vs 写回、写分配 vs 写不分配,现代 CPU 默认写回 + 写分配,写回产生的脏数据是多核一致性的核心对象。
  • MESI 用 M/E/S/I 四个状态标记缓存行,核心不变量是"任意时刻最多一个核心持有 M 状态"。
  • 伪共享是缓存行粒度(64B)带来的副作用:不同变量落在同一行会互相失效,拖慢性能。
  • volatile 的可见性底层靠 MESI 落地,但 MESI 不解决原子性和指令重排,所以还需要锁和内存屏障。
  • 解决伪共享的核心手段是缓存行填充(手写填充、@Contendedalignas)。

南一

前端工程师,在这里整理面试知识,也记录从零做一个网站的过程。

关于本站 →