Compare-And-Swap(CAS)详解

在C++11中关于CAS的操作其中有两个,分别是compare_exchange_weak和compare_exchange_strong,CAS为什么还有强弱之分呢?

实现CAS的操作包括两个:

  • 实现原子性CAS原语
  • 实现LL/SC对(Load-Linked/Store-Conditiona)

为什么会存在LL/SC对的使用,而不直接实现CAS原语呢?

要说明LL/SC对的使用,不得不说一个问题,ABA问题。

ABA

在多线程场景下CAS会出现ABA问题,关于ABA问题这里简单科普下,例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下

线程1,期望值为A,欲更新的值为B
线程2,期望值为A,欲更新的值为B

线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程。

Load-Linked/Store-Conditional(LL/SC对)

在多线程程序中,为了实现对共享变量的互斥访问,一般都会用spinlock实现,而spinlock需要一个TestAndSet的原子操作。而这种原子操作是需要专门的硬件支持才能完成的,在MIPS中,是通过特殊的Load,Store操作LL(Load Linked,链接加载)以及SC(Store Conditional,条件存储)完成的。

LL 指令的功能是从内存中读取一个字,以实现接下来的 RMW(Read-Modify-Write) 操作;SC 指令的功能是向内存中写入一个字,以完成前面的 RMW 操作。LL/SC 指令的独特之处在于,它们不是一个简单的内存读取/写入的函数,当使用 LL 指令从内存中读取一个字之后,比如 LL d, off(b),处理器会记住 LL 指令的这次操作(会在 CPU 的寄存器中设置一个不可见的 bit 位),同时 LL 指令读取的地址 off(b) 也会保存在处理器的寄存器中。接下来的 SC 指令,比如 SC t, off(b),会检查上次 LL 指令执行后的 RMW 操作是否是原子操作(即不存在其它对这个地址的操作),如果是原子操作,则 t 的值将会被更新至内存中,同时 t 的值也会变为1,表示操作成功;反之,如果 RMW 的操作不是原子操作(即存在其它对这个地址的访问冲突),则 t 的值不会被更新至内存中,且 t 的值也会变为0,表示操作失败。

SC 指令执行失败的原因有两种:

  • 在 LL/SC 操作序列的过程中,发生了一个异常(或中断),这些异常(或中断)可能会打乱 RMW 操作的原子性。
  • 在多核处理器中,一个核在进行 RMW 操作时,别的核试图对同样的地址也进行操作,这会导致 SC 指令执行的失败。

在一般实现中,处理器有两个专门的域给LL和SC指令,即上文中的“不可见的bit位”以及保存ll操作地址的“寄存器”。再LL之后,处理器会监测各种事件,当发生异常或者有别的处理器对该地址发了invalid请求时,会将不可见的bit位重置,从而导致后面的SC失败。

通过LL/SC对实现的CAS并不是一个原子性操作,但是它确实执行了原子性的CAS,目标内存的单元内容要么不变,要么发生原子性变化。

compare_exchange_weak和compare_exchange_strong

由于通过LL/SC对实现的CAS并不是一个原子性操作,于是在该CAS的执行过程中,可能会被中断,例如:线程X在执行LL行后,OS决定将X调度出去,等OS重新调度恢复X之后,SC将不再响应,这时CAS将返回false,CAS失败的原因不在数据本身(数据没变化),而是其他外部事件(线程被中断了)。

正是因为如此,C++11标准中添入两个compare_exchange原语-弱的和强的。也因此这两原语分别被命名为compare_exchange_weak和compare_exchange_strong。即使当前的变量值等于预期值,这个弱的版本也可能失败,比如返回false。可见任何weak CAS都能破坏CAS语义,并返回false,而它本应返回true。而Strong CAS会严格遵循CAS语义。

那么,何种情形下使用Weak CAS,何种情形下使用Strong CAS呢?通常执行以下原则:

  • 倘若CAS在循环中(这是一种基本的CAS应用模式),循环中不存在成千上万的运算(循环体是轻量级和简单的,本例的无锁堆栈),使用compare_exchange_weak。否则,采用强类型的compare_exchange_strong。
False sharing(伪共享)

现代处理器中,cache是以cache line为单位的,一个cache line长度L为64-128字节,并且cache line呈现长度进一步增加的趋势。主存储和cache数据交换在 L 字节大小的 L 块中进行,即使缓存行中的一个字节发生变化,所有行都被视为无效,必需和主存进行同步。存在这么一个场景,有两个变量share_1和share_2,两个变量内存地址比较相近被加载到同一cahe line中,cpu core1 对变量share_1进行操作,cpu core2对变量share_2进行操作,从cpu core2的角度看,cpu core1对share_1的修改,会使得cpu core2的cahe line中的share_2无效,这种场景叫做False sharing(伪共享)。

由于LL/SC对比较依赖于cache line,当出现False sharing的时候可能会造成比较大的性能损失。加载连接(LL)操作连接缓存行,而存储状态(SC))操作在写之前,会检查本行中的连接标志是否被重置。如果标志被重置,写就无法执行,SC返回 false。考虑到cache line比较长,在多核cpu中,cpu core1在一个while循环中变量share_1执行CAS修改,而其他cpu corei在对同一cache line中的变量share_i进行修改。在极端情况下会出现这样的一个livelock(活锁)现象:每次cpu core1在LL(share_1)后,在准备进行SC的时候,其他cpu core修改了同一cache line的其他变量share_i,这样使得cache line发生了改变,SC返回false,于是cpu core1又进入下一个CAS循环,考虑到cache line比较长,cache line的任何变更都会导致SC返回false,这样使得cup core1在一段时间内一直在进行一个CAS循环,cpu core1都跑到100%了,但是实际上没做什么有用功。

为了杜绝这样的False sharing情况,我们应该使得不同的共享变量处于不同cache line中,一般情况下,如果变量的内存地址相差住够远,那么就会处于不同的cache line,于是我们可以采用填充(padding)来隔离不同共享变量,如下:

struct Foo {
int volatile nShared1;
char _padding1[64]; // padding for cache line=64 byte
int volatile nShared2;
char _padding2[64]; // padding for cache line=64 byte
};

上面,nShared1和nShared2就会处于不同的cache line,cpu core1对nShared1的CAS操作就不会被其他core对nShared2的修改所影响了。

上面提到的cpu core1对share_1的修改会使得cpu core2的share_2变量的cache line失效,造成cpu core2需重新加载同步share_2;同样,cpu core2对share_2变量的修改,也会使得cpu core1所在的cache line实现,造成cpu core1需要重新加载同步share_1。这样cpu core1的一个修改造成cpu core2的一个cache miss,cpu core2的一个修改造成cpu core1的一个cache miss的反复现象就是所谓的Cache ping-pong问题,出现大量Cache ping-pong意味着大量的cache miss,会造成巨大的性能损失。我们同样可以采用填充(padding)来隔离不同共享变量来解决cache ping-pong。

参考

https://www.jiqizhixin.com/articles/2019-01-22-12

发布了337 篇原创文章 · 获赞 14 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/LU_ZHAO/article/details/105284019