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和内存屏障(这些都会涉及机器架构或编译器)而已,并不是真正的无锁。。。