一致性问题
iCache和dCache的一致性
为什么会有一致性问题?
上一次分享中,我们提到,L1Cache 分为 dCache(数据缓存) 和 iCache(指令缓存)。其中数据缓存是可读写的,而指令缓存是只读的。
我们的程序在执行的时候,指令一般是不会修改的。这就不会存在任何一致性问题。但是,总有些情况使得代码在执行的时候去修改自己的指令,例如:断点调试的时候就需要修改指令。当我们修改指令,步骤如下:
- 将需要修改的指令数据加载到 dCache 中。
- 修改成新指令,写回 dCache。
这里会有2个情况导致 iCache 和 dCache的不一致问题:
- 如果旧指令已经缓存在 iCache 中。那么对于程序执行来说依然会命中 iCache。
- 如果旧指令没有缓存 iCache,那么指令会从主存中缓存到 iCache 中。如果 dCache 使用的是写回策略,即当 CacheLine 需要被替换时,才会去更新主存中的数据,那这里从iCache中获取的数据依然是旧的。
如何解决?
硬件解决
硬件上可以让 iCache 和 dCache 之间通信,每一次修改 dCache 数据的时候,硬件负责查找 iCache 是否命中,如果命中,也更新 iCache。当加载指令的时候,先查找 iCache,如果 iCache 没有命中,再去查找 dCache 是否命中,如果 dCache 没有命中,从主存中读取。
这确实解决了问题,软件基本不用维护两者一致性。但是 self-modifying code 是少数,为了解决少数的情况,却给硬件带来了很大的负担,得不偿失。因此,大多数情况下由软件维护一致性。
软件解决
当操作系统发现修改的数据可能是代码时,可以采取下面的步骤维护一致性:
- 将需要修改的指令数据加载到 dCache 中。
- 修改成新指令,写回 dCache。
- clean dCache中修改的指令对应的CacheLine,保证dCache中新指令写回主存。
- invalid iCache中修改的指令对应的CacheLine,保证从主存中读取新指令。
多核cache的一致性
为什么会有一致性问题?
假设两个CPU都读取0x40地址数据到私有的L1Cache中。此时,CPU0执行写操作,写入值0x01,CPU0私有的L1Cache更新CacheLine的值。然后,CPU1读取0x40数据,CPU1发现命中Cache,然后返回0x00值,并不是CPU0写入的0x01。这就造成了CPU0和CPU1私有L1 Cache数据不一致现象。也就是说当某个CPU修改了共享的变量时,并没有通知其他CPU。
如何解决?
Bus Snooping Protocol(总线嗅探)
- 方式:当CPU0修改自己私有的Cache时,硬件就会广播通知到总线上其他所有的CPU。对于每个CPU来说会有特殊的硬件监听广播事件,并检查是否有相同的数据被缓存在自己的CPU。如果CPU1私有Cache已经缓存即将修改的数据,那么CPU1的私有Cache也需要更新对应的CacheLine。
- 缺点:Bus Snooping的方法简单,但需要每时每刻监听总线上的一切活动。也就是说,不管别的CPU私有Cache是否缓存相同的数据,都需要发出一次广播事件。这在一定程度上加重了总线负载,也增加了读写延迟。
MESI Protocol
MESI代表“Modified”、“Exclusive”、“Shared”和“Invalid”四种状态的缩写,特定缓存行可以处在该协议采用的这四种状态上:
- 处于“Modified”状态的缓存行:数据只被当前CPU的私有cache持有,不与其他CPU的私有cache共享;当前CPU已经对缓存行的数据进行了修改,即私有cache中的数据与主存中的数据不一致。
- 处于“Exclusive”状态的缓存行:数据只被当前CPU的私有cache持有,不与其他CPU的私有cache共享;并且缓存行的数据还未被修改,即私有cache中的数据与主存中的数据一致。
- 处于“Shared”状态的缓存行:数据不仅被当前CPU的私有cache持有,还被至少一个其他CPU的私有cache共享;并且缓存行的数据还未被修改,即私有cache中的数据与主存中的数据一致。
- 处于“Invalid”状态的缓存行:表示该缓存行已经失效了,不能再被继续使用了,当CPU命中私有cache时,需要强制从主存中读取数据。
可以看到,当CacheLine状态是Modified或者Exclusive状态时,说明数据是被独占的,因此修改数据时不需要发送消息给其他CPU。那相比于总线,这就在一定程度上减轻了带宽压力。
举个例子:
- 当CPU0读取0x40数据,数据被缓存到CPU0私有Cache,此时其他CPU没有缓存0x40数据,所以我们标记cacheline状态为Exclusive。
- 然后CPU1读取0x40数据,发送消息给其他CPU,发现数据被缓存到CPU0私有Cache,数据从CPU0 Cache返回给CPU1。此时CPU0和CPU1同时缓存0x40数据,那么CacheLine状态从Exclusive切换到Shared状态。
- 接下来,CPU0继续修改0x40地址数据,发现0x40内容所在CacheLine状态是Shared。CPU0发出Invalid消息到其他CPU,这里是CPU1。CPU1接收到invalid消息,将0x40所在的CacheLine置为Invalid状态,表明当前cache line无效。然后CPU0收到CPU1已经Invalid的消息,修改0x40所在的CacheLine中数据,并更新CacheLine状态为Modified。
- 如果CPU0继续修改0x40数据,发现对应的CacheLine的状态是Modified。此时,CPU0不需要向其他CPU发送消息,直接更新数据即可。
- 如果0x40所在的CacheLine需要替换,发现CacheLine状态是Modified,那么,数据应该先写回主存。
为了维护这个状态机,需要各个CPU之间进行通信,会引入下面几种类型的消息:
- 读消息:该消息包含要读取的缓存行的物理地址。
- 读响应消息:该消息包含较早前的读消息所请求的数据,这个读响应消息要么由物理内存提供,要么由某一个其它CPU上的缓存提供。例如,如果某一个CPU上的缓存拥有处于“Modified”状态的目标数据,那么该CPU上的缓存必须提供读响应消息。
- 使无效消息:该消息包含要使无效的缓存行的物理地址,所有其它CPU上的缓存必须移除相应的数据并且响应此消息。
- 使无效应答消息:一个接收到使无效消息的CPU必须在移除指定数据后响应一个使无效应答消息。
- 读使无效消息:该消息包含要被读取的缓存行的物理地址,同时指示其它CPU上的缓存移除对应的数据。因此,正如名字所示,它将读消息和使无效消息合并成了一条消息。读使无效消息同时需要一个读响应消息及一组使无效应答消息进行应答。
- 写回消息:该包含要回写到物理内存的地址和数据。这个消息允许缓存在必要时换出处于“Modified”状态的数据,以便为其它数据腾出空间。
内存一致性
Store Buffer
MESI缓存一致性协议可以保证系统中的各个CPU核上的缓存都是一致的。但是也带来了一个很大的问题,由于所有的操作都是“同步”的,必须要等待远端CPU完成指定操作后收到响应消息才能真正执行对应的存储或加载操作,这样会极大降低系统的性能。比如说,如果CPU0和CPU1上同时缓存了同一段数据,如果CPU0想对其进行更改,那么必须先发送使无效消息给CPU1,等到CPU1真的将该缓存的数据段标记成“Invalid”状态后,会向CPU0发送使无效应答消息,理论上只有CPU0收到这个消息后,才可以真的更改数据。但是,从要更改到真的能更改已经经过了好几个阶段了,这时CPU0只能等在那里。 那么,为了进一步加快内存访问的速度,只能稍微放松一下对缓存一致性的要求。具体的,会引入如下两个模块:
- 存储缓冲: 前面提到过,在写数据之前我们先要得到缓存段的独占权,如果当前CPU没有独占权,要先让系统中别的CPU上缓存的同一段数据都变成无效状态。为了提高性能,可以引入一个叫做存储缓冲(Store Buffer)的模块,将其放置在每个CPU和它的缓存之间。当前CPU发起写操作,如果发现没有独占权,可以先将要写入的数据放在存储缓冲中,并继续运行,仿佛独占权瞬间就得到了一样。当然,存储缓冲中的数据最后还是会被同步到缓存中的,但就相当于是异步执行了,不会让CPU等了。并且,当前CPU在读取数据的时候应该首先检查其是否存在于存储缓冲中。
- 无效队列:如果当前CPU上收到一条消息,要使某个缓存段失效,但是此时缓存正在处理其它事情,那这个消息可能无法在当前的指令周期中得到处理,而会将其放入所谓的无效队列(Invalidation Queue)中,同时立即发送使无效应答消息。那个待处理的使无效消息将保存在队列中,直到缓存有空为止。
为什么会有一致性问题?
硬件MESI协议只解决CPU缓存层面的一致性,如果值只被写入Store Buffer,而没写入Cache,MESI就管不着。
因此,当cpu0改变变量x的值并写入Store Buffer,但还没写入Cache中时,cpu1去读取变量x的值,读到的还是cpu0没修改之前的值。
如何解决?
内存屏障
内存屏障
概念介绍
内存屏障是硬件之上、操作系统或JVM之下,解决硬件层面的可见性与重排序问题,是对并发作出的最后一层支持。再向下是硬件提供的支持;向上是操作系统或JVM对内存屏障作出的各种封装。
可见性
问题来源:
- 一个最简单的可见性问题来自多核时代,计算机内部的缓存架构,也就是上文多核cache一致性所讲的问题。
- 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于CPU与L1 Cache之间的缓存,也就是上文所说的strore buffer。
- 各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,但显然在CPU级别的缓存可见性问题面前,一切都失效了。
解决方式:
从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:当一个CPU修改了共享变量的值,其它CPU在使用前,能够得到最新的修改值。
因此,在CPU缓存层面上,由实现MESI协议对总线机制进行优化。
重排序
重排序的目的:与其等待阻塞指令(如等待缓存刷入)完成,不如先去执行其他指令。与处理器乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。
重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。
在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。
处理器执行时的乱序优化(内存屏障)
当处理器按照书写顺序执行时,如果从内存读取数据的加载指令、除法运算指令等延迟(等待结果的时间)较长的指令后面紧跟着使用该指令结果的指令,就会陷入长时间的等待。
但有时,下一条指令并不依赖于前面那条延迟较长的指令,只要有了操作数就能执行。此时可以打乱机器指令的顺序,就算指令位于后边,只要可以执行,就先执行,这就是乱序执行(Out-of-Order)。
乱序执行时,由于数据依赖性而无法立即执行的指令会被延后,因此可以减轻数据灾难的影响。
- 将指令送入解码单元解码
- 解码后的指令根据各自的指令种类,将解码后的指令送往各自的保留站中保存下来。如果操作数位于寄存器中,就把操作数从寄存器中读出来,和指令一起放入保留站。相反,如果操作数还在由前面的指令进行计算,那么就把那条指令的识别信息保存下来。
- 指令在保留站中等待,直到操作数齐备,然后指令被允许依次送到流水线进行运算。即使指令位于前面,如果操作数没准备好,也不能开始执行,所以保留站中的指令执行顺序与程序不一致(乱序)。
- 保留站会监视执行流水线输出的结果,如果产生的结果正好是等待中的指令的操作数,就将其读入,这样操作数齐备后,等待中的指令就可以执行了。
编译器编译时的优化(优化屏障)
受到处理器预取单元的能力限制,处理器每次只能分析一小块指令的并发性,如果指令相隔比较远 就无能为力了。
但是从编译器的角度来看,编译器能够对很大一个范围的代码进行分析,能够从更大的范围内分辨出可以并发的指令,并将其尽量靠近排列让处理器更容易并发执行,充分利用 处理器的乱序并发功能。所以现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能 力,并且可以对访存的指令进行进一步的乱序,减少逻辑上不必要的访问主存,以及尽量提高 Cache命中率和CPU的LSU(load/store unit)的工作效率。
一段代码想要最终被计算机执行,首先需要被翻译成机器可识别和执行的指令,代码编译的过程往往包含几个步骤:
代码 -> 词法语法分析 -> 语义分析 -> 中间代码生成 -> 目标代码生成
在这个过程中:
- (1)、(2)依赖于上层的编程语言设计。
- (3)会将分析结果编译成中间代码。在这个阶段,编译器会尝试对中间代码进行优化,通过减少无效或冗余的代码、计算强度优化等手段,以助于减少最终生成的指令数,或使用更高效的指令。
- (4)基于中间代码生成机器可执行的目标代码,这个过程和操作系统、指令集、内存等相关。其中,不同的指令集也会带来不同的效率。
也就是说编译器在对执行结果不产生影响的情况下,对代码进行修改来对编译速度、生成代码大小和生成代码执行速度进行优化。那么编译器具体会做哪些优化,可参见硬核文章:编译器都做了哪些优化?
这里简要介绍一下针对下面这段代码,编译器进行了哪些优化:
#include <stdlib.h>
void main() {
int loop = 1000000000;
long sum = 0;
int index = 1;
printf("%d", index + 6);
}
汇编结果:
.LC0:
.string "%d"
main:
pushq %rbp #
movq %rsp, %rbp #,
subq $16, %rsp #,
movl $1000000000, -16(%rbp) #, loop
movq $0, -8(%rbp) #, sum
movl $1, -12(%rbp) #, index
movl -12(%rbp), %eax # index, tmp60
addl $6, %eax #, D.2418
movl %eax, %esi # D.2418,
movl $.LC0, %edi #,
movl $0, %eax #将累加器置归0,
call printf #
ret
开启编译优化:
.LC0:
.string "%d"
main:
movl $7, %esi #,
movl $.LC0, %edi #,
xorl %eax, %eax #
jmp printf #
- 移除没有用到的变量;
- 对于index+1这个计算过程进行提前预判为7,并在编译阶段进行替换;
- 对
movl $0, %eax指令优化为占用大小更小的xorl %eax, %eax指令。
缓存同步顺序(可见性问题)
缓存同步顺序的问题,就是修改内存的操作不是按照实际修改的顺序被别的CPU感知的,这可以被拆解为两个问题:
- 由于引入store buffer,修改内存的操作不是按照实际修改的顺序被“提交”给缓存系统的;
- 由于引入无效队列,别的CPU不能按照“提交”给缓存系统的次序感知内存的更改。
举个例子:
假设有CPU0上要执行对三个变量的写操作:
Store A = 1;
Store B = 2;
Store C = 3;
这三个变量在缓存中的状态不一样,假设A变量和B变量在CPU0和CPU1中的缓存都存在,也就是处于“Shared”状态,而C变量是CPU0独占的,也就是处于“Exclusive”状态。假设系统经历了如下几个步骤:
- 在对变量A和B赋值时,CPU0发现其实别的CPU也缓存了,因此会将它们临时放到存储缓冲中。
- 在对变量C赋值时,CPU0发现是独占的,那么可以直接修改缓存的值,该缓存行的状态被切换成了“Modified”。注意,这个时候,如果在CPU1上执行了读取变量C的操作,其实已经可以读到变量C的最新值了,CPU1发送读消息,CPU0发送读响应消息,包含最新的数据,同时将缓存行的状态都切换成“Shared”。但是,如果这个时候如果CPU1尝试读取变量A或者变量B的数据,将会获得老的数据,因为CPU1上对应变量A和B的缓存行的状态仍然是“Shared”,而不是Invalid。
- CPU0开始处理对应变量A和B的存储缓冲,将它们更新进缓存,但之前必须要向CPU1发送使无效消息。这里再次假设变量A的缓存正忙,而变量B的可以立即处理。那么变量A的使无效消息将存放在CPU1的无效队列中,而变量B的缓存行已经失效。这时,如果CPU1尝试获得变量B,是可以获得最新的数据的,而变量A还是不行。
- CPU1对应变量A的缓存已经空闲了,可以处理当前无效队列的请求,因此变量A对应的缓存行将失效。直到这时CPU1才可以真正的读到变量A的最新值。
通过以上的步骤可以看到,虽然在CPU0上是先对变量A赋值,接着对B赋值,最后对C赋值,但是在CPU1上“看到”的顺序刚好是相反的,先“看到”C,接着“看到”B,最后看到“A”。在CPU1上会产生一种错觉,方式CPU0是先对C赋值,再对B赋值,最后对A赋值一样。
解决方式:
如果重排序带来一些问题,那么我们可以通过禁用重排序来解决。
对于编译器层面,可以通过volatile标记来解决;对于处理器硬件层面,则可以通过内存屏障来解决。
内存屏障/内存栅栏/内存栅障/屏障指令
屏障类型
Store: 将处理器缓存的数据刷新到内存中。
Load: 将内存存储的数据拷贝到处理器的缓存中。
有以下几种屏障类型:
- LoadLoad Barriers:该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作。
Load1;
LoadLoad;
Load2
- StoreStore Barriers:该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
Store1;
StoreStore;
Store2;
- LoadStore Barriers:确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作。
Load1;
LoadStore;
Store2;
- StoreLoad Barriers:该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令
Store1;
StoreLoad;
Load2;
StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。
x86架构的实现范例
内存屏障是一种标准,各厂商的实现方式和实现程度各不相同。下面介绍 x86指令集 中对内存屏障的实现。
-
StoreStore Barriers:通过sfence指令实现StoreStore Barriers。
- 强制所有在sfence指令之前的store指令,都在该sfence指令执行之前被执行;
- 在遇到sfence指令时,发送缓存失效信号,并把store buffer中的数据刷出到CPU的L1 Cache中;
- 所有在sfence指令之后的store指令,都在该sfence指令执行之后被执行。即,禁止对sfence指令前后store指令的重排序跨越sfence指令,使所有Store Barrier之前发生的内存更新都是可见的。
-
LoadLoad Barriers:lfence指令实现了LoadLoad Barriers。
- 强制所有在lfence指令之后的load指令,都在该lfence指令执行之后被执行;
- 当遇到lfence指令时,会等待队列中之前的load操作都执行完,才能执行之后的load指令。即,禁止对lfence指令前后load指令的重排序跨越lfence指令.
- 并且lfence指令之前的load操作读到的一定是最新值,这就需要配合使用sfence指令。即使所有Store Barrier之前发生的内存更新,对Load Barrier之后的load操作都是可见的。
-
StoreLoad Barriers:mfence指令实现了Full Barrier。
- mfence指令综合了sfence指令与lfence指令的作用,强制所有在mfence指令之前的store/load指令,都在该mfence指令执行之前被执行;
- 所有在mfence指令之后的store/load指令,都在该mfence指令执行之后被执行。即,禁止对mfence指令前后store/load指令的重排序跨越mfence指令,使所有Full Barrier之前发生的读写操作,对所有Full Barrier之后的读写操作都是可见的。
优化屏障(volatile)
在处理器层面,可以通过内存屏障来禁止重排序优化;而在编译器层面,则可以使用优化屏障来禁止重排序优化,即volatile标记。
如果硬件架构本身已经保证了内存可见性(如单核处理器、一致性足够的内存模型等),那么volatile就是一个空标记,不会插入相关语义的内存屏障。
如果不保证,以x86架构为例,JVM对volatile变量的处理如下:
- 在遇到写volatile标记的变量v时,在后面插入一个sfence指令。这样,sfence之前的所有store(包括写v)不会被重排序到sfence之后,sfence之后的所有store不会被重排序到sfence之前,禁用跨sfence的store重排序;且sfence之前修改的值都会被写回缓存,并标记其他CPU中的缓存失效。
- 在遇到读volatile标记的变量v时,在前面插入一个lfence指令。这样,lfence之后的load(包括读v)不会被重排序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存可见性。
编译器层面的优化屏障也是隐式的内存屏障。
参考文献
《深入理解计算机操作系统》9.6节
之一:X86段式内存管理与保护模式_stillvxx的博客-CSDN博客