单核时代,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 是“没有”。一致性协议要保证的核心不变量只有两条:
- 任意时刻,同一地址在 M 状态的缓存行最多只有一份;
- 处于 M 状态的缓存行,其数据必须是最新的——其他核要读,必须从它这里拿到最新值。
冲突是怎么解决的
当多个 CPU 同时访问、修改同一个缓存行时,MESI 通过状态转换和总线通信来化解冲突。
修改共享行:RFO 请求
如果一个 CPU 想修改一个处于 S 状态的缓存行,它不能直接写——直接写就会制造出两个不一致的脏副本。流程是:
- 发送 RFO(Request For Ownership) 请求到总线,声明“我要独占这一行”;
- 其他缓存了该行的 CPU 嗅探到请求后,把自己的副本置为 I;
- 所有副本都失效后,请求方把本地状态从 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 里的副本会被“同步删除”吗?
不会,也不需要。
两个原因:
- Cache 对进程透明,也不感知进程。Cache 是硬件按物理地址(Virtual → Physical → Cache Line)索引的,硬件并不知道哪行数据属于哪个进程。进程退出时操作系统只回收页表和物理页,不会、也无法逐条通知 Cache“这些数据作废”。
- 留着旧副本不会造成错误。物理页被回收后可能分配给别的进程,但分配时要么清零、要么随地址映射变化通过上下文切换时的缓存刷新来保证正确性;更重要的是,Cache 一致性协议保证的是“同一物理地址”的多个副本一致,而不是“同一份数据活多久”。
Cache 的淘汰完全由硬件按访问局部性管理:Cache 满了或需要腾地方时,按替换算法(LRU 或近似 LRU 的伪随机策略)选择牺牲行。脏行(M 状态)先写回主存再淘汰,干净行直接丢弃。
所以更准确的回答是:Cache 不跟随进程消亡,它跟随“这块数据还会不会被访问”而存在或消失。
小结
MESI 用四种状态加一组转换规则,在多核之间维护了一条朴素的不变量:同一地址,任意时刻只有一份最新副本,或者所有副本一致。它靠总线嗅探(或目录协议)传播变更,靠写缓冲区和失效队列换取性能,也正因如此,把性能缝隙留给了软件——这恰是并发编程要关心内存模型的原因。
回头看开头那个 x = x + 1 的问题,答案也自然浮现:靠 MESI 本身不能保证两个自增都生效,你需要的是 CAS、锁或者原子变量——这些原语的底层,最终都是同一套缓存一致性机制。