目录
1 介绍
回顾以往研究者的各种blocking算法、non-blocking算法、lock-free算法,它们要么基于并发的FIFO队列,要么基于单向循环链表,要么基于compare_and_swap原语,甚至基于double_compare_and_swap原语。然而,简单、快速、实用的非阻塞和阻塞并发队列算法的发现,令michael和scott很惊讶,因为在以往的文献中从未有过。
算法中出现的ABA问题描述:如果一个进程在一个内存共享的位置上读到了一个值A,并计算了一个新值,打算使用compare_and_swap原子操作设置这个共享位置上的值为新值,如果在读到了值A和打算设置之间,另外一个进程改变了值A为B,并且再次又改回了值A,然而第一个进程的原子操作可能是成功的。
2 算法
图1显示了非阻塞的注释伪代码 队列数据结构和操作。该算法将队列实现为具有Head和Tail指针的单链表。与Valois的算法一样,Head始终指向哑节点,这是列表中的第一个节点。Tail指向列表中的最后一个节点或倒数第二个节点。该算法使用compare_and_swap,使用修改计数器来避免ABA问题。为了允许队列出列以释放出列节点,出队操作确保Tail不指向出列节点或任何前任节点。这意味着可以安全地重新使用出列节点。
为了获得我们所依赖的各种指针指向的一致值,重新检查早期值的读取的序列以确保他们没有改变是肯定的。这些读取序列和普拉卡什等人的快照是相似的,而不是比之更简单。 (我们只需要检查一个共享变量而不是两个)。一个在Stone的阻塞算法中的类似的技术可用于防止竞争条件。我们使用Treiber的简单和高效的非阻塞堆栈算法[21]来实现一个非阻塞列表。
图2显示了twolock的注释伪码的队列数据结构和操作。算法采用单独的头部锁和尾部锁,以完成 enqueues和dequeues之间的并发性。就像在非阻塞队列中,我们在列表的开头保留一个哑节点。由于哑节点,入队永远不必访问Head,而且dequeuers永远不必访Tail,从而避免因各个进程尝试以不同的顺序获取锁而产生的潜在死锁问题。
3 正确性
算法是安全的,因为它们满足以下属性:
1.链表始终是连接的。
2.链表中新增的节点仅在最后一个节点之后插入。
3.节点仅从链表的开头删除。
4. Head总是指向链表中的第一个节点。
5. Tail总是指向链表中的节点。 (例如:不会指向被删除的节点)
最初,所有这些属性都成立。通过归纳,我们证明他们将继续持有这些属性,假设ABA 问题永远不会发生。
- 链接列表始终连接的,因为一旦新节点被插入到对列中,则该节点在释放之前其next指针不会被设置为NULL,且直到从队列头部删除出队列该节点才会被释放(属性3)。
- 在无锁算法中,新增节点仅会在链表的末尾被插入,因为它要通过Tail指针链接,并且Tail指针总是指向链表中的一个节点(属性5),新插入的节点被链接到一个具有NULL值的next指针的节点, 并且只有这样一个节点是链表中的最后一个节点 (属性1)。 在基于锁的算法中,新增节点仅会在链表的末尾被插入,那是因为新增节点被插入到Tail指向的节点的后面,在此算法Tail总是指向链表中的最后一个节点,Tail受tail lock保护。
- 节点从列表的开头删除,因为节点只有被Head指针指向时才会被删除,并且Head总是指向列表中的第一个节点(属性4)。
- Head总是指向列表中的第一个节点,因为它只是原子地将其更改为下一个节点 (使用头锁或使用compare_and_swap)。当这发生时,它Head指向的节点被视为从列表中删除。
- Tail总是指向链表中的节点,因为它永远不会落后于Head,所以它永远不会指向一个已被删除节点。此外,当Tail更改其值时,总是摆动到列表中Tail的下一个节点,如果Tail的next指针为NULL,它永远不会尝试更改其值。
structure pointer_t {
ptr: pointer to node_t,
count: unsigned integer
}
structure node_t {
value: data type,
next: pointer_t
}
structure queue_t {
Head: pointer_t,
Tail: pointer_t
}
4 进一步实现
用java语言来实现这个算法(只实现了入队方法),可能的实现如下:
package net.jcip.examples;
import java.util.concurrent.atomic.*;
import net.jcip.annotations.*;
/**
* LinkedQueue
* <p/>
* Insertion in the Michael-Scott nonblocking queue algorithm
*
* @author Brian Goetz and Tim Peierls
*/
@ThreadSafe
public class LinkedQueue <E> {
private static class Node <E> {
final E item;
final AtomicReference<LinkedQueue.Node<E>> next;
public Node(E item, LinkedQueue.Node<E> next) {
this.item = item;
this.next = new AtomicReference<LinkedQueue.Node<E>>(next);
}
}
private final LinkedQueue.Node<E> dummy = new LinkedQueue.Node<E>(null, null);
private final AtomicReference<LinkedQueue.Node<E>> head
= new AtomicReference<LinkedQueue.Node<E>>(dummy);
private final AtomicReference<LinkedQueue.Node<E>> tail
= new AtomicReference<LinkedQueue.Node<E>>(dummy);
public boolean put(E item) {
LinkedQueue.Node<E> newNode = new LinkedQueue.Node<E>(item, null);
while (true) {
LinkedQueue.Node<E> curTail = tail.get();
LinkedQueue.Node<E> tailNext = curTail.next.get();
if (curTail == tail.get()) {
if (tailNext != null) { A
//队列处于中间状态,推进tail指针的指向(advance it)
tail.compareAndSet(curTail, tailNext); B
} else {
// 在稳定状态下,尝试插入新节点
if (curTail.next.compareAndSet(null, newNode)) { C
// 成功插入新节点,推进tail指针的指向(advance it)
tail.compareAndSet(curTail, newNode); D
return true;
}
}
}
}
}
}
插入新的元素涉及到两个指针的更新。首先通过更新当前队尾元素的next指针把新节点链接到队尾元素;然后释放tail指针,让其重新指向新的队尾元素。
所以在这两个操作之间队列可能处于中间状态(当前获取的队尾元素的next指针不为null),如下图:
在第二次更新后,队列再次处于稳定状态(当前获取的队尾元素的next指针为null),如下图:
put方法在插入新元素之前,将首先检查队列是否处于中间状态(步骤A),如果是,那么有另一个线程正在插入元素(在步骤C和D之间)。此时线程不会等待其他线程执行完成,而是帮助它完成操作,并将尾节点向前推进一个节点(步骤B)。然后,它将重复执行这种检查,以免另一个线程已经开始插入新元素,并继续推进尾节点,直到它发现队列处于稳定状态后,才会开始执行自己的插入操作。
由于步骤C中的CAS将把新节点链接到队列尾部,因此如果两个线程同时插入元素,那么这个CAS将失败。在这样的情况下,并不会造成破坏:不会发生任何变化,并且当前的线程只需重新读取尾节点并再次重试。如果步骤C成功了,那么插入操作将生效,第二个CAS(步骤D)被认为是一个“清理操作”因为它既可以由执行插入操作的线程来执行,也可以由其他任何线程来执行。如果步骤D失败,那么执行插入操作的线程将返回,而不是重新执行CAS,因为不再需要重试——另一个线程已经在步骤B中完成了这个工作。这种方式能够工作,因为在任何线程尝试将一个新节点插入到队列之前,都会首先通过检查tail.next是否非空来判断是否需要清理队列。如果是,它首先会推进为尾节点(可能需要执行多次),直到队列处于稳定状态。
需要说明的是这个实现没有处理ABA的问题,我们可以把AtomicReference换成AtomicStampedReference来应对这个问题。
5 ConcurrentLinkedQueue
略。
你的打赏是我奋笔疾书的动力!![](https://i-blog.csdnimg.cn/blog_migrate/4f85cfe3e750342ef39a068e8582aae2.png)
支付宝打赏:
微信打赏: