mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3082 字
9 分钟
缓存一致性:MESI 协议
2026-02-17

单核时代,缓存是 CPU 和内存之间的加速器——逻辑简单,性能提升显著。多核时代,每个核心都有自己的 L1/L2 缓存,同一个内存地址的数据可能在多个核心的缓存中存在副本。当核心 A 修改了自己缓存中的数据,核心 B 的缓存中还是旧值——数据不一致了。

缓存一致性协议(Cache Coherence Protocol)就是解决这个问题的硬件机制。理解 MESI 协议,就理解了多核编程中”伪共享”等性能陷阱的根源。

一、缓存一致性问题#

1.1 问题场景#

sequenceDiagram participant CoreA as 核心 A participant CacheA as 缓存 A participant Memory as 主存 participant CacheB as 缓存 B participant CoreB as 核心 B Note over Memory: x = 100 CoreA->>CacheA: 读取 x CacheA->>Memory: 缓存未命中 Memory-->>CacheA: x = 100 Note over CacheA: x = 100 CoreB->>CacheB: 读取 x CacheB->>Memory: 缓存未命中 Memory-->>CacheB: x = 100 Note over CacheB: x = 100 CoreA->>CacheA: 写入 x = 200 Note over CacheA: x = 200 Note over CacheB: x = 100 不一致! Note over Memory: x = 100 也不一致! CoreB->>CacheB: 读取 x Note over CacheB: 返回 100 读到旧值!

1.2 一致性模型的定义#

缓存一致性必须满足两个条件:

  1. 写传播(Write Propagation):一个核心的写操作必须最终对其他核心可见
  2. 写串行化(Write Serialization):所有核心必须以相同的顺序看到写操作

二、MESI 协议#

2.1 四种状态#

MESI 协议以四种状态的首字母命名:

状态全称含义可读可写
M(Modified)已修改数据已修改,与主存不一致,只有本缓存有副本
E(Exclusive)独占数据与主存一致,只有本缓存有副本(无需通知)
S(Shared)共享数据与主存一致,多个缓存可能有副本(需先通知)
I(Invalid)无效缓存行无效,不包含有效数据

2.2 状态转换图#

stateDiagram-v2 [*] --> I : 初始 I --> E : 本地读(其他缓存无副本) I --> S : 本地读(其他缓存有副本) I --> M : 本地写(其他缓存无副本) E --> M : 本地写 E --> S : 远端读 E --> I : 远端写(实际不会发生,因为 E 意味着独占) S --> M : 本地写(需先使其他副本无效) S --> I : 远端写 S --> S : 远端读 M --> S : 远端读(先写回主存) M --> I : 远端写(先写回主存) note right of M : 脏数据,仅本缓存有 note right of E : 干净数据,仅本缓存有 note right of S : 干净数据,多缓存共享 note right of I : 无有效数据

2.3 关键状态转换详解#

本地读命中

  • M/S/E → 直接从缓存读取,无需访问总线
  • I → 缓存未命中,需要从主存或其他缓存加载

本地写命中

  • M → 直接写入(无需通知其他缓存)
  • E → 直接写入,状态变为 M(无需通知其他缓存,因为独占)
  • S → 发出Invalidate消息使其他副本无效,然后写入,状态变为 M

远端读

  • 本地 M → 将脏数据写回主存,状态变为 S
  • 本地 E → 状态变为 S
  • 本地 S → 保持 S
  • 本地 I → 无影响

远端写

  • 本地 M → 将脏数据写回主存,状态变为 I
  • 本地 E/S → 状态变为 I

2.4 MESI 的消息类型#

消息含义触发条件
Read请求读取某个缓存行本地缓存未命中
Read Response返回请求的数据其他缓存或主存响应
Invalidate请求使某个缓存行无效本地写入共享行
Invalidate Ack确认缓存行已无效其他缓存响应
Writeback将脏数据写回主存M 状态行被驱逐或远端读取

三、MESI 的性能问题#

3.1 写入共享行的开销#

当核心 A 写入一个 S 状态的缓存行时:

  1. A 发出 Invalidate 消息
  2. 等待所有持有该行副本的核心确认(Invalidate Ack)
  3. 收到所有确认后,A 才能将状态改为 M 并写入

