A LITTLE CORNER OF MY WORLD

读一段文字,留一点时光

认真记录的每个瞬间,都有自己的光。

返回所有文章
技术实践

多 CPU 操作 Cache 如何保证一致性:MESI 协议解析

多个 CPU 各自缓存了同一份数据,一个改了一个还没看到,怎么办?从 MESI 四种状态、总线嗅探到写缓冲区,把缓存一致性讲清楚。

文章目录
  1. 一致性问题的由来
  2. MESI 的四种状态
  3. 冲突是怎么解决的
  4. 修改共享行:RFO 请求
  5. 读取独占行:总线事务转发
  6. 总线嗅探(Bus Snooping)
  7. 写缓冲区与失效队列
  8. 程序结束后,Cache 会被同步删除吗
  9. 小结

单核时代,Cache 只是让 CPU 别老等内存;多核时代,Cache 成了并发正确性的一部分。

一致性问题的由来

先看一个反直觉的事实:在多核 CPU 上,下面这段代码的结果可能不是 0。

// CPU0 和 CPU1 同时执行
x = x + 1;   // x 是共享变量,初始值为 0

即使两个核心物理上串行执行了这两条指令,只要 x 在两个核的 Cache 里各有一份副本,后写的那个核刷新了自己的副本,另一个核的副本还是旧值,主存中的最终值就可能只是 1。

问题的根源在于 Cache 的本质:它是数据的副本。副本一旦分散到多个核手里,“读到哪里算哪里”就成了一个分布式系统问题——只不过这个分布式系统的节点是 CPU 核心,总线是通信链路。

为了解决这个问题,硬件层面引入了缓存一致性协议(Cache Coherence Protocol),目前最主流的就是 MESI。

MESI 的四种状态

MESI 是四个状态的首字母缩写,每个缓存行(Cache Line,通常 64 字节)在任意时刻都处于其中一种状态:

状态 含义 与主存的关系 是否唯一
M(Modified,已修改) 数据被改过,还没写回主存 不一致 是,仅此一份
E(Exclusive,独占) 数据与主存一致,且只有我缓存了 一致 是,仅此一份
S(Shared,共享) 数据与主存一致,其他核可能也有副本 一致 否,可多份共存
I(Invalid,无效) 副本已失效,不能直接用 —— 需要时重新读取

四种状态覆盖了一条缓存行在分布式视角下的全部生命周期:

                   读命中
        ┌──────────────────────────┐
        │                          │
        ▼                          │
   ┌─────────┐   其他核读取同一行   │
   │ E 独占  │ ──────────────────► ┌─────────┐
   └─────────┘                     │ S 共享  │
        │                          └─────────┘
        │ 本地写入                      │  ▲
        ▼                              ▼  │ 其他核写回/失效广播
   ┌─────────┐  写回主存          ┌─────────┐
   │ M 修改  │ ─────────►        │ I 无效  │
   └─────────┘  降级为 S          └─────────┘
                                    读未命中重新装载

直观理解这四个状态:E 是“干净且独占”,M 是“脏且独占”,S 是“干净且共享”,I 是“没有”。一致性协议要保证的核心不变量只有两条:

  1. 任意时刻,同一地址在 M 状态的缓存行最多只有一份;
  2. 处于 M 状态的缓存行,其数据必须是最新的——其他核要读,必须从它这里拿到最新值。

冲突是怎么解决的

当多个 CPU 同时访问、修改同一个缓存行时,MESI 通过状态转换和总线通信来化解冲突。

修改共享行:RFO 请求

如果一个 CPU 想修改一个处于 S 状态的缓存行,它不能直接写——直接写就会制造出两个不一致的脏副本。流程是:

  1. 发送 RFO(Request For Ownership) 请求到总线,声明“我要独占这一行”;
  2. 其他缓存了该行的 CPU 嗅探到请求后,把自己的副本置为 I;
  3. 所有副本都失效后,请求方把本地状态从 S 升级为 M,然后执行写入。

