0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

聊聊原子变量、锁、内存屏障那点事(2)

Linux阅码场 来源:未知 作者:李倩 2018-08-30 08:23 次阅读

接上一篇文章,问题背景描述的差不多了,下面该解决方案登场了。

编译器优化乱序和CPU执行乱序的问题可以分别使用优化屏障 (Optimization Barrier)和内存屏障 (Memory Barrier)这两个机制来解决:

优化屏障 (Optimization Barrier):避免编译器的重排序优化操作,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。这就保证了编译时期的优化不会影响到实际代码逻辑顺序。

IA-32/AMD64架构上,在Linux下常用的GCC编译器上,优化屏障定义为(linux kernel, include/linux/compiler-gcc.h):


1
2

/* The "volatile" is due to gcc bugs */
#define barrier() __asm__ __volatile__("": : :"memory")

优化屏障告知编译器:

1.内存信息已经修改,屏障后的寄存器的值必须从内存中重新获取

2.必须按照代码顺序产生汇编代码,不得越过屏障

C/C++的volatile关键字也能起到优化限制的作用,但是和Java中的volatile(Java 5之后)不同,C/C++中的volatile不提供任何防止乱序的功能,也并不保证访存的原子性。

内存屏障 (Memory Barrier)分为写屏障(Store Barrier)、读屏障(Load Barrier)和全屏障(Full Barrier),其作用有两个:

防止指令之间的重排序

保证数据的可见性

关于第一点,关于指令重排,这里不考虑架构的话,Load和Store两种操作会有Load-Store、Store-Load、Load-Load、Store-Store这四种可能的乱序结果。 上文提到的三种屏障则是限制这些不同乱序的机制。

关于第二点。写屏障会阻塞直到把Store Buffer中的数据刷到Cache中;读屏障会阻塞直到Invalid Queue中的消息执行完毕。以此来保证核间各级数据的一致性。

这里要强调,内存屏障解决的只是顺序一致性的问题,不解决Cache一致性的问题(这是Cache一致性协议的责任,也不需要程序员关注)。Store Buffer和Load Buffer等组件是属于流水线的一部分,和Cache无关。这里一定要区分清楚这两点,Cache一致性协议只是保证了Cache一致性(Cache Coherence),但是不关注顺序一致性(Sequential Consistency)的问题。比如,一个处理器对某变量A的写入操作仅比另一个处理器对A的读取操作提前很短的一点时间,那就不一定能确保该读取操作会返回新写入的值。这个新写入的值多久之后能确保被读取操作读取到,这是内存一致性模型(Memory Consistency Models)要讨论的问题。

完全的确保顺序一致性需要很大的代价,不仅限制编译器的优化,也限制了CPU的执行效率。为了更好地挖掘硬件的并行能力,现代的CPU多半都是介于两者之间,即所谓的宽松的内存一致性模型(Relaxed Memory Consistency Models)。不同的架构在重排上有各自的尺度,在严格排序和自由排序之间会有各自的偏向。偏向严格排序的一边,称之为强模型(Strong Model),而偏向于自由排序的一边,称之为弱模型(Weak Model)。AMD64架构是強模型:

特别地,早先时候,AMD64架构也会有Load-Load乱序发生(Memory Ordering in Modern Microprocessors, PaulE.McKenney, 2006)。

注意这里的IA-64(Intanium Processor Family)是弱模型,它和Intel® 64不是一回事。后者是从AMD交叉授权来的,源头就是AMD64架构。这里不讨论历史,只需要知道平时说的x86-64/x64就是指的AMD64架构即可。

《Intel® 64 and IA-32 Architectures Software Developer’s Manual》有如下的阐述:

简单翻译一下:

读操作之间不能重新排序

写操作不能跟旧的读操作排序

主存写操作不能跟其他的写操作排序,但是以下情况除外:

带有CLFLUSH(失效缓存)指令的写操作

带有non-temporal move指令的流存储(写入)(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, 和 MOVNTPD,都是SSE/SSE2扩展的指令)

字符串操作(REP STOSD等)

不同内存地址的读可以与较早的写排序,同一地址的情况除外