这个等待过程可能需要数十到数百个周期——如果其他核心很忙或者距离很远(跨 NUMA 节点)。

3.2 Store Buffer:缓解写延迟#

为了不阻塞写入核心,现代 CPU 引入了Store Buffer

graph LR CORE["核心"] --> SB["Store Buffer<br/>写缓冲"] SB --> CACHE["L1 缓存"] CACHE --> L2["L2 缓存"] CORE -->|"写入"| SB CORE -->|"继续执行<br/>(不等待 Invalidate Ack)"| CORE style SB fill:#fff9c4,stroke:#f9a825

Store Buffer 的工作流程:

  1. 核心写入数据到 Store Buffer
  2. 核心继续执行后续指令(不等待 Invalidate Ack)
  3. Store Buffer 在收到所有 Ack 后,将数据写入 L1 缓存

Store Buffer 带来的新问题:Store Buffer 可见性延迟——核心 A 写入的数据在 Store Buffer 中,核心 B 立即读取可能看到旧值。这就是内存排序问题的根源,详见第 8 章

3.3 Invalidate Queue:进一步优化#

为了快速响应 Invalidate 消息,核心引入了Invalidate Queue

  1. 收到 Invalidate 消息后,立即回复 Ack
  2. 将 Invalidate 操作放入队列,稍后执行
  3. 这进一步减少了写操作的延迟

但这也带来了更大的可见性延迟——核心可能还没来得及执行 Invalidate,就响应了 Ack。

四、MOESI 与 MESIF#

4.1 MOESI(AMD)#

AMD 在 MESI 基础上增加了 O(Owner) 状态:

状态含义
O(Owner)数据已修改,与主存不一致,但多个缓存可能有副本

O 状态的意义:当多个缓存共享修改后的数据时,其中一个缓存是”Owner”,负责响应远端读取请求,而不需要写回主存。这减少了写回主存的次数。

4.2 MESIF(Intel)#

Intel 在 MESI 基础上增加了 F(Forward) 状态:

状态含义
F(Forward)数据与主存一致,多个缓存共享,本缓存负责响应远端读取

F 状态的意义:当多个缓存共享同一行时,只有一个缓存是 F 状态,负责响应远端读取。这避免了所有 S 状态缓存都响应导致的带宽浪费。

4.3 三种协议的对比#

特性MESIMOESIMESIF
共享脏数据不支持O 状态不支持
响应远端读所有 S 都可以O 负责响应F 负责响应
写回主存S→M 时需要O→M 不需要同 MESI
使用者早期 CPUAMDIntel

五、伪共享:多核性能的隐形杀手#

5.1 什么是伪共享?#

伪共享(False Sharing)发生在:不同核心修改同一缓存行中的不同变量。虽然逻辑上没有共享数据,但物理上共享了缓存行。

sequenceDiagram participant CoreA participant CacheLine as 缓存行 (64B) participant CoreB Note over CacheLine: [var_a | var_b | ...padding...] Note over CoreA: 修改 var_a Note over CoreB: 修改 var_b CoreA->>CacheLine: 写入 var_a(状态 M) Note over CoreB: 缓存行无效! CoreB->>CacheLine: 写入 var_b(需重新获取) Note over CoreA: 缓存行无效! CoreA->>CacheLine: 写入 var_a(需重新获取) Note over CoreB: 缓存行无效! Note over CoreA,CoreB: 反复互相使对方缓存行无效!

5.2 伪共享的代码示例#

#include <stdio.h>
#include <pthread.h>
#include <time.h>
#define N 100000000
// 伪共享版本
struct Counters {
int count_a; // 线程 A 修改
int count_b; // 线程 B 修改
// 两个变量在同一缓存行中!
};
struct Counters counters = {0, 0};
void *thread_a(void *arg) {
for (int i = 0; i < N; i++) {
counters.count_a++; // 使线程 B 的缓存行无效
}
return NULL;
}
void *thread_b(void *arg) {
for (int i = 0; i < N; i++) {
counters.count_b++; // 使线程 A 的缓存行无效
}
return NULL;
}
// 修复版本:缓存行对齐
struct CountersFixed {
int count_a;
char pad_a[60]; // 填充到 64 字节
int count_b;
char pad_b[60]; // 填充到 64 字节
} __attribute__((aligned(64)));
struct CountersFixed counters_fixed = {0, {0}, 0, {0}};

