在linux抢占计数-preempt count中,抢占除了抢占计数外,还涉及到一个概念:内存屏障,不仅如此,我们常见的RCU锁中,也涉及到内存屏障,为此,需要学习下何为内存屏障,且会对代码运行产生什么效果?
一、抢占中的内存屏障
对于下面的伪代码,会发生什么?
preempt_count_inc()
/* 临界区 */
do_something()
preempt_count_dec()
以我目前的认知,当out-of-order的CPU流水线对上述代码汇编重新编排,是不是有可能导致临界区代码先于抢占执行,这样肯定达不到关闭抢占的目的。
preempt_count_inc()
barrier()
/* 临界区 */
do_something()
barrier()
preempt_count_dec()
那如果加入内存屏障的话,可以保障功能正确,从直观角度来说,内存屏障的作用就是保障该指令前的操作必须先于该指令后的操作完成。防止指令重新编排,严格按照代码顺序执行。
#define preempt_disable() \
do { \
preempt_count_inc(); \
barrier(); \
} while (0)
编译期内存乱序
当使用gcc对代码做出优化时,有可能会指令重排,造成内存访问乱序,以如下代码为例:
int a = b = 0;
//thread1
void fun1()
{
a = 1;
b = 1;
}
//thread2
void fun2()
{
do{ }while(!b);
assert(1==a);
}
未做优化,gcc -S汇编如下:
fun1():
push rbp
mov rbp, rsp
mov DWORD PTR a[rip], 1 # a = 1
mov DWORD PTR b[rip], 1 # b = 1
nop
pop rbp
ret
fun2():
push rbp
mov rbp, rsp
.L3:
mov eax, DWORD PTR b[rip] # read var b
test eax, eax # test b ?= 0
je .L3 # loop L3
mov eax, DWORD PTR a[rip] # read var a
cmp eax, 1 # compare a ?= 1
je .L5 # return
mov ecx, OFFSET FLAT:.LC0
mov edx, 16
mov esi, OFFSET FLAT:.LC1
mov edi, OFFSET FLAT:.LC2
call __assert_fail # assert fail
.L5:
nop
pop rbp
ret
使用O2优化,gcc -O2 -S:
fun1():
mov DWORD PTR a[rip], 1
mov DWORD PTR b[rip], 1
ret
fun2():
cmp DWORD PTR a[rip], 1
jne .L8
ret
.L8:
push rax
mov ecx, OFFSET FLAT:.LC0
mov edx, 16
mov esi, OFFSET FLAT:.LC1
mov edi, OFFSET FLAT:.LC2
call __assert_fail
当使用O2优化代码后,可以看到fun2的汇编直接执行了断言操作,并未对变量b做判断处理,未按照代码逻辑严格执行,这种情况下,有可能会造成断言失败。
我们可以使用volatile关键字对变量a、b做出限制,使编译器每次从内存中重新读取,而非取值寄存器,避免内存访问乱序。
volatile int a = 0;
volatile int b = 0;
此时使用O2优化,gcc -O2 -S,可以看到指令严格按照代码逻辑执行。
fun2():
.L4:
mov eax, DWORD PTR b[rip] # read var b
test eax, eax # test b ?= 0
je .L4 # loop L3
mov eax, DWORD PTR a[rip] # read var a
cmp eax, 1 # compare a ?= 1
jne .L11 # assert fail
ret
.L11:
push rax
mov ecx, OFFSET FLAT:.LC0
mov edx, 16
mov esi, OFFSET FLAT:.LC1
mov edi, OFFSET FLAT:.LC2
call __assert_fail
内核对避免编译器指令重排的源码如下,用过"memory"告知gcc,其修改了memory,后续对memory的访问不能依赖寄存器的值,需要重新从内存取值,其效果同volatile相同。
#define barrier() __asm__ __volatile__("": : :"memory")
运行期内存乱序
除了编译器内存乱序,还存在运行期内存乱序,这主要是由于多CPU互相访问内存引起。在SMP系统中,存在多级Cache,当在cpu0上对foo更改时,为了保障CPU间数据的一致性,cpu0会通过MESI协议同其余cpu通信,这个过程必然涉及对内存的并发访问,有可能会出现访问乱序。而在UP系统,对内存的访问都是串行化执行,只要编译器的内存乱序可以保障,硬件就可以保障运行期的指令严格按照代码逻辑执行,也从侧面说明,barrier()只能防止编译器指令重排,而无法避免运行期指令重排。
内核对避免运行期指令重排的源码如下,摘取x86的实现:
/*
* Force strict CPU ordering.
* And yes, this might be required on UP too when we're talking
* to devices.
*/
#ifdef CONFIG_X86_32
#define mb() asm volatile(ALTERNATIVE("lock; addl $0,0(%%esp)", "mfence", \
X86_FEATURE_XMM2) ::: "memory", "cc")
#define rmb() asm volatile(ALTERNATIVE("lock; addl $0,0(%%esp)", "lfence", \
X86_FEATURE_XMM2) ::: "memory", "cc")
#define wmb() asm volatile(ALTERNATIVE("lock; addl $0,0(%%esp)", "sfence", \
X86_FEATURE_XMM2) ::: "memory", "cc")
#else
#define mb() asm volatile("mfence":::"memory")
#define rmb() asm volatile("lfence":::"memory")
#define wmb() asm volatile("sfence" ::: "memory")
#endif
x86一共提供三种类型的内存屏障,其作用就是严格保障CPU顺序执行。
- mfence:兼具lfence、sfence
- lfence(load fence):读屏障,读操作前插入该指令,使高速缓存中的数据无效,重新从memory中读取数据,可以保障该指令前的读操作先于指令后的写操作完成,与Cache架构的Invalidate Queue相关
- sfence(store fence):写屏障,写操作后插入该指令,使缓存中的数据写回到memory。可以保障该指令前的写操作先于指令后的写操作完成,与Cache架构的Store Buffer相关
二、写内存屏障
当代SMP系统均为多级Cache,其主要目的是协调内存与CPU之间的读写性能差异。为了保障多核心间的数据一致性,需要MESI协议,对于高速的数据读写,各核心之间频繁的协议通信,本身就是一项十分耗时的工作。试想,当CPU执行foo=1,且当前CPU的cacheline未缓存foo,此时会发生cache miss,根据MESI协议,该CPU需要向其余CPU发送read invalid消息,该CPU需要等待其余CPU置对应的cacheline invalid,并回复read response及invalid ack。但是这种等到对我们无意义,因为最终还是要修改foo,为了提升效率,因此,有了Cache的第一次改进:
相较与早期Cache,在L1、L2级Cache间增加了Store Buffer,如此一来,对于foo=1这样的写操作,可以将其先保存在Store Buffer中,无需等待其他CPU。但是根据谢宝友老师的文章,还是存在读取脏数据的风险,参考上述代码:
对于全局变量a、b,若此时cpu0缓存变量b,其执行fun1代码段,cpu1缓存变量a,其执行fun2代码段,可能存在如下执行顺序:
// CPU1 // CPU2
mov DWORD PTR a[rip], 1 # a = 1 .L3:
mov eax, DWORD PTR b[rip] #read b
test eax, eax #compare b ?= 0
sete al
test al, al
jne .L3 # loop L3
_______________________________________________________________________________________________________
mov DWORD PTR b[rip], 1 # b = 1
_______________________________________________________________________________________________________
mov eax, DWORD PTR a[rip] # read a
cmp eax, 1 # compare a ?= 1
.......
call __assert_fail # assert fail
这种时序下,必然导致断言失败,可以看如下时序图:
时序1
具体如下:
- cpu0第一次读取a时,由于cacheline没有缓存a,会触发cache miss,并发出‘read invalid(使读无效)’消息,同时将a=1缓存在store buffer中
- cpu1读取b,同样由于cacheline没有缓存b,触发cache miss,并发出‘read b’消息,loop等待
- cpu0执行b=1,直接命中cacheline,更新后,变更状态为M
- cpu0响应‘read b’消息,并发送新值到cpu1,更改状态为S
- cpu1将新值写入其缓存,在下一次loop时,退出循环
- cpu1执行断言操作,读取a值,由于此时cacheline状态必然为M或E状态,所以直接从缓存中读取a=0,断言失败
- cpu1响应‘read invalid’消息,将所在cacheline置为状态I,但已经晚了
- cpu0响应‘invalid ack’消息,将store buffer中消息写入memory
分析以上时序,根源在于cpu1响应cpu0的‘read invalid’消息太晚,导致cpu1的cache未更新,只要能保障write buffer to memory先于read a操作发生,就可以避免这种乱序,我们可以采用写内存屏障mb()、wmb()做到这一点。
int a = b = 0;
//thread1
void fun1()
{
a = 1;
wmb();
//mb()
b = 1;
}
//thread2
void fun2()
{
do{ }while(!b);
assert(1==a);
}
在这种情况下,b=1必须等待store buffer中数据清空,才可以继续执行,store buffer清空意味着a值已经写回到memory,可以保障后续的断言成功。
三、读内存屏障
还需注意一点,cpu的store buffer容量都较小,意味着很快会被填满,此时后续的指令必须等待Cache刷新完成,等待store buffer清空,才可继续。由于需要在多个cpu之间进行MESI消息,必定是一个耗时操作,所以有了第二次改进:
相较与前期Cache,L2级Cache与Memory间增加了Invalid Queue,如此一来,对于类似foo=1的频繁写操作,cpu1可以将收到的‘read invalid’消息暂存于Invalid Queue,并即刻发送‘invalid ack’消息,无需真正的将cacheline无效,避免由于cpu忙而导致无暇顾及cacheline无效,影响其余cpu。仍以上述代码为例:
int a = b = 0;
//thread1
void fun1()
{
a = 1;
wmb();
//mb()
b = 1;
}
//thread2
void fun2()
{
do{ }while(!b);
assert(1==a);
}
由前述分析,并结合Invalid Queue,其汇编执行可能如下:
// CPU1 // CPU2
mov DWORD PTR a[rip], 1 # a = 1 .L3:
mov eax, DWORD PTR b[rip] #read b
test eax, eax #compare b ?= 0
sete al
test al, al
jne .L3 # loop L3
_______________________________________________________________________________________________________
mov DWORD PTR b[rip], 1 # b = 1
_______________________________________________________________________________________________________
mov eax, DWORD PTR a[rip] # read a
cmp eax, 1 # compare a ?= 1
.......
call __assert_fail # assert fail
这种情况,也会导致断言失败,可以看如下时序图:
具体如下:
- cpu0第一次读取a时,由于cacheline没有缓存a,会触发cache miss,并发出‘read invalid(使读无效)’消息,同时将a=1缓存在store buffer中
- cpu1响应‘read invalid’消息,将该消息缓存在Invalid Queue
- cpu0响应‘invalid ack’消息,将store buffer中消息写入memory,状态可能是E或者M
- cpu1读取b,同样由于cacheline没有缓存b,触发cache miss,并发出‘read b’消息,loop等待
- cpu0执行b=1,直接命中cacheline,更新后,变更状态为M
- cpu0响应‘read b’消息,并发送新值到cpu1,更改状态为S
- cpu1将新值写入其缓存,在下一次loop时,退出循环
- cpu1执行断言操作,读取a值,由于此时cacheline状态不是I状态,必然为M或E状态,所以直接从缓存中读取a=0,断言失败
分析以上时序,根源在于cpu1虽然响应cpu0的‘read invalid’消息,但是并未真正将cacheline置为无效状态,导致cpu1的cache未更新,只要能保障Invalid Queue中的条目都刷新到Cache,就可以避免这种乱序,我们可以采用读内存屏障mb()、rmb()做到这一点。
int a = b = 0;
//thread1
void fun1()
{
a = 1;
wmb(); // 写内存屏障
//mb();
b = 1;
}
//thread2
void fun2()
{
do{ }while(!b);
rmb(); // 读内存屏障
// mb();
assert(1==a);
}
在这种情况下,assert(1==a)必须等待Invalid Queue中的条目刷新Cache,才可以继续执行,此时,cacheline状态已是无效,必然从内存中去读新值,可以保障后续的断言成功。
四、关于smp的release与acquire
smp_load_acquire
#define smp_load_acquire(p) __smp_load_acquire(p)
#define READ_ONCE(var) (*((volatile typeof(var) *)(&(var))))
#define __smp_load_acquire(p) \
({ \
typeof(*p) ___p1 = READ_ONCE(*p); \
compiletime_assert_atomic_type(*p); \
__smp_mb(); \
___p1; \
})
smp_store_release
#define smp_store_release(p, v) __smp_store_release(p, v)
#define __smp_store_release(p, v) \
do { \
compiletime_assert_atomic_type(*p); \
__smp_mb(); \
WRITE_ONCE(*p, v); \
} while (0)
从源码来看,smp_load_acquire(p)用于获取变量p地址,自然是用于后续的内存操作,smp_store_release(p, v)用于更新p值为v,既然涉及内存屏障,自然需要考虑到指令重排,我们以Lockless Programming这篇文章中的例子理解下这两个接口。
假如多线程访问变量data及标记flag,当写端线程thread1更新了data后,设置flag告知读端线程thread数据已更新,即如下伪代码:
update_data();
set_flag();
如果发生指令重排,即
set_flag();
update_data();
此时其他thread可能在先于data更新前读取flag,必然数据错误,此时可以在data与flag间添加内存屏障保障order,即:
update_data();
smp_mb();
set_flag();
即接口smp_store_release实现的功能
update_data();
smp_store_release(flag,1);
对于读端线程,需要通过读取flag标记决定是否更新data,即:
if( read_flag()){
update_data();
}
若发生指令重排,
update_data();
if( read_flag()){
//
}
虽然逻辑上不可能,但是在CPU指令运行期确实是可能发生的,此时读取的数据也是不对的,此时可以在data与flag间添加内存屏障保障order,即:
int flag = read_flag();
smp_mb();
if(flag){
update_data();
}
即接口smp_load_acquire实现的功能
int *flag = smp_load_acquire(&flag);
if(*flag){
update_data();
}
五、总结
- store buffer:为了解决写更新操作时的无效等待
- invalid queue:为了解决store buffer容量小,而导致的频繁刷新cache
参考:
谢宝友:深入理解Linux RCU:从硬件说起之内存屏障
理解Memory Barrier(内存屏障)
Lockless Programming