对I/O指令、锁指令、序列化指令的读写不能重排序

读不能越过较早的读屏障指令(LFENCE)或者全屏障指令(MFENCE)

写不能越过较早的读屏障指令(LFENCE)、写屏障指令(SFENCE)和全屏障指令(MFENCE)

读屏障指令(LFENCE)不能越过较早的读

写屏障指令(SFENCE)不能越过较早的写

全屏障指令(MFENCE)不能越过较早的读和写

在多处理器的情况下,单处理器内部的内存访问排序仍然依照以上的原则,并且规定处理器与处理器之间遵循如下的原则:

某个处理器的全部写操作以同样的顺序被其它处理器观察到

不同处理器之间的写操作不重排序

排序遵循逻辑上的因果关系

第三方总是观察到一致的写操作顺序

那么上文提到的四种可能的乱序在AMD64下明确说明不会有Load-Load乱序、Load-Store乱序,明确会出现Store-Load乱序,Store-Store乱序除了几种例外的情况也不会出现。参考文献5中给出了在Linux下重现出Store-Load乱序的代码,有兴趣的读者可以自行测试。

但是内存一致性模型不仅仅是没有指令重排就会保证一致的。但是如果仅仅只考虑指令重排,完全按照该规则来思考,就会遇到违反直觉的事情。特别的,在对写缓存的同步处理上,AMD64内存访问模型的Intra-Processor Forwarding Is Allowed这个特性比较要命:

只考虑指令重排的话,AMD64架构既然不会有Load-Load重排的,r2=r4=0就不可能会出现,但是实际的结果是违反直觉的。出现这个现象的原因就是Intel对Store Buffer的处理上,Store Buffer的修改对其他CPU核心是不可见的。Processor 0对_x的修改缓存在了Processor 0的Store Buffer中,还未提交到L1 Cache,自然也不会失效掉Processor 1的L1 Cache中的相关行。Processor 1对_y的修改同理。

对于以上问题,AMD64提供了三个内存屏障指令来解决:

sfence指令为写屏障(Store Barrier),作用是:

保证了sfence前后Store指令的顺序,防止Store重排序

通过刷新Store Buffer保证sfence之前的Store要指令对全局可见

lfence指令读屏障(Load Barrier),作用是:

保证了lfence前后的Load指令的顺序,防止Load重排序

刷新Load Buffer

mfence指令全屏障(Full Barrier),作用是:

保证了mfence前后的Store和Load指令的顺序,防止Store和Load重排序

保证了mfence之后的Store指令全局可见之前,mfence之前的Store指令要先全局可见

如前文所说,AMD64架构上是不存在Load-Load重排的,但是当一个CPU核心收到其他CPU核心失效Cache Line的消息后,立即回复给对方一个应答信号。但是此时并没有立即失效掉Cache Line,而是将其包装成一个结构投递到自身的Load Buffer里。AMD64架构上不存在Load-Load重排并不意味着流水线真的就一条一条执行Load指令。在保证两个CPU核看到的Store顺序一致的情况下,是允许Load乱序的。比如连续的两个访存指令,指令1 Cache Miss,指令2 Cache Hit,实际上指令2是不会真的等待指令1的Load完成整个Cache替换过程后才执行的。实际流水线的实现中,Load先是乱序执行,然后有一个Load-ordering-Buffer(Load Buffer)的结构,在Load Commit之前检测冲突,Load过的地址是否又被其他CPU核心写过(没有存在失效信息)。只要没有冲突,这种乱序就是安全的。如果发生冲突,这种乱序就违反x86要求,需要被取消并Flush流水线。而上文提到的lfence指令会刷新Load Buffer,保证当前CPU核心立即读取到最新的数据。

另外, 除了显式的内存屏障指令,有些指令也会造成指令保序的效果,比如I/O操作的指令、exch等原子交换的指令,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。

说了这么多,环形队列(Ring buffer)在IA-32/AMD64架构上到底怎么实现才能保证安全?Linux Kernel里的KFIFO的实现可以拿来参考(include/linux/kfifo.h):

现可以拿来参考(include/linux/kfifo.h):