5.3 伪共享的性能影响#

场景2 线程执行时间说明
伪共享~3.5 秒每次写入都使对方缓存行无效
缓存行对齐~0.5 秒各自的变量在不同缓存行
加速比~7x仅仅是数据布局的改变!
Warning

伪共享是最常见的多核性能陷阱之一。它不会导致正确性问题——程序结果仍然正确——但性能可能下降 5-10 倍。更隐蔽的是,perf top 可能显示 CPU 利用率很高,但实际吞吐量很低——CPU 大量时间花在缓存一致性协议的消息传递上。

5.4 检测伪共享#

# 使用 perf 检测缓存一致性流量
perf stat -e cache-misses,LLC-load-misses,cycles ./your_program
# 高 cache-misses + 高 LLC-load-misses 可能是伪共享
# 使用 perf c2c(Cache-to-Cache)专门检测伪共享
perf c2c record ./your_program
perf c2c report
# 查找 "Shared Data Cache Line Table" 中的高冲突缓存行

5.5 缓存行对齐的技巧#

// 方法 1:手动填充
struct AlignedCounter {
int count;
char padding[60];
};
// 方法 2:编译器属性
struct AlignedCounter2 {
int count;
} __attribute__((aligned(64)));
// 方法 3:C++11 alignas
struct alignas(64) AlignedCounter3 {
int count;
};
// 方法 4:Linux 内核的 __cacheline_aligned
#define ____cacheline_aligned __attribute__((__aligned__(SMP_CACHE_BYTES)))
#define __cacheline_aligned ____cacheline_aligned
struct Counters {
int count_a ____cacheline_aligned;
int count_b ____cacheline_aligned;
};

六、MESI 协议与内存排序#

6.1 Store Buffer 导致的内存重排#

Store Buffer 导致了一个关键问题:核心自己看到的是最新值(从 Store Buffer 读取),但其他核心看到的是旧值(从缓存读取)

// 核心 A
data = 42; // 写入 Store Buffer
flag = 1; // 写入 Store Buffer
// 核心 B
while (flag != 1); // 可能读到 flag=1 但 data 还是 0!
print(data); // 可能打印 0!

这就是为什么需要内存屏障(Memory Barrier)——详见第 8 章:内存排序

6.2 MESI 与原子操作#

原子操作(如 __atomic_fetch_add)在 MESI 协议上的实现:

  1. 获取缓存行的 E 或 M 状态(通过 Invalidate 消息)
  2. 锁定缓存行(其他核心无法获取)
  3. 执行修改
  4. 释放缓存行

如果变量恰好在同一缓存行中,原子操作的开销很小(缓存行已在 M 状态)。如果变量在其他核心的缓存中,需要先获取独占权——这就是缓存行弹跳,详见第 15 章:无锁编程

七、动手实验#

7.1 实验 1:伪共享的性能对比#

