四、内存一致性模型
内存一致性模型(Memory Consistency Model)是用来描述多线程对共享存储器的访问行为,在不同的内存一致性模型里,多线程对共享存储器的访问行为有非常大的差别。这些差别会严重影响程序的执行逻辑,甚至会造成软件逻辑问题。
不同的处理器架构,使用了不同的内存一致性模型,目前有多种内存一致性模型,从上到下模型的限制由强到弱:
- 顺序一致性(Sequential Consistency)模型
- 完全存储定序(Total Store Order)模型
- 部分存储定序(Part Store Order)模型
- 宽松存储(Relax Memory Order)模型
注意,这里说的内存模型是针对可以同时执行多线程的平台,如果只能同时执行一个线程,也就是系统中一共只有一个CPU核,那么它一定是满足顺序一致性模型的。
对于内存的访问,我们只关心两种类型的指令的顺序,一种是读取,一种是写入。对于读取和加载指令来说,它们两两一起,一共有四种组合:
- LoadLoad:前一条指令是读取,后一条指令也是读取。
- LoadStore:前一条指令是读取,后一条指令是写入。
- StoreLoad:前一条指令是写入,后一条指令是读取。
- StoreStore:前一条指令是写入,后一条指令也是写入。
顺序一致性模型
顺序存储模型是最简单的存储模型,也称为强定序模型。CPU会按照代码来执行所有的读取与写入指令,即按照它们在程序中出现的次序来执行。同时,从主存储器和系统中其它CPU的角度来看,感知到数据变化的顺序也完全是按照指令执行的次序。也可以理解为,在程序看来,CPU不会对指令进行任何重排序的操作。在这种模型下执行的程序是完全不需要内存屏障的。但是,带来的问题就是性能会比较差,现在已经没有符合这种内存一致性模型的系统了。
为了提高系统的性能,不同架构都会或多或少的对这种强一致性模型进行了放松,允许对某些指令组合进行重排序。注意,这里处理器对读取或写入操作的放松,是以两个操作之间不存在数据依赖性为前提的,处理器不会对存在数据依赖性的两个内存操作做重排序。
完全存储定序模型
这种内存一致性模型允许对StoreLoad指令组合进行重排序,如果第一条指令是写入,第二条指令是读取,那么有可能在程序看来,读取指令先于写入指令执行。但是,对于其它另外三种指令组合还是可以保证按照顺序执行。
这种模型就相当于前面提到的,在CPU和缓存中间加入了存储缓冲,而且这个缓冲还是一个满足先入先出(FIFO)的队列。先入先出队列就保证了对StoreStore这种指令组合也能保证按照顺序被感知。
我们非常熟悉的X86架构就是使用的这种内存一致性模型。
部分存储定序模型
这种内存一致性模型除了允许对StoreLoad指令组合进行重排序外,还允许对StoreStore指令组合进行重排序。但是,对于其它另外两种指令组合还是可以保证按照顺序执行。
这种模型就相当于也在CPU和缓存中间加入了存储缓冲,但是这个缓冲不是先入先出的。
宽松存储模型
这种内存一致性模型允许对上面说的四种指令组合都进行重排序。
这种模型就相当于前面说的,既有存储缓冲,又有无效队列的情况。
这种内存模型下其实还有一个细微的差别,就是所谓的数据依赖性的问题。例如下面的程序,假设变量A初始值是0:
CPU 0 |
CPU 1 |
A = 1; |
Q = P; |
<write barrier> |
B = *Q; |
P = &A; |
五、内存屏障的使用规则
前面提到过了,读、写内存屏障应该配对使用,或者换做通用内存屏障也需要成对的使用,否则起不到想要的效果。
配对使用场景
首先,来看最常用的组合,一个CPU上执行两个写入操作,中间用写内存屏障分割,另一个CPU上执行两个读取操作,中间用读内存屏障分割:
注意,在这种场景下写入变量的顺序和读取变量的顺序刚好要是相反的。加了这一对读、写内存屏障后,可以保证,在两个CPU都执行完上面的代码后,如果Y的值等于2,那么X的值一定等于1。Y的值等于2,意味着在CPU0上对B赋值2的语句已经执行过了,由于有写内存屏障的存在,也就意味着对A赋值1的语句在之前肯定也被执行过了。在CPU1上,由于有读内存屏障的存在,表示读取变量A值的语句一定在读取变量B值的语句之后被执行,也就可以保证,这时候变量A的值一定已经被赋值成了1。
第二种场景,两个CPU上都执行一个读取操作,接着一个写入操作,中间用通用内存屏障分割:
假设变量A和B的初始值都是0,当两个CPU都执行完上面的代码后,如果有Y等于2,那么X一定等于0。如果Y等于2,也就意味着在CPU1上对A赋值1的语句,一定在CPU0上读取变量A值的语句之后被执行。同时,由于对称性,如果有X等于1,那么X一定等于0。
最后一种场景,一个 CPU 执行一个读取操作,后面跟一个通用内存屏障,再后面是一个写入操作;而另外一个CPU执行一对由
写内存屏障分开的写入操作:
这种情况下,如果X的值等于1,那么必然有B的值等于1。如果X的值等于1,就意味着在这之前CPU1上已经执行过了对A赋值1的语句,由于写内存屏障的存在,也就能够保证在CPU1上已经执行过了对B赋值2的语句,而在CPU0上由于有通用内存屏障的存在,那么对B赋值1的语句一定会在对X赋值的语句之后执行。也就是说,可以保证在CPU0上对B赋值1的语句,一定会在CPU1上对B赋值2的语句之后被执行。
为什么在单核系统上没有乱序的问题
还要说明一下,无论如何,即使某一个体系的内存模型再弱,有一些基本规则还是必须要遵守的(当然,对于编译器优化来说也要遵循这些规则。):
单个CPU总是按照编程顺序来感知它自己的内存访问。
仅仅在操作不同地址时,CPU才对给定的存储操作进行“真”重新排序。
还有,如果程序一直可以保证只在单个CPU核上执行,也就不存在所谓的缓存一致性问题。因此,仅仅在两个CPU之间或者CPU与设备之间存在需要交互的可能性时,才需要内存屏障。任何代码只要能够保证没有这样的交互就不需要使用内存屏障。也就是说,如果当前系统中只有一个CPU核,并且程序没有和设备打交道,即使是多线程的,也不需要使用内存屏障。
第一个规则从直观上说感觉和前面讲的重排序有点矛盾,不是说可以按照任何次序执行嘛,怎么又可以保证按照编程顺序来感知了。我们还是用前面的例子来说,代码如下:
thread0(void)
{
A = 1;
B = 2;
}
thread1(void)
{
while (B != 2)
continue;
assert(A != 0);
}
这段程序在没有编译器优化重排序的情况下,在单CPU系统上其实是可以正确运行的。CPU在执行的时候是不知道所谓的线程的,线程是操作系统的概念,CPU执行的时候只能感知到的是一个指令序列。对于上面的例子,这个指令序列应该是这样的:
Store A = 1;
Store B = 2;
......
Back:
Load B;
Test B != 2;
Jump Back If True;
Load A;
Test A != 0;
中间省略的是一些可能的线程切换的代码。然后,CPU会对这组指令序列进行重排序优化。但是,前面说了重排序优化是有前提条件的,因此无论如何不会将前面的将变量A赋值为1的语句重排序到后面读取变量A的语句之后,当然也不会将变量B赋值为2的语句重排序到后面读取变量B的语句之后,从而保证了这个两线程程序的正确运行。所以,前面的第一个规则其实和所谓的指令重排序是不矛盾的。
因此,在单CPU核的系统下,硬件保证程序至少看上去是按照指令的顺序被执行的,唯一可能更改指令顺序的就是编译器了,这时候所有内存屏障都将退化成编译器屏障。
但是,不使用内存屏障不代表不使用相应的同步机制。如果某个操作不是原子的,那么多线程访问它即使在单CPU的系统上也会出现问题,只不过这个问题不是因为重排序引起的。
六、有什么优化方法?
6.1硬件上的优化
store buffer
有一种可以阻止CPU进入阻塞状态的方法,就是在CPU和cache之间加入一个store buffer的硬件结构,如下图:
加入了这个硬件结构后,但CPU0需要往某个地址中写入一个数据时,它不需要去关心其他的CPU的local cache中有没有这个地址的数据,它只需要把它需要写的值直接存放到store buffer中,然后发出invalidate的信号,等到成功invalidate其他CPU中该地址的数据后,再把CPU0存放在store buffer中的数据推到CPU0的local cache中。每一个CPU core都拥有自己私有的store buffer,一个CPU只能访问自己私有的那个store buffer。
store buffer的弊端
先看看下面的代码,思考一下会不会出现什么问题
a = 1;
b = a + 1;
assert(b = 2);
我们假设变量a的数据存放在CPU1的local cache line中,变量b在CPU0的cache line中,如果我们单纯使用CPU来运行上面这段程序,应该是正常运行的,但是如果加入了store buffer这个硬件结构,就会出现assert失败的问题,到底是为什么呢?我们来走一走程序的流程。
CPU0执行a=1这条命令,由于CPU0本地没有数据,会发出read invalidate消息从CPU1那获得数据,并发出invalidate命令
- CPU0把要写入的数据放到store buffer中
- CPU1接收到了CPU0发来的read invalidate信号,将其local cache line 中的数据发送给CPU0,并把自己本地cache line中的数据设置为无效
- CPU0执行b=a+1
- CPU0接收到CPU1返回回来的a的值,等于0,b=0+1=1
- CPU0将store buffer中的a=1的值推入到cache中,造成了数据不一致性
store forwarding
硬件上出现一种新的设计,为了解决优化上面所说的store buffer的弊端, 具体结构如下图:
当CPU执行load操作的时候,不但要看cache,还要看store buffer中是否有内容,如果store buffer有该数据,那么就采用store buffer中的值。
但是这样,就能保证程序的正确运行,就能保证数据的一致性了吗?并不是!!
6.2软件上的优化
我们先来分析分析一个例子,看看下面的例子有可能会出现什么问题
void foo(void)
{
a = 1;
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
同样,我们假设a和b都是初始化为0,CPU0执行foo函数,CPU1执行bar函数,a变量在CPU1的cache中,b在CPU0的cache中
CPU0执行a=1的操作,由于a不在local cache中,CPU0会将a的值写入到store buffer中,然后发出read invalidate信号从其他CPU获取a 的值
CPU1执行while(b == 0),由于b不在local cache中,CPU发送一个read message到总线上,看看是否可以从其他CPU的local cache中或者memory中获取数据
CPU0继续执行b=1,由于b就在自己的local cache中,所以CPU0可以直接把b=1直接写入到local cache
CPU0收到read message,将最新的b=1的值回送到CPU1,同时把存放b数据的cache line在状态设置为sharded
CPU1收到来自CPU0的read response消息,将b变量的最新值1写入自己的cachelline,并设置为sharded。
由于b的值为1,CPU1跳出while循坏,继续执行
CPU1执行assert(a==1),由于CPU1的local cache中a的值还是旧的值为0,assert(a==1)失败。
这个时候,CPU1收到了CPU 0的read invalidate消息(执行了a=1),清空了自己local cache中a的值,但是已经太晚了
CPU0收到CPU1的invalidate ack消息后,将store buffer中的a的最新值写入到cache line,然并卵,CPU1已经assertion fali了。
这个时候,就需要加入一些memory barrier的操作了。说了这么久,终于说到了内存屏障了,我们把代码修改成下面这样:
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
可以看到我们在foo函数中增加了一个smp_mb()的操作,smp_mb有什么作用呢?
它主要是为了让数据在local cache中的操作顺序服务program order的顺序,那么它又是怎么让store buffer中存储的数据按照program order的顺序写入到cache中的呢?我们看看数据读写流程跟上面没有加smp_mb的时候有什么区别
- CPU0执行a=1的操作,由于a不在local cache中,CPU0会将a的值写入到store buffer中,然后发出read invalidate信号从其他CPU获取a 的值
- CPU1执行while(b == 0),由于b不在local cache中,CPU发送一个read message到总线上,看看是否可以从其他CPU的local cache中或者memory中获取数据
- CPU0在执行b=1前,我们先执行了smp_mb操作,给store buffer中的所有数据项做了一个标记marked,这里也就是给a做了个标记
- CPU0继续执行b=1,虽然b就在自己的local cache中,但是由于store buffer中有marked entry,所遇CPU0并没有把b的值直接写到cache中,而是把它写到了store buffer中
- CPU0收到read message,将最新的b=1的值回送到CPU1,同时把存放b数据的cache line在状态设置为sharded
- CPU1收到来自CPU0的read response消息,将b变量的值0写入自己的cachelline(因为b的最新值1还存放在store buffer中),并设置为sharded。
- 完成了bus transaction之后,CPU1可以load b到寄存器中了,当然,这个时候b的指还是0
- CPU1收到了来自CPU0的read invalidate消息(执行了a=1),清空了自己local cache中a的值
- CPU0将存放在store buffer中的a=1的值写入cache line中,并设置为modified
- 由于store buffer中唯一一个marked的entry已经写入到cache line中了,所以b也可以进入cache line。不过需要注意的是,当前b对应的cache line状态还是sharded(因为在CPU1中的cache line中还保留有b的数据)
- CPU0发送invalidate消息,CPU1清空自己的b cacheline,CPU0将b cacheline设置为exclusive
你以为这样就完了吗????NONONONO!!!!太天真了
6.3Invalidate Queues
不幸的是:每个CPU的store buffer不能实现地太大,其存储队列的数目也不会太多。当CPU以中等的频率执行store操作的时候(假设所有的store操作都导致了cache miss),store buffer会很快的呗填满。在这种情况下,CPU只能又进入阻塞状态,直到cacheline完成invalidation和ack的交互后,可以将store buffer的entry写入cacheline,从而让新的store让出空间之后,CPU才可以继续被执行。
这种情况也可能发生在调用了memory barrier指令之后,因为一旦store buffer中的某个entry被标记了,那么随后的store都必须等待invalidation完成,因此不管是否cache miss,这些store都必须进入store buffer。
这个时候,invalidate queues就出现了,它可也缓解这个情况。store buffer之所以很容易被填满,主要是因为其他CPU在回应invalidate acknowledge比较慢,如果能加快这个过程,让store buffer中的内容尽快写入到cacheline,那么就不会那么容易被填满了。
而invalidate acknowledge不能尽快回复的主要原因,是因为invalidate cacheline的操作没有那么块完成,特别是在cache比较繁忙的时候,如果再收到其他CPU发来的invalidate请求,只有在完成了invalidate操作后,本CPU才会发送invalidate acknowledge。
然而,CPU其实不需要完成invalidate就可以回送acknowledgement消息,这样就不会阻止发送invalidate的那个CPU进去阻塞状态。CPU可以将这些接收到的invalidate message存放到invalidate queues中,然后直接回应acknowledge,表示自己已经收到请求,随后会慢慢处理,当时前提是必须在发送invalidate message的CPU发送任何关于某变量对应cacheline的操作到bus之前完成。结构如下图:
七、关于内存屏障的一些补充
在实际的应用程序开发中,开发者可能完全不知道内存屏障就写出了正确的多线程程序,
这主要是因为各种同步机制中已隐含了内存屏障(但和实际的内存屏障有细微差别),使得不直接使用内存屏障也不会存在任何问题。但如果你希望编写诸如无锁数据结构,那么内存屏障意义重大。
在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操作。以之前的代码示例为例:
// thread 1
x = 42;
smb_wmb();
ok = 1;
// thread 2
while(!ok);
smb_rmb();
do(x);
我们这么做,是希望在thread2执行到do(x)时(在ok验证的确=1时),x = 42的确是有效的(写屏障之前出现的STORE操作),此时do(x),的确是在执行do(42)(读屏障之后出现的LOAD操作)
利用内存屏障实现无锁环形缓冲区
最后,以一个使用内存屏障实现的无锁环形缓冲区(只有一个读线程和一个写线程时)来结束本文。
本代码源于内核FIFO的一个实现,内容如下(略去了非关键代码):
代码来源:linux-2.6.32.63\kernel\kfifo.c
unsigned int __kfifo_put(struct kfifo *fifo,
const unsigned char *buffer, unsigned int len)
{
unsigned int l;
len = min(len, fifo->size - fifo->in + fifo->out);
/*
* Ensure that we sample the fifo->out index -before- we
* start putting bytes into the kfifo.
* 通过内存屏障确保先读取fifo->out后,才将buffer中数据拷贝到
* 当前fifo中
*/
smp_mb();
/* first put the data starting from fifo->in to buffer end */
/* 将数据拷贝至fifo->in到fifo结尾的一段内存中 */
l = min(len, fifo->size - (fifo->in & (fifo->size - 1)));
memcpy(fifo->buffer + (fifo->in & (fifo->size - 1)), buffer, l);
/* then put the rest (if any) at the beginning of the buffer */
/* 如果存在剩余未拷贝完的数据(此时len – l > 0)则拷贝至
* fifo的开始部分
*/
memcpy(fifo->buffer, buffer + l, len - l);
/*
* Ensure that we add the bytes to the kfifo -before-
* we update the fifo->in index.
*/
/*
* 通过写操作屏障确保数据拷贝完成后才更新fifo->in
*/
smp_wmb();
fifo->in += len;
return len;
unsigned int __kfifo_get(struct kfifo *fifo,
unsigned char *buffer, unsigned int len)
{
unsigned int l;
len = min(len, fifo->in - fifo->out);
/*
* Ensure that we sample the fifo->in index -before- we
* start removing bytes from the kfifo.
*/
/*
* 通过读操作屏障确保先读取fifo->in后,才执行另一个读操作:
* 将fifo中数据拷贝到buffer中去
*/
smp_rmb();
/* first get the data from fifo->out until the end of the buffer */
/* 从fifo->out开始拷贝数据到buffer中 */
l = min(len, fifo->size - (fifo->out & (fifo->size - 1)));
memcpy(buffer, fifo->buffer + (fifo->out & (fifo->size - 1)), l);
/* then get the rest (if any) from the beginning of the buffer */
/* 如果需要数据长度大于fifo->out到fifo结尾长度,
* 则从fifo开始部分拷贝(此时len – l > 0)
*/
memcpy(buffer + l, fifo->buffer, len - l);
/*
* Ensure that we remove the bytes from the kfifo -before-
* we update the fifo->out index.
*/
/* 通过内存屏障确保数据拷贝完后,才更新fifo->out */
smp_mb();
fifo->out += len;
return len;
}
这里__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()来确保修改out索引之前缓冲区中数据已被成功读取并写入用户buffer中了。(我认为在__kfifo_put中添加的这个smp_mb()是没有必要的。
理由如下,kfifo仅支持一写一读,这是前提。在这个前提下,in和out两个变量是有着依赖关系的,这的确没错,并且我们可以看到在put中,in一定会是最新的,因为put修改的是in的值,而在get中,out一定会是最新的,因为get修改out的值。这里的smp_mb()显然是希望在运行时,遵循out先load新值,in再load新值。的确,这样做是没错,但这是否有必要呢?out一定要是最新值吗?out如果不是最新值会有什么问题?如果out不是最新值,实际上并不会有什么问题,仅仅是在put时,fifo的实际可写入空间要大于put计算出来的空间(因为out是旧值,导致len在计算时偏小),这并不影响程序执行的正确性。
从最新linux-3.16-rc3 kernel的代码:lib\kfifo.c的实现:
__kfifo_in中也可以看出memcpy(fifo->data + off, src, l);
memcpy(fifo->data, src + l, len – l);
之前的那次smb_mb()已经被省去了,当然更新in之前的smb_wmb()还是在kfifo_copy_in中被保留了。之所以省去这次smb_mb()的调用,我想除了省去调用不影响程序正确性外,是否还有对于性能影响的考虑,尽量减少不必要的mb调用)。
对于in索引,在__kfifo_put中,通过smp_wmb()保证先向缓冲区写入数据后才修改in索引, 由于这里只需要保证写入操作有序,所以选用写操作屏障,在__kfifo_get中,通过smp_rmb()保证先读取了in索引(这时in索引用于确定缓冲区中实际存在多少可读数据)才开始读取缓冲区中数据(并写入用户buffer中),由于这里指需要保证读取操作有序,故选用读操作屏障。
什么时候需要注意考虑内存屏障(补充)
从上面的介绍我们已经可以看出,在SMP环境下,内存屏障是如此的重要,
在多线程并发执行的程序中,一个数据读取与写入的乱序访问,就有可能导致逻辑上错误,而显然这不是我们希望看到的。
作为系统程序的实现者,我们涉及到内存屏障的场景主要集中在无锁编程时的原子操作。
执行这些操作的地方,就是我们需要考虑内存屏障的地方。
从我自己的经验来看,使用原子操作,一般有如下三种方式:
- (1). 直接对int32、int64进行赋值;
- (2). 使用gcc内建的原子操作内存访问接口;
- (3). 调用第三方atomic库:libatomic实现内存原子操作。
对于第一类原子操作方式,显然内存屏障是需要我们考虑的,例如kernel中kfifo的实现,就必须要显示的考虑在数据写入和读取时插入必要的内存屏障,以保证程序执行的顺序与我们设定的顺序一致。,对于使用gcc内建的原子操作访问接口,基本上大多数gcc内建的原子操作都自带内存屏障,他可以确保在执行原子内存访问相关的操作时,执行顺序不被打断。
当然,其中也有几个并未实现full barrier,具体情况可以参考gcc文档对对应接口的说明。同时,gcc还提供了对内存屏障的封装接口:__sync_synchronize (…),这可以作为应用程序使用内存屏障的接口(不用写汇编语句)。,对于使用libatomic库进行原子操作,原子访问的程序。Libatomic在接口上对于内存屏障的设置粒度更新,他几乎是对每一个原子操作的接口针对不同平台都有对应的不同内存屏障的绑定。
接口实现上分别添加了_release/_acquire/_full等各个后缀,分别代表的该接口的内存屏障类型,具体说明可参见libatomic的README说明。如果是调用最顶层的接口,已AO_compare_and_swap为例,最终会根据平台的特性以及宏定义情况调用到:AO_compare_and_swap_full或者AO_compare_and_swap_release或者AO_compare_and_swap_release等。
我们可以重点关注libatomic在x86_64上的实现,libatomic中,在x86_64架构下,也提供了应用层的内存屏障接口:AO_nop_full404 Not Found,对于第一类原子操作方式,显然内存屏障是需要我们考虑的,例如kernel中kfifo的实现,就必须要显示的考虑在数据写入和读取时插入必要的内存屏障,以保证程序执行的顺序与我们设定的顺序一致。
对于使用gcc内建的原子操作访问接口,基本上大多数gcc内建的原子操作都自带内存屏障,他可以确保在执行原子内存访问相关的操作时,执行顺序不被打断。
当然,其中也有几个并未实现full barrier,具体情况可以参考gcc文档对对应接口的说明。同时,gcc还提供了对内存屏障的封装接口:__sync_synchronize (…),这可以作为应用程序使用内存屏障的接口(不用写汇编语句)。,对于使用libatomic库进行原子操作,原子访问的程序。Libatomic在接口上对于内存屏障的设置粒度更新,他几乎是对每一个原子操作的接口针对不同平台都有对应的不同内存屏障的绑定。
接口实现上分别添加了_release/_acquire/_full等各个后缀,分别代表的该接口的内存屏障类型,具体说明可参见libatomic的README说明。如果是调用最顶层的接口,已AO_compare_and_swap为例,最终会根据平台的特性以及宏定义情况调用到:AO_compare_and_swap_full或者AO_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这类接口的效率不是很高)。