unsignedint__kfifo_put(structkfifo *fifo,constunsignedchar*buffer,unsignedintlen){
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.
*/
smp_mb();
/* first put the data starting from fifo->in to buffer end */
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 */
memcpy(fifo->buffer, buffer + l, len - l);
/*
* Ensure that we add the bytes to the kfifo -before-
* we update the fifo->in index.
*/
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.
*/
smp_rmb();
/* first get the data from fifo->out until the end of the 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 */
memcpy(buffer + l, fifo->buffer, len - l);
/*
* Ensure that we remove the bytes from the kfifo -before-
* we update the fifo->out index.
*/
smp_mb();
fifo->out += len;
return len;
}

代码中的smp_wmb()、smp_rmb()和smp_mb()在AMD64架构上分别对应sfence、lfence、mfence指令。但是Linux Kernel的代码要兼容所有的SMP架构,还要考虑很多弱内存模型的架构。所以这里的内存同步操作很多,但是不一定在AMD64上是必要的。当然,如果要考虑跨平台跨架构的代码,这样做是最保险的(另外Linux Kernel 4.0上KFIFO这个数据结构变化很大,内存同步操作也仅剩下smp_wmb(),这个还没顾得上研究)。

如果IA-32/AMD64架构下,Ring Buffer如果要实现单Reader和单Writer不需要内存同步,需要满足哪些特性呢?

以下面的定义为例:

structring_buffer {

uint32_tread_index;

uint32_twrite_index;

uchar_tbuffer[BUFF_LEN];

};

首先,read_index和write_index的写入操作必须是原子的,这就要求这两个变量本身在P6 Family及以后的CPU上至少是不能跨Cache行的。同时如果是32-bit的变量则P6之前的CPU还要保持32-bit字节对齐,如果是64-bit变量在IA-32上无法保障(IA-32下64bit的变量Store操作不是原子的)。另外,为了避免False Sharing,这两个变量最好按照Cache行对齐,即:

structring_buffer {

uint32_tread_index __attribute__ ((aligned(64)));

uint32_twrite_index __attribute__ ((aligned(64)));

uchar_tbuffer[BUFF_LEN];

};

然后在入队和出队的地方插入编译屏障禁止掉编译器优化,根据Intel的文档,就能保证不会出现乱序问题:

主存写操作不能跟其他的写操作排序,但是以下情况除外:

带有CLFLUSH(失效缓存)指令的写操作

带有non-temporal move指令的流存储(写入)(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, 和 MOVNTPD,都是SSE/SSE2扩展的指令)

字符串操作(REP STOSD等)

在多处理器的情况下,单处理器内部的内存访问排序仍然依照以上的原则,并且规定处理器与处理器之间遵循如下的原则:

某个处理器的全部写操作以同样的顺序被其它处理器观察到

第三方总是观察到一致的写操作顺序

至于串操作,对buffer的修改可能是memcpy之类的操作,而对index的操作是普通赋值。memcpy在某些库中的实现使用了串操作指令又会怎样?会导致Store操作乱序吗?Intel有如下的说明:

所以不担心index的修改出现在rep:stosd之前。但是这样做是有这样的前提的,即Reader和Writer当前的修改不需要立即被对方知晓,即允许一段时间内的“不一致”。否则,必然需要内存屏障来确保修改操作全局一致。

以上的结论很容易引起口水仗,所以这里再次强调该结论只是在AMD64架构下,且不考虑可移植性的情况下成立。但是,按照我个人看法,这几个屏障指令不见得在所有Intel的CPU上都是有意义的,甚至有些屏障指令在Intel某些CPU上没有该屏障本身的语义。比如lfence本意是限制Load重排,然而AMD64就没有Load-Load乱序(内存可见性另说)。这几个屏障指令更像是Intel提供给软件开发者的一个Interface,在需要加屏障的地方让开发者加吧。至于实际上需不需要,CPU本身会判断,如果不需要的话直接由CPU直接NOP掉即可。这也是一种长远的考虑,那你问我在AMD64架构的CPU上写代码的时候,需要强一致的时候加不加屏障?那当时是要加的。按照Interface写代码是最保险的,万一Intel以后出一个采用弱一致模型的CPU(替被市场淘汰的IA-64默哀三分钟),遗留代码出点问题就不好了。