#include <stdio.h>
#include <pthread.h>
#include <time.h>
#define N 500000000
// 伪共享版本
struct Bad {
int a;
int b;
};
// 对齐版本
struct Good {
int a;
char pad1[60];
int b;
char pad2[60];
} __attribute__((aligned(64)));
struct Bad bad = {0, 0};
struct Good good = {0, {0}, 0, {0}};
void *inc_a_bad(void *arg) {
for (int i = 0; i < N; i++) bad.a++;
return NULL;
}
void *inc_b_bad(void *arg) {
for (int i = 0; i < N; i++) bad.b++;
return NULL;
}
void *inc_a_good(void *arg) {
for (int i = 0; i < N; i++) good.a++;
return NULL;
}
void *inc_b_good(void *arg) {
for (int i = 0; i < N; i++) good.b++;
return NULL;
}
int main() {
pthread_t t1, t2;
clock_t start, end;
// 伪共享
start = clock();
pthread_create(&t1, NULL, inc_a_bad, NULL);
pthread_create(&t2, NULL, inc_b_bad, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
end = clock();
printf("伪共享: %.3f\n", (double)(end - start) / CLOCKS_PER_SEC);
// 对齐
start = clock();
pthread_create(&t1, NULL, inc_a_good, NULL);
pthread_create(&t2, NULL, inc_b_good, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
end = clock();
printf("对齐: %.3f\n", (double)(end - start) / CLOCKS_PER_SEC);
return 0;
}

7.2 实验 2:观察缓存一致性流量#

# 使用 perf c2c 检测伪共享
perf c2c record -- ./false_sharing_test
perf c2c report
# 查看缓存一致性相关事件
perf stat -e \
offcore_response.demand_data_rd.any_response,\
offcore_response.demand_rfo.any_response \
./your_program
# demand_rfo = Read For Ownership = 获取独占权的请求

八、MESI 协议详细状态转换#

8.1 完整状态转换矩阵#

下面表格列出所有可能的转换,行表示当前状态,列表示触发事件:

当前状态本地读本地写远端读远端写替换(驱逐)
I→ E(无其他副本)/ → S(有其他副本)→ M(发出 Read For Ownership)不可能(I 状态无数据)不可能保持 I
E保持 E→ M→ S(其他核心请求读)→ I→ I
S保持 S→ M(发出 Invalidate)保持 S→ I→ I
M保持 M保持 M→ S(写回主存 + 传递数据)→ I(写回主存 + 传递数据)→ I(写回主存)

8.2 关键转换的时序分析#

**S → M(本地写入共享行)**是最重要的转换之一,也是伪共享的根源:

sequenceDiagram participant CoreA as 核心 A participant Bus as 总线/互联 participant CoreB as 核心 B participant CoreC as 核心 C Note over CoreA: 缓存行状态 S Note over CoreB: 缓存行状态 S Note over CoreC: 缓存行状态 S CoreA->>Bus: Invalidate 消息(地址 X) Bus->>CoreB: 传递 Invalidate Bus->>CoreC: 传递 Invalidate CoreB->>Bus: Invalidate Ack CoreC->>Bus: Invalidate Ack Bus->>CoreA: 收集所有 Ack Note over CoreA: 状态变为 M,可以写入 Note over CoreB: 状态变为 I Note over CoreC: 状态变为 I

这个过程需要等待所有持有副本的核心确认——在 4 插槽 NUMA 系统上可能需要 100+ 周期。

8.3 E 状态的价值#

E(Exclusive)状态是一个”免费写”的机会——核心独占数据,写入时无需通知任何其他核心,直接从 E 变为 M。这省去了整个 Invalidate 广播和等待 Ack 的过程。

// 利用 E 状态优化:先读再写
// 如果数据只在本核心缓存中(E 状态),写入几乎免费
int value = shared_data; // 读 → 如果其他核心没有副本,状态为 E
shared_data = value + 1; // 写 → E→M,无需 Invalidate!
// vs. 如果其他核心有副本(S 状态):
shared_data = shared_data + 1; // S→M,需要广播 Invalidate
Note

E 状态是 MESI 协议相对于更简单的 MSI 协议的关键改进。MSI 没有 E 状态——读入的数据总是 S 状态,即使没有其他核心持有副本。这意味着每次写入都需要广播 Invalidate,即使没有任何其他核心需要被通知。E 状态消除了这种”无效广播”。

九、目录式缓存一致性(Directory-Based Coherence)#

9.1 为什么需要目录式协议#

Snoop(侦听)式协议在总线架构上工作良好——所有消息通过总线广播,每个核心都能”听到”。但在多插槽 NUMA 系统中,广播所有消息的代价太高:

  • 4 插槽:每条消息需要发送到 3 个远端插槽
  • 8 插槽:每条消息需要发送到 7 个远端插槽
  • 带宽浪费严重,延迟也高

目录式协议的核心思想:维护一个目录,记录每个缓存行在哪些核心中有副本,只向有副本的核心发送消息

9.2 目录的结构#

┌──────────────┬──────────────────────────────────────┐
│ 缓存行地址 │ 位向量(Sharers) │
├──────────────┼──────────────────────────────────────┤
│ 0x1000 │ [1, 0, 0, 1, 0, 0, 0, 0] │ ← Core 0, 3 有副本
│ 0x2000 │ [0, 0, 1, 0, 0, 0, 0, 0] │ ← Core 2 独占(E 状态)
│ 0x3000 │ [0, 0, 0, 0, 0, 0, 0, 0] │ ← 无缓存副本
└──────────────┴──────────────────────────────────────┘
协议类型消息数量适用规模代表
Snoop(侦听)O(N) 广播2-4 插槽Intel UPI
目录式(全映射)O(k) 只通知有副本的8+ 插槽SGI Origin 2000
目录式(稀疏)O(k) 近似大规模AMD Infinity Fabric

9.3 Snoop vs 目录式对比#

graph TB subgraph Snoop["Snoop 协议"] S_CORE0["Core 0"] --> S_BUS["总线广播"] S_CORE1["Core 1"] --> S_BUS S_CORE2["Core 2"] --> S_BUS S_CORE3["Core 3"] --> S_BUS S_BUS -->|"所有核心都收到"| S_ALL["Invalidate 广播<br/>即使只有 1 个有副本"] end subgraph Dir["目录式协议"] D_CORE0["Core 0"] --> D_DIR["目录"] D_CORE1["Core 1"] --> D_DIR D_CORE2["Core 2"] --> D_DIR D_CORE3["Core 3"] --> D_DIR D_DIR -->|"只通知有副本的核心"| D_TARGET["定向 Invalidate<br/>只发给 Core 0, 3"] end style S_ALL fill:#ffcdd2,stroke:#c62828 style D_TARGET fill:#e8f5e9,stroke:#2e7d32

十、缓存一致性与 NUMA 的交互#

10.1 跨 NUMA 节点的缓存一致性#

在 NUMA 系统中,MESI 协议的 Invalidate 消息需要通过互联总线(Intel UPI / AMD Infinity Fabric)传递到远端插槽。这带来了额外的延迟:

操作本地(同一插槽)远端(跨插槽)延迟倍数
S→M(Invalidate 1 个副本)~20 周期~80-120 周期4-6x
S→M(Invalidate 4 个副本)~40 周期~200-300 周期5-7x
M→S(写回 + 传递数据)~30 周期~100-150 周期3-5x

这意味着伪共享在 NUMA 系统上的代价远高于单插槽系统——跨插槽的缓存行弹跳可能每次写入都要 100+ 周期。

10.2 NUMA 感知的缓存一致性优化#

// NUMA 系统上的伪共享更加致命
// 解决方案:不仅缓存行对齐,还要 NUMA 感知分配
struct alignas(64) NumaAwareCounter {
std::atomic<int> count;
};
// 每个线程在本地 NUMA 节点分配计数器
void* local_counter = numa_alloc_onnode(sizeof(NumaAwareCounter),
numa_node_of_cpu(sched_getcpu()));
// 线程只更新本地计数器,定期汇总到全局
// 避免跨插槽的缓存行弹跳
Warning

在 4+ 插槽的 NUMA 系统上,伪共享的代价可能比单插槽系统高出 5-10 倍。如果你在双插槽系统上测试多线程代码觉得”还行”,部署到 4 插槽系统后可能突然变得很慢——这就是跨 NUMA 伪共享的威力。

十一、小结#

上一章理解了缓存层次结构。

概念要点对软件的影响
MESI 四种状态M/E/S/I理解缓存一致性消息的触发条件
Store Buffer缓解写延迟导致内存排序问题(Ch8)
Invalidate Queue快速响应 Invalidate增加可见性延迟
伪共享不同变量在同一缓存行多线程性能下降 5-10 倍
缓存行对齐消除伪共享__attribute__((aligned(64)))
MOESI/MESIFMESI 的扩展优化共享数据的响应

下一步内存排序与内存屏障——为什么你写的顺序不一定是 CPU 执行的顺序?x86 TSO 和 ARM 弱序有什么区别?C++ 的 memory_order 如何映射到硬件?

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

缓存一致性:MESI 协议
https://blog.souloss.com/posts/cpu-architecture/cache-coherence/
作者
Souloss
发布于
2026-02-17
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时