,接上篇《关于多线程同步的一切:伪共享》,原子,意味着不可切分的最小单元,程序中的原子操作指任务不可切分到更小的步骤。,原子性(atomic)是一个可见性的概念:,注意:我们说的是从线程视角观察不到完成一半的状态,而并非不存在物理上的进度状态,它取决于你的观察视角。,比如说一个线程中被互斥锁保护的区域,对另一个线程是atomic的,因为从另一个线程视角来看,它没法进入临界区读到数据中间状态,但是对kernel而言却不是atomic的。,从线程视角只能观察到未做和已做两种状态,观察不到完成一半的状态,任务执行不会被中断,也不会穿插进其他操作。,原子性对多线程操作是一个非常重要的属性,因为它不可切分,所以,一个线程没法在另一个线程执行原子操作的时候穿插进去。,比如一个线程原子的写入共享数据,那么其他线程没有办法读到“半修改的数据”;同样,如果一个线程原子读取共享数据,那么它读取的是共享变量在那个瞬间的值,因此原子的读和写没有数据竞争(Data Race)。,原子操作常用于与顺序无关的场景。,原子指令指单一的不可再分的不可中断的被硬件直接执行的机器指令,原子指令是无锁编程的基石。,原子指令常被分成两类:,通常,一条简单的store/load机器指令是原子的,比如数据复制指令(mov)可以把内存位置的数据读取到CPU寄存器,相当于Load数据。,x86架构读/写“按数据类型对齐要求对齐的长度不大于机器字长的数据”是原子的。,那什么是数据类型对齐要求呢?,比如在x86_64架构LLP64系统上(LLP64指long、long long和pointer类型是64位的),只要int32类型数据满足放置在起始地址除4为0,int64/long类型数据满足起始地址除8为0,则该数据就是满足类型对齐要求,那么对它的读和写,都是原子的。,一字节的数据读写一定是原子的。,其实,Intel新CPU架构确保读写放置在一个Cache Line的数据(不大于机器字长),跨Cache Line的数据访问无法保证原子性。,C/C++编程中,变量和结构体会自动满足对齐要求,比如:,全局变量i会被放置在起始地址可以被4整除的内存位置,局部变量y会被放置在起始地址可以被8整除的内存位置,而结构体内的x成员会被放置在起始地址可以被4整除的内存位置。,为了把ptr安置在起始地址可以被8整除的内存位置,编译器会在s后加入填充,从而使得ptr也满足对齐要求。,通过C malloc()接口动态分配的内存,其返回值一般也会对齐到8/16字节,如果有更高的内存对齐要求,可以通过aligned_alloc(alignment, size)接口。C++中的alignas关键字用于设置结构或变量的对齐要求。,对一个满足对齐要求的不大于机器字长的类型变量赋值是原子的,不会出现半完成(即只完成一半字节的赋值),读的情况亦如此。,注意:对长度大于机器字长的数据读写,是不符合原子操作特征的,比如在x86_64系统上,对下面结构体变量的读写都是非原子的:,foo1包含3个int成员共12字节,大于机器字长8字节,所以对`foo1 = f`不是原子的。,基于以上知识,我们便知道,一些getter/setter接口,即使在多线程环境下,也可以不用加锁,比如:,但是,如果你把一块buf,强转成Foo,然后调用它的getter/setter,则是危险的,有可能破坏前述的对齐要求。,如果你把一个int变量编码进一个buf,则最好使用memcpy,而不是强转+赋值。,但有时候,我们需要更复杂的操作指令,而不仅仅是单独的读或写,它需要把几个动作组合在一起完成某项任务。,比如语句`++count`对应到“读+修改+写”三个操作,但这3个操作不是一个原子操作。所以,多线程程序中使用`++count`,多个执行流会交错执行,会导致计数错误(通常结果比预期数值小)。,考虑另一个情况:读+判断,来我们看一下经典单件实现:,因为对instance的判断和`instance = new Singleton`不是原子的,所以,我们需要加锁:,但为了性能,更好的方案是加双检,代码变成下面这样:,第一个检查,如果instance不为空,那么直接返回instance,大多数时候命中这个情况,因为instance一旦被创建,就不再为空。,如果instance为空,那么再加锁、然后第二次检查instance是否为空,为什么要双检呢?因为前面的检查通过后,有可能其他线程创建了实例,导致instance不再为空。,看起来一切都挺好的,高效又缜密。,但双检真的安全吗?这其实是一个非常经典的问题。它有2个风险:,逻辑上,需要几个操作是一个密不可分的整体,现代CPU通常都直接提供这类原子指令的支持,这类RMW原子指令通常包括:,,以上所有操作都是在一个内存位置执行多个动作,但这些操作都是原子单步的,它不会被中断,也不会穿插进其他操作,这个重要属性使得RMW指令非常适合用来实现无锁编程。,虽然CPU在执行机器指令的时候,会把它分成更小粒度的微指令(micro-operations),但程序员应把关注点放在微指令上层的原子指令上。,前面讲的原子指令是硬件层面,不同架构甚至不同型号CPU有不同的原子指令,它是CPU层面的东西,跨平台特性差,用它编写的代码不可移植,所以应该尽量避免直接使用原子指令。,,回到软件层面,软件层面的原子操作包括三个层次:,(1) 操作系统层面,linux操作系统提供atomic这种原子类型,配合相关的编程接口使用,大多数它只是对原子指令的简单封装,但它屏蔽了硬件差异,比原子指令更易用:,(2) 编译器层面,gcc提供原子操作build-in函数,使用gcc编译c/c++代码,可以直接使用它们:,gcc在实现C++11之后,新的原子接口,以__atomic为前缀,推荐使用下面这些接口:,(3) 编程语言层面,也通常提供原子操作类型和接口,这也是使用原子操作的推荐方式,它有良好的跨平台性和可移植性,程序员应优先使用它们:,
© 版权声明
文章版权归作者所有,未经允许请勿转载。