并发系列(一)——原子操作

这个系列纯粹个人工作和学习以来关于并发的简单总结。

概略


并发可以粗暴分为两大类——加锁与无锁。如果场景对性能有一定追求,那无锁无疑是第一选择。无锁有多种实现,其中最基本的就是Atomic原子操作。这也是第一篇的主题。

原子操作一般伪代码如下

 // 写
 y.store (20, memory_order);
 // 读
 x.load (memory_order);

上述代码就可以看出,原子操作分为两个方面:原子读写与顺序。

重排序


CPU在执行程序时往往不是严格按照代码的指令顺序执行。只要执行结果与程序在单核环境下执行结果一致这个原则,那么CPU可以自由排列指令顺序。排序的原因往往是为了加快执行速度。不仅如此,在不违背上述原则的情况下,编译器往往也会根据一些特定的规则对代码指令顺序进行重新排序。同样也是为了能更快执行程序。因此,重排序可发生在两个时机。一个是编译时,一个是运行时。

编译器重排

编译器重排序容易理解。根据局部性原理,将同一个值的读写操作放在一起往往能取得不错的性能提升。如

int x,y;
x+=y;
y=0;

重排为

int x,y;
y=0;
x+=y;

不仅不会违背原则,同时也能利用局部性原理。

运行期重排

CPU运行期间的重排形成原因则更加复杂。首先我们画一个简单的内存体系示意图。

当一个核读取数据时,它会从L1读取。如果持续未命中,则依次从L2,L3,主内存读取,直到命中返回。这种内存体系带来巨大的性能提升的同时,我们可以很自然想到一个问题。什么时候写回下一级缓存?

答案是:无法确定。我们直到每跨一级缓存,读写速率都会发生变化。因此,写回下一级是一个耗时操作。当当前缓存不足,数据被替换出去,才被写到下一级缓存。同时,我们知道,每个核都是独立。从每个核的视角中,电脑中是不存在其他核的。自己只管飞速执行当前程序,其他不管。

这导致一个经典的缓存问题:缓存一致性问题。

假设两个核同时都从主内存读取了同一个值到了各自的L1。在某个时间,其中一个核修改了数据,那么另一个核L1缓存的数据就过时。二核缓存出现了不一致。

为了解决缓存不一致问题,底层提供多种方案。现在只大略说下其中一种:MESI。

MESI是一种缓存一致性协议。它为每个值的指针上设置一个2bit标识位。

  • E(Exclusive) 数据被一个核独占,且与内存一样。
  • S(Shared) 数据被多个核共享状态,且与内存一样
  • M(Modify) 修改状态
  • I(Invalid) 无效状态

当一个数据被核A从内存加载到缓存时,这个数据被设置为E状态。表示它仅被核A独占,且未被修改。当核B也加载同一个数据时,会检测到数据已被核A独占,设置状态位为S。同时,核A也会修改为S。这个时候,核A修改了数据,那么它就会修改状态为M,并广播所有核。拥有这个数据的核修改状态为I。当核B再次读写这个数据,它发现了这个数据为I,就会要求核A把数据写回共有缓存或者内存,再读取。

上述是协议简单的描述。具体的更加复杂,感兴趣可以谷歌。

上述描述中,又出现了一个问题。在上面中,修改本地缓存数据,核A需要广播。这里还有一个细节,那就是它需要等待其他核的确认才能进行修改。这对核来说也是一个阻塞操作。为了提升性能,我们加入了一个存储缓存和无效队列。核A将值写入这个缓存,等待其他核确认再将该值覆盖到数据对应指针所在的位置。

上述加缓存的操作又带来新的风险,那就是什么时候将值覆盖回去是不确定的。

static boolean a = false;
static int value = 0;

thread 1 (on core A) 
value = 10;
a = true;

thread 2 (on core B)
if(a){
	assert value == 10;
}

假设上述代码编译器没有重排序,那么是否thread 2一定会正确返回?不一定。假设thread 1保存着a(状态为E),但不保存value(假设状态为Invalid)。

这个假设下,因为状态为I,thread 1首先把value写入存储缓存,然后广播给其他核,让他们把自己的值写入内存。其他核会把广播消息放入无效队列,并立刻回应thread 1。至于无效队列中的操作何时处理是不确定的。thread 1继续把a值写入缓存,因为是E,所以立刻完成。尽管在在thread 1视角,读取value时,需要先查看存储缓存,再查看自己的本地缓存,因此value是已经修改的,为10。但是对于其他线程来说,thread 1没把值覆盖回去,那么value的值就是0。

上述过程中出现了短暂的“脏读”,因为无效队列中的操作执行是不确定,因此thread 2可能不知道自己的值已经无效了,但这是可以接受的。这种情况下,对外接来说,上述程序似乎发生了重排。thread 1的执行顺序为

thread 1 (on core A) 
a = true;
value = 10;

事实上,关于执行效率和缓存是否一致达成了微妙的平衡。

重排序解决方案

为了解决重排序带来的问题,编译器和CPU都提供了解决方案——内存屏障,也就是原子顺序。编译器的内存屏障就是禁止命令重排,CPU方面则是提供几个指令。命令个数和行为都因平台而异。但可以粗暴分成两种。

  1. 写屏障 Store

    覆盖处理器存储缓存中所有的值。这意味着所有其他核都察觉到了处理的写。

    理解如下:存储缓存中的一个值,只有它收到无效广播的回应才能覆盖。而一个核收到无效广播时,表明如果它持有这个值的缓存,那么这个缓存已经无效了。我们可以说所有的核都收到值已经更新到了最新的值的消息了。当然新的值它还需要去拉。同时为了简化模型,我们可以认为此时新的值已经写到了内存中。

  2. 读屏障 Load

    执行无效队列中的指令。这以为着处理器读到的值都会最新的。

    理解如下:无效队列中操作会把一个值的缓存设置为无效。当执行后,再次读取,处理器都需要去内存拉取最新的值。

原子顺序

拥有读写屏障后,我们就可以简单实现原子顺序了。

  • Released

    不加限制

  • Released-Acquired

    单处理器的读写屏障结合。

  • Sequence

    多处理器的读写屏障结合。

原子指令

相对原子顺序,原子指令比较简单。一般而言,递增一个值需要先读取一个值到缓存,缓存递增,然后缓存写回内存。分为三步。但是CPU可以直接提供一个包含三步为一步的指令。

实践


  1. 状态位

    把一个数据结构包装成状态机,用原子操作来修改其状态位是一个很常规的操作。原子操作提供

  2. 指针

    将两个指针合并为一个32位值,分别存放在高16位和低16位是并发窃取队列中常见操作。

  3. 计数

    多个线程对一个field进行递增计数时,可以使用原子操作,原子顺序可以是最松散的releaxed

TODO 实践需要增加实际的栗子和详细描述?

posted @ 2020-12-21 08:44  SourMango  阅读(217)  评论(0编辑  收藏  举报