原子操作
在多核同步的众多手段中,原子操作可以说是最基础的,但需要注意的是,单核系统(UP)同样需要原子操作,只不过多核系统(SMP)要比单核系统中的原子操作面临更多的问题。“原子(atom)”一词来自希腊语,意思是“不可分割(indivisible)”。当然,现代物理学中所说的“原子”并非是不可分割的。
UP的原子读/写
在UP系统中,如果CPU仅仅是从内存中读取(read/load)一个变量的值,或者仅仅是往内存中写入(write/store)一个变量的值,都是不可打断,也不可分割的。Linux中实现原子性的读一个变量与原子性的写一个变量的函数分别是atomic_read()和atomic_set()。
#define atomic_read(v) READ_ONCE((v)->counter)
#define atomic_set(v,i) WRITE_ONCE(((v)->counter), (i))
READ_ONCE()和WRITE_ONCE()取代了以前的ACCESS_ONCE(),它们的实现很简单,以32位的变量操作为例:
READ_ONCE(p) --> *(__u32 *)res = *(volatile __u32 *)p
WRITE_ONCE(p, val) --> *(volatile __u32 *)x = *(__u32 *)val;
就是直接赋值,并没有借助什么特殊的指令。那为什么要使用这两个宏呢?原因将在下文给出。
UP的原子RMW
如果CPU不是简单地读/写一个变量,而是需要修改一个变量的值,那么它首先需要将变量的值从内存读取到寄存器中(read),然后修改寄存器中变量的值(modify),最后将修改后的值写回到该变量所在的内存位置(write),这一过程被称为RMW(Read-Modifiy-Write)。
RMW操作有可能被中断所打断,因而不是原子的。那如何保证RMW操作的原子性呢?最简单的方法就是关闭中断。
比如给变量加上一个常数的原子操作atomic_add(),就可以这样实现:
void atomic_add(int i, atomic_t *v)
{
unsigned long flags;
raw_local_irq_save(flags);
v->counter += i;
raw_local_irq_restore(flags);
}
SMP的原子读/写
在SMP系统中,多个CPU通过共享的总线和内存相连接(参考这篇文章),如果它们同时申请访问内存,那么总线就会从硬件上进行仲裁,以确定接下来哪一个CPU可以使用总线,然后将总线授权给它,并且允许该CPU完成一次原子的load操作或者store操作。在完成每一次操作之后,就会重复这个周期:对总线进行仲裁,并授权给另一个CPU。每次只能有一个CPU使用总线。
Linux中,atomic_read()函数和atomic_set()函数在SMP中的实现和在UP中的实现完全一样,本身就是原子的,不需要借助其他任何特殊的手段或者指令。
SMP的原子RMW
而SMP系统中的一个CPU如果想实现RMW操作,情况会是怎样的呢?假设我们要给一个多核共享变量的值加1:
very_important_count++;
那么结果可以是这样的:
也可以是这样的:
可见,CPU A和CPU B对共享变量的访问出现了竞态(race condition),中间执行的代码路径形成了交织(interleave),造成最后的结果可能不一致。我们需要保证在SMP系统中,单个CPU的RMW操作也是原子的。对此,不同架构的处理器给出了不同的解决方法。
- 锁总线
x86处理器常用的做法是给总线上锁(bus lock),以获得在一定的时间窗口内对总线独占的授权,就好像是一个CPU在告诉总线说“在我完成之前,别让其他CPU来读写内存的数据”。
有一些指令,比如XCHG,在执行时会被硬件自动/隐式地加上LOCK#信号,实现总线的锁定。软件也可以显示地在指令前面加上一个名为"lock"的前缀来达到相同的效果,锁总线的时间等于指令执行的时间。不过,并非所有的指令都可以加"lock"前缀,允许添加的指令包括CMPXCHG, 用于算术运算的ADD, SUB, INC, DEC,以及用于位运算的BTS, BTC等。
除了和I/O紧密相关的(比如MMIO),大部分的内存都是可以被cache的,对于特性为Cacheable的内存,和CPU打交道的是缓存在它自己的cache中的内存数据。根据前面文章介绍的cache一致性原理,对应内存的同一位置,不能有2个及以上的CPU的cache line同时处于modified状态,那么只要获得了变量所在的cache line的排他性修改权(相当于给cache上锁),就可以实现SMP系统的RMW原子操作。
所以,虽然使用的是"lock"指令前缀,但此时总线和内存都不会被上锁,bus lock实际成了cache lock。
那如果RMW操作的数据不是自然对齐的呢?不是自然对齐也没有关系,只要操作的数据是在一条cache line里面,cache lock就足以保证原子性。
那再进一步,如果RMW操作的数据跨越了2个cache line,连cache line都没有对齐呢?
那么这就不是一次cache line操作可以完成的了,cache lock就不够了,只能是bus lock。这种跨越cache line的访问被称为"split access",此时的bus lock对应地被称为"split lock"(参考这个patch)。
因此,只要没有设置Alignment Check的硬件检测,那么加上"lock"指令前缀后,不管是非自然对齐的,还是非cache line对齐的RMW操作,通通都可以保证原子性。不过,非对齐的数据访问对性能影响很大(使用一次split lock会消耗大约1000个时钟周期),是应该尽量避免的。
来看下Linux中atomic_add()在x86上的实现:
static __always_inline void arch_atomic_add(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "addl %1,%0"
: "+m" (v->counter) // input+output
: "ir" (i)); // input
}
其中"LOCK_PREFIX"在SMP系统中就是"lock"指令前缀,在UP系统中则为空。虽然前面讲的关中断方法可以实现UP系统的RMW原子操作,但开关中断的开销有点大。
中断只会发生在一条指令执行之前或者之后,而不会发生在指令执行期间,"add"[注1]同时完成了读和写,但它是单一的一条指令,不会被打断,因此UP系统中直接使用"add"指令就可以了,不用关中断,没有其他CPU抢总线,也不用上bus/cache lock。
回到SMP系统的讨论,还是上面那个例子,如果CPU A和CPU B同时调用atomic_add()去给共享变量的值加1,那么只有一个CPU能成功地执行"lock add"这条指令,假设A成功了,B失败了,那么A随后会将该变量的值设为6,其cache line为modified状态,B对应的包含该变量的cache line则为invalid状态。
接下来B将运行刚才没有获准执行的这条"lock add"指令,由于此时它的cache line是invalid状态,根据硬件维护的cache一致性协议,B中cache line中变量的值将变为6,并回到shared状态,B的"lock add"也将基于新的值(6)来做加1运算,所以最终结果就是7,不会因为竞态而出现结果的不一致。
- LL/SC
以上讨论的是Linux基于x86的RMW原子操作的实现,那基于ARM的又该如何实现呢?
在ARMv8.1之前,为实现RMW的原子操作采用的方法主要是LL/SC(Load-Link/Store-Conditional)。ARMv7中实现LL/SC的指令是LDREX/STREX,其实就是比基础的LDR和STR指令多了一个"EX","EX"表示exclusive(独占)。具体说来就是,当用LDREX指令从内存某个地址取出数据放到寄存器后,一个硬件的monitor会将此地址标记为exclusive。
假设CPU A先进行load操作,并标记了变量v所在的内存地址为exclusive,在CPU A进行下一步的store操作之前,CPU B也进行了对变量v的load操作,那么这个内存地址的exclusive就成了CPU B标记的了。
图片来源 http://jake.dothome.co.kr/wp-content/uploads/2015/12/barriers-12a.png
之后CPU A使用STREX进行store操作,它会测试store的目标地址的exclusive是不是自己标记的(是否为自己独占),结果不是,那么store失败。接下来CPU B也执行STREX,因为exclusive是自己标记的,所以可以store成功,exclusive标记也同步失效。此时CPU A会再次尝试一轮LL/SC的操作,直到store成功。
ARM64使用LL/SC模式实现原子操作的相关代码位于"/arch/arm64/include/asm/atomic_ll_sc.h",它通过对"##"粘合符的运用,将不同原子操作的实现放进了同一段代码中。
__LL_SC_PREFIX(arch_atomic_##op(int i, atomic_t *v))
{
unsigned long tmp;
int result;
asm volatile("// atomic_" #op "\n"
" prfm pstl1strm, %2\n" // prefetch memory
"1: ldxr %w0, %2\n" // w0 = %2(v->counter)
" " #asm_op " %w0, %w0, %w3\n" // w0 = w0 + w3 (假设是add)
" stxr %w1, %w0, %2\n" // %2(v->counter) = w0, 执行结果存储在w1
" cbnz %w1, 1b" // 若w1 != 0,说明store失败,跳转到标号1重试
"=&r" (result), "=&r" (tmp), "+Q" (v->counter) // input+output
: "Ir" (i)); // input
}
这里的"ldxr"和"stxr"就是前面讲的"ldrex"和"strex",只不过到了ARMv8.0中被改了个名字而已。可以看到,代码实现中会有个比较和循环,失败则会重试。
重试一次还好,如果CPU之间竞争比较激烈,可能导致重试的次数较多,所以从2014年的ARMv8.1开始,ARM推出了用于原子操作的LSE(Large System Extension)指令集扩展,新增的指令包括CAS, SWP和LD<OP>, ST<OP>等,其中<OP>可以是ADD, CLR, EOR, SET等。ARM64使用LSE指令实现原子操作的相关代码位于"/arch/arm64/include/asm/atomic_lse.h"。
static inline void arch_atomic_##op(int i, atomic_t *v)
{
register int w0 asm ("w0") = i;
register atomic_t *x1 asm ("x1") = v;
asm volatile(ARM64_LSE_ATOMIC_INSN(__LL_SC_ATOMIC(op),
" " #asm_op " %w[i], %[v]\n")
: [i] "+r" (w0), [v] "+Q" (v->counter) // input+output
: "r" (x1) // input
: "x16", "x17", "x30"); // clobber list
}
确实比LL/SC的实现方式简洁了很多,一条指令就可以搞定,比如实现arch_atomic_add(),只用LDADD指令即可,load操作和store操作合二为一,比较类似于x86的实现(用add指令)。
既然load和store都二合一了,那为啥还分别有LD<OP>和ST<OP>呢?其实,两者的效果是一样的,比如STADD就可以看做是LDADD的别名(alias)。
STADD <Xs>, [<Xn|SP>]
等同于
LDADD <Xs>, XZR, [<Xn|SP>]
注[1]:这里是gcc支持的C语言内嵌汇编,使用的是AT&T汇编的语法风格,"add"后面的"l"表示操作的数据是4个字节,而不是x86本身有addl这条指令。