并发系列(一)——原子操作
这个系列纯粹个人工作和学习以来关于并发的简单总结。
概略
并发可以粗暴分为两大类——加锁与无锁。如果场景对性能有一定追求,那无锁无疑是第一选择。无锁有多种实现,其中最基本的就是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方面则是提供几个指令。命令个数和行为都因平台而异。但可以粗暴分成两种。
-
写屏障 Store
覆盖处理器存储缓存中所有的值。这意味着所有其他核都察觉到了处理的写。
理解如下:存储缓存中的一个值,只有它收到无效广播的回应才能覆盖。而一个核收到无效广播时,表明如果它持有这个值的缓存,那么这个缓存已经无效了。我们可以说所有的核都收到值已经更新到了最新的值的消息了。当然新的值它还需要去拉。同时为了简化模型,我们可以认为此时新的值已经写到了内存中。
-
读屏障 Load
执行无效队列中的指令。这以为着处理器读到的值都会最新的。
理解如下:无效队列中操作会把一个值的缓存设置为无效。当执行后,再次读取,处理器都需要去内存拉取最新的值。
原子顺序
拥有读写屏障后,我们就可以简单实现原子顺序了。
-
Released
不加限制
-
Released-Acquired
单处理器的读写屏障结合。
-
Sequence
多处理器的读写屏障结合。
原子指令
相对原子顺序,原子指令比较简单。一般而言,递增一个值需要先读取一个值到缓存,缓存递增,然后缓存写回内存。分为三步。但是CPU可以直接提供一个包含三步为一步的指令。
实践
-
状态位
把一个数据结构包装成状态机,用原子操作来修改其状态位是一个很常规的操作。原子操作提供
-
指针
将两个指针合并为一个32位值,分别存放在高16位和低16位是并发窃取队列中常见操作。
-
计数
多个线程对一个field进行递增计数时,可以使用原子操作,原子顺序可以是最松散的
releaxed
。
TODO 实践需要增加实际的栗子和详细描述?