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

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

3天内不再提示

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

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

突然想聊聊这个话题,是因为知乎上的一个问题多次出现在了我的Timeline里:请问,多个线程可以读一个变量,只有一个线程可以对这个变量进行写,到底要不要加锁?可惜的是很多高票答案语焉不详,甚至有所错漏。所以我想在这篇文章里斗胆聊聊这个水挺深的问题。受限于个人水平,文章若有错漏,还望读者不吝赐教。

首先约定,由于CPU的架构和设计浩如烟海,本文站在工程师的角度,只谈IA32/AMD64(x86-64)架构,不讨论其他架构的细节和差异。并且文章中主要引用Intel的文档予以佐证,不关注AMD在实现细节上的差异。

众所周知,当一个执行中的程序的数据被多个执行流并发访问的时候,就会涉及到同步(Synchronization)的问题。同步的目的是保证不同执行流对共享数据并发操作的一致性。早在单核时代,使用锁或者原子变量就很容易达成这一目的。甚至因为CPU的一些访存特性,对某些内存对齐数据的读或写也具有原子的特性。

比如,在《Intel® 64 and IA-32 Architectures Software Developer’s Manual》的第三卷System Programming Guide的Chapter 8 Multiple-Processor Management里,就给出了这样的说明:

也就是说,有些内存对齐的数据的访问在CPU层面就是原子进行的(注意这里说的只是单次的读或者写,类似普通变量i的i++操作不止一次内存访问)。此时,环形队列(Ring buffer)这种数据结构在某些架构的单核CPU上,只有一个Reader和一个Writer的情况下是不需要额外同步措施的。原因就是read_index和writer_index的写操作在满足对齐内存访问的情况下是原子的,不需要额外的同步措施。注意这里我加粗了单核CPU这个关键字,那么到了多核心处理器的今天,该操作就不是原子了吗?不,依旧是原子的,但是出现了其他的干扰因素迫使可能需要额外的同步措施才能保证原本无锁代码的正确运行。

首先是现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。编译期指令重排是通过调整代码中的指令顺序,在不改变代码语义的前提下,对变量访问进行优化。从而尽可能的减少对寄存器的读取和存储,并充分复用寄存器。但是编译器对数据的依赖关系判断只能在单执行流内,无法判断其他执行流对竞争数据的依赖关系。就拿无锁环形队列来说,如果Writer做的是先放置数据,再更新索引的行为。如果索引先于数据更新,Reader就有可能会因为判断索引已更新而读到脏数据。

