单核时代,缓存是 CPU 和内存之间的加速器——逻辑简单,性能提升显著。多核时代,每个核心都有自己的 L1/L2 缓存,同一个内存地址的数据可能在多个核心的缓存中存在副本。当核心 A 修改了自己缓存中的数据,核心 B 的缓存中还是旧值——数据不一致了。
缓存一致性协议(Cache Coherence Protocol)就是解决这个问题的硬件机制。理解 MESI 协议,就理解了多核编程中”伪共享”等性能陷阱的根源。
一、缓存一致性问题
1.1 问题场景
1.2 一致性模型的定义
缓存一致性必须满足两个条件:
- 写传播(Write Propagation):一个核心的写操作必须最终对其他核心可见
- 写串行化(Write Serialization):所有核心必须以相同的顺序看到写操作
二、MESI 协议
2.1 四种状态
MESI 协议以四种状态的首字母命名:
| 状态 | 全称 | 含义 | 可读 | 可写 |
|---|---|---|---|---|
| M(Modified) | 已修改 | 数据已修改,与主存不一致,只有本缓存有副本 | ||
| E(Exclusive) | 独占 | 数据与主存一致,只有本缓存有副本 | (无需通知) | |
| S(Shared) | 共享 | 数据与主存一致,多个缓存可能有副本 | (需先通知) | |
| I(Invalid) | 无效 | 缓存行无效,不包含有效数据 |
2.2 状态转换图
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 状态的缓存行时:
- A 发出 Invalidate 消息
- 等待所有持有该行副本的核心确认(Invalidate Ack)
- 收到所有确认后,A 才能将状态改为 M 并写入
这个等待过程可能需要数十到数百个周期——如果其他核心很忙或者距离很远(跨 NUMA 节点)。
3.2 Store Buffer:缓解写延迟
为了不阻塞写入核心,现代 CPU 引入了Store Buffer:
Store Buffer 的工作流程:
- 核心写入数据到 Store Buffer
- 核心继续执行后续指令(不等待 Invalidate Ack)
- Store Buffer 在收到所有 Ack 后,将数据写入 L1 缓存
Store Buffer 带来的新问题:Store Buffer 可见性延迟——核心 A 写入的数据在 Store Buffer 中,核心 B 立即读取可能看到旧值。这就是内存排序问题的根源,详见第 8 章。
3.3 Invalidate Queue:进一步优化
为了快速响应 Invalidate 消息,核心引入了Invalidate Queue:
- 收到 Invalidate 消息后,立即回复 Ack
- 将 Invalidate 操作放入队列,稍后执行
- 这进一步减少了写操作的延迟
但这也带来了更大的可见性延迟——核心可能还没来得及执行 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 三种协议的对比
| 特性 | MESI | MOESI | MESIF |
|---|---|---|---|
| 共享脏数据 | 不支持 | O 状态 | 不支持 |
| 响应远端读 | 所有 S 都可以 | O 负责响应 | F 负责响应 |
| 写回主存 | S→M 时需要 | O→M 不需要 | 同 MESI |
| 使用者 | 早期 CPU | AMD | Intel |
五、伪共享:多核性能的隐形杀手
5.1 什么是伪共享?
伪共享(False Sharing)发生在:不同核心修改同一缓存行中的不同变量。虽然逻辑上没有共享数据,但物理上共享了缓存行。
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 | 仅仅是数据布局的改变! |
伪共享是最常见的多核性能陷阱之一。它不会导致正确性问题——程序结果仍然正确——但性能可能下降 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_programperf 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 alignasstruct 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 读取),但其他核心看到的是旧值(从缓存读取)。
// 核心 Adata = 42; // 写入 Store Bufferflag = 1; // 写入 Store Buffer
// 核心 Bwhile (flag != 1); // 可能读到 flag=1 但 data 还是 0!print(data); // 可能打印 0!这就是为什么需要内存屏障(Memory Barrier)——详见第 8 章:内存排序。
6.2 MESI 与原子操作
原子操作(如 __atomic_fetch_add)在 MESI 协议上的实现:
- 获取缓存行的 E 或 M 状态(通过 Invalidate 消息)
- 锁定缓存行(其他核心无法获取)
- 执行修改
- 释放缓存行
如果变量恰好在同一缓存行中,原子操作的开销很小(缓存行已在 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_testperf 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(本地写入共享行)**是最重要的转换之一,也是伪共享的根源:
这个过程需要等待所有持有副本的核心确认——在 4 插槽 NUMA 系统上可能需要 100+ 周期。
8.3 E 状态的价值
E(Exclusive)状态是一个”免费写”的机会——核心独占数据,写入时无需通知任何其他核心,直接从 E 变为 M。这省去了整个 Invalidate 广播和等待 Ack 的过程。
// 利用 E 状态优化:先读再写// 如果数据只在本核心缓存中(E 状态),写入几乎免费int value = shared_data; // 读 → 如果其他核心没有副本,状态为 Eshared_data = value + 1; // 写 → E→M,无需 Invalidate!// vs. 如果其他核心有副本(S 状态):shared_data = shared_data + 1; // S→M,需要广播 InvalidateE 状态是 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 目录式对比
十、缓存一致性与 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()));// 线程只更新本地计数器,定期汇总到全局// 避免跨插槽的缓存行弹跳在 4+ 插槽的 NUMA 系统上,伪共享的代价可能比单插槽系统高出 5-10 倍。如果你在双插槽系统上测试多线程代码觉得”还行”,部署到 4 插槽系统后可能突然变得很慢——这就是跨 NUMA 伪共享的威力。
十一、小结
上一章理解了缓存层次结构。
| 概念 | 要点 | 对软件的影响 |
|---|---|---|
| MESI 四种状态 | M/E/S/I | 理解缓存一致性消息的触发条件 |
| Store Buffer | 缓解写延迟 | 导致内存排序问题(Ch8) |
| Invalidate Queue | 快速响应 Invalidate | 增加可见性延迟 |
| 伪共享 | 不同变量在同一缓存行 | 多线程性能下降 5-10 倍 |
| 缓存行对齐 | 消除伪共享 | __attribute__((aligned(64))) |
| MOESI/MESIF | MESI 的扩展 | 优化共享数据的响应 |
下一步:内存排序与内存屏障——为什么你写的顺序不一定是 CPU 执行的顺序?x86 TSO 和 ARM 弱序有什么区别?C++ 的
memory_order如何映射到硬件?
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






