小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
详细介绍计算机缓存一致性协议的概念,以及指令重排序的概念。
1 硬件的效率与缓存一致性
“让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能” 之间的因果关系,看起来顺理成章,实际上它们之间的关系并没有想象中的那么简单,其中一个重要的复杂性来源是绝大多数的运算任务都不可能只靠处理器 “计算” 就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。
由于计算机的存储设备与处理器的运算速度有几个数量级的差距,为避免大量时间花在磁盘IO、网络通信或者数据库访问上,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),如图:
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly 及 Dragon Protocol 等。
1.1. 现代计算机缓存
现代计算机一般都有 2 个以上 CPU,而且每个 CPU 还有可能包含多个核心。因此,如果应用是多线程的话,这些线程可能会在各个 CPU 核心中并行运行。
在 CPU 内部有一组 CPU 寄存器,也就是 CPU 的储存器。
- CPU 操作寄存器的速度要比操作计算机主存快的多。
- 在主存和 CPU 寄存器之间还存在一个 CPU 缓存,CPU 操作 CPU 缓存的速度快于主存但慢于 CPU 寄存器。某些 CPU 可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作 RAM,所有的 CPU 都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。
- 当一个 CPU 需要访问主存时,会先读取一部分主存数据到 CPU 缓存,进而在读取 CPU 缓存到寄存器。当 CPU 需要写数据到主存时,同样会先 flush 寄存器到CPU 缓存,然后再在某些节点把缓存数据 flush 到主存。
缓存大大缩小了高速 CPU 与低速内存之间的差距。以三层缓存架构为例。
- Core0 与 Core1 命中了内存中的同一个地址,那么各自的 L1 Cache 会缓存同一份数据的副本。
- Core0 修改了数据,两份缓存中的数据不同了,Core1 L1 Cache 中的数据相当于失效了。
除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于 CPU 与 L1 Cache 之间的缓存。
1.2 高速缓存一致性协议MESI协议
多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。MESI 是指4种状态的首字母。每个Cache line(缓存行(Cache line):缓存存储数据的单元。)有4个状态,可用2个bit表示,它们分别是:
状态 | 描述 | 监听任务 |
M 修改 (Modified) | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 |
E 独享、互斥 (Exclusive) | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
S 共享 (Shared) | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
I 无效 (Invalid) | 该Cache line无效。 | 无 |
MESI协议其基本原理为:
- Core0 修改数据 v 后,发送一个信号,将 Core1 缓存的数据 v 标记为失效,并将修改值写回内存。
- Core0 可能会多次修改数据 v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1 缓存的数据 v 保持着失效标记。
- Core1 使用数据 v 前,发现缓存中的数据 v 已经失效了,得知数据 v 已经被修改,于是重新从其他缓存或内存中加载数据 v。
1.3 重排序
1.3.1 重排序概述
除了增加高速缓存之外,在执行程序时,为了使得处理器内部的运算单元能尽量被充分利用,为了提高性能,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,并会在计算之后将乱序执行的结果充重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。
重排序分3种类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:现代处理器采用 并行技术 来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变对应机器指令的执行顺序。
- 内存系统的重排序:处理器使用 缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。(导致的可见性问题也可以通过 MESI 协议解决)
上图中的 1 属于 编译器重排序,2 和 3 属于 处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。
1.3.2 重排序的条件
重排序不是随意重排序,它需要满足以下两个条件。
1.3.2.1 数据依赖性
如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。数据依赖分为下列3种类型:
编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
1.3.2.2 as-if-serial语义
as-if-serial语义的意思是:所有的动作都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身(单线程下的执行)的应有结果是一致的,编译器、runtime 和处理器都必须遵守 as-if-serial 语义。注意这对多线程无效。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。asif-serial语义使单线程下程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
1.3.3 指令级并行的重排序(处理器)
只要不影响程序单线程、顺序执行的结果,不违背上面的两个条件。就可以对两个指令重排序。乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。
不优化时的执行过程 | 优化时的执行过程 |
指令获取。 | 指令获取。 |
如果输入的运算对象是可以获取的(比如已经存在于寄存器中),这条指令会被发送到合适的功能单元。如果一个或者更多的运算对象在当前的时钟周期中是不可获取的(通常需要从主内存获取),处理器会开始等待直到它们是可以获取的。 | 指令被发送到一个指令序列(也称执行缓冲区或者保留站)中。 |
指令在合适的功能单元中被执行。 | 指令将在序列中等待,直到它的数据运算对象是可以获取的。然后,指令被允许在先进入的、旧的指令之前离开序列缓冲区。(此处表现为乱序) |
功能单元将运算结果写回寄存器。 | 指令被分配给一个合适的功能单元并由之执行。 |
结果被放到一个序列中。 | |
仅当所有在该指令之前的指令都将他们的结果写入寄存器后,这条指令的结果才会被写入寄存器中。(重整乱序结果) |
1.3.4 编译器优化的重排序
和处理器乱序执行的目的是一样的,与其等待阻塞指令(如等待缓存刷入)完成,不如先执行其他指令。与处理器乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。
1.3.5 重排序对多线程的影响
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。因此多线程下要程序员额外保证正确的代码顺序。
如下:
程序中用到了局部变量r1和r2,以及共享变量A和B。可能会出现r2=2 ,r1=1这样的结果。直觉上,应当要么指令1先执行要么指令3先执行。如果指令1先执行,它不应该能看到指令4中写入的值。如果指令3先执行,它不应该能看到指令2写的值。
如果某次执行表现出了这样的行为,那么我们可能得出这样的结论,指令4要在指令1之前执行,指令1要在指令2之前执行,指令2要在指令3之前执行,指令3要在指令4之前执行。如此,从表面看来,有悖常理。
然而,从单个线程的角度看,只要重排序不会影响到该线程的执行结果,编译器就可以对该线程中的指令进行重排序。如果指令1与指令2重排序,那就很容易看出为什么会出现r2 = 2和r1 = 1这样的结果了。
1.4. 前向替换
还有一种编译器的优化可能会导致多线程程序发生出人意料的结果:
一种常规的编译器优化会在使用r5的时候重用r2:它们读取的都是r1.x且它们之间没有写r1.x的操作。
在线程1第一次读取r1.x与读取r3.x之间,线程2对r6.x进行了赋值。如果编译器决定在r5处重用r2的值,那么r2和r5的值都是0,r4的值是3。从编程人员的角度来看,p.x的值从0变为3后又变回了0。
尽管这样的行为让人颇感意外,但多数JVM实现都允许这种行为。
参考资料:
- 《JSR133规范》
- 《Java并发编程之美》
- 《Java并发编程的艺术》
如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!