5.2.5. 简易 CPU 模型 - 解耦 CPU 的 Invalidate 与 Store Buffer - Invalidate Queues
加入 Invalidate Queues 之后,CPU 结构如下所示:
有了 Invalidate Queue,CPU 可以将 Invalidate 放入这个队列之后立刻将 Store Buffer 中的对应数据刷入 CPU 缓存。同时,CPU 在想主动发某个缓存行的 Invalidate 消息之前,必须检查自己的 Invalidate Queue 中是否有相同的缓存行的 Invalidate 消息。如果有,必须等处理完自己的 Invalidate Queue 中的对应消息再发。
同样的,Invalidate Queue 也带来了乱序执行。
5.2.6. 简易 CPU 模型 - 由于 Invalidate Queues 带来的进一步乱序 - 需要内存屏障
假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0。假设 CPU A (缓存行里面包含 a(shared), b(Exclusive))执行:
CPU B(缓存行里面包含 a(shared))执行:
1.CPU A 执行 a = 1:
(1)CPU A 缓存里面有 a(shared),CPU A 将 a 的修改(a=1)放入 Store Buffer,发送 Invalidate 消息。
2.CPU B 执行 while (b == 0) continue:
(1)CPU B 缓存里面没有 b,发布 Read 消息。
(2)CPU B 收到 CPU A 的 Invalidate 消息,放入 Invalidate Queue 之后立刻返回。
(3)CPU A 收到 Invalidate 消息的响应,将 Store Buffer 中的缓存行刷入 CPU 缓存
3.CPU A 执行 smp_mb():
(1)因为 CPU A 已经把 Store Buffer 中的缓存行刷入 CPU 缓存,所以这里直接通过
4.CPU A 执行 b = 1:
(1)因为 CPU A 本身包含 b 的缓存行 (Exclusive),直接更新缓存行即可。
(2)CPU A 收到 CPU B 之前发的 Read 消息,将 b 的缓存行状态更新为 Shared,之后发送 Read Response 包含 b 的最新值
(3)CPU B 收到 Read Response, b 的值为 1
5.CPU B 退出循环,开始执行 assert(a == 1)
(1)由于目前关于 a 的 Invalidate 消息还在 Invalidate queue 中没有处理,所以 CPU B 看到的还是 a = 0,assert 失败
所以,我们针对这种乱序,在 CPU B 执行的代码中也加入内存屏障,这里内存屏障不仅等待 CPU 刷完所有的 Store Buffer,还要等待 CPU 的 Invalidate Queue 全部处理完。加入内存屏障,CPU B 执行的代码是:
这样,在前面的第 5 步,CPU B 退出循环,执行 assert(a == 1) 之前需要等待 Invalidate queue 处理完:
(1)处理 Invalidate 消息,将 b 置为 Invalid
(2)继续代码,执行 assert(a == 1),这时候缓存内不存在 b,需要发 Read 消息,这样就能看到 b 的最新值 1 了,assert 成功。
5.2.7. 简易 CPU 模型 - 更细粒度的内存屏障
我们前面提到,在我们前面提到的 CPU 模型中,smp_mb()
这个内存屏障指令,做了两件事:等待 CPU 刷完所有的 Store Buffer,等待 CPU 的 Invalidate Queue 全部处理完。但是,对于我们这里 CPU A 与 CPU B 执行的代码中的内存屏障,并不是每次都要这两个操作同时存在:
所以,一般 CPU 还会抽象出更细粒度的内存屏障指令,我们这里管等待 CPU 刷完所有的 Store Buffer 的指令叫做写内存屏障(Write Memory Buffer),等待 CPU 的 Invalidate Queue 全部处理完的指令叫做读内存屏障(Read Memory Buffer)。
5.2.8. 简易 CPU 模型 - 总结
我们这里通过一个简单的 CPU 架构出发,层层递进,讲述了一些简易的 CPU 结构以及为何会需要内存屏障,可以总结为下面这个简单思路流程图:
- CPU 每次直接访问内存太慢,会让 CPU 一直处于 Stall 等待。为了减少 CPU Stall,加入了 CPU 缓存。
- CPU 缓存带来了多 CPU 间的缓存不一致性,所以通过 MESI 这种简易的 CPU 缓存一致性协议协调不同 CPU 之间的缓存一致性
- 对于 MESI 协议中的一些机制进行优化,进一步减少 CPU Stall:
- 通过将更新放入 Store Buffer,让更新发出的 Invalidate 消息不用 CPU Stall 等待 Invalidate Response。
- Store Buffer 带来了指令(代码)乱序,需要内存屏障指令,强制当前 CPU Stall 等待刷完所有 Store Buffer 中的内容。这个内存屏障指令一般称为写屏障。
- 为了加快 Store Buffer 刷入缓存,增加 Invalidate Queue,
5.3. CPU 指令乱序相关
CPU 指令的执行,也可能会乱序,我们这里只说一种比较常见的 - 指令并行化。
5.3.1. 增加 CPU 执行效率 - CPU 流水线模式(CPU Pipeline)
现代 CPU 在执行指令时,是以指令流水线的模式来运行的。因为 CPU 内部也有不同的组件,我们可以将执行一条指令分成不同阶段,不同的阶段涉及的组件不同,这样伪解耦可以让每个组件独立的执行,不用等待一个指令完全执行完再处理下一个指令。
一般分为如下几个阶段:取指(Instrcution Fetch,IF)、译码(Instruction Decode,ID)、执行(Execute,EXE)、存取(Memory,MEM)、写回(Write-Back, WB)
5.3.2. 进一步降低 CPU Stall - CPU 乱序流水线(Out of order execution Pipeline)
由于指令的数据是否就绪也是不确定的,比如下面这个例子:
倘若数据 a 没有就绪,还没有载入到寄存器,那么我们其实没必要 Stall 等待加载 a,可以先执行 c = 1; 由此,我们可以将程序中,可以并行的指令提取出来同时安排执行,CPU 乱序流水线(Out of order execution Pipeline)就是基于这种思路:
如图所示,CPU 的执行阶段分为:
- Instructions Fetch:批量拉取一批指令,进行指令分析,分析其中的循环以及依赖,分支预测等等
- Instruction Decode:指令译码,与前面的流水线模式大同小异
- Reservation stations:需要操作数输入的指令,如果输入就绪,就进入 Functoinal Unit (FU) 处理,如果没有没有就绪就监听 Bypass network,数据就绪发回信号到 Reservation stations,让指令进图 FU 处理。
- Functional Unit:处理指令
- Reorder Buffer:会将指令按照原有程序的顺序保存,这些指令会在被 dispatched 后添加到列表的一端,而当他们完成执行后,从列表的另一端移除。通过这种方式,指令会按他们 dispatch 的顺序完成。
这样的结构设计下,可以保证写入 Store Buffer 的顺序,与原始的指令顺序一样。但是加载数据,以及计算,是并行执行的。前面我们已经知道了在我们的简易 CPU 架构里面,有着多 CPU 缓存 MESI, Store Buffer 以及 Invalidate Queue 导致读取不到最新的值,这里的乱序并行加载以及处理更加剧了这一点。并且,结构设计下,仅能保证检测出同一个线程下的指令之间的互相依赖,保证这样的互相依赖之间的指令执行顺序是对的,但是多线程程序之间的指令依赖,CPU 批量取指令以及分支预测是无法感知的。所以还是会有乱序。这种乱序,同样可以通过前面的内存屏障避免。
5.4. 实际的 CPU
实际的 CPU 多种多样,有着不同的 CPU 结构设计以及不同的 CPU 缓存一致性协议,就会有不同种类的乱序,如果每种单独来看,就太复杂了。所以,大家通过一种标准来抽象描述不同的 CPU 的乱序现象(即第一个操作为 M,第二个操作为 N,这两个操作是否会乱序,是不是很像 Doug Lea 对于 JMM 的描述,其实 Java 内存模型也是参考这个设计的),参考下面这个表格:
我们先来说一下每一列的意思:
- Loads Reordered After Loads:第一个操作是读取,第二个也是读取,是否会乱序。
- Loads Reordered After Stores:第一个操作是读取,第二个是写入,是否会乱序。
- Stores Reordered After Stores:第一个操作是写入,第二个也是写入,是否会乱序。
- Stores Reordered After Loads:第一个操作是写入,第二个是读取,是否会乱序。
- Atomic Instructions Reordered With Loads:两个操作是原子操作(一组操作,同时发生,例如同时修改两个字这种指令)与读取,这两个互相是否会乱序。
- Atomic Instructions Reordered With Stores:两个操作是原子操作(一组操作,同时发生,例如同时修改两个字这种指令)与写入,这两个互相是否会乱序。
- Dependent Loads Reordered:如果一个读取依赖另一个读取的结果,是否会乱序。
- Incoherent Instruction Cache/Pipeline:是否会有指令乱序执行。
举一个例子来看即我们自己的 PC 上面常用的 x86 结构,在这种结构下,仅仅会发生 Stores Reordered After Loads 以及 Incoherent Instruction Cache/Pipeline。其实后面要提到的 LoadLoad,LoadStore,StoreLoad,StoreStore 这四个 Java 中的内存屏障,为啥在 x86 的环境下其实只需要实现 StoreLoad,其实就是这个原因。
5.5. 编译器乱序
除了 CPU 乱序以外,在软件层面还有编译器优化重排序导致的,其实编译器优化的一些思路与上面说的 CPU 的指令流水线优化其实有些类似。比如编译器也会分析你的代码,对相互不依赖的语句进行优化。对于相互没有依赖的语句,就可以随意的进行重排了。但是同样的,编译器也是只能从单线程的角度去考虑以及分析,并不知道你程序在多线程环境下的依赖以及联系。再举一个简单的例子,假设没有任何 CPU 乱序的环境下,有两个变量 x = 0,y = 0,线程 1 执行:
线程 2 执行:
那么线程 2 是可能 assert 失败的,因为编译器可能会让 x = 1
与 y = 1
之间乱序。
编译器乱序,可以通过增加不同操作系统上的编译器屏障语句进行避免。例如线程一执行:
这样就不会出现 x = 1
与 y = 1
之间乱序的情况。
同时,我们在实际使用的时候,一般内存屏障指的是硬件内存屏障,即通过硬件 CPU 指令实现的内存屏障,这种硬件内存屏障一般也会隐式地带上编译器屏障。编译器屏障一般被称为软件内存屏障,仅仅是控制编译器软件层面的屏障,举一个例子即 C++ 中的 volaile,它与 Java 中的 volatile 不一样, C++ 中的 volatile 仅仅是禁止编译器重排即有编译器屏障,但是无法避免 CPU 乱序。
以上,我们就基本搞清楚了乱序的来源,以及内存屏障的作用。接下来,我们即将步入正题,开始我们的 Java 9+ 内存模型之旅。在这之前,再说一件需要注意的事情:为什么最好不要自己写代码验证 JMM 的一些结论,而是使用专业的框架去测试