什么是内存屏障(Memory Barriers)
内存屏障是一种基础语言,在不同的计算机架构下有不同的实现细节。本文主要在x86_64处理器下,通过Linux及其内核代码来分析和使用内存屏障
对大多数应用层开发者来说,“内存屏障”(memory Barrier)是一种陌生,甚至有些诡异的技术。实际上,他机制常被用在操作系统内核中,用于实现同步、驱动程序利用它,能够实现高效的无锁数据结构,提高多线程程序的性能表现。本文首先探讨了内存屏障的必要性,之后介绍如何利用内存屏障实现一个无锁唤醒振荡器(队列),用于在多个线程间进行高效的数据交换。
理解内存屏障
开发者显然不明白一个事实——程序实际运行时很可能并不完全按照开发者编写的顺序访问内存。例如:
|
|
这里,y = 1很可能先于x = r执行。这就是内存乱序访问。内存乱序访问发生的原因是为了提升程序运行时的性能。编译器和CPU都可能引起内存乱序访问:
- 编译时,编译器优化进行指令重排而导致内存乱序访问;
- 运行时,多CPU间交互引入内存乱序访问。
编译器和CPU引入内存乱序访问通常不会带来什么问题,但在一些特殊情况下(主要是多线程程序中),逻辑的正确性依赖于内存访问顺序,接下来,内存乱序访问会带来逻辑上的错误,例如:
|
|
ok
初始化为0,线程1等待ok
被设置为1后执行do函数。假设,线程2对内存的写操作乱序执行,如果x
判断晚于ok
完成判断,那么do函数接受的实参很有可能出乎开发者的意料,不为42。
我们可以引入内存屏障来避免上述问题的出现。内存屏障可以让CPU或者编译器在内存访问上进行。内存屏障之前的内存访问操作一定要先于其之后的完成。内存屏障包括两类:编译器屏障和CPU内存屏障。
编译时内存乱序访问
编译器对代码进行优化时,可能会改变实际执行指令的顺序(例如g++下O2或者O3都会实际执行指令的顺序),改变看一个例子:
|
|
首先直接编译次源文件:g++ -S test.cpp
。我们得到相关的编译代码如下:
|
|
这里我们可以看到,x = r
并且y = 1
并没有乱序执行。现使用优化选项O2(或O3)编译上面的代码(g++ -O2 –S test.cpp
),生成代码如下:
|
|
我们可以清楚地看到经过编译器优化之后,movl $1, y(%rip)先于movl %eax, x(%rip)执行,这意味着,编译器优化导致了内存乱序访问。避免次次行为的办法就是使用编译器屏障(又叫优化屏障)。Linux内核提供了函数barrier(),用于让编译器保证其之前的内存访问先于其之后的内存访问完成。(这个强制保证顺序的需求在哪里?换句话说乱序会带来什么问题? – 一个线程执行了 y =1 ,但实际上 x=r 还没有执行完成,此时被另一个线程抢占,另一个线程执行,发现y =1,认为此时x必定=r,执行相应逻辑,造成错误)内核实现barrier()如下:
|
|
现在把这个编译器barrier加入代码中:
|
|
再编译,就会发现内存乱序访问已经不存在了。除了barrier()函数外,本例还可以使用volatile这个关键字来避免编译时内存乱序访问(且只能避免编译时的乱序访问) ,为什么呢,可以参考前面部分的说明,编译器对于 volatile 声明到底做了什么 – volatile 关键字对于编译器而言,是开发者告诉编译器,这个变量内存的修改,可能不再是你可视范围了内部修改,不要对这个变量相关的代码进行优化)。volatile关键字允许对易失性变量之间的内存进行访问,这里可以x和y的定义来解决问题:
|
|
通过 volatile 关键字,使得 x 相对 y、y 相对 x 在内存访问上是集群的。实际上,Linux 内核中,宏ACCESS_ONCE
可以避免编译器对于连续的ACCESS_ONCE
实例进行指令重排,其就是通过volatile
实现的:
|
|
此代码只是将变量转换为易失性的最后。现在我们有了第三个修改方案:
|
|
到这里,基本上就阐述完成了编译时内存乱序访问的问题。下面看看CPU有怎样的行为。
运行时内存乱序访问
运行时,CPU本身是会乱序执行指令的。早期的处理器为阵列处理器(in-order ports),总是按开发者编写的顺序执行指令,如果指令的输入操作对象(input operands)不可用(通常由于需要从内存中获取),那么处理器不会转而执行那些输入操作对象可用的指令,而是当前等待输入操作对象可用。相比之下,乱序处理器(out-of) -顺序处理器)会先处理那些可用的输入操作对象的指令(而不是顺序执行)从而避免了等待,提高了效率。现代计算机上,处理器运行的速度比内存快很多,小区处理器花在等待可用的数据时间里已可处理大量指令了。即使现代处理器会乱序执行,但在单个CPU上,指令可以通过指令队列顺序获取并执行,结果利用队列顺序返回注册堆(详情可参考http ) ://en.wikipedia.org/wiki/Out-of-order_execution),这使得程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的,因此内存屏障是没有必要使用的(前提是不考虑编译器优化的情况下)。
SMP架构需要内存接口的进一步解释:从体系结构上来看,首先在SMP架构下,每个CPU与内存之间,都分配了自己的高速缓存(Cache),以减少访问内存时的冲突
采用高速服务器的写操作有两种模式:(1).由于(Write through)模式,每次写时,都直接将数据写回内存中,效率相对较低;(2).回写(Write back)模式,写的时候先写回告诉存储,然后由高速存储的硬件再周转复用缓冲线(Cache Line)时自动将数据写回内存,或者由软件主动“冲刷”有关的缓冲线(Cache Line)。出于性能的考虑,系统往往采用的是模式2来完成数据写入。由于存在高速缓存这一层,由于采用了Write back模式的数据写入,才导致在 SMP 架构下,对高速存储的运用可能会改变对内存操作的顺序。上面的一个简单代码如下:
|
|
这里CPU1执行时,x一定是打印出42吗?让我们来看看以下图为例的说明:
假设,正好CPU0的高速缓存中有x,此时CPU0就将x=42
写入到了高速缓存中,另外一个ok也在高速缓存中,但由于周转复用高速缓冲线(Cache Line)而导致会ok=1
刷会到了内存中,此时CPU1首先执行对ok内存的读取操作,他读到了ok为1的结果,然后跳出循环,读取x的内容,而此时,由于实际读取的x(42)还仅在CPU0的高速缓存中,导致CPU1读到的数据为x(17)。程序中编排好的内存访问顺序(指令序号:program ordering)是先写x,再写y。而实际上出现在该CPU外部,即系统进程上的顺序(处理器顺序:processor ordering),却是先写入y,再写入x(这个例子中x装载)。在SMP架构中,每个CPU都只知道自己什么时候会改变内存的内容,但是不知道其他CPU会在什么时候改变内存的内容,也不知道自己本地的高速缓存中的内容是否与内存中的内容交互。反过来,每个CPU都可能因为改变了内存内容,而使得其他CPU的高速缓存变的不一致。在SMP架构下,由于高速缓存的存在而导致内存访问顺序(读或写都可能书序被改变) )的改变很可能会影响到CPU间的同步与互斥。因此需要有一种手段,使得在某些操作之前,把这种“欠下”的内存操作(本例中的x=42的内存写入)入)全部最终地、物理地完成,就希望把欠下的债都结清,然后再开始新的(通常是比较重要的)活动一样。这种手段就是内存屏障,其本质原理就是对系统交互加锁。
回过头来,我们再来看看为什么非SMP架构(UP架构)下,运行时内存乱序访问不存在。在单处理器架构下,各个进程在宏上是一堆的,但在少数上却是串是的,因为在同一时间点上,只有一个进程真正在运行(系统中只有一个处理器)。在这种情况下,我们接下来看看上面提到的例子:
thread0和完成thread1的指令都会在CPU0上按照指令顺序执行。thread0通过CPU0x=42
的高速缓存写入后,再将ok=1
写入内存,此后串行的将thread0换出,thread1换入,此时x=42
明显读取内存,但由于thread1的执行仍然是在CPU0上执行,他仍然访问的是CPU0的高速缓存,因此,及时x=42
先写回到内存中,thread1势还是先从高速缓存中读到x=42
,再从内存中读到ok=1
综上所述,在单CPU上,多线程执行不存在运行时内存乱序访问,我们从内核源码也可以得到类似的结论(代码未完全摘录)
|
|
这里可以看到对内存屏障的定义,如果是SMP架构,smp_mb定义为mb(),mb()为CPU内存屏障(接下来要谈的),不是SMP架构时(高通UP架构),直接使用编译器屏障,运行时内存乱序访问并不存在。
多CPU情况下会存在内存乱序访问?我们知道每个CPU都存在Cache,当一个特定的数据第一次被其他CPU获取时,这个数据为什么明显不在对应CPU的Cache中(这就是Cache Miss)。这意味着CPU要从内存中快速获取数据(这个过程需要CPU等待几百个周期),这个数据会被加载到CPU的Cache中,这样后续就可以直接从Cache上访问。当某个CPU进行写时操作修改时,他必须确保其他CPU已将数据从他们的Cache中移除(以便保证一致性),只有在操作完成后移除,此CPU才能安全地数据。显然,存在多个Cache时,必须通过一个缓存一致性协议来避免数据不一致的问题,而这个通信的过程就可能导致乱序访问的出现,甚至运行时内存乱序访问。受篇幅所限,这里不再深入讨论整个细节,有兴趣的读者可以研究一下《内存屏障:软件黑客的硬件观点》这篇文章,它详细分析了整个过程。
现在通过一个例子来仔细说明多CPU下内存乱序访问的问题:
|
|
变量x、y、r1、r2
均被初始化为0,run1和run2运行在不同的线程中。如果run1和run2在同一个cpu下执行完成,那么就如我们所料,r1和r2的值不会同时为0,而假设run1而run2在不同的CPU下执行完成后,由于存在内存乱序访问的可能,那么r1和r2可能同时为0。我们可以利用CPU内存屏障来运行时避免内存乱序访问(x86_64):
|
|
x86/64 系统架构提供了三中内存屏障指令:(1) sfence ; (2) 栅栏; (3) mfence。(参考介绍: http: //peeterjoot.wordpress.com/2009/12/04/intel-memory-ordering-fence-instructions-and-atomic-operations/以及Intel文档:http://www .intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf和http://www .intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html)
-
sfence
对 SFENCE 指令之前发出的所有存储到内存指令执行序列化操作。此序列化操作保证在 SFENCE 指令之后的任何存储指令全局可见之前,按程序顺序位于 SFENCE 指令之前的每个存储指令都是全局可见的。 SFENCE 指令按照存储指令、其他 SFENCE 指令、任何 MFENCE 指令和任何序列化指令(例如 CPUID 指令)排序。它没有根据加载指令或 LFENCE 指令进行排序。表示sfence确保:sfence指令前置的读取(store/release)指令,按照在sfence前置的指令顺序进行执行。写内存提供屏障这样的保证:所有出现在屏障之前的STORE操作都将先于所有在界面之后出现的 STORE 操作被系统中的其他组件所感知。
[!]注意,写屏障一般需要与读屏障或数据依赖屏障使用;请参阅“SMP内存界面配置”章节。 (原文注:因为写屏障只保证自己提交的顺序,而无法影响其他代码读内存的顺序。所以配置使用很重要。其他类型的屏障亦是同理。)
-
lfence
对 LFENCE 指令之前发出的所有从内存加载指令执行序列化操作。此序列化操作保证在 LFENCE 指令之后的任何加载指令全局可见之前,按程序顺序位于 LFENCE 指令之前的每个加载指令都是全局可见的。 LFENCE 指令相对于加载指令、其他 LFENCE 指令、任何 MFENCE 指令和任何序列化指令(例如 CPUID 指令)进行排序。它不是根据存储指令或 SFENCE 指令来排序的。也就是说lfence确保:lfence指令对称的读取(load/acquire)指令,按照在mfence对称的指令顺序进行执行。读屏障包含数据依赖屏障的功能,并且保证所有出现在屏障之前的LOAD操作都将首先,所有出现在屏幕中的 LOAD 操作都被系统中的其他组件所获取。
[!]注意,读屏障一般要跟写屏障使用;请参阅“SMP 内存接口的设置使用”章节。
-
mfence
对在 MFENCE 指令之前发出的所有从内存加载和存储到内存的指令执行序列化操作。此序列化操作保证在 MFENCE 指令之后的任何加载或存储指令全局可见之前,按程序顺序位于 MFENCE 指令之前的每个加载和存储指令全局可见。 MFENCE 指令相对于所有加载和存储指令、其他 MFENCE 指令、任何 SFENCE 和 LFENCE 指令以及任何序列化指令(例如 CPUID 指令)进行排序。也就是说mfence指令:确保所有mfence指令的写入(store/release)指令之前,都在该mfence指令之后的写入(store/release)指令之前(指令序,Program Order)执行;同时,他还确保所有mfence指令之后的读取(load/acquire)指令,都在该mfence指令之前的读取(load/acquire)指令之后执行。即:既保证写者能够按照指令完成顺序读取数据,也保证读卡器能够按照指令顺序完成数据读取。通用内存保证所有出现在屏障之前的 LOAD 和 STORE 操作都将先于所有出现在屏障之后的 LOAD 和 STORE 操作被系统中的其他组件所采集。
sfence我认为它的动作,可以看做是一定将数据写回内存,而不是写到高速缓存中。lfence的动作,可以看做是一定将数据从高速存储中抹掉,从内存中读出来,而不是直接从高速缓存中读取。mfence则正好结合了两个操作。sfence只保证写者在将数据(A->B)读出内存的顺序,并不能保证其他人读(A,B)数据这时,一定是按照先读A更新后的数据,再读B更新后的数据这样的顺序,很有可能读者读到的顺序是A旧数据,B更新后的数据,A更新后的数据(只是这个更新后面的数据出现在读者的后面,他并没有“实际”去读);同理,lfence也能保证读者在读入顺序时,按照先读A最新在内存中的数据,再读B最新的在内存中的数据的顺序,但如果没有写者围栏的配合,显然,即使顺序一致,内容还是可能有乱序。
为什么仅仅通过保证写者的写入顺序(sfence),还是可能有问题?还是之前的例子
|
|
如果对于“写入”操作顺序化,实际上,还是有可能使上面的代码出现r1,r2同时为0(初始值)的场景:
当CPU0上的thread0执行时,x被先行写回内存中,但如果此时y在CPU0的高速缓存中,则此时y从缓存中写入,并被赋予r1写回内存,此时r1为0。同理,CPU1上的thread1执行时,y被先行写入到内存中,如果此时x在CPU1的高速缓存中存在,则此时r2被赋予了x的(过时)值0,同样存在了r1,r2同时为0。这个现象实际上就是所谓的r1=y
读顺序与x=1
写顺序存在逻辑上的乱序导致(或者是r2 = x
与y=1
存在乱序) – 读操作与写操作之间存在乱序。而mfence就是这类乱序也亮度掉
如果是Bymfence,是怎样解决该问题的呢?
当thread1在CPU0上对x=1
进行读取时,x=1
被刷新到内存中,由于是mfence,他要求r1的读取操作从内存读取数据,而不是从内存中读取数据,因此,此时如果y更新为1,则r1 = 1
;如果y没有更新为1,则r1 = 0
,同时由于x更新为1,r2必须从内存中读取数据,则此时r2 = 1
。总而言之就是r1,r2,一个=0,一个=1。
关于内存界面的一些补充
在实际的应用程序开发中,开发者可能完全不知道内存界面就写出了正确的多线程程序,这主要是各种同步机制中已隐含了内存界面(但实际的内存界面有模拟)差别),使得不直接使用内存屏障也不会存在任何问题。但如果你希望编写这样的无锁数据结构,那么内存屏障意义重大。
在Linux内核中,除了前面说到的编译器屏障—barrier()和ACESS_ONCE(),还有CPU内存屏障:
- 通用接口,保证读写操作,包括mb()和smp_mb();
- 写操作屏障,仅保证写操作社区,包括wmb()和smp_wmb();
- 读操作界面,仅保证读操作社区,包括rmb()和smp_rmb();
注意,所有的CPU内存屏障(除了数据依赖屏障外)都隐含了交叉器屏障(如果使用CPU内存屏障后就消耗再额外添加交叉器屏障了)。这里的smp开通的内存屏障会根据配置在单处理器上直接使用编译器屏障,而在SMP上才使用CPU内存屏障(即mb()、wmb()、rmb())。
还需要注意一点是,CPU内存屏障中某些类型的需要屏障成对使用,否则会出错,详细来说就是:一个写操作屏障需要和读操作(或者数据依赖)屏障一起使用(当然,通用屏障)也可以的),反之亦然。
通常,我们希望在写屏障出现之前的 STORE 操作始终匹配度屏障或者数据依赖屏障之后出现的 LOAD 操作。以之前的代码示例为例:
|
|
我们实际上,是希望在thread2执行到do(x)时(在ok验证确实=1时),x = 42确实是有效的(写屏障出现之前的STORE操作),此时do(x),确实是在执行do(42)(读屏障之后出现的LOAD操作)
利用内存屏障实现无锁环形
最后,以一个利用内存屏障实现的无锁环形线(只有一个读线程和一个写线程时)来结束本文。本代码来自于内核FIFO的一个实现,内容如下(略去非关键代码):
代码来源:linux-2.6.32.63\kernel\kfifo.c
|
|
|
|
这里__kfifo_put
是一个线程用于向fifo
中读取数据,另外一个线程可以调用__kfifo_get
,从而fifo
安全读取数据。代码中in和out的索引用于指定环形动脉实际的头和尾。具体的in和out所指向的弧形的位置通过与操作来求取(例如:fifo->in & (fifo->size -1)
),这样相比取余操作来求拂表的做法效率要高显着。使用与操作求拂表的前提是弧形弧形的大小必须是2的N次方,换算而言,就是说环形曲面的大小为一个1的二进制数,则index & (size – 1)
求取的下标(这不难理解)。
索引in和out被两个线程访问。in和out指明了拓扑中实际数据的边界,所以in和out同拓扑数据机制存在访问上的顺序关系,由于不适用同步,所以保证顺序关系就需要用到内存屏障了。索引in和out都分别只被一个线程修改,而被两个线程读取。__kfifo_put
先通过in和out来确定可以向波形中写入数据量的多少,然后,out索引器先被读取,才能真正将用户buffer中的数据写入湿度,因此这里应该使用到了smp_mb()
,对应的,__kfifo_get
也使用smp_mb()
来确保修改出索引器之前的湿度表中数据已读取成功并读取用户buffer中了。(我认为在__kfifo_put
中添加的这个smp_mb()
是没有必要的。理由如下,kfifo只支持一写一读,这是前提。在这个前提下,in和out两个变量是有依赖关系的,这也没错,而且我们可以看到在put中,in一定会是最新的,因为put是in的值,而在get中,out一定会是最新的,因为get修改out的值。这里的smp_mb ()显然是希望在运行时,遵循out先加载新值,in再加载新值。确实,这样做没错,但是是否有必要呢?out一定要是最新值吗?out如果不是最新值会有什么问题?如果out不是最新值,实际上并不会有什么问题,在put时,fifo的实际可计算写入空间要大于put计算出来的空间(因为out是旧值,导致len在时偏小),这并不影响程序执行的正确性。来自最新linux-3.16-rc3内核的代码:lib\kfifo.c的实现:__kfifo_in
中也可以看出memcpy(fifo->data + off, src, l); memcpy(fifo->data, src + l, len - l);
前面的那次smb_mb()
已经被省去了,当然更新之前的smb_wmb()
还是在kfifo_copy_in
中被保留了。为了省去这次smb_mb()
的调用,我想除了省去调用不影响程序正确性之外,是否还有对于性能影响的考虑,尽量减少不必要的mb调用)对于索引,在__kfifo_put
中,通过smp_wmb()
保证先向系数读取数据后才修改索引,由于这里只需要保证读取操作数组,所以采用写操作界面,在__kfifo_get
中,通过smp_rmb()
保证先读取了索引中的数据(其次在索引中用于确定彩虹中实际存在多少剩余数据)才开始读取彩虹中数据(并读取用户缓冲区中),由于这里指需要保证读取操作网格,故采用读取操作屏障。
什么时候需要注意考虑记忆互动(补充)
从上面的介绍我们已经可以看出,在SMP环境下,内存中断非常重要,在多线程并发执行的程序中,一个数据读取与乱序访问,就有可能导致逻辑上错误,而显然这不是我们希望看到的。作为系统程序的实现者,我们涉及到内存屏障的场景主要集中在无锁编程时的原子操作。执行这些操作的地方,就是我们需要考虑内存屏障的地方。
从我自己的经验来看,使用原子操作,一般有以下清晰的方式:(1)。直接对int32、int64进行属性;(2).使用gcc内建的原子操作内存访问接口;(3).调用第三方atomic库:libatomic实际内存原子操作。
- 对于第一类原子操作方式,显然内存交互是需要我们考虑的,例如kernel中kfifo的实现,就必须要在数据读取和读取时插入必要的内存交互显示的考虑,以保证程序执行的顺序与我们设定的顺序一致。
- 对于使用 gcc 内建的原子操作访问接口,基本上大多数 gcc 内建的原子操作都自带内存交互,他可以保证在执行原子内存访问相关的操作时,执行顺序不被打断。在这种情况下,这些内置函数被认为是一个完整的障碍。也就是说,任何内存操作数都不会在整个操作中向前或向后移动。此外,将根据需要发出指令,以防止处理器推测整个操作的负载以及操作后对存储进行排队。”(http://gcc.gnu.org/onlinedocs/gcc-4.4.5/gcc/Atomic-当然,其中也有几个容易实现完全屏障,具体情况可以参考gcc文档对对应接口的说明。同时,gcc还提供了对内存接口的封装接口:__sync_synchronize (…),这可以作为应用程序使用内存接口的接口(不用写接口语句)。
- 用于使用libatomic库进行原子操作,原子访问的程序。Libatomic在接口上对于内存接口的设置粒度更新,他几乎是对每一个原子操作的接口针对不同的平台都有对应的不同内存接口的绑定。提供多种架构上原子内存更新操作的实现。这允许在相当可移植的代码中直接使用它们。与早期的类似包不同,这个包明确考虑了内存屏障语义,并允许构建跨各种架构的最小开销的代码。”接口实现上分别添加了_release/_acquire/_full等各个后缀,分别代表的该接口的内存接口类型,具体说明可参见libatomic的README说明。如果是调用最赚钱的接口,已AO_compare_and_swap为例,最终会根据平台的特性以及宏定义情况调用到:AO_compare_and_swap_fullAO_compare_and_swap_release或者AO_compare_and_swap_release等。我们可以重点关注libatomic在x86_64上的实现,libatomic中,在x86_64架构下,还提供了应用层的内存接口接口:AO_nop_full
综合以上三点,总结下来就是:如果你在程序中是裸着写内存,读内存,则需要显着地使用内存接口来保证你程序的正确性,gcc内建不提供简单的封装了内存接口的内存读写只是存在,因此,如果使用gcc内建函数,你仍然裸读,裸写,此时你还是必须显式使用内存屏障。如果你通过libatomic进行内存访问,在x86_64架构下,使用AO_load /AO_store,你可以不再显着式的使用内存屏障(但从实际使用的情况来看,libatomic这类接口的效率并不是很高)