CPU 缓存一致性协议 MESI概念详解(详解四种状态转换与伪共享优化)

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,过程如下:

  1. Core 1 首次读 count,总线上无其他持有者,主存返回 100,缓存行进入 E;
  2. Core 2 读 count,Core 1 嗅探到 BusRd,把数据供给 Core 2,自身降为 S,Core 2 也是 S;
  3. Core 1 写 count = 110,先发 Invalidate,Core 2 收到后置为 I,Core 1 完成写入进入 M,此刻主存仍是 100;
  4. 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 观察缓存行争用,或给热点字段加缓存行填充后对比吞吐变化。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部