MESI 是多核处理器保证各核私有缓存数据一致的硬件协议,Intel 与 AMD 的 x86 处理器都基于它实现。协议给每个缓存行打上 Modified、Exclusive、Shared、Invalid 四种状态标记,通过总线嗅探把读写意图广播给其他核心,从而满足写传播、写串行化、读己之写三条硬性要求。它带来的直接工程后果有两个:写共享数据必须先让其他核心失效,产生额外延迟;协议以缓存行(x86 主流为 64 字节)为粒度工作,两个无关变量落在同一行就会引发伪共享。理解状态转换规则,是写出可扩展并发代码的前提。
一、一致性问题从哪里来
现代 CPU 每个核心都有私有 L1、L2 缓存。写策略分两种:写直达(write-through)把更新同步刷回主存;写回(write-back)只改私有缓存,等到缓存行被换出或需要共享时才落主存。写回性能好,但两个核心缓存同一地址时,主存与各核缓存可能出现三份不同的值。
考虑这个场景:
// 初始:内存中 x = 0
// Core 0 // Core 1
load x // 读到 0 load x // 读到 0
x = x + 1 // 本地 1 x = x + 10 // 本地 10
store x store x
// 主存里最终是几?哪个核心的值才对?
没有一致性协议,两个核心各持一份脏数据,程序员期望的”内存只有一个确定值”直接崩塌。
1.1 一致性协议必须满足的三条要求
写传播(Write Propagation):一个核心的写最终必须对所有核心可见。写串行化(Write Serialization):所有核心观察到的、对同一地址的写顺序必须一致。读己之写(Read-After-Write):同一核心写完再读,必须读到自己刚写的值。
二、四种状态的含义
| 状态 | 可读 | 可写 | 其他缓存是否有副本 | 与主存是否一致 |
|---|---|---|---|---|
| Modified(已修改) | 是 | 是,无总线开销 | 无 | 不一致,脏数据需写回 |
| Exclusive(独占) | 是 | 是,静默转 M | 无 | 一致 |
| Shared(共享) | 是 | 否,须先失效他核 | 可能有 | 一致 |
| Invalid(无效) | 否 | 否 | 不适用 | 不适用 |
M 状态意味着本核持有唯一有效副本且主存已过期,缓存行被替换前必须写回。E 状态是”干净的独占”,写它不需要通知任何核心,因此 E → M 是零总线开销的静默升级——这是 MESI 相比更早的 MSI 协议最主要的优化点。S 状态下多个核心持有相同的干净副本,任何写操作都要先广播失效。I 表示缓存行不存在或已被作废。
三、状态转换规则
3.1 本地读写触发的转换
| 当前状态 | 本地读 | 本地写 |
|---|---|---|
| M | 命中,保持 M | 命中,保持 M,无总线流量 |
| E | 命中,保持 E | 转 M,无总线流量 |
| S | 命中,保持 S | 发 BusUpgr,收齐 Ack 后转 M |
| I | 发 BusRd;他核有副本转 S,无副本转 E | 发 BusRdX,作废他核副本后转 M |
I 状态下的写又叫 RWITM(Read-With-Intent-To-Modify),一次总线事务同时完成取数据与作废其他副本。
3.2 嗅探到总线请求触发的转换
| 当前状态 | 嗅探到 BusRd(他核读) | 嗅探到 BusRdX / Invalidate(他核写) |
|---|---|---|
| M | 先写回主存,供数后转 S | 写回主存后转 I |
| E | 供数后转 S | 转 I |
| S | 保持 S | 转 I |
| I | 无动作 | 无动作 |
总线上跑的信号主要有 BusRd(读请求)、BusRdX(读并获取所有权)、BusUpgr(S 升级为 M 的作废请求)、Flush(写回)、FlushOpt(缓存间直传优化)。
3.3 一次完整时序
两个核心并发访问变量 count,初值 100,过程如下:
- Core 1 首次读
count,总线上无其他持有者,主存返回 100,缓存行进入 E; - Core 2 读
count,Core 1 嗅探到 BusRd,把数据供给 Core 2,自身降为 S,Core 2 也是 S; - Core 1 写
count = 110,先发 Invalidate,Core 2 收到后置为 I,Core 1 完成写入进入 M,此刻主存仍是 100; - Core 2 再读
count,发现本地 I,发出 BusRd;Core 1 嗅探到请求,先把 110 写回主存,再把数据交给 Core 2,双方都变为 S,Core 2 读到 110。
第 3 步是关键:写一个 S 状态的缓存行,必须等所有持有者回 Ack,这段等待就是共享变量高频写入时性能塌陷的根因。
四、总线嗅探与缓存间直传
嗅探(Bus Snooping)是硬件机制:每个核心的缓存控制器持续监听总线上所有内存事务,发现涉及自己持有的缓存行就触发状态转换。当请求方要读的数据正被另一核以 M 或 E 持有时,持有方可以直接把数据送过去,不必绕主存一圈,这叫缓存间直传(Cache-to-Cache Transfer),能省下一次主存往返。
嗅探的短板是扩展性。所有核心必须嗅探每一笔事务,核数增加时广播流量与作废次数快速增长,总线成为瓶颈——这也是大核数服务器改用目录式(Directory-based)一致性的原因。
五、Store Buffer 与 Invalidate Queue 的副作用
写 S 状态缓存行要等 Ack,核心就得空转。硬件的解法是加两级异步缓冲:
Store Buffer 让写操作先落入缓冲区,核心立刻执行后续指令,Ack 回来再真正刷入缓存行。为保证本核能读到自己刚写的值,还需要 Store Forwarding——加载操作直接从 Store Buffer 取数。
Invalidate Queue 解决对端拥塞。收到 Invalidate 消息的核心若正忙,会立即回 Ack 并把消息塞进队列稍后处理,避免让发起方一直阻塞。
代价是两者都打破了”写立刻可见”的直觉,指令看起来被重排,可见性问题重新出现。硬件因此暴露内存屏障指令:写屏障强制 Store Buffer 中的内容刷出,读屏障强制先处理完 Invalidate Queue 再执行后续加载,全屏障两者兼具。Java 的 volatile、C++ 的 std::atomic 内存序,最终都落到这些屏障指令上。MESI 只保证缓存一致(coherence),不保证程序需要的内存顺序(consistency),更不提供锁与屏障这类同步语义。
六、MESIF 与 MOESI 变体
| 协议 | 厂商 | 新增状态 | 解决的问题 |
|---|---|---|---|
| MESI | 通用基线 | — | 四态一致性 |
| MESIF | Intel | F(Forward) | S 副本众多时指定唯一应答者,降低主存带宽压力 |
| MOESI | AMD | O(Owned) | 脏数据可直接共享,无需先写回主存 |
MESIF 中当多个核心持有同一行的 S 副本时,其中一个被提升为 F,由它统一响应读请求,避免多核同时应答或回落主存。MOESI 的 O 状态表示”已修改且被共享”,持有者负责应答请求与最终写回,脏行共享时省掉一次主存写。
七、伪共享:MESI 最常见的工程代价
MESI 以缓存行为单位工作,不认识变量边界。两个线程各写自己的变量,只要变量挤在同一缓存行,硬件就当作同一份共享数据处理,反复作废、反复重取,实际数据毫无冲突。
#include <pthread.h>
#include <stdio.h>
#define ITER 100000000L
/* 坏例子:两个计数器落在同一缓存行 */
struct { long c0; long c1; } shared;
/* 好例子:填充到不同缓存行(x86 主流缓存行 64 字节) */
struct { long c0; char pad[64]; long c1; } padded;
void *bump_shared_0(void *_) { for (long i = 0; i < ITER; i++) shared.c0++; return 0; }
void *bump_shared_1(void *_) { for (long i = 0; i < ITER; i++) shared.c1++; return 0; }
void *bump_pad_0(void *_) { for (long i = 0; i < ITER; i++) padded.c0++; return 0; }
void *bump_pad_1(void *_) { for (long i = 0; i < ITER; i++) padded.c1++; return 0; }
static double run(void *(*f0)(void *), void *(*f1)(void *)) {
struct timespec a, b; pthread_t t0, t1;
clock_gettime(CLOCK_MONOTONIC, &a);
pthread_create(&t0, 0, f0, 0); pthread_create(&t1, 0, f1, 0);
pthread_join(t0, 0); pthread_join(t1, 0);
clock_gettime(CLOCK_MONOTONIC, &b);
return (b.tv_sec - a.tv_sec) + (b.tv_nsec - a.tv_nsec) / 1e9;
}
int main(void) {
printf("false sharing : %.3fs\n", run(bump_shared_0, bump_shared_1));
printf("padded : %.3fs\n", run(bump_pad_0, bump_pad_1));
return 0;
}
编译运行 gcc -O2 -pthread fs.c && ./a.out,两组耗时差距在多核机器上通常十分明显。具体倍数取决于核心拓扑与缓存层级,不必套用固定数字,自行测量即可。
Java 侧对应的手段是 jdk.internal.vm.annotation.Contended 注解(需配 -XX:-RestrictContended),JVM 会自动为字段两侧补齐填充;JDK 内部的 ConcurrentHashMap 计数单元与 LongAdder 都用了这个机制。定位方法上,Linux 下可用 perf c2c record 与 perf c2c report 直接找出发生缓存行争用的字段。
常见问题(FAQ)
Q1:MESI 已保证一致性,为什么还需要 volatile?
写缓冲与失效队列引入了异步,volatile 插入屏障强制刷新,保障顺序与可见性。
Q2:E 状态存在的意义是什么?
让独占且干净的缓存行可以静默升级为 M,写入时省掉一次总线作废广播。
Q3:怎么判断程序踩了伪共享?
用 perf c2c 观察缓存行争用,或给热点字段加缓存行填充后对比吞吐变化。