也就是说,写操作拿到的不是数据,而是所有权。这正是 MESI 和早期 MSI 协议的关键区别:E 状态的引入让“独占”这件事可以无声地完成,省掉一次总线事务。

读取独占行:总线事务转发

如果一个 CPU 读一个 M 状态的行,数据在别人手里且比主存新,必须由那个核出手:持有 M 的核把数据写回主存(或直接由总线事务转发),双方状态同时降为 S。

读 E 状态的行则最简单:数据干净又独占,直接读,同时广播一声“我也缓存了”,两边一起降为 S。

总线嗅探(Bus Snooping)

以上所有协作都依赖一个机制:总线嗅探。每个 Cache 控制器监听总线上所有其他核发出的请求(读、写、失效),发现和自己有关的就按协议规则响应。

CPU0: 写 x ──RFO──► ┌────────┐ ── 嗅探 ──► CPU1: x 置为 I
                     │  总线  │
CPU2: 读 x ──BusRd──►└────────┘── 嗅探 ──► CPU0: 数据写回,降为 S

嗅探的问题也显而易见:广播会被所有人听到。核心数少时没问题,核心数一多,总线带宽和缓存控制器的压力都成为瓶颈。所以大规模多核用的是基于目录的协议(Directory-based),把“谁有什么副本”记在一张表里,点对点通信,避免全局广播。

写缓冲区与失效队列

按协议严格执行,每次写都要等所有核 ACK,CPU 大量时间会浪费在等待上。现代 CPU 用两个异步结构来提速:

  • 写缓冲区(Store Buffer):CPU 发起写时,把写操作放入缓冲区就继续往下执行,由缓冲区负责在后台完成 RFO、等待 ACK、真正写入 Cache。
  • 失效队列(Invalidate Queue):收到失效请求的核不必立刻处理,先把请求排进队列、立即回复 ACK,之后再异步执行失效。

代价是引入了新的内存可见性问题——比如经典的“写缓冲区导致写写重排”。这也是为什么高级语言(Java、C++、Go)都要提供 volatile、原子指令、内存屏障之类的原语:语言层面的并发正确性,最终要落到这些硬件机制的缝隙里。

程序结束后,Cache 会被同步删除吗

一个常见的好奇:进程退出时,它用过的内存会被回收,那 Cache 里的副本会被“同步删除”吗?

不会,也不需要。

两个原因:

  1. Cache 对进程透明,也不感知进程。Cache 是硬件按物理地址(Virtual → Physical → Cache Line)索引的,硬件并不知道哪行数据属于哪个进程。进程退出时操作系统只回收页表和物理页,不会、也无法逐条通知 Cache“这些数据作废”。
  2. 留着旧副本不会造成错误。物理页被回收后可能分配给别的进程,但分配时要么清零、要么随地址映射变化通过上下文切换时的缓存刷新来保证正确性;更重要的是,Cache 一致性协议保证的是“同一物理地址”的多个副本一致,而不是“同一份数据活多久”。

Cache 的淘汰完全由硬件按访问局部性管理:Cache 满了或需要腾地方时,按替换算法(LRU 或近似 LRU 的伪随机策略)选择牺牲行。脏行(M 状态)先写回主存再淘汰,干净行直接丢弃。

所以更准确的回答是:Cache 不跟随进程消亡,它跟随“这块数据还会不会被访问”而存在或消失。

小结

MESI 用四种状态加一组转换规则,在多核之间维护了一条朴素的不变量:同一地址,任意时刻只有一份最新副本,或者所有副本一致。它靠总线嗅探(或目录协议)传播变更,靠写缓冲区和失效队列换取性能,也正因如此,把性能缝隙留给了软件——这恰是并发编程要关心内存模型的原因。

回头看开头那个 x = x + 1 的问题,答案也自然浮现:靠 MESI 本身不能保证两个自增都生效,你需要的是 CAS、锁或者原子变量——这些原语的底层,最终都是同一套缓存一致性机制。

✿

感谢读到这里,愿这些文字对你有所帮助。

彭森龙
彭森龙 / PengSenLong

写一点代码,记录一点生活。欢迎来留言板聊聊。 留言板