原子操作和原子指令

引子

考虑如下的简单程序,全局变量x初始值为0:

int x = 0;

void thread1_func() {
  x++;
  print(x);
}

void thread2_func() {
  x++;
  print(x);
}

程序输出 1 2 或 2 2很容易理解,但也有可能输出为1 1。 Why?
原因便是x++不是原子操作,如果把它转为CPU指令形式,则很容易理解:
(1) Load x
(2) Inc x
(3) Store x
当第一个线程运行完第一步时,第二个线程也运行到此,这时它们得到的值都是0,然后将值加1再存回去,这时两个线程运行完时,x的值是1。

原子操作

最简单的解决方式便是使用原子操作,Linux中提供了以atomic_开头的原子操作函数,例如:

#define atomic_inc(v)
#define atomic_dec(v)
#define atomic_add(i, v)
...

v是atomic_t类型的变量,定义如下:

typedef struct {
  inc counter;
} atomic_t;

原子操作需要靠硬件实现,我们以arm64平台为例,看看atomic_add函数是如何实现的。
<arch/arm64/include/asm/atomic_lse.h>

#define ATOMIC_OP(op, asm_op)  \
static  inline  void  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) \
 : "r" (x1) \
 : __LL_SC_CLOBBERS); \
}
ATOMIC_OP(add, stadd)

GCC内联汇编

在介始具体实现前,我们先了解一下GCC内联汇编,GCC内联汇编的格式如下:
asm volatile(指令部:输出部:输入部:损坏部)

  • 指令部中,数字加上前缀%,例如%0,%1,表示需要使用寄存器的操作数。为了提高可读性,可以使用汇编符号名来替代%前缀表示的操作数。
  • 输出部用于规定输出变量的约束条件,通常以=号开头,接着一个字母表示操作数类型的说明,然后是关于变量结合的约束
  • 输入部描述输入操作数,用逗号隔开
  • 损坏部一般以memory开头,告诉编译器指令改变了内存中的值,在执行完汇编代码后重新加载该值,目的是防止编译乱序。clobber list描述了汇编代码对寄存器的修改情况。为何要有clober list?我们的c代码是gcc来处理的,当遇到嵌入汇编代码的时候,gcc会将这些嵌入式汇编的文本送给gas进行后续处理。这样,gcc需要了解嵌入汇编代码对寄存器的修改情况,否则有可能会造成大麻烦。例如:gcc对c代码进行处理,将某些变量值保存在寄存器中,如果嵌入汇编修改了该寄存器的值,又没有通知gcc的话,那么,gcc会以为寄存器中仍然保存了之前的变量值,因此不会重新加载该变量到寄存器,而是直接使用这个被嵌入式汇编修改的寄存器,这时候,我们唯一能做的就是静静的等待程序的崩溃。还好,在output operand list 和 input operand list中涉及的寄存器都不需要体现在clobber list中(gcc分配了这些寄存器,当然知道嵌入汇编代码会修改其内容),因此,大部分的嵌入式汇编的clobber list都是空的,或者只有一个cc,通知gcc,嵌入式汇编代码更新了condition code register。
    有了基本的了解后,现在开始解读上面的代码:
  • 将变量i存放到寄存器w0中
  • 将atomic_t类型的指针v存放到寄存器x1中
  • 指令部使用原子指令stadd把变量i的值加到v->counter中,w表示位宽是32bit, x表示64bit
  • 输出部[i]表示汇编符号为i的变量,+表示可读可写,r表示变量放入寄存器,Q表示需要通过指针间接寻址
  • 输入部, r表示输入量在寄存器x1中
  • 损坏部,通知GCC有资源更新

原子指令

上节中我们发现原子add操作是通过原子指令stadd实现的,在不同的架构上实现的方式可能不一样。

Bus Lock(锁总线)

CPU执行原子指令时,给总线上锁,这样在释放前,可以防止其它CPU的内存操作。

Cache Lock

除了和IO紧密相关的(如MMIO),大部分的内存都是可以被cache的,由前面介绍的cache一致性原理,我们知道由cacheline处于Exclusive或Modified时,该变量只有当前CPU缓存了数据,因此当进行原子操作时,发出Read Invalidate消息,使其它CPU上的缓存无效,cacheline变成Exclusive状态然后将该cacheline上锁,接着就可以取数据,修改并写入cacheline,如果这时有其它CPU也进行原子操作,发出read invalidate消息,但由于当前CPU的cacheline是locked状态,因此暂时不会回复消息,这样其它CPU就一直在等待,直到当前CPU完成,使cacheline变为unlocked状态。

LL/SC

在ARMv8.1之前,为实现RMW的原子操作的方法主要是LL/SC(Load-link/Store-condition).ARMv7中实现的指令是LDREX/STREX,原理如下:
假设CPU0进行load操作,标记变量V所在的内存地址为exclusive, 在CPU0进行store前,这时CPU1也对变量V进行了load操作,这时exclusive标记属于CPU1而不再属于CPU0,在CPU0进行store时会测试该地址的exclusive标记是不是自己的,如果不是,store失败。CPU1进行store, 因为exclusive标记是自己的,所以store成功,同时exclusive失效,这时CPU0会再次尝试一试LL/SC操作,直天成功为止。
如果CPU之间竞争比较激烈,可能导致重试的次数比较多,因此从2014年ARMv8.1开始,ARM推出了原子操作的LSE(Large System Extention)指令集扩展,新增的指令包括CAS, SWP和LD , ST 等,其中 可以是ADD, CLR, EOR, SET等,如例子中的stadd。

猜你喜欢

转载自www.cnblogs.com/miaolong/p/12587812.html
今日推荐