下面说说锁和原子变量。对于数据竞争(Data Races)的情况,最简单和最常见的场景就是使用Mutex了,包括并不限于互斥锁、自旋锁、读写锁等。拿互斥锁来说,除了保护临界区只能有一个执行流之外,还有其他的作用。这里要引入宽松的内存一致性模型(Relaxed Memory Consistency Models)中的Release Consistency模型[6]来解释,这个模型包含了同步操作Acquire和Release:

Acquire: 在此操作后的所有读写操作必然发生在Acquire这个动作之后

Release: 在此操作前的所有读写操作必然发生在Release这个动作之前

要注意的是Acquire和Release都只保证了一半的顺序:

对于Acquire来说,并没保证Acquire前的读写操作不会发生在Acquire动作之后

对于Release来说,并没保证Release后的读写操作不会发生在Release动作之前

因此Acquire和Release的组合便形成了内存屏障。

Mutex的Lock操作暗含了Acquire语义,Unlock暗含了Release语义。这里是脱离架构在讨论的,在具体的平台上如果Load和Store操作暗含Acquire和Release语义的话自然保证一致,否则可以是相关的内存屏障指令。所以Mutex不仅会保证执行的序列化,同时也保证了访存的一致性。与之类似,平台提供的原子变量除了保证内存操作原子之外,也会保证访存的一致性。

GCC提供了Built-in的原子操作函数可以使用,GCC 4以后的版本也提供了Built-in的屏障函数__sync_synchronize(),这个屏障函数既是编译屏障又是内存屏障,代码插入这个函数的地方会被安插一条mfence指令。不过GCC 4.4以上才支持mfence,这个问题的讨论(bug?)在这里,Patch在这里。

实际上无锁的代码仅仅是不需要显式的Mutex来完成,但是存在数据竞争(Data Races)的情况下也会涉及到同步(Synchronization)的问题。从某种意义上来讲,所谓的无锁,仅仅只是颗粒度特别小的“锁”罢了,从代码层面上逐渐降低级别到CPU的指令级别而已,总会在某个层级上付出等待的代价,除非逻辑上彼此完全无关。另外,Lockfree和Lockless是两个概念,但这个话题太大,我个人尚且拿捏不住,就此打住。至于工程上,普通的程序员老老实实的用Mutex就好了,普通的计数类场景用原子变量也无可厚非。诸如无锁队列这种能明确证明其正确性的数据结构在一些场合也是很有价值的,用用无妨(但是多说一句,CAS这种乐观锁在核数很多的时候不见得高效,竞争太厉害的时候总体消耗很可能超出普通的锁)。但是如果不能做到在任何时候都能想明白顺序一致性的话,还是老老实实的用Mutex吧,否则造成的麻烦可比提升的这一点点效率折腾多了。

最后,讨论这些问题的文章太多了,各路说法到处飞,我也不敢保证这篇文章的说法全部正确,但至少我觉得是可以自圆其说的。如果你觉得哪里的描述有问题,不妨一起讨论,我们一起纠正这些错误的观点。

文章的撰写过程中参考了若干资料,下面列出的参考的资料和文章中,个别文章我只是“部分同意”原作者的观点,因为引用了作者部分说法,所以一并列出。这不代表我完全同意原作者观点,具体细节请读者自行判断(有了冲突,自然是以Intel最新文档的说法为准)。

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • amd
    amd
    +关注

    关注

    25

    文章

    5201

    浏览量

    132638
  • 编译器
    +关注

    关注

    1

    文章

    1577

    浏览量

    48625
