本文摘译自维基百科。
分析比较了在各种指令集体系结构上常见的两种原子指令:比较并交换(CAS:Compare And Swap)和锁读条件写(LL/SC:Load-Link/Store-Conditional)。
比较并交换
概述
比较并交换是多线程中用来实现同步的原子指令,它需要三个参数:内存地址,旧值、新值。指令首先比较内存地址中保存的值与旧值,若相同,则把新值写入内存地址;若不同,则指令执行失败。指令的执行过程是原子的,不可被中断的。指令的返回值可以是指示指令执行成功与否的布尔值,也可以是从内存地址中读出来的值。指令的伪代码形式如下。
function cas(p: pointer to int, old: int, new: int) returns bool{
if *p != old
return false
*p = new
return true
}
CAS指令的目的是,从地址读取一个旧值,在旧值的基础上,计算出一个新值,然后再把新值写入地址。若在读取旧值之后,该地址又被其他进程或线程写入,则CAS指令失败。此时必须重新从地址读取,获得旧值,重新计算新值,方能完成特定的算法。
作为最基本的同步机制,CAS可以被用来实现信号量和互斥锁等同步机制,也可以用来实现复杂的无锁和无等待算法。Maurice Herlihy于1991年证明,CAS和LL/SC是等价的,都可以被用来实现原子读、原子写、fetch-and-add等等复杂的同步算法。下面是用CAS实现的原子计数器的算法的伪代码。
function add(p: pointer to int, a: int) return int {
done = false
while not done{
value = *p
done = cas(p, value, value + a)
}
return value + a
}
ABA问题
一些基于CAS的算法需要处理可能的误报,也就是旧值发生了某种形式的变化,但是未被察觉,也被叫做ABA问题。一个典型的例子就是,内存中保存的值是一个地址,算法的执行过程中,该值被其他进程或者线程修改之后,最终的值与原来的一样,但是这个地址可能已经从有效空间转入被垃圾收集的空间了。此时,CAS指令不能够察觉此种变化,从而可能会引发一些错误。
解决ABA问题的一个有效办法是采用两倍长度的CAS,例如把32位的CAS改成64位的CAS。其中一半的长度不变,另一半的长度保存一个计数器,只有当值和计数器都没有变化时,才写入新值,更新计数器。这就大大降低了ABA问题的发生概率(除非值和计数器都发生了变化,并且变得和原来一样)。
另一个用来解决ABA问题的办法是安全的地址回收(SMR: Safe Memory Reclamation),这要求每次给出的指针值都与历史上出现的值不同,这就彻底解决了ABA问题。
代价与收益
CAS指令的出现,使得多核机器上,处理器能够以原子操作的方式测试并修改一个内存地址,有效地实现了同步机制。
CAS指令的代价较低,大约与从内存中读取数据的指令的代价相当。发表于2013年的一篇文章指出,在Intel Xeon(Westmere-EX)系统上,CAS的代价是一条非cache读指令的1.15倍,在AMD Opteron(Magny-Cours)系统上是1.35倍。
实现
从1970年起,CAS指令就作为IBM 370系列芯片(及所有后继芯片)的一部分,用来支持处理器的并行,并且替代了早期的“disabled spin locks”和“test-and-set”等机制。
x86(从80486开始)和安腾架构中把CAS指令实现为CMPXCHG(compare and exchange)指令。
截至2013年,绝大多数的微处理器架构都在硬件级别支持CAS指令。CAS也成为了支持同步机制的最流行的指令。Linux核心的原子计数器和原子位掩码都使用了CAS形式的实现。而SPARC 32和PA-RISC是不支持CAS的少数处理器的代表,把Linux移植到它们上面时,使用了spinlock。
锁读条件写
概述
load-link和store-conditional是多线程中用来实现同步机制的一对指令。load-link指令返回内存位置的当前值,后续的store-conditional指令先检查内存值是否在load-link指令之后是否被更新,若没有,则向内存地址写入新值。
对于LL/SC指令来说,如果一个地址经过若干次被修改之后,又恢复了原来的值,该指令仍然会执行失败。从这一点上来说,LL/SC指令比CAS指令的要求更强,因为CAS在这种情况下会成功执行。
但是在LL/SC的实际实现中,即使没有对指定内存的修改,LL/SC指令也很可能会失败。因为上下文的切换、其他的load-link操作、甚至是其他普通的load或者store操作,都可能导致store-conditional条件失败。在一些老的实现中,内存总线上任何更新信息的广播都可能导致LL/SC的失败。上述这些容易失败的LL/SC被一些研究者称为弱LL/SC。
实现
很多芯片上都实现了LL/SC指令,如下所示。
1. Alpha: ldl_l/stl_c和ldq_l/stq_c
2. Power/PC: lwarx/stwcx, ldarx/stdcx
3. MIPS: ll/sc
4. ARM: Idrex/strex (ARMv6和v7), ldaxr/strlxr (ARM v8)
5. RISC-V: lr/sc
一般来说,CPU以cache行的粒度来处理内存地址,对cache行的任何改动(通过其他的store-conditional或者只是普通的store操作),都足以导致store-conditional的失败。
所有上述平台的实现都是某种形式的弱LL/SC。
PowerPC的实现,允许在LL/SC指令执行期间对其他cache line的读取甚至存储。
在一些ARM的实现中,定义了一些与体系结构相关的块,大小从8字节到2048字节不等。在LL/SC指令对的执行期间,任何其他访问同一个内存块的正常指令都会导致LL/SC指令失败。在另外一些ARM的实现中,任何对于任意地址空间的修改都会导致LL/SC指令失败。前者的实现更强一些,并且更实用。
与CAS指令相比,LL/SC指令的优点在于它更适合RISC体系结构,主要体现在以下两个方面。
1. 读指令和写指令分离,符合设计理念。
2. 指令都只需要两个寄存器(分别存放地址和值),符合RISC的ISA。CAS则需要3个寄存器:地址、旧值和新值,并且读和写之间还有依赖关系。