-
why? 为什么要有内存屏障,内存屏障主要解决什么问题
-
What? 内存屏障都有哪些
-
How? 内存屏障如何使用
本篇文章主要解决前两个问题
一、为什么要有内存屏障
我们都知道计算机运算任务需要CPU和内存相互配合共同完成,其中CPU负责逻辑计算,内存负责数据存储。CPU要与内存进行交互,如读取运算数据、存储运算结果等。
由于内存和CPU的计算速度有几个数量级的差距,为了提高CPU的利用率,现代处理器结构都加入了一层读写速度尽可能接近CPU运算速度的高速缓存来作为内存与CPU之间的缓冲。
将运算需要使用的数据复制到缓存中,让CPU运算可以快速进行,计算结束后再将计算结果从缓存同步到主内存中,这样处理器就无须等待缓慢的内存读写了。就像下面这样。
每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取,但是这样的弊端也很明显:不能实时的和内存发生信息交换,会使得不同CPU执行的不同线程对同一个变量的缓存值不同 。
总结来说,现在大多数现代计算机为了提高性能而采取乱序执行,乱序执行可能会导致程序运行不符合我们预期,这使得内存屏障成为必须。
二、乱序访问
乱序访问分为两类,一类是编译时内存乱序访问,一类是运行时乱序访问。
2.1 编译时内存乱序访问
编译器对代码做出优化时,可能改变实际执行指令的顺序(例如g++下O2或者O3都会改变实际执行指令的顺序),看一个例子
int x, y, r;
void f()
{
x = r;
y = 1;
}
首先直接编译次源文件:g++ -S test.cpp。我们得到相关的汇编代码如下:
movl r(%rip), %eax
movl %eax, x(%rip)
movl $1, y(%rip)
这里我们可以看到,x = r和y = 1并没有乱序执行。现使用优化选项O2(或O3)编译上面的代码(g++ -O2 –S test.cpp),生成汇编代码如下:
movl r(%rip), %eax
movl $1, y(%rip)
movl %eax, x(%rip)
我们可以清楚地看到经过编译器优化之后,movl $1, y(%rip)先于movl %eax, x(%rip)执行,这意味着,编译器优化导致了内存乱序访问。
避免此行为的办法就是使用编译器屏障(又叫优化屏障)。 Linux内核提供了函数barrier(),用于让编译器保证其之前的内存访问先于其之后的内存访问完成。
#define barrier() __asm__ __volatile__("": : :"memory")
现在把此编译器barrier加入代码中:再编译,就会发现内存乱序访问已经不存在了。
int x, y, r;
void f()
{
x = r;
__asm__ __volatile__("": : :"memory")
y = 1;
}
2.2 运行时内存乱序访问
运行时,CPU本身是会乱序执行指令的。 早期的处理器为有序处理器(in-order processors),总是按开发者编写的顺序执行指令, 如果指令的输入操作对象(input operands)不可用(通常由于需要从内存中获取), 那么处理器不会转而执行那些输入操作对象可用的指令,而是等待当前输入操作对象可用。
相比之下,乱序处理器(out-of-order processors)会先处理那些有可用输入操作对象的指令(而非顺序执行) 从而避免了等待,提高了效率。
现代计算机上,处理器运行的速度比内存快很多, 有序处理器花在等待可用数据的时间里已可处理大量指令了。即便现代处理器会乱序执行,但在单个CPU上,指令能通过指令队列顺序获取并执行,结果利用队列顺序返回寄存器堆,这使得程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的, 因此内存屏障是没有必要使用的(前提是不考虑编译器优化的情况下)。
SMP架构需要内存屏障的进一步解释:
从体系结构上来看,首先在SMP架构下,每个CPU与内存之间,都配有自己的高速缓存(Cache),以减少访问内存时的冲突
采用高速缓存的写操作有两种模式:
-
穿透(Write through)模式,每次写时,都直接将数据写回内存中,效率相对较低;
-
回写(Write back)模式,写的时候先写回告诉缓存,然后由高速缓存的硬件再周转复用缓冲线(Cache Line)时自动将数据写回内存,或者由软件主动地“冲刷”有关的缓冲线(Cache Line)
出于性能的考虑,系统往往采用的是模式2来完成数据写入。 正是由于存在高速缓存这一层,正是由于采用了Write back模式的数据写入,才导致在SMP架构下,对高速缓存的运用可能改变对内存操作的顺序。
以上面的一个简短代码为例:
// thread 0 -- 在CPU0上运行
x = 42;
ok = 1;
// thread 1 – 在CPU1上运行
while(!ok);
print(x);
这里CPU1执行时, x一定是打印出42吗?让我们来看看以下图为例的说明:
-
假设,正好CPU0的高速缓存中有x,此时CPU0仅仅是将x=42写入到了高速缓存中
-
另外一个ok也在高速缓存中,但由于周转复用高速缓冲线(Cache Line)而导致将ok=1刷回到内存中
-
此时CPU1首先执行对ok内存的读取操作,他读到了ok为1的结果,进而跳出循环,读取x的内容.
-
而此时,由于实际写入的x(42)还只在CPU0的高速缓存中,导致CPU1读到的数据为x(17)。
三、内存屏障分类
3.1 编译屏障
编译屏障只是告诉编译器,不要对当前代码进行过度的优化,保证生成的汇编代码的次序与当前高级语言的次序保持一致。编译屏障对CPU执行时产生的重排序没有任何作用。
3.2 写内存屏障
一个写内存屏障可以提供这样的保证,站在系统中的其它组件的角度来看,在屏障之前的写操作看起来将在屏障后的写操作之前发生。
如果映射到上面的例子来说,首先,写内存屏障会对处理器指令重排序做出一些限制,也就是在写内存屏障之前的写入指令一定不会被重排序到写内存屏障之后的写入指令之后。其次,在执行写内存屏障之后的写入指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,将它们全部“提交”到内存中。这样的话系统中的其它组件(包括别的CPU),就可以保证在看到写内存屏障之后的写入数据之前先看到写内存屏障之前的写入数据。
写内存屏障仅仅限制了CPU对写操作的排序,对加载操作没有任何效果,对其它的指令也没有作用。而且,写内存屏障只是保证在写内存屏障之后的写入操作一定是在写内存屏障之前的写入操作之后被系统其它组件感知,它并不能保证在写内存屏障之前的所有写入操作的顺序,也不能保证在写内存屏障之后的所有写入操作的顺序。
写内存屏障只管自己CPU上的写入操作能够按照一定次序被系统中其它部件感知,但是如果其它部件有缓存将旧数据缓存下来了,这它管不着。这个是下面介绍的读内存屏障要管的事,因此一般写内存屏障要和读内存屏障配对使用。
3.3 读内存屏障
一个读内存屏障可以提供这样的保证,站在系统中其它组件的角度来看,所有在读内存屏障之前的加载操作将在读内存屏障之后的加载操作之前发生。
还是用上面的例子来说明,首先,读内存屏障也会对处理器指令重排做出一些限制,也就是在读内存屏障之前的读取指令一定不会被重排序到读内存屏障之后的读取指令之后。其次,在执行读内存屏障之后的读取指令之前,一定要保证处理完当前CPU的无效队列。这样的话,当前CPU的缓存状态将完全遵照MESI协议,可以保证缓存数据一致性。
读内存屏障仅仅限制了CPU对加载操作的排序,对存储操作没有任何效果,对其它指令也没有任何作用。而且,读内存屏障只是保证在读内存屏障之后的读取操作一定是在读内存屏障之前的读取操作之后才去感知内存数据变化的,它并不能保证读内存屏障之前的所有读取操作顺序,也不能保证读内存屏障之后的所有读取操作的顺序。
读内存屏障只管自己CPU上的读取操作能够按照一定次序去感知系统内存中的值,但是对于其它CPU写入系统内存的次序没有任何约束。这个是上面介绍的写内存屏障要管的事,因此一般读内存屏障要和写内存屏障配对使用。
3.4 通用内存屏障(读写内存屏障)
一个通用内存屏障可以提供这样的保证,站在系统中其它组件的角度来看,通用内存屏障之前的加载、存储操作都将在通用内存屏障之后的加载、存储操作之前发生。
还是用上面的例子来说明,首先,通用内存屏障也会对处理器指令重排做出一些限制,也就是在通用内存屏障之前的写入和读取指令一定不会被重排序到通用内存屏障之后的写入和读取指令之后。其次,在执行通用内存屏障之后的任何写入和读取取指令之前,一定要保证清空当前CPU存储缓冲中的所有写操作,并且还要处理完当前CPU的无效队列。
通用内存屏障等同于同时包含了读和写内存屏障的功能,因此也可以替换它们中的任何一个,只不过可能会一定程度上影响性能。
通用内存屏障同时限制了CPU对加载操作和存储操作的排序,但是对其它指令没有任何作用。而且,通用内存屏障只是保证在通用内存屏障之后的所有写入和读取操作一定是在通用内存屏障之前的写入和读取操作之后才执行,它并不能保证通用内存屏障之前的所有读取和写入操作的顺序,也不能保证通用内存屏障之后的所有读取和写入操作的顺序。
一般写内存屏障、读内存屏障和通用内存屏障都会默认包含编译屏障
四、四、现有架构的内存屏障操作
4.1 x86/64
x86/64系统架构提供了三种内存屏障指令: (1) sfence; (2) lfence; (3) mfence。
// linux-6.9.1/arch/arm64/include/asm/barrier.h
#define __mb() asm volatile("mfence":::"memory")
#define __rmb() asm volatile("lfence":::"memory")
#define __wmb() asm volatile("sfence" ::: "memory")
-
sfence:可以看做是一定将数据写回内存,而不是写到高速缓存中。
-
lfence:可以看做是一定将数据从高速缓存中抹掉,从内存中读出来,而不是直接从高速缓存中读出来。
-
mfence则正好结合了两项操作。
4.2 arm64
arm64系统提供的下面几种内存屏障指令:
#define isb() asm volatile("isb" : : : "memory")
#define dmb(opt) asm volatile("dmb " #opt : : : "memory")
#define dsb(opt) asm volatile("dsb " #opt : : : "memory")
#define __smp_mb() dmb(ish)
#define __smp_rmb() dmb(ishld)
#define __smp_wmb() dmb(ishst)
#define __mb() dsb(sy)
#define __rmb() dsb(ld)
#define __wmb() dsb(st)
#define __dma_mb() dmb(osh)
#define __dma_rmb() dmb(oshld)
#define __dma_wmb() dmb(oshst)
五、总结
现代计算机为了提高性能多采取乱序执行,乱序执行又分为编译乱序和执行乱序两类,前者是编译器的行为,后者是cpu运行时的行为。编译乱序可以通过编译屏障来解决,而执行乱序一般通过读内存屏障、写内存屏障以及读写内存屏障来解决。
一个专注于“嵌入式知识分享”、“DIY嵌入式产品”的技术开发人员,关注我,一起共创嵌入式联盟。