剖析内存屏障
剖析内存屏障
理解乱序
乱序可以分为两个大类,即:软件和硬件
软件
- 编译器优化
- 编译时
- 目的通常是为了更有效率的使用寄存器
- 多线程时可见
硬件
- 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
的值被更新为0
CPU0
执行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
,同时在其缓存行中将其标记为shared
CPU1
接收到包含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
,并且将该行缓存行标记为shared
CPU1
接收包含b
的高速缓存行,并将其安装到其高速缓存中CPU1
执行while (b == 0) continue;
,并且变量b
在其缓存行中,但是这仍然是旧值,即b = 0
,其新值仍在CPU0
的store buffer
中,于是重复while
语句CPU1
接收到读无效read invalidate
消息,并将包含a
的缓存行传输到CPU0
,并使该行标记为无效Invalidate
CPU0
接收包含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
的消息read
CPU0
收到确认Acknowledge
消息,并将包含b
的缓存行置为独占Exclusive
状态,CPU0
将b
的新值存储到缓存行中CPU0
接收到读取消息,并将包含新值b
的缓存行传输到CPU1
,并将自己的该行缓存行标记为shared
CPU1
接收到包含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
消息给CPU0
CPU0
收到了CPU1
的invalidate acknowledge
消息,就可以将a
从store buffer
中放入缓存行CPU0
执行b = 1
,发生缓存命中,因此可以直接将b
的新值放在其缓存行中CPU0
接到读取消息,并将包含现在的新b
值发送给CPU1
,同时在缓存行中标记其为shared
CPU1
接收到包含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
消息到CPU1
CPU1
执行while
语句,但是b
发生缓存未命中,发出read
消息CPU1
收到CPU0
的invalidate
消息,将其入队invalidate queue
,并立即发送invalidate acknowledge
消息CPU0
收到来自CPU1
的响应,于是可以通过内存屏障,将a
的值写入缓存,执行b = 1
语句,发生缓存命中,将b
的新值放入缓存行中CPU0
收到read
消息,并将包含b
新值的缓存行发送到CPU1
,并将该缓存行标记为shared
CPU1
收到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 |
|