缓存一致性与MESI协议
文章目录
一:回顾CPU的三层缓存结构
由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存
CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。
由于单核 CPU 的性能遇到瓶颈(主频与功耗的矛盾),芯片厂商开始在 CPU 芯片里集成多个 CPU 核心,每个核心有各自的 L1 / L2 缓存。
其中 L1 / L2 缓存是核心独占的,而 L3 缓存是多核心共享的。
基于局部性原理的应用,CPU Cache 在读取内存数据时,每次不会只读一个字或一个字节,而是一块块地读取,每一小块数据也叫 CPU 缓存行。
为了标识 Cache 块中的数据是否已经从内存中读取,需要在 Cache 块上增加一个 有效位(Valid bit)。
无论对 Cache 数据检查、读取还是写入,CPU 都需要知道访问的内存数据映射在 Cache 上的哪个位置,这就是 Cache - 内存地址映射问题
映射方案有直接映射、全相联映射和组相联映射 3 种方案。
当缓存块满或者内存块映射的缓存块位置被占用时,就需要使用 替换策略 将旧的 Cache 块换出腾出空闲位置。
基于以上结构,就会存在缓存一致性问题。
二:什么是 CPU 缓存一致性问题
CPU 缓存一致性(Cache Coherence)问题指 CPU Cache 与内存的不一致性问题。
事实上,在分析缓存一致性问题时,考虑 L1 / L2 / L3 的多级缓存没有意义,所以我们提出缓存一致性抽象模型,只考虑核心独占的缓存。
CPU 三级缓存与抽象模型
在单核 CPU 中,只需要考虑 Cache 与内存的一致性。
但是在多核 CPU 中,由于每个核心都有一份独占的 Cache,就会存在一个核心修改数据后,两个核心 Cache 数据不一致的问题。
因此,我认为 CPU 的缓存一致性问题应该从 2 个维度理解:
- 纵向:Cache 与内存的一致性问题: 在修改 Cache 数据后,如何同步回内存?
- 横向:多核心 Cache 的一致性问题: 在一个核心修改 Cache 数据后,如何同步给其他核心 Cache?
三:Cache 与内存的一致性问题
1:CPU Cache 的读取过程
Cache 的读取过程会受到 Cache 的写入策略影响,我们暂且用相对简单的 “写直达策略” 的读取过程:
- CPU 在访问内存地址时,会先检查该地址的数据是否已经加载到 Cache 中(Valid bit 是否为 1)
- 如果数据在 Cache 中,则直接读取 Cache 块上的字到 CPU 中
- 如果数据不在 Cache 中:
- 如果 Cache 已装满或者 Cache 块被占用,先执行替换策略,腾出空闲位置
- 访问内存地址,并将内存地址所处的整个内存块写入到映射的 Cache 块中;
- 读取 Cache 块上的字到 CPU 中
但是,CPU 不仅会读取 Cache 数据,还会修改 Cache 数据,这就是第 1 个一致性问题 —— 在修改 Cache 数据后,如何同步回内存?
2:写直达策略(重点)
写直达策略是解决 Cache 与内存一致性最简单直接的方式:
在每次写入操作中,同时修改 Cache 数据和内存数据,始终保持 Cache 数据和内存数据一致:
- 如果数据不在 Cache 中,则直接将数据写入内存;
- 如果数据已经加载到 Cache 中,则不仅要将数据写入 Cache,还要将数据写入内存。
写直达的优点和缺点都很明显:
- 优点: 每次读取操作就是纯粹的读取,不涉及对内存的写入操作,读取速度更快;
- 缺点: 每次写入操作都需要同时写入 Cache 和写入内存,在写入操作上失去了 CPU 高速缓存的价值,需要花费更多时间。
3:写回策略(重点)
既然写直达策略在每次写入操作都会写内存,那么有没有什么办法可以减少写回内存的次数呢?这就是写回策略:
- 写回策略会在每个 Cache 块上增加一个 脏(Dirty)标记位 ,当一个 Cache 被标记为脏时,说明它的数据与内存数据是不一致的
- 在写入操作时,我们只需要修改 Cache 块并将其标记为脏,而不需要写入内存
- 那么,什么时候才将脏数据写回内存呢?—— 就发生在 Cache 块被替换出去的时候
- 在写入操作中,如果替换策略换出的旧 Cache 块是脏的,就会触发一次写回内存操作
- 读取操作中,如果目标内存块不在 Cache 中,且替换策略换出的旧 Cache 块是脏的,就会触发一次写回内存操作
在目标内存块不在 Cache 中时,写直达策略会直接写入内存。而写回策略会先把数据读取到 Cache 中再修改 Cache 数据,这似乎有点多余?
其实还是为了减少写回内存的次数。
虽然在未命中时会增加一次读取操作,但后续重复的写入都能命中缓存。
否则,只要一直不读取数据,写回策略的每次写入操作还是需要写入内存。
通过写直达或写回策略,已经能够解决 “在修改 Cache 数据后,如何同步回内存” 的问题。
四:多核心 Cache 的一致性问题
在单核 CPU 中,我们通过写直达策略或写回策略保持了Cache 与内存的一致性。
但是在多核 CPU 中,由于每个核心都有一份独占的 Cache,就会存在一个核心修改数据后,两个核心 Cache 不一致的问题。
可以看到:由于两个核心的工作是独立的,在一个核心上的修改行为不会被其它核心感知到
所以不管 CPU 使用写直达策略还是写回策略,都会出现缓存不一致问题。
所以,我们需要一种机制,将多个核心的工作联合起来,共同保证多个核心下的 Cache 一致性,这就是缓存一致性机制。
1:写传播 & 事务串行化
缓存一致性机制需要解决的问题就是 2 点:
- 写传播(Write Propagation): 每个 CPU 核心的写入操作,需要传播到其他 CPU 核心;
- 事务串行化(Transaction Serialization): 各个 CPU 核心所有写入操作的顺序,在所有 CPU 核心看起来是一致
第 1 个特性解决了 “感知” 问题,如果一个核心修改了数据,就需要同步给其它核心,很好理解。
但只做到同步还不够,如果各个核心收到的同步信号顺序不一致,那最终的同步结果也会不一致。
举个例子:假如 CPU 有 4 个核心,Core 1 将共享数据修改为 1000,随后 Core 2 将共享数据修改为 2000。
在写传播下,“修改为 1000” 和 “修改为 2000” 两个事务会同步到 Core 3 和 Core 4。
但是,如果没有事务串行化,不同核心收到的事务顺序可能是不同的,最终数据还是不一致。
2:总线嗅探 & 总线仲裁
写传播和事务串行化在 CPU 中是如何实现的呢?—— 就是计算机总线系统。
- 写传播 - 总线嗅探:
- 总线除了能在一个主模块和一个从模块之间传输数据,还支持一个主模块对多个从模块写入数据,这种操作就是广播。
- 要实现写传播,其实就是将所有的读写操作广播到所有 CPU 核心
- 而其它 CPU 核心时刻监听总线上的广播,再修改本地的数据;
- 事务串行化 - 总线仲裁:
- 总线的独占性要求同一时刻最多只有一个主模块占用总线,天然地会将所有核心对内存的读写操作串行化。
- 如果多个核心同时发起总线事务,此时总线仲裁单元会对竞争做出仲裁
- 未获胜的事务只能等待获胜的事务处理完成后才能执行。
提示: 写传播还有 “基于目录(Directory-base)” 的实现方案。
基于总线嗅探和总线仲裁,现代 CPU 逐渐形成了各种缓存一致性协议,例如 MESI 协议。
3:MESI 协议
MESI 协议其实是 CPU Cache 的有限状态机,一共有 4 个状态(MESI 就是状态的首字母):
- M(Modified,已修改): 表明 Cache 块被修改过,但未同步回内存;
- E(Exclusive,独占): 表明 Cache 块被当前核心独占,而其它核心的同一个 Cache 块会失效;
- S(Shared,共享): 表明 Cache 块被多个核心持有且都是有效的;
- I(Invalidated,已失效): 表明 Cache 块的数据是过时的。
MESI 协议在 MSI 的基础上增加了 E(独占)状态,以减少只有一份缓存的写操作造成的总线通信。
在 “独占” 和 “共享” 状态下,Cache 块的数据是 “清” 的,任何读取操作可以直接使用 Cache 数据;
在 “已失效” 和 “已修改” 状态下,Cache 块的数据是 “脏” 的,它们和内存的数据都可能不一致。
在读取或写入 “已失效” 数据时,需要先将其它核心 “已修改” 的数据写回内存,再从内存读取;
在 “共享” 和 “已失效” 状态,核心没有获得 Cache 块的独占权(锁)。
在修改数据时不能直接修改,而是要先向所有核心广播 RFO(Request For Ownership)请求 ,将其它核心的 Cache 置为 “已失效”,等到获得回应 ACK 后才算获得 Cache 块的独占权。
这个独占权这有点类似于开发语言层面的锁概念,在修改资源之前,需要先获取资源的锁;
在 “已修改” 和 “独占” 状态下,核心已经获得了 Cache 块的独占权(锁)。在修改数据时不需要向总线发送广播,能够减轻总线的通信压力。
事实上,完整的 MESI 协议更复杂,但我们没必要记得这么细。我们只需要记住最关键的 2 点:
- 阻止同时有多个核心修改的共享数据: 当一个 CPU 核心要求修改数据时,会先广播 RFO 请求获得 Cache 块的所有权,并将其它 CPU 核心中对应的 Cache 块置为已失效状态;
- 延迟回写: 只有在需要的时候才将数据写回内存,当一个 CPU 核心要求访问已失效状态的 Cache 块时,会先要求其它核心先将数据写回内存,再从内存读取。
MESI 协议有一个非常 nice 的在线体验网站,你可以对照文章内容,在网站上操作指令区,并观察内存和缓存的数据和状态变化。
4:写缓冲区 & 失效队列
MESI 协议保证了 Cache 的一致性,但完全地遵循协议会影响性能。
因此,现代的 CPU 会在增加写缓冲区和失效队列将 MESI 协议的请求异步化,以提高并行度:
写缓冲区(Store Buffer)
由于在写入操作之前,CPU 核心 1 需要先广播 RFO 请求获得独占权,在其它核心回应 ACK 之前,当前核心只能空等待,这对 CPU 资源是一种浪费。
因此,现代 CPU 会采用 “写缓冲区” 机制:写入指令放到写缓冲区后并发送 RFO 请求后,CPU 就可以去执行其它任务
等收到 ACK 后再将写入操作写到 Cache 上。
失效队列(Invalidation Queue)
由于其他核心在收到 RFO 请求时,需要及时回应 ACK。但如果核心很忙不能及时回复,就会造成发送 RFO 请求的核心在等待 ACK。
因此,现代 CPU 会采用 “失效队列” 机制:先把其它核心发过来的 RFO 请求放到失效队列,然后直接返回 ACK
等当前核心处理完任务后再去处理失效队列中的失效请求。
5:指令重排
事实上,写缓冲区和失效队列破坏了 Cache 的一致性。
举个例子:初始状态变量 a 和变量 b 都是 0,现在 Core1 和 Core2 分别执行这两段指令,最终 x 和 y 的结果是什么?
// core1
a = 1; // A1
x = b; // A2
// core2
b = 2; // B1
y = a; // B2
我们知道在未同步的情况下,这段程序可能会有多种执行顺序。
不管怎么执行,只要 2 号指令是在 1 号指令后执行的,至少 x 或 y 至少一个有值。
但是在写缓冲区和失效队列的影响下,程序还有以意料之外的方式执行:
执行顺序(暂时不考虑CPU的超前流水线控制) | 结果 |
---|---|
A1 → A2 → B1 → B2 | x = 0, y = 1 |
A1 → B1 → A1 → B2 | x = 2, y = 1 |
B1 → B2 → A1 → A2 | x = 1, y = 0 |
B1 → A1 → B2 → A2 | x = 2, y = 1 |
A2 → B1 → B2 → A1(A1 与 A2 重排) | x = 0, y = 0 |
B2 → A1 → A2 → B1(B1 与 B2 重排) | x = 0, y = 0 |
可以看到:从内存的视角看,直到 Core1 执行 A3 来刷新写缓冲区,写操作 A1 才算真正执行了。
虽然 Core 的执行顺序是 A1 → A2 → B1 → B2,但内存看到的顺序却是 A2 → B1 → B2 → A1
变量 a 写入没有同步给对变量 a 的读取,Cache 的一致性被破坏了。
6:数据一致性 vs 顺序一致性
6.1:数据一致性
数据一致性讨论的是同一份数据在多个副本之间的一致性问题,你也可以理解为多个副本的状态一致性问题。
例如内存与多核心 Cache 副本之间的一致性,或者数据在主从数据库之间的一致性。
当我们从 CPU 缓存一致性问题开始,逐渐讨论到 Cache 到内存的写直达和写回策略,再讨论到 MESI 等缓存一致性协议
从始至终我们讨论的都是 CPU 缓存的 “数据一致性” 问题,只是为了简便我们从没有刻意强调 “数据” 的概念。
数据一致性有强弱之分:
- 强数据一致性: 保证在任意时刻任意副本上的同一份数据都是相同的,或者允许不同,但是每次使用前都要刷新确保数据一致,所以最终还是一致。
- 弱数据一致性: 最终一致性就行。
MESI 协议就是强数据一致性的,但引入写缓冲区或失效队列后就成了弱数据一致性,随着缓冲区和失效队列被消费,各个 Cache 最终还是会趋向一致状态。
6.2:顺序一致性
顺序一致性讨论的是对多个数据的多次操作顺序在整个系统上的一致性。在并发编程中,存在 3 种指令顺序:
- 编码顺序: 指源码中指令的编写顺序,是程序员视角看到的指令顺序,不一定是实际执行的顺序;
- 执行顺序: 指单个线程或处理器上实际执行的指令顺序;
- 全局执行顺序: 每个线程或处理器上看到的系统整体的指令顺序,在弱顺序一致性模型下,每个线程看到的全局执行顺序可能是不同的。
顺序一致性模型是计算机科学家提出的一种理想参考模型,为程序员描述了一个极强的全局执行顺序一致性,由 2 个特性组成:
- 执行顺序与编码顺序一致: 保证每个线程中指令的执行顺序与编码顺序一致;
- 全局执行顺序一致: 保证每个指令的结果会同步到主内存和各个线程的工作内存上,使得每个线程上看到的全局执行顺序一致。
🎉 举个例子
线程 A 和线程 B 并发执行,线程 A 执行 A1 → A2 → A3,线程 B 执行 B1 → B2 → B3。
那么,在顺序一致性内存模型下,虽然程序整体执行顺序是不确定的,但是线程 A 和线程 B 总会按照 1 → 2 → 3 编码顺序执行
而且两个线程总能看到相同的全局执行顺序。
6.3:弱顺序一致性(一定要理解)
虽然顺序一致性模型对程序员非常友好,但是对编译器和处理器却不见得喜闻乐见。
如果程序完全按照顺序一致性模型来实现,那么处理器和编译器的很多重排序优化都要被禁止,这对程序的 “并行度” 会有影响。例如:
- 重排序问题: 编译器和处理器不能重排列没有依赖关系的指令;
- 内存可见性问题: CPU 不能使用写回策略,也不能使用写缓冲区和失效队列机制。其实,从内存的视角看也是指令重排问题。
所以,在 Java 虚拟机和处理器实现中,实际上使用的是弱顺序一致性模型:
- 不要求执行顺序与编码顺序一致: 不要求单线程的执行顺序与编码顺序一致,只要求执行结果与强顺序执行的结果一致【只看结果,不看过程】
- 不要求全局执行顺序一致: 允许每个线程看到的全局执行顺序不一致,甚至允许看不到其他线程已执行指令的结果。
举个单线程的例子:
在这段计算圆面积的代码中,在弱顺序一致性模型下,指令 A 和 指令 B 可以不按编码顺序执行。
因为 A 和 B 没有数据依赖,所以对最终的结果也没有影响。
但是 C 对 A 和 B 都有数据依赖,所以 C 不能重排列到 A 或 B 的前面,否则会改变程序结果。
double pi = 3.14; // A double r = 1.0; // B double area = pi * r * r; // C(数据依赖于 A 和 B,不能重排列到前面执行)
再举一个多线程的例子
我们在 ChangeThread 线程修改变量,在主线程观察变量的值。
在弱顺序一致性模型下,允许 ChangeThread 线程 A 指令的执行结果不及时同步到主线程,在主线程看来就像没执行过 A 指令。
public class VisibilityTest {
public static void main(String[] args) {
ChangeThread thread = new ChangeThread();
thread.start();
while (true) {
if (thread.flag) { // B
System.out.println("Finished");
return;
}
}
}
public static class ChangeThread extends Thread {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true; // A
System.out.println("Change flag = " + flag);
}
}
}
Change flag = true
// 无限等待
7:指令重排
7.1:重排序类型
从源码到指令执行一共有 3 种级别重排序:
- 编译器重排序 -> 将循环内重复调用的操作提前到循环外执行
for (int i = 0; i < 10; i++) {
flag = true; // 这行可以拿出来
//.....
}
- 处理器系统重排序 -> 例如指令并行技术将多条指令重叠执行,或者使用分支预测技术提前执行分支的指令,并把计算结果放到重排列缓冲区的硬件缓存中,当程序真的进入分支后直接使用缓存中的结算结果
- 存储器系统重排序 -> 例如写缓冲区和失效队列机制,即是可见性问题,从内存的角度也是指令重排问题。
7.2:什么是数据依赖性
编译器和处理器在重排序时,会遵循数据依赖性原则,不会试图改变存在数据依赖关系的指令顺序。
如果两个操作都是访问同一个数据,并且其中一个是写操作,那么这两个操作就存在数据依赖性。
此时一旦改变顺序,程序最终的执行结果一定会发生改变。
数据依赖性 | 描述 | 示例 |
---|---|---|
写后读 | 写一个数据,再读这个数据 | a = 1; // 写 b = a; // 读 |
写后写 | 写一个数据,再写这个数据 | a = 1; // 写 a = 2; // 读 |
读后写 | 读一个数据,再写这个数据 | b = a; // 读 a = 1; // 写 |
7.3:指令重排序安全吗
数据依赖性原则只对单个处理器或单个线程有效
因此即使在单个线程或处理器上遵循数据依赖性原则,在多处理器或者多线程中依然有可能改变程序的执行结果。
线程A
a = 2; // A1
flag = true; // A2
线程B
while (flag) { // B1
return a * a; // B2
}
重排序在单线程程序下是安全的(与预期一致),但在多线程程序下是不安全的。
而为了纠正弱顺序一致性的影响,编译器和处理器都提供了 “内存屏障指令” 来保证程序关键节点的执行顺序能够与程序员的预期一致。
在高级语言中,我们不会直接使用内存屏障,而是使用更高级的语法,即 synchronized、volatile、final、CAS 等语法。