MESI 协议(Modified, Exclusive, Shared, Invalidated)是用于解决多核 CPU 缓存一致性问题的协议。它定义缓存行四种状态,保证多个 CPU 核心访问共享数据最终一致。
- Modified(已修改):缓存行数据已经被修改,还没写回主存。数据只存在当前 CPU 缓存,和内存不一致。
- Exclusive(独占):只有当前 CPU 缓存存有这份数据,缓存和内存内容完全一致,其他 CPU 没有该缓存行副本。
- Shared(共享):多个 CPU 缓存都保存这份缓存行,缓存内容和内存保持一致。
- Invalidated(已失效):缓存行数据作废,不允许使用。
简单讲:MESI 只保证最终一致性。它能保证所有缓存副本最后会达成一样的值,但不能保证修改发生之后立刻全局同时可见。只依靠 MESI 无法消除短暂的可见性窗口,所以软件层面必须补充同步语义(原子变量、内存屏障)。
多线程场景:一个共享变量在多个 CPU 缓存行处于 Shared 状态。某一个 CPU 想要修改该缓存行,必须广播 Invalidate 消息,要求其余 CPU 把该缓存行置为失效。 如果 CPU 写操作原地阻塞,等待所有 CPU 处理完失效并返回 ACK 之后才继续执行指令,多核性能会严重下降。 为了消除这种阻塞,CPU 引入存储缓冲区 Store Buffer:写指令不必原地卡住,把已经计算完成的新值放入 Store Buffer,CPU 流水线继续执行后续指令;等到收到全部其他核心的 ACK 应答之后,本地缓存行状态切换为 Modified,再将 Store Buffer 内对应条目提交写入 L1 缓存。
通俗过程: 多个 CPU 共享同一缓存行。某个 CPU 执行写操作:
- 算出修改后的新值,存入自身私有 Store Buffer,这份新值只有本 CPU 可见,还没有写入 L1 缓存;同时向其他 CPU 广播 Invalidate 失效通知。
- 其余 CPU 收到失效消息:立刻回复 ACK 代表 “消息已收到”,但并不会马上把本地缓存行标记为 Invalid,而是将失效请求丢入失效队列 (Invalidate Queue)排队延后处理。在队列消息处理完成之前,本地 L1 缓存行依旧有效,可以继续读到旧数据。
- 写 CPU 收集完全部 ACK,将缓存行状态从 Shared 切换为 Modified,把 Store Buffer 中新数据提交到本地 L1 缓存;这时新数据才正式进入缓存子系统,MESI 才能够把更新传播给其他核心。
由此产生两个造成 “读到旧数据” 的时间窗口:
- 写端窗口:Store Buffer 还没有提交到 L1 缓存,新值还停留在 CPU 私有缓冲区,其他 CPU 完全看不到;
- 读端窗口:读 CPU 已经回复 ACK,但失效消息积压在失效队列未处理,依旧复用本地旧缓存行。
硬件出于性能设计保留了 Store Buffer、失效队列,带来短暂可见性延迟。MESI 管缓存副本最终同步,但管不了缓冲区带来的短暂延迟,因此需要软件通过内存屏障、原子变量补充同步语义。
普通变量只有多线程读完全没问题;一旦出现写,就有可能别的线程不会立刻看到最新修改。多个线程同时写入普通共享变量,会产生数据竞争,结果未定义。
我们业务上理想期望:一个线程修改共享变量,修改对其余线程立刻可见;对应硬件理想状态:CPU 修改缓存行后,其余 CPU 缓存行马上失效,马上获取最新缓存行副本或者从内存读取最新值。
但现实硬件做不到 “立刻全局同步”: 跨核消息传递、ACK 应答、Store Buffer 提交、失效队列消费都需要时间。硬件优先追求运行速度,故意引入缓冲区掩盖延迟;“立刻全局可见” 是昂贵的强同步,不是硬件默认行为,需要我们主动使用 acquire/release、seq_cst 内存序来约束。
此时就引入我们的主题:内存序列(内存序)究竟做了什么,来解决可见性与指令重排问题。我们结合 MESI 协议,讲解 C++ 的三种内存模型:
- 宽松模型:
memory_order_relaxed - 获取‑释放模型:
memory_order_acquire/memory_order_release/memory_order_acq_rel - 顺序一致模型:
memory_order_seq_cst(原子变量默认内存序)
① 宽松模型memory_order_relaxed这是约束最弱的模型。它仅仅保证原子变量本身多线程读写不会发生数据撕裂,保证操作是原子的;不做任何指令重排限制,也不会建立先行关系 (happens‑before)。 编译器和 CPU 可以自由对原子操作前后的普通读写做重排。它只能用于单纯计数这类场景,不能用来同步其他共享普通变量。
② 获取‑释放模型它保留宽松模型原子性保证;当release写与acquire读配对成功(acquire 读到 release 写入的新值)时,会建立先行关系 (happens‑before)。
⚠️重点:它没有全局统一顺序。不同 CPU 看到多组独立原子操作的先后顺序可以不一样,只约束这一对配对的读写之间的数据可见性,不会强制整个系统所有核心看到完全相同的指令时序。 对应硬件层面:约束 Store Buffer 提交顺序;但不会强制排空全部缓冲区、不会强制消费完所有失效队列。
③ 顺序一致模型memory_order_seq_cst在获取‑释放模型的基础之上,额外提供全局总序保证:所有 CPU 看到全部原子操作的执行顺序是完全一致的。 硬件上通常会插入完整内存屏障,尽可能排空 Store Buffer、推动失效队列消息处理;但随之带来更大的性能开销。这也是std::atomic不指定内存序时的默认选项。
⚠️重要区分:内存序是 C++ 语言抽象规则,标准并不直接规定 CPU 硬件实现。 MESI 协议只管 L1/L2 缓存行的最终一致性;而 Store Buffer、Invalidate Queue 带来的指令重排、短暂可见性缺口,是 CPU 硬件性能优化产物。 内存序本质是告诉编译器:哪些指令不能乱序;告诉 CPU:需要插入什么样的屏障指令,去约束缓冲区的行为,以此在软件层面构建出 happens‑before 先行关系。
MESI 保证缓存最终一致,但解决不了缓冲区造成的短暂 “读到旧数据” 窗口;内存序就是用来约束这些硬件优化行为,给程序员提供可控的同步语义。
原子变量只有 R‑M‑W(读‑修改‑写,fetch_add/compare_exchange)会锁住缓存行;单纯的load、store不会锁缓存行。
当缓存行处于 Shared 共享状态,CPU 要执行 R‑M‑W 时,通过 MESI 协议把缓存行切换到 Modified (M) 状态,并且对该缓存行施加硬件锁。 在锁持有期间,其他 CPU 想要修改该缓存行会被阻塞等待,必须等到本次完整 R‑M‑W 事务执行完毕、释放缓存行锁之后,才能继续操作。
⚠️注意:缓存行锁保护的是整套读‑改‑写的不可分割性,不是立刻让修改对其他 CPU 全局可见;只是保证别的核不能中途插进来修改,避免增量丢失。而在进行读操作时,依然可能会读到旧值。
而单纯的atomic::load、atomic::store没有缓存行锁:
store依靠 MESI 协议抢夺 M 状态完成写入;load直接读取本地缓存副本。
它们只保证原子性:数据不会出现撕裂(不会读到半截更新的垃圾值,只能读到完整旧值或完整新值)。但不保证修改立刻对其他线程可见。受 CPU 内部Store‑Buffer写缓冲、Invalidate‑Queue失效队列影响,其他线程依然可以读到过期旧值;并发多个 store 还会发生后写覆盖先写,造成前面写入逻辑丢失。
我们发现,一个CPU的数据是否对其他线程可见主要取决于MESI中的store buffer 是否被执行,刷新到L1中,并且失效队列是否被执行,其他CPU中的状态是否变成INVALID。只要做好这两点·,就能实现可见性的调整。
读到这里读者可能会说,这原子变量和内存序列读也可能读到旧值,那我应该用什么办法来保障,一旦其他线程读,一定能读到新值呢?
可以额外用一组原子变量来做同步,利用 acquire-release 模型建立先行关系。
acquire-release 模型保证:一旦 load 读到其他线程 release store 写入的值,写线程在 release 之前所有内存写操作,对当前线程可见。也就是只要读到这个新值:写端 CPU 的 release 屏障保证 release 之前的 store 都已经退出 store buffer、提交到 L1 缓存;在 ARM 硬件上,acquire 屏障会刷新本地失效队列,保证后续内存访问不会读到本核残留的旧缓存,此时就建立了同步关系,进而产生先行关系。我们了解到,acquire-release同步原语其实本质上就是阻止cpu重排指令,这样当最新的CPU缓存都已经从store buffer中清空被其他线程读到,之前的指令就一定已经在store buffer中被清空了。在写层面上,因为后面的操作不会被排到前面去,自然也就建立了happens-before关系。