那禁止编译器对该类变量的优化,解决了编译期的重排序就没事了吗?不,CPU还有乱序执行(Out-of-Order Execution)的特性。流水线(Pipeline)和乱序执行是现代CPU基本都具有的特性。机器指令在流水线中经历取指、译码、执行、访存、写回等操作。为了CPU的执行效率,流水线都是并行处理的,在不影响语义的情况下。处理器次序(Process Ordering,机器指令在CPU实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,即满足As-if-Serial特性。显然,这里的不影响语义依旧只能是保证指令间的显式因果关系,无法保证隐式因果关系。即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。从此单核时代CPU的Self-Consistent特性在多核时代已不存在,多核CPU作为一个整体看,不再满足Self-Consistent特性。

简单总结一下,如果不做多余的防护措施,单核时代的无锁环形队列在多核CPU中,一个CPU核心上的Writer写入数据,更新index后。另一个CPU核心上的Reader依靠这个index来判断数据是否写入的方式不一定可靠。index有可能先于数据被写入,从而导致Reader读到脏数据。

所有的麻烦到这里就结束了吗?当然不,还有Cache的问题。前文提到的都是顺序一致性(Sequential Consistency)的问题,没有涉及Cache一致性(Cache Coherence)的问题。虽然说一般情况下程序员只需要关注顺序一致性即可,但是区分清楚这两个概念也能更好的解释内存屏障(Memory Barrier)。

开始提到Cache一致性协议之前,先介绍两个名词:

Load/Read CPU读操作,是指将内存数据加载到寄存器的过程

Store/Write CPU写操作,是指将寄存器数据写回主存的过程

现代处理器的缓存一般分为三级,由每一个核心独享的L1、L2 Cache,以及所有的核心共享L3 Cache组成:

由于Cache的容量很小,一般都是充分的利用局部性原理,按行/块来和主存进行批量数据交换,以提升数据的访问效率。以前写过一篇《浅析x86架构中cache的组织结构》,这里不再赘述。既然各个核心之间有独立的Cache存储器,那么这些存储器之间的数据同步就是个比较复杂的事情。缓存数据的一致性由缓存一致性协议保证。这里比较经典的当属MESI协议。Intel的处理器使用从MESI中演化出的MESIF协议,而AMD使用MOESI协议。缓存一致性协议的细节超出了本文的讨论范围,有兴趣的读者可以自行研究。

传统的MESI协议中有两个行为的执行成本比较大。一个是将某个Cache Line标记为Invalid状态,另一个是当某Cache Line当前状态为Invalid时写入新的数据。所以CPU通过Store Buffer和Invalidate Queue组件来降低这类操作的延时。如图:

当一个核心在Invalid状态进行写入时,首先会给其它CPU核发送Invalid消息,然后把当前写入的数据写入到Store Buffer中。然后异步在某个时刻真正的写入到Cache Line中。当前CPU核如果要读Cache Line中的数据,需要先扫描Store Buffer之后再读取Cache Line(Store-Buffer Forwarding)。但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache Line之后才会触发失效操作。而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate Queue中,随后异步将其设为Invalid状态。和Store Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题。当然这里的Store Buffer和Invalidate Queue的说法是针对一般的SMP架构来说的,不涉及具体架构。事实上除了Store Buffer和Load Buffer,流水线为了实现并行处理,还有Line Fill Buffer/Write Combining Buffer 等组件,参考文献8-10给出了相关的资料可以进一步阅读。

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

    关注

    31

    文章

    5317

    浏览量

    119997
  • cpu
    cpu
    +关注

    关注

    68

    文章

    10824

    浏览量

    211126
  • 编译器
    +关注

    关注

    1

    文章

    1618

    浏览量

    49047

原文标题:浅墨: 聊聊原子变量、锁、内存屏障那点事(1)

文章出处:【微信号:LinuxDev,微信公众号:Linux阅码场】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

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

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

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

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

    详解Linux内核原子操作

    原子操作(atomic operation),不可分割的操作。其通过原子变量来实现,以保证单个CPU周期内,读写该变量,不能被打断,进而判断该变量
    发表于 07-04 11:16 465次阅读

    编程中的命名设计那点

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

    MCU上的无原子读操作

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

    CPU和内存那点事儿

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

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

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

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

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

    内存屏障是什么

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

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

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

    可以了解并学习Linux 内核的同步机制

    Linux内核同步机制,挺复杂的一个东西,常用的有自旋,信号量,互斥体,原子操作,顺序,RCU,内存屏障等。
    发表于 05-14 14:10 689次阅读

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

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

    Rust原子类型和内存排序

    原子类型在构建无数据结构,跨线程共享数据,线程间同步等多线程并发编程场景中起到至关重要的作用。本文将从Rust提供的原子类型和原子类型的内存
    的头像 发表于 10-31 09:21 907次阅读

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

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

    如何实现一个多读多写的线程安全的无队列

    加锁。那么如何实现一个多读多写的线程安全的无队列呢? 互斥:mutexqueue(太简单不介绍了) 互斥+条件变量:blockqueue(太简单不介绍了)
    的头像 发表于 11-08 15:25 1199次阅读
    如何实现一个多读多写的线程安全的无<b class='flag-5'>锁</b>队列