一、为什么会有缓存一致性问题?
1. CPU 缓存的层次结构
现代 CPU 为了弥补「CPU 运算速度」和「主存读写速度」之间的巨大差距(CPU 比主存快约 100 倍),引入了多级缓存架构:
- L1 缓存:每个 CPU 核心私有,分为指令缓存和数据缓存,容量约 32KB~64KB,访问延迟约 1ns
- L2 缓存:每个 CPU 核心私有,容量约 256KB~512KB,访问延迟约 3ns
- L3 缓存:所有 CPU 核心共享,容量约几 MB 到几十 MB,访问延迟约 15ns
- 主存(内存):所有核心共享,容量几十 GB,访问延迟约 100ns
核心问题 :L1、L2 是每个核心私有的,当多个核心同时操作同一个主存地址的数据时,每个核心的私有缓存中都会保存该数据的副本。如果一个核心修改了自己缓存中的副本,其他核心的缓存副本仍然是旧值,就会导致数据不一致。
2. 一个简单的不一致例子
假设主存中有一个全局变量 x = 0,两个 CPU 核心 Core1 和 Core2 同时操作它:
- Core1 读取 x,将 x 所在的 64 字节数据块从主存加载到自己的 L1 缓存,此时 Core1 缓存中 x=0
- Core2 也读取 x,同样将 x 加载到自己的 L1 缓存,此时 Core2 缓存中 x=0
- Core1 将 x 修改为 1,并更新到自己的 L1 缓存(此时还未写回主存)
- Core2 读取 x,由于自己的 L1 缓存中已经有 x 的副本,直接返回旧值 0
这就出现了严重的问题:Core1 已经把 x 改成了 1,但 Core2 看到的还是 0,两个核心对同一个变量的认知不一致。
二、缓存一致性的基础:缓存行(Cache Line)
CPU 缓存不是按单个字节管理的,而是按缓存行(Cache Line) 为单位进行数据传输和管理,主流 CPU 的缓存行大小是 64 字节。
也就是说:
- 即使你只修改 1 个字节,CPU 也会把该字节所在的整个 64 字节缓存行从主存读到缓存
- 当缓存行被修改后,CPU 也会把整个 64 字节的缓存行写回主存
缓存行是缓存一致性协议的最小操作单位,所有一致性协议都是围绕「缓存行的状态同步」设计的。
三、主流缓存一致性协议:MESI 协议
MESI 是目前 x86 架构 CPU 使用最广泛的缓存一致性协议,它是一种基于总线监听的协议:所有核心都监听总线上的消息,根据消息更新自己缓存行的状态。
1. MESI 的四个状态
每个缓存行都有一个状态标记,共 4 种状态,这也是 MESI 名称的由来:
表格
| 状态 | 全称 | 含义 |
|---|---|---|
| M | Modified(修改态) | 该缓存行的数据被当前核心修改过,与主存不一致;只有当前核心持有最新副本,其他核心的副本都是无效的;当该缓存行被替换出缓存时,必须先写回主存 |
| E | Exclusive(独占态) | 该缓存行的数据与主存一致 ;只有当前核心持有该副本,其他核心都没有;此时修改该缓存行无需通知其他核心,直接修改后状态变为 M |
| S | Shared(共享态) | 该缓存行的数据与主存一致 ;有多个核心同时持有该副本;此时修改该缓存行必须先通知其他核心将副本置为无效 |
| I | Invalid(无效态) | 该缓存行的数据无效,不能使用;要访问该数据必须重新从主存或其他核心的有效副本读取 |
2. MESI 协议的工作流程
我们还是用前面的变量 x(初始值 0)为例,完整演示 MESI 协议的状态流转:
步骤 1:Core1 首次读取 x
- Core1 发送
Read请求到系统总线 - 主存和其他核心监听总线,主存返回 x 所在的缓存行
- Core1 将缓存行加载到自己的 L1 缓存,状态设为E(独占)(因为只有它持有该副本)
步骤 2:Core2 首次读取 x
- Core2 发送
Read请求到总线 - Core1 监听到总线的 Read 请求,发现自己持有该缓存行的 E 态副本,于是将自己的副本发送给 Core2,同时主存也返回该缓存行
- Core1 和 Core2 的缓存行状态都变为S(共享)(现在两个核心都持有副本)
步骤 3:Core1 修改 x 为 1
- Core1 发现自己的缓存行是 S 态,不能直接修改,于是发送
Read For Ownership(RFO,读并独占)请求到总线 - RFO 请求的作用是:通知其他核心将该缓存行置为无效,同时获取该缓存行的独占权
- Core2 监听到 RFO 请求,将自己的缓存行状态设为I(无效),并返回确认消息
- Core1 收到所有核心的确认后,将自己的缓存行状态改为M(修改),然后将 x 的值改为 1
- 此时主存中的 x 仍然是 0,只有 Core1 的缓存中有最新值
步骤 4:Core2 再次读取 x
- Core2 发现自己的缓存行是 I 态,发送
Read请求到总线 - Core1 监听到 Read 请求,发现自己持有该缓存行的 M 态副本,于是先将缓存行写回主存,然后将副本发送给 Core2
- Core1 和 Core2 的缓存行状态都变回S(共享),主存中的 x 也更新为 1
步骤 5:Core2 修改 x 为 2
- 流程和步骤 3 完全一致:Core2 发送 RFO 请求→Core1 将缓存行置为 I→Core2 的缓存行变为 M→修改 x 为 2
3. MESI 的核心消息类型
核心之间通过总线发送以下 5 种核心消息来同步缓存行状态:
Read:请求读取某个缓存行Read For Ownership(RFO):请求读取并独占某个缓存行(用于修改共享态的缓存行)Invalidate:通知其他核心将指定缓存行置为无效Invalidate Acknowledge:确认收到 Invalidate 消息Writeback:将修改后的缓存行写回主存
四、缓存一致性带来的性能问题:伪共享(False Sharing)
伪共享是缓存一致性协议最常见的性能陷阱,也是面试高频考点。
1. 什么是伪共享?
当多个独立的变量被放在同一个缓存行中,而不同的 CPU 核心同时修改这些不同的变量时,会导致缓存行频繁地在核心之间失效和同步,即使这些变量之间没有任何逻辑关联,也会互相影响,严重降低性能。
2. 伪共享的例子
假设我们有两个全局变量a和b,都是 int 类型(4 字节),它们在内存中是连续存储的,刚好位于同一个 64 字节的缓存行中:
cpp
// 伪共享场景:a和b在同一个缓存行
struct Data {
int a; // Core1只修改a
int b; // Core2只修改b
};
此时:
- Core1 修改 a:发送 RFO 请求,让 Core2 的缓存行置为 I
- Core2 修改 b:发送 RFO 请求,让 Core1 的缓存行置为 I
- 如此反复,缓存行在两个核心之间来回失效和同步,性能会下降几十倍
本质:两个核心修改的是不同的变量,但因为它们在同一个缓存行,缓存一致性协议会把整个缓存行当作一个整体来处理,导致不必要的同步开销。
3. 伪共享的解决方法:缓存行填充
解决伪共享的核心思路是:让每个变量独占一个缓存行,避免多个变量共享同一个缓存行。
方法 1:手动填充字节
在变量前后填充足够的字节,让每个变量的大小刚好等于缓存行大小(64 字节):
c
运行
cpp
// 解决伪共享:每个变量独占一个缓存行
struct Data {
int a;
char padding1[60]; // 4 + 60 = 64字节,填满一个缓存行
int b;
char padding2[60];
};
方法 2:编译器对齐指令
使用编译器提供的对齐指令,让变量自动对齐到缓存行边界:
- C/C++:
__attribute__((aligned(64))) - Java:
@sun.misc.Contended(需要 JVM 参数-XX:-RestrictContended开启)
Java 示例:
java
运行
cpp
// Java中解决伪共享
@Contended
public class Data {
volatile int a;
volatile int b;
}
五、扩展:其他缓存一致性协议
MESI 协议有很多变种,不同 CPU 架构会做优化:
- MOESI 协议 :在 MESI 基础上增加了
Owned(拥有态),允许核心直接将自己的 M 态缓存行共享给其他核心,无需先写回主存,AMD 处理器使用该协议 - MESI-F 协议 :在 MESI 基础上增加了
Forward(转发态),优化了共享态缓存行的读取性能,Intel 处理器使用该协议
六、缓存一致性与内存屏障的区别
很多人会混淆这两个概念,它们解决的是完全不同的问题:
- 缓存一致性:解决「多个 CPU 核心的缓存副本不一致」的问题,由硬件自动实现,保证「一个核心修改的数据,其他核心最终能看到」
- 内存屏障:解决「CPU 指令重排序」和「数据可见性时机」的问题,由软件显式调用,保证「修改操作对其他核心的可见性顺序」
简单来说:缓存一致性是硬件提供的基础保证,内存屏障是软件对硬件行为的显式控制。
总结
- 缓存一致性问题的根源是多核 CPU 的私有缓存架构,多个核心同时操作同一主存数据时会出现副本不一致
- 缓存行是缓存管理的最小单位(64 字节),所有一致性协议都围绕缓存行状态同步设计
- MESI 协议通过 4 种状态(M/E/S/I)和总线监听机制,实现了缓存行的一致性
- 伪共享是缓存一致性带来的主要性能问题,通过缓存行填充让每个变量独占一个缓存行可以解决
- 缓存一致性是硬件自动实现的,和内存屏障是不同层面的概念