剖析内存屏障
剖析内存屏障
理解乱序
乱序可以分为两个大类,即:软件和硬件
软件
- 编译器优化
- 编译时
- 目的通常是为了更有效率的使用寄存器
- 多线程时可见
硬件
- CPU乱序执行
- 运行时
- 目的通常是为了并行执行互相无干扰的指令,以节省时间
- 通常在多核系统中可见
这两种情况都会导致程序的执行顺序与程序员书写的顺序不一致,特别是多线程环境中,逻辑的正确性可能依赖于内存访问顺序
这是一个出错的例子:
1 | |
ok作为全局变量,被初始化为0,现在两线程并发,有可能ok的赋值被乱序到x = 42之前(之所以会出现这种原因,是由于编译器发现x和ok的赋值没有依赖关系,它并不知道另一个线程对其有依赖),那么,线程1就判断条件通过,执行do(x),他的实参x就不是42了,这种错误非常难以发现
经典示例
注意如下场景:
多线程环境 or 多进程共享内存时
一个线程/进程操作一个共享内容后,设置一个标志位,用以通知其余线程/进程
1 | |
在编译器和处理器中,由于flag和shared_variable两个变量没有依赖关系,因此有可能发生重排,也就是flag = 1在shared_variable = new_value之前发生,这就会导致,其他进程/线程以为shared_variable已经是新值了,但其实这个赋值还没有发生,此时导致故障
内存屏障
通常,内存屏障应用于并发无锁编程
理解了乱序之后,就需要想办法解决由此产生的问题
既然乱序有两种原因导致,那么内存屏障也应该有两种
- 编译器屏障
- CPU屏障
编译器屏障
编译器屏障是用来指挥编译器的,不会产生任何运行时的指令
它仅仅是用来告诉编译器,不要重排屏障前后的指令
经典的编译器屏障就是barrier
其实现可能就是单纯的这样
1 | |
这句代码在编译完成后,不会产生任何运行时指令,但是可以有效隔绝屏障前后的指令乱序
by the way, volatile关键字可以起到同样的作用
但仍有细微的差异:volatile会绑定该变量,这导致这个变量永远不能被寄存器缓存,每次访问该变量都会强制从内存中取值。而barrier只会影响该语句所在的上下文
那么,barrier本身是完美的么?
也不尽然
barrier会使当前上下文的所有寄存器缓存失效,这意味着所有的寄存器都需要从内存中更新内容,但是这可能是不必要的,有时我们可能只关心某一个寄存器中的内容
这就引出了ACCESS_ONCE!!!!!
1 | |
虽然这里仍使用了volatile,但却是在一个临时变量中,这意味着仅在调用ACCESS_ONCE的时候抑制编译器优化,其他时候访问变量仍会启用编译器优化
在GCC4.6和4.7版本中有一个bug,如果ACCESS_ONCE的参数是非标量(就是结构体,联合体,数组等),volatile标记会被编译器移除,例如通过ACCESS_ONCE访问pte_t类型变量会导致错误
标量类型:只包含单一数值的简单数据类型,包括整形,浮点,指针
向量类型:包含多个数值,数组,结构体,联合体
1 | |
由此,Linux引入了READ_ONCE和WRITE_ONCE
1 | |
核心思路是:将向量类型转化为标量类型,然后再使用ACCESS_ONCE的方式,WRITE_ONCE同理
CPU屏障
内存操作序
这是通常理解的内存屏障,在CPU运行时起作用,既然是内存乱序,针对内存的操作一共只有读和写两种操作。
将读操作记为Load,将写操作记为Store,那么内存重排的次序排列组合共有四种:
StoreStore:两次写入StoreLoad:先写入再加载(这个代价最大,一般实现为全屏障)LoadLoad:两次加载LoadStore:先加载再写入
其中,STORESTORE和STORELOAD两种重排涉及store buffer
LOADLOAD涉及invalidation queue
存储缓冲区
CPU0执行STORE操作时,需要通知其他CPU缓存失效,这是由MESI机制决定的
但是这会造成CPU0在发出Invalidate到收到其他CPU的Acknowledgement这段时间进行忙等,由于STORE操作本质上是异步的,这段忙等完全可以让CPU0用来做其他事情
因此硬件工程师们设计了一个存储缓冲区store buffer,CPU可以直接写入store buffer然后去做其他事情
这个store buffer的位置在CPU和L1 cache之间,只有STORE操作会往里面写入数据,CPU发送Invalidate指令,将新数据写入store buffer,然后CPU继续执行其他操作,store buffer则等待收到其他CPU的Acknowledgement后,将其中的内容放入缓存行
注意,MESI指令是针对缓存的,store buffer不是缓存,因此不受MESI影响,这会导致,在收到其他CPU的Acknowledgement回复之前,这个数据只在当前CPU的store buffer中存在
e.g: 查看如下代码
1 | |
假设CPU0执行上述代码:
CPU0执行a = 1,但是a不在CPU0的缓存行中,发生缓存未命中,CPU0发出MESI的read invalidate消息,CPU0将a的值1写入到CPU0的store buffer中CPU1收到read invalidate消息,回复CPU0读响应消息read response和应答消息Acknowledgement,读响应消息将CPU1的缓存中的a置为0CPU0执行b = a + 1,CPU0收到了CPU1的读响应read response消息,其中变量a的值被描述为0,因此,CPU0的缓存行中,变量a的值被更新为0CPU0执行b = a + 1,然后a的值为0,因此b的值为1- 断言失败
导致上述问题的关键在于,CPU0的存储缓冲区和缓存行中缓存了相同的变量,他们是无需同步的
由此需要改进存取缓冲区的机制,即:
CPU执行完存储STORE操作后,将值写入到store buffer,然后继续执行其他指令,当CPU在存储之后执行加载LOAD操作时,会先去store buffer里面查找,再去缓存行查找。如果store buffer里面有对应的数据,则加载操作直接使用store buffer中的数据,而不是缓存行中的数据。
StoreStore乱序
如上所述,store buffer可以加快CPU执行速度,然而,在SMP系统中,如果多个线程/进程在多个CPU上并行时,store buffer会导致STORESTORE乱序
e.g:
1 | |
全局变量a,b的初始值为0,最初,变量a仅在CPU1的缓存行中,变量b仅在CPU0的缓存行中,假定CPU0执行foo,CPU1执行bar,运行过程为:
CPU0执行a = 1,因为变量a不在CPU0的缓存行里面,因此CPU0将a的新值放在其存储缓冲区store buffer,并发出读无效read invalidate信息CPU1执行while(b == 0) continue这一句,但是CPU1没有变量b的缓存,因此发出读read信息CPU0执行b = 1,它已经拥有该缓存行(或者说,该缓存行已经处于modified或exclusive状态),因此CPU0将b的新值写入其缓存行中CPU0收到读取read消息,并将包含更新过后的b值的缓存行传输到CPU1,同时在其缓存行中将其标记为sharedCPU1接收到包含b的高速缓存行,并将其放在CPU1的高速缓存行中CPU1现在可以通过语句while(b == 0) continue;,因为b现在是1了CPU1现在执行断言assert(1 == a),发现a的值为0,断言失败CPU1收到读无效read invalidate消息,并将包含a的缓存行传输到CPU0,并从其缓存中使该缓存行无效,但为时已晚,断言已经执行过了CPU0接收包含a的缓存行,并用store buffer中a的新值修改,对于CPU0而言,缓存行中的a已经成为新的,Modified状态的值,CPU0无异常,但是成为CPU1断言失败的受害者
这导致了一个现象,从CPU1的角度看,CPU0中出现了内存重排序,然而在CPU0的视角中,它是正常执行的
1 | |
两条STORE指令被重排,因此称为STORESTORE
针对这个问题,就需要使用内存屏障
问题的关键在于:两条写指令,前一条不在当前核的缓存中,后一条在,那么a = 1时,a被放在store buffer中,b = 1由于在缓存中,可以直接执行,而不会收到来自其他CPU的invalidate acknowledge信息
引入store buffer后,多个CPU上并行运行的线程或进程的执行逻辑之间就有了联系,但是,编译器和硬件很难感受到这种逻辑联系,只有软件程序员知道,这就是内存屏障的用武之地!
内存屏障的指令由硬件实现,不同架构的CPU有不同的内存屏障指令,内存屏障指令需要程序员在代码中显式调用,如:
1 | |
这里的内存屏障smp_mb()用于实现STORESTORE同步,当smp_mb()被调用时,CPU首先对store buffer执行刷新操作。在刷新操作期间,需要接收到所有其他CPU的无效确认invalidate acknowledge消息后,store buffer中缓存的修改后的数据将被更新到缓存行,此时,当store buffer中的所有数据都更新到缓存行时,store buffer造成的影响消失,CPU可以在smp_mb()内存屏障之后继续执行存储操作,smp_mb()内存屏障之后的加载操作可以在不完成store buffer刷新操作的情况下进行。
当调用smp_mb()来刷新store buffer时,CPU可以等到store buffer中的数据被清除后再继续内存屏障之后的存储操作
这里是指,当刷新store buffer时,可以继续执行STORESTORE后面的存储操作,但是不能直接写入到缓存行里面,只能放在store buffer里面
在上述代码中,如果没有使用smp_mb()内存屏障,当CPU0执行b = 1时,CPU0可以直接将变量b的新值写入缓存行中,因为变量b在CPU0中是MESI的E或M状态,即缓存命中
现在加了smp_mb()内存屏障,当CPU0执行b = 1时,即使发生缓存命中,变量b的新值也无法写入缓存行,因为此时正在执行store buffer的刷新操作,相反,变量b的新值必须写入store buffer中,然后,变量b的新值可以在store buffer刷新的过程中更新的缓存行中
在使用了smp_mb()内存屏障后,上述代码的执行流程变为:
CPU0执行a = 1,该变量a发生缓存未命中,因此CPU0将新值a放入其store buffer中,并传输read invalidate消息CPU1执行while (b == 0) continue;,但是变量b发生缓存未命中,CPU1发出read消息CPU0执行smp_mb(),并将当前缓冲区条目a = 1进行标记CPU0执行b = 1,发生缓存命中,但是store buffer中有一个标记条目,因此,CPU0将变量b的新值放在store buffer中(但是放在未标记的条目中)CPU0接收到read消息,并将包含b的原始值的缓存行传输到CPU1,并且将该行缓存行标记为sharedCPU1接收包含b的高速缓存行,并将其安装到其高速缓存中CPU1执行while (b == 0) continue;,并且变量b在其缓存行中,但是这仍然是旧值,即b = 0,其新值仍在CPU0的store buffer中,于是重复while语句CPU1接收到读无效read invalidate消息,并将包含a的缓存行传输到CPU0,并使该行标记为无效InvalidateCPU0接收包含a的缓存行并且用store buffer中a的新值修改,将该行置于Modified状态- 在
CPU0中,由于a = 1是store buffer中的唯一被标记的条目,CPU0的store buffer还可以存储b的新值,此时,CPU0的有变量b的缓存行仍然还是被标记为shared状态的 - 因此,
CPU0向CPU1发送invalidate消息 CPU1收到invalidate消息,并将b对应的缓存行置为Invalidate,同时向CPU0发送应答Acknowledge消息CPU1执行while (b == 0) continue;,但是缓存行中的b是invalidate状态,因此CPU1发送读取b的消息readCPU0收到确认Acknowledge消息,并将包含b的缓存行置为独占Exclusive状态,CPU0将b的新值存储到缓存行中CPU0接收到读取消息,并将包含新值b的缓存行传输到CPU1,并将自己的该行缓存行标记为sharedCPU1接收到包含b的缓存行并将其安装到自己的缓存行中CPU1现在可以完成while循环的执行,然后执行assert语句,由于a不在其缓存行中,它会发起读read消息,获取到CPU0中的a变量,两个CPU都将该变量置为shared,并且该变量值为1,通过断言
StoreLoad乱序
查看一个示例:
1 | |
内存A和B的初始值都是0,CPU0执行A=1,CPU1执行B=1,但是两个CPU读取的结果都有可能是0
假设变量A最初只在CPU1的缓存行中,变量B只在CPU0的缓存行中,有以下过程:
- 当
CPU0执行A = 1时,发生缓存未命中,因此,A的新值被保留在store buffer中,并向CPU1发送一条无效invalidate消息 - 然后,
CPU0继续执行READ(B),发生缓存命中,B值为0 - 当
CPU1执行B = 1时,发生缓存未命中,因此,B的新值被放置在store buffer中,CPU1发出invalidate消息 - 然后,
CPU1继续执行READ(A),缓存命中,A值为0 - 然后,
CPU1从CPU0接收到关于变量A的invalidate消息,并将缓存行中的变量A的值更新为1 - 然后,
CPU0从CPU1接收到关于变量B的invalidate消息,并将缓存行中的变量B的值更新为1
这种重排称为StoreLoad重排,解决方案是在Store和Load操作中间加入StoreLoad内存屏障,当遇到内存屏障时,store buffer会被刷新到缓存行中,此时,CPU必须等待store buffer中的数据刷新完成,然后才能访问内存屏障之后的访存操作
invalidation queue
store buffer的容量一般比较小,在这两种情况下,其很快就会被填满:
- 当
CPU执行STORE操作时发生缓存未命中时,当前STORE操作修改的数据必须放在store buffer里面,然后才能继续执行后续指令。当CPU连续执行多个STORE操作期间发生缓存未命中时,store buffer会很快填满,此时,CPU必须等待其他CPU的无效确认消息,之后CPU就可以将store buffer里面的内容更新到缓存行中,这是store buffer中有可用空间的唯一办法 - 当
CPU调用内存屏障操作时,内存屏障之后的STORE操作必须在store buffer中,这种情况下,无论相应的缓存行是否准备好,store buffer都有可能很快被填满,CPU必须等待其他CPU的无效确认消息
当store buffer被填满时,CPU必须等待其他CPU的无效确认消息,使用invalidation queue可以减少等待时间
有时,CPU会延迟发送无效确认消息,因为CPU在发送无效确认invalidate acknowledge消息之前必须对相应缓存行执行无效invalidate操作,但是,当CPU的LOAD/STORE操作比较重时,CPU通常会将接收到的无效消息搁置,当负载变轻时,再处理无效消息并回复无效确认消息
使用了无效队列invalidation queue后,CPU可以将收到的无效消息缓存到invalidation queue中,并立即发送无效确认消息,之后,CPU在发送invalidate消息时,必须检查invalidation queue中是否存在对应的invalidate消息
LoadLoad乱序
invalidation queue队列机制会导致LoadLoad乱序
示例代码如下:
1 | |
全局变量a和b都初始为0,a同时缓存在CPU0和CPU1里面(即变量a状态为shared),变量b仅存放在CPU0的缓存行中
假设CPU0运行foo函数,CPU1运行bar函数,运行过程是:
CPU0执行a = 1,相应的高速缓存行在CPU0中是shared的,因此CPU0将新值a放入store buffer中,并发送invalidate消息到其他CPU,使CPU1中的变量a的状态变为Invalidation(CPU0中的a为Modified了)CPU1执行while语句,但是变量b发生缓存未命中,因此其发送read消息CPU1收到了CPU0的invalidate消息,将其放入invalidation queue,并立即发送invalidate acknowledge消息给CPU0CPU0收到了CPU1的invalidate acknowledge消息,就可以将a从store buffer中放入缓存行CPU0执行b = 1,发生缓存命中,因此可以直接将b的新值放在其缓存行中CPU0接到读取消息,并将包含现在的新b值发送给CPU1,同时在缓存行中标记其为sharedCPU1接收到包含b的数据,并将其放在缓存行中CPU1现在可以完成while语句了,因为b现在是1,于是继续执行下一个指令CPU1执行断言失败,由于a的旧值0仍在CPU1的高速缓存行中- 尽管断言失败,
CPU1仍会处理排队中的invalidate消息,并(迟缓的,于事无补的)使高速缓存行中的a状态变为Invalidation(但是没啥卵用,错误已经发生了)
使用内存屏障可以解决这个问题
CPU1在执行LOAD操作来访问变量a的值之前,需要检查invalidation queue中是否存在要处理变量a的无效消息(a在屏障之后),即:
1 | |
因此,当CPU的两次LOAD操作之间调用LoadLoad屏障时,LoadLoad屏障会标记invalidations queue中待处理的invalidate消息对应的缓存行,当LoadLoad屏障之后的加载操作对应的缓存行被标记后,就会等待对应的pending invalidate消息处理完毕后,才会执行LOAD操作
LoadLoad屏障将等待invalidation queue的所有待处理的invalidate消息被处理完成后,再执行屏障后的加载操作,代码流程为:
CPU0执行a = 1,缓存命中,但是由于a是shared状态,因此需要写入store buffer,发出invalidate消息到CPU1CPU1执行while语句,但是b发生缓存未命中,发出read消息CPU1收到CPU0的invalidate消息,将其入队invalidate queue,并立即发送invalidate acknowledge消息CPU0收到来自CPU1的响应,于是可以通过内存屏障,将a的值写入缓存,执行b = 1语句,发生缓存命中,将b的新值放入缓存行中CPU0收到read消息,并将包含b新值的缓存行发送到CPU1,并将该缓存行标记为sharedCPU1收到b的新值,将其放入缓存行中CPU1现在可以通过while语句,并遇到了内存屏障CPU1必须停止运行,直到处理完其invalidate queue中的invalidate消息CPU1使a的缓存行无效(即处理了来自CPU0的,变量a的invalidate消息)CPU1执行assert语句,a发生缓存未命中,并向CPU0发起read消息CPU0使用包含新值a的缓存行进行回复此read消息CPU1收到此缓存行,其中a的值为1,通过断言
LoadStore乱序
这是一个单纯的内存重排,LOAD操作如果发生缓存未命中,则需要从主存/其他CPU中获取,这是一个比较漫长的过程,CPU可能在这个等待的过程中继续执行下一条STORE指令
CPU内存屏障
如上所述,CPU乱序的解决方案是使用内存屏障
内存屏障共有三类:
- 全屏障:限制语义最强,但是性能最差,应对所有乱序情况
- 读屏障:保证读屏障之前的读操作一定在读屏障之后的读操作前完成,应对
LoadLoad乱序 - 写屏障:保证写屏障之前的写操作一定在写屏障之后的写操作前完成,应对
StoreStore乱序
内存屏障在不同平台上的实现
x86架构中的内存屏障
在x86架构中,内存屏障由以下指令实现:
- MFENCE:全屏障,确保读写操作的顺序。
- LFENCE:读屏障,确保读操作的顺序。
- SFENCE:写屏障,确保写操作的顺序。
x86架构中的内存模型通常较为严格,因此在大多数情况下,编写多线程代码时不需要显式使用这些屏障指令。但是在涉及高性能、精确控制的场合,内存屏障仍然是必不可少的。
ARM架构中的内存屏障
ARM架构的内存模型相对宽松,因此在多核系统中更加依赖于内存屏障:
- DMB(Data Memory Barrier):确保内存访问的顺序。
- DSB(Data Synchronization Barrier):确保所有内存访问完成,并且后续指令在屏障前的指令完成后再执行。
- ISB(Instruction Synchronization Barrier):确保处理器重新获取指令,并在后续指令执行前更新状态。
在ARM系统中,由于处理器和内存之间的操作可能会重排,因此内存屏障在保证操作顺序和数据一致性方面尤为重要。
内存模型
SC模型(sequential consistency, 顺序一致性)
CPU会按照程序中的顺序依次执行STORE和LOAD
最严格的内存模型
TSO模型(Total Store Order, 完全存储顺序)
x86使用这种模型
TSO模型允许store-load乱序
store buffer会按照FIFO的顺序将数据写入到L1 cache
PSO模型(Part Store Order, 部分存储顺序)
PSO模型,store buffer不再按照FIFO顺序写入L1 cache
PSO模型允许store-load, store-store乱序
RMO模型(Relaxed Memory Order,弱内存模型)
aarch64架构采用此种内存模型
RMO对四种操作皆可乱序
缓存一致性
对于每个CPU核心而言,都有其自己独立的L1 cache
同一个簇内的CPU核心,有它们簇内共享的L2 cache
所有CPU核心,共享L3 cache
CPU在执行内存读写操作时,首先会去自己的L1 cache查找缓存,如果没有,依次向L2 cache,L3 cache查找,如果都没有找到,再去主存中查找
那么,如果CPU0从主存中读取了变量a,另一个线程在CPU1上也读取了这个变量a,此时就会出现竞态问题,如果CPU0修改了变量a,而CPU1上的另一个线程需要这个修改后的变量a来执行一些操作,这就需要在缓存行之间有一些同步的协议,也就是MESI
MESI协议(Modified, Exclusive, Shared, Invalid)是多处理器系统中一种广泛使用的缓存一致性协议。它通过维护缓存行的状态来确保缓存的一致性,避免上述的数据不一致问题。
MESI协议的四种状态
- M(Modified):缓存行已被当前核心修改,且与主内存中的数据不一致。只有当前核心持有该缓存行。
- E(Exclusive):缓存行仅存在于当前核心的缓存中,但数据与主内存一致。
- S(Shared):缓存行可能存在于多个核心的缓存中,且数据与主内存一致。
- I(Invalid):缓存行无效,数据与主内存不一致或不再被使用。
MESI协议的核心思想是通过状态转换来确保各个核心的缓存数据一致。当一个核心对某个缓存行进行修改时,其他核心中对应的缓存行状态将变为无效(Invalid)。当一个核心需要访问一个已失效的缓存行时,它会从主内存或其他核心的缓存中获取最新数据,并更新其缓存状态。
有限状态自动机的状态转换结束两种场景:缓存所在处理器的读写;其他处理器的读写。总线请求被[总线窥探器]监视。
处理器对缓存的请求:
PrRd: 处理器请求读一个缓存块PrWr: 处理器请求写一个缓存块
总线对缓存的请求:
BusRd: 窥探器请求指出其他处理器请求读一个缓存块BusRdX: 窥探器请求指出其他处理器请求写一个该处理器不拥有的缓存块BusUpgr: 窥探器请求指出其他处理器请求写一个该处理器拥有的缓存块Flush: 窥探器请求指出请求回写整个缓存到主存FlushOpt: 窥探器请求指出整个缓存块被发到总线以发送给另外一个处理器(缓存到缓存的复制)
总结
内存屏障与缓存一致性协议是互补的
- MESI协议确保当一个核心修改了某个缓存行时,其他核心能够及时感知到这一变化,并更新或失效它们的缓存行,从而保证数据一致性。
- 内存屏障确保共享数据的更新顺序不会被编译器或处理器的重排机制打乱,从而防止数据竞态和不一致问题。
例子:考虑一个典型的生产者-消费者问题。生产者线程会将数据写入缓冲区,然后设置一个标志位通知消费者线程数据已准备好。消费者线程则等待标志位的变化,并读取缓冲区中的数据。
- 生产者线程在写入缓冲区数据后,需要插入一个写屏障,以确保缓冲区数据在标志位更新前被完全写入。
- 消费者线程在读取标志位前,需要插入一个读屏障,以确保它看到的标志位变化对应的是最新的缓冲区数据。
MESI协议会确保生产者线程对缓冲区的修改在消费者线程中可见,而内存屏障则确保消费者读取到的缓冲区数据和标志位状态是一致的。
reference
1 | |
1 | |