收藏 人收藏

    评论

    相关推荐

    从硬件引申出内存屏障,带你深入了解Linux内核RCU

    本文从硬件的角度引申出内存屏障,这不是内存屏障的详尽手册,但是相关知识对于理解RCU有所帮助。
    的头像 发表于 09-19 11:39 5959次阅读
    从硬件引申出<b class='flag-5'>内存</b><b class='flag-5'>屏障</b>,带你深入了解Linux内核RCU

    ARM体系结构之内存序与内存屏障

    本文介绍 Armv8-A 架构的内存序模型,并介绍 arm 的各种内存屏障。本文还会指出一些需要明确内存保序的场景,并指明如何使用内存
    发表于 06-15 18:19 953次阅读
    ARM体系结构之<b class='flag-5'>内存</b>序与<b class='flag-5'>内存</b><b class='flag-5'>屏障</b>

    编程中的命名设计那点

    编程中的命名设计那点
    发表于 08-17 09:32

    MCU上的无原子读操作

    32位变量任何一个字节的时候,剩下的7个字节都可能改变。2、认为在中断函数建立数据拷贝这个理由同上,无论如何复制,都难以避免读的瞬间数据被破坏3、建立单字节原子该体系必须支持测试清零
    发表于 03-06 09:39

    「正点原子Linux连载」第四十七章Linux并发与竞争

    atomic_t的数据结构,原子位操作是直接对内存进行操作,API函数如表47.2.3.1所示:表47.2.3.1原子位操作函数表47.3自旋47.3.1 自旋
    发表于 03-19 15:03

    ORC科普大全

    ORC科普大全,本以为上周的《ORC发电那点》阅读量顶多200,没想到最终破千,说明关注ORC的人不少,这样小编就继续写下去吧,也谢谢各位捧场。今天我们来聊聊国际ORC发电巨头,ORMAT公司的
    发表于 07-07 07:38

    CPU和内存那点事儿

    我们之前讲过CPU,也说了CPU和内存那点事儿,今天咱就再来说说有关内存,作为一个程序员,你必须要懂的哪那些硬核知识!大白话聊一聊,很重要!先来大白话的跟大家聊一聊,我们这里说的内存
    发表于 07-27 08:02

    导致ARM内存屏障的原因究竟有哪些

    与程序员的代码逻辑不符,导致一些错误的发生,为了保证内存访问的一致性,也是保证程序的正确性,使用内存屏障来保证内存的访问顺序。ARM采用的是弱一致性
    发表于 05-09 09:32

    RT-Thread实现的互斥属性包括哪些

    的情况下尝试重新获得该互斥,则返回错误。这样就保证当不允许多次加锁时不会出现死锁。条件变量属性函数原型int pthread_condattr_init(pthread_condattr_t *attr
    发表于 08-12 15:06

    学习下ARM内存屏障(memory barrier)指令

    据标记放在普通型内存中。如果需要严格的内存访问顺序,即在需要强制排序的情况下,可以通过使用显式屏障操作来实现。处理器必须始终负责由地址依赖性引起的危险:STR X0, [X2]LDR
    发表于 02-07 14:08

    内存屏障是什么

    内存屏障,也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是CPU或编译器在对
    发表于 11-14 09:43 6259次阅读
    <b class='flag-5'>内存</b><b class='flag-5'>屏障</b>是什么

    聊聊原子变量、锁、内存屏障那点事(1)

    首先是现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。编译期指令重排是通过调整代码中的指令顺序,在不改变代码语义的前提下,对变量访问进行优化。从而尽可能的减少对寄存器的读取和存储
    的头像 发表于 08-30 08:20 3890次阅读

    Linux内核的内存屏障的原理和用法分析

    圈里流传着一句话“珍爱生命,远离屏障”,这足以说明内存屏障是一个相当晦涩和难以准确把握的东西。使用过弱的屏障,会导致软件不稳定。
    的头像 发表于 09-05 09:13 1548次阅读

    一文彻底搞懂内存屏障与volatile

    内存屏障与 volatile 是高并发编程中比较常用的两个技术,无锁队列的时候就会用到这两项技术。然而这两项技术涉及比较广的基础知识,所以比较难以理解,也比较不容易解释清楚。关于内存屏障
    的头像 发表于 11-29 11:43 1839次阅读

    C语言:指针中内存是如何存放变量

    在程序中定义一个变量,那么在程序编译的过程中,系统会根据你定义变量的类型来分配「相应尺寸」的内存空间。那么如果要使用这个变量,只需要用变量
    发表于 01-08 10:14 263次阅读