无锁编程本质论

时间:2021-06-19 12:11:24
无锁编程真的是不涉及锁么?无锁编程实现的本质是什么?需要操作系统或者编译器的支持么?本文尝试解答这些问题。

1 锁引发的问题

使用锁时要特别防止出现死锁或活锁。死锁的情况很简单,就是申请者在申请过程中由于顺序原因(多个锁没有按固定顺序申请)进入堵塞状态了,指定顺序即可规避。我们只看一个活锁的例子

无锁编程本质论

 

两个线程都在尽量避免死锁,但是却有可能(尴尬地)进入了活锁场景:两个线程都不会如期进入do_something_x(),也都不会退出while(),而是处于反复尝试过程里!可见,活锁的含义就是申请者都活着(没有堵塞住)但还是锁住了。

上面活锁的例子,其实和下面这个(不使用任何锁的)例子是一个意思:

无锁编程本质论

 

 

 

 

不考虑cpu/compiler优化的情况下,如果两个线程都在运行这个while(),则有可能两个线程都退不出这个while()。因为两个线程都在修改X,有可能造成while(X==0)的X一直为0!

 

2 无锁实例

无锁编程的本质就是处理最关键的点(一般针对最精简的数据结构里面的某个字段),所谓“好钢用在刀刃上”:使用CAS原子操作方式将有锁操作压缩到最小范围(可见无锁的本质还是有锁的,原子操作也是锁性质的),CAS原子操作会封装成下面的形式:(以32bit机为例)

bool cas32( int * pVal, int oldVal, int newVal );

pVal表示要要考察值的地址,oldVal表示期望的旧值,newVal表示可以替换时的新值。此函数相当于:

int compare_and_swap (int* reg, int oldval, int newval) 
{   
     int old_reg_val = *reg;   
     if (old_reg_val == oldval)      
              *reg = newval;   
     return old_reg_val; 
}

这样,无锁相当于把锁因素转化为原子CAS操作而压缩到了最精简的数据结构,以至于看上去貌似无锁!我们看一个queue的无锁化:(本实例来自1994年的《Implementing Lock-Free Queues》,代码中“^.”相当于“->”)

无锁编程本质论

 

 

搞清这个无锁queue,需要理解几个要点:

  • Queue始终含有一个dummy冗余节点,包括初始时。在后续的删除过程中,这个dummy节点会随时切换(指向旧节点)。

  • 对于EnQueue(),第一个CAS确保第一个取到tail的操作者顺利加入队列;第二个CAS让没有顺利加入时(会反复尝试)能够快速推进tail指针的更新;第三个CAS很显然,第一个顺利完成入队列操作自然需要完成tail指针的更新,虽然有可能已经被第二个CAS提前更新过了。

  • 对于Dequeue(),唯一的CAS是确保第一个取到head值的操作者顺利删除队列第一个节点。

总之,在EnQueue和DeQueue已有支持多线程特性基础(入队列和出队列在队列中至少一个节点的情况下,是不需要加锁保护的)上,通过CAS的使用,EnQueue()和DeQueue()各自都分别能支持多线程了。这样,看上去很美:无锁!

另外,需要注意异常情况,比如上面的EnQueue()如果在执行过程中某个线程挂掉,是否影响其它线程?如果删掉里面第二个CAS操作,那么EnQueue()就会有问题:thread1成功完成第一个CAS,开始第三个CAS(因为删除了第二个CAS,其实此时应该是第二个了)时挂掉了,那么其它thread都会死循环:死等next字段为NUL的tail,但其实这个tail的next永远停留在thread1刚入队列的那个节点了。

以上可见,EnQueue()里第二个CAS很重要,但防止上述某个线程中途崩溃的方法还有其它途径,比如这个EnQueue()版本:

无锁编程本质论

 

  

 

处理原则都一样,就是:即使CAS没能顺利成功也需要推进tail指针!

 

3 gcc中的无锁支持

bool __sync_bool_compare_and_swap(type *ptr, type oldval type newval, ...)

type __sync_val_compare_and_swap(type *ptr, type oldval type newval, ...)

These builtins perform an atomic compare and swap. That is, ifthe current value of *ptr is oldval, thenwrite newval into *ptr.

The “bool” version returns true if the comparison is successfuland newval was written. The “val” version returns the contentsof *ptr before the operation.

我们仔细看下__sync_bool_compare_and_swap():

无锁编程本质论

 

 

我们聚焦cmpxchg:

cmpxchg %ecx, %ebx;如果EAX与EBX相等则ECX送EBX且ZF置1;否则EBX送EAX且ZF清0

%eax是旧值,%ecx就是新值,%ebx就是待考察的值。原来,这条指令就是无锁根源!几乎所有CPU都支持CAS原子操作,x86下对应的原子操作就是cmpxchg指令。

 

4 CAS的ABA问题

ABA问题是指由于进程切换导致状态变化的遗漏。比如进程看到的变量是“A”,但其实可能已经经过多次状态变化了:“A->B->A”。

解决办法一般是增加一个引用计数字段。这样,CAS同时检查引用计数和目标内容两个值是否都没有发生变化。但麻烦的是引用计数字段也有一个溢出问题。


5 内核中的无锁

查看内核kfifo机制可看到,内核采用另一种无锁方式:内存屏障。

gcc中也对内存屏障有封装:

__sync_synchronize (...)

This builtin issues a full memory barrier.

 

6 总结

可见,无锁的本质是使用CAS和内存屏障(这些都会涉及机器架构或编译器)而已,并不是真正的无锁。。。