无锁队列的实现

耗子叔曾经写过一篇同名的博客,主要参考了John D. Valois 1994年10月在拉斯维加斯的并行和分布式系统国际大会上的一篇论文——《Implementing Lock-Free Queues》。但是从目前现状来看,这篇论文中提到的算法是有问题的,并没有在实际中被采用。现在被广泛采用的无锁队列实现都是基于Maged M. Michael和Michael L. Scott 95年的论文《Simple, Fast, and Practical Non-Blocking and Blocking ConcurrentQueue Algorithms》,例如C++ Boost库中的lockfree模块和Java concurrent包中的ConcurrentLinkedQueue类。在本博客中,首先介绍Valois方法存在的问题,然后介绍Michael和Scott所做的改进,最后简单介绍一下ABA问题。如果大家对无锁队列不甚了解,可以先阅读耗子叔的博客,然后再阅读本博客。

1. Valois无锁队列

1.1 版本1

Valois的方法虽然存在问题,但是却是后续改进的基础,我们看一下他论文中给出的无锁队列实现方法。

Initialize()
    head=new node();
    head->next=NULL;
    tail=head;
end

Enqueue(data)
    q=new node();
    q->value=data;
    q->next=NULL;
    repeat
        p=tail;
        succ=CAS(&p->next,NULL,q);
        if not succ 
        CAS(&tail,p,p->next);
    until succ
    CAS(&tail,p,q);
end

Dequeue(value)
    repeat
        p=head;
        if p->next==NULL
            return false;
    until CAS(&head,p,p->next)
    *value=p->next->value;
    return true;
end

在初始化过程中,head和tail都指向一个dummy节点,这样就避免了队列在为空或者只有一个元素的时候操作出现问题。更重要的是,这个dummy节点避免了队列只有一个元素时插入和删除操作之间的竞争(如何避免我也不清楚)。后续的无锁队列实现都采用了这种方式。
Enqueue操作有三个CAS操作,第一个CAS操作尝试把新节点添加到队列末尾,第二个CAS操作在其他线程添加节点成功导致该线程添加失败的情况下尝试修改tail指针,使其指向其他线程成功添加的节点。第三个CAS操作在添加成功之后尝试修改tail指针。通过三个CAS操作实现的Enqueue可以保证tail指针始终指向末尾节点或者末尾节点的前一个节点。但是存在的问题是第二个CAS会带来过多的竞争。
上述无锁队列最大的问题在于Dequeue。由于在出队的过程中只判断了head指针,所以可能会出现head指针跑到tail指针前面的情况,这样就破坏了队列的结构。后续的修改也主要是针对Dequeue进行的。

1.2 版本2

针对版本1中Enqueue带来的过多竞争,我们可以将tail的定义放宽,只是作为末尾节点的“提示”,只要距离真正末尾的位置能够预测即可。按照这样的原则可以将版本1中的Enqueue修改如下:

Enqueue(data)
    q=new node();
    q->value=data;
    q->next=NULL;
    p=tail;
    oldp=p;
    repeat
        while p->next!=NULL
            p=p->next;
    until CAS(&p->next,NULL,q)
    CAS(&tail,oldp,q);
end

上述代码减少了一个CAS操作,避免了失败线程修改tail指针的操作。但是会引入新的问题,导致线程花费大量时间在遍历队列上,因为tail指针可能离真正的末尾很远,每个线程都需要从tail指针位置遍历到真正的末尾进行添加操作。

2. Michael和Scott的改进

初始化操作是一样的,Enqueue操作主要还是仿照Valois的版本一实现的,不同于他们原始论文中的方法,我们下面给出Java ConcurrentLinkedQueue类中的实现:

Enqueue(data)
    n=new node();
    n->value=data;
    n->next=NULL;
    repeat
        t=tail;
        s=t->next;
        if t==tail
            if s==NULL
                if CAS(&t->next,s,n)
                    CAS(&tail,t,n);
                    return true;
            else 
                CAS(&tail,t,s);
end

上述代码虽然和Volois的版本一类似,但在执行起来有较大不同。Valois的版本一总是会先执行一个CAS操作尝试在队列末尾添加新元素,而改进的代码则通过引入一个新的临时变量s来避免执行CAS操作,相比之下会少执行一次CAS操作。
相比Valois的代码,改进的代码最大的不同在Dequeue上,为了避免head指针跑到tail指针前面,我们必须在Dequeue操作中考虑修改tail指针,具体实现如下:

Dequeue(value)
    repeat
        h=head;
        t=tail;
        first=h->next;
        if h==head
            if h==t
                if first==NULL 
 return false;
                CAS(&tail,t,first);
            else if CAS(&head,h,first)
                *value=first->value;
                break;
    free(h);
 return true;
end

上述代码核心部分分成两部分:1. 判断head指针和tail指针是否指向同一个位置,若是表明此刻head指针追上tail指针了,我们需要让head指针等待tail指针往前走一步;2. 否则表示我们可以删除队列头元素,若线程竞争失败则重复竞争直到成功,因为Dequeue要么删除一个元素要么队列为空。
Java ConcurrentLinkedQueue类的poll方法即是仿照上面的代码实现的,但是与Michael和Scott的原始代码与稍有不同。Michael和Scott的原始代码在执行删除操作时,会先获得队首元素的值再执行CAS操作,我感觉这种方式效率略差,读取了队首值之后CAS操作还是可能会失败,从而做了一次无效的写操作。相比之下 ConcurrentLinkedQueue类的poll函数是先执行CAS操作获得这个要删除的队首节点,然后再获取值,这种方式更高效也更安全。

3. ABA问题

Java的ConcurrentLinkedQueue类就是按照上面介绍的改进实现的,所以不太可能有其他问题了。但是,Java的垃圾回收机制掩盖了无锁操作的一个缺陷—ABA问题。如果在算法中采用自己的方式来管理节点对象的内存,那么可能出现ABA问题。在这种情况下,即使队列的头结点仍然只像之前观察到的节点,那么也不足以说明队列的内容没有发生变化。
关于ABA问题,维基百科上有一个经典的解释:“Natalie正在车里和两个孩子等红灯。孩子比较闹腾,她回头斥责他俩。当她回头的时候发现还是红灯所以继续等待。但实际上,在她回头教育孩子的时候信号灯已经由红灯变为绿灯又变为红灯,但是她却不知道以为什么都没有发生。”简单来说,ABA问题就是指在无锁竞争环境下,由于每个线程都可能被打断,所以对内存的访问也是不可靠的。由于CAS操作只检查某个位置的内容是否等于给定的值,这就导致某个位置的值A被修改为B然后又修改为A也会通过CAS操作。
不要认为这个问题无关痛痒,在多线程环境下可能会导致非常严重的错误。举个例子,线程A在执行Dequeue的CAS前被打断,此时该线程的局部变量h=head,first=h->next;后续有另一个线程B也执行Dequeue操作成功,此时A线程认为的head空间就被释放;接着一系列线程执行Enqueue操作和Dequeue操作之后,如果之前被释放的空间被重复利用,则可能会出现 A认为的head又恰巧出现在队列的开头;这时A再继续执行CAS操作就会成功。虽然head都是相同的内存,但是内容可能不一样,next指针指向的位置也不一样,first指针早已失效。但是成功执行CAS操作就会将队首元素删除,同时指向一个可能不存在的位置,从而导致队列的结构被破坏。
ABA问题的根本原因在于内存的重复利用。在具有垃圾回收功能的语言中,不存在这个问题。(原因不明,在ConcurrentLinkedQueue类的注释中有这样一句话:Also note that like most non-blocking algorithms in this package, this implementation relies on the fact that in garbage collected systems, there is no possibility of ABA problems due to recycled nodes, so there is no need to use “counted pointers” or related techniques seen in versions used in non-GC’ed settings.)
解决ABA问题的方法比较容易理解,对每一个要访问的内存加版本号,每次操作都更新版本号。不是只是更新某个引用的值,而是更新两个值,包含一个引用和一个版本号。即使这个值由A变成B,然后又变为A,版本号也将是不同的。Java的AtomicStampedReference以及AtomicMarkableReference支持在两个变量上执行原子的条件更新。AtomicStampedReference将更新一个“对象—-引用”二元组,通过在引用上加上“版本号”,从而避免ABA问题。在Michael和Scott的原始代码中给出的即是有版本号的实现。

4. 总结

通过对两篇论文中的无锁队列实现进行分析,我们可以了解其实现的细节和可能遇到的问题。可以看出,要实现一个没有问题且高效的无锁队列还是非常困难的,尤其在设计ABA问题上,如果没有十足的把握请不要自己实现,直接采用线程的库即可。Java中是concurrent包,C++中有boost库^_^。

5. 参考资料

  1. 非阻塞同步算法与CAS(Compare and Swap)无锁算法
  2. 无锁队列的实现
  3. ABA problem
点赞