无锁数据结构简介

Introduction to Lock-Free Data Structures

1.简介

在本教程中,我们将学习什么是非阻塞数据结构以及为什么它们是基于锁的并发数据结构的重要替代方案。

首先,我们将讨论一些术语,例如无障碍,无锁和无等待。

其次,我们将研究非阻塞算法(如CAS)的基本构造块。

第三,我们将研究Java中无锁队列的实现,最后,我们将概述有关如何实现等待自由的方法。

2.锁定与饥饿

首先,让我们看一下阻塞线程和饥饿线程之间的区别。

 width=

在上图中,线程2获得了数据结构上的锁。 当线程1也尝试获取锁时,它需要等待直到线程2释放锁为止。 它不会在获得锁定之前继续进行。 如果在线程2持有锁的同时将其挂起,线程1将不得不永远等待。

下图说明了线程饥饿:

 width=

在这里,线程2访问数据结构,但不获取锁。 线程1尝试同时访问数据结构,检测并发访问,然后立即返回,通知线程它无法完成(红色)操作。 然后,线程1将再次尝试,直到成功完成操作为止(绿色)。

这种方法的优点是我们不需要锁。 但是,可能发生的情况是,如果线程2(或其他线程)以高频率访问数据结构,那么线程1需要大量尝试,直到最终成功。 我们称此为饥饿。

稍后,我们将看到比较和交换操作如何实现非阻塞访问。

3.非阻塞数据结构的类型

我们可以区分三个级别的非阻塞数据结构。

3.1。 无障碍

无障碍是无阻塞数据结构的最弱形式。 在这里,我们仅要求在所有其他线程都被挂起的情况下保证线程继续执行。

更准确地说,如果所有其他线程都被挂起,则线程不会继续挨饿。 这与使用锁不同,在这种意义上,如果线程正在等待锁,而持有该锁的线程被挂起,则等待线程将永远等待。

3.2。 无锁

如果在任何时候至少有一个线程可以继续进行,则数据结构可提供无锁定的自由。 所有其他线程可能正在饥饿。 与无障碍的区别在于,即使没有线程被挂起,也至少有一个非饥饿线程。

3.3。 免等待

如果一个数据结构是无锁的,则它是免等待的,并且保证每个线程在经过有限数量的步骤后才能继续运行,也就是说,线程不会因"数量过多"的步骤而饥饿。

3.4。 摘要

让我们以图形表示形式总结这些定义:

 width=

图像的第一部分显示无障碍,因为只要我们暂停其他线程(底部的黄色),线程1(顶部线程)就可以继续前进(绿色箭头)。

中间部分显示了锁定自由。 至少线程1可以继续进行,而其他线程可能会饿死(红色箭头)。

最后一部分显示了等待自由。 在这里,我们保证线程1在饥饿一段时间(红色箭头)之后可以继续(绿色箭头)。

4.非阻塞基元

在本节中,我们将研究三个基本操作,这些操作可帮助我们在数据结构上构建无锁操作。

4.1。 比较和交换

用于避免锁定的基本操作之一是比较和交换(CAS)操作。

比较和交换的想法是,仅当变量仍然具有与我们从主存储器中获取变量的值相同的值时,才更新变量。 CAS是一个原子操作,这意味着一起获取和更新是一个单独的操作:

 width=

在这里,两个线程都从主内存中获取值3。 线程2成功(绿色)并将变量更新为8。由于线程1的第一个CAS期望该值仍为3,因此CAS失败(红色)。 因此,线程1再次获取该值,并且第二个CAS成功。

这里重要的是,CAS不获取数据结构上的锁定,但是如果更新成功,则返回true,否则返回false。

以下代码段概述了CAS的工作方式:

1
2
3
4
5
6
7
8
9
volatile int value;

boolean cas(int expectedValue, int newValue) {
    if(value == expectedValue) {
        value = newValue;
        return true;
    }
    return false;
}

仅当新值仍具有期望值时,我们才使用新值更新该值,否则它将返回false。 以下代码片段显示了如何调用CAS:

1
2
3
4
5
6
7
8
9
void testCas() {
    int v = value;
    int x = v + 1;

    while(!cas(v, x)) {
        v = value;
        x = v + 1;
    }
}

我们尝试更新我们的值,直到CAS操作成功,即返回true。

但是,线程有可能陷入饥饿状态。 如果其他线程同时在同一变量上执行CAS,则可能会发生这种情况,因此该操作永远不会针对特定线程成功(或将花费不合理的时间才能成功)。 尽管如此,如果比较和交换失败,我们知道另一个线程已经成功,因此我们也确保了全局进展,这是释放锁所必需的。

重要的是要注意,硬件应支持比较和交换,以使其成为真正的原子操作,而无需使用锁定。

Java在类sun.misc.Unsafe中提供了比较交换的实现。

此外,比较和交换不能防止A-B-A问题。 我们将在下一节中介绍它。

4.2。 加载链接/存储条件

比较和swapis加载链接/存储条件的替代方法。 让我们首先回顾一下比较交换。 如前所述,CAS仅在主存储器中的值仍然是我们期望的值时才更新该值。

但是,如果该值已更改,并且与此同时已更改回其先前的值,则CAS也将成功。

下图说明了这种情况:

 width=

线程1和线程2都读取变量的值3。然后线程2执行CAS,成功将变量设置为8。然后,线程2再次执行CAS将变量设置为3, 这也成功了。 最后,即使变量的值在两次之间进行了两次修改,线程1也会执行一次CAS,期望值为3,并且也成功执行。

这称为A-B-A问题。 当然,取决于用例,此行为可能不是问题。 但是,其他人可能不希望这样做。 Java通过AtomicStampedReference类提供了load-link / store-conditional的实现。

4.3。 提取并添加

另一种选择是获取和添加。 此操作将主存储器中的变量增加给定值。 同样,重要的一点是操作是原子发生的,这意味着没有其他线程可以干涉。

Java在其原子类中提供了获取和添加的实现。 示例是AtomicInteger.incrementAndGet(),它增加值并返回新值; 和AtomicInteger.getAndIncrement(),它返回旧值,然后递增该值。

5.从多个线程访问链接的队列

为了更好地理解两个(或多个)线程同时访问队列的问题,让我们看一下链接的队列和两个试图同时添加元素的线程。

我们要看的队列是一个双向链接的FIFO队列,在该队列中,我们在最后一个元素(L)之后添加新元素,并且变量tail指向该最后一个元素:

 width=

要添加新元素,线程需要执行三个步骤:1)创建新元素(N和M),并将指向下一个元素的指针设置为null; 2)指向L的前一个元素的引用和指向N的L的下一个元素的引用(分别为M)。 3)分别将尾点指向N(M):

 width=

如果两个线程同时执行这些步骤,会出什么问题? 如果上图中的步骤以ABCD或ACBD的顺序执行,则L以及尾部将指向M。N将保持与队列的断开连接。

如果按照ACDB的顺序执行步骤,则tail将指向N,而L将指向M,这将导致队列不一致:

 width=

当然,解决此问题的一种方法是让一个线程获得队列上的锁。 我们将在下一章中介绍的解决方案将通过使用我们之前看到的CAS操作在无锁操作的帮助下解决该问题。

6. Java中的非阻塞队列

让我们看一下Java中的基本无锁队列。 首先,让我们看一下类成员和构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
public class NonBlockingQueue<T> {

    private final AtomicReference<Node<T>> head, tail;
    private final AtomicInteger size;

    public NonBlockingQueue() {
        head = new AtomicReference<>(null);
        tail = new AtomicReference<>(null);
        size = new AtomicInteger();
        size.set(0);
    }
}

重要的部分是将头和尾引用声明为AtomicReferences,以确保对这些引用的任何更新都是原子操作。 Java中的此数据类型实现必要的比较和交换操作。

接下来,让我们看一下Node类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
private class Node<T> {
    private volatile T value;
    private volatile Node<T> next;
    private volatile Node<T> previous;

    public Node(T value) {
        this.value = value;
        this.next = null;
    }

    // getters and setters
}

在这里,重要的部分是将对上一个和下一个节点的引用声明为volatile。 这样可以确保我们始终在主内存中更新这些引用(因此对所有线程都是直接可见的)。 实际节点值相同。

6.1。 无锁添加

我们的无锁添加操作将确保我们在尾部添加新元素,即使多个线程想要同时添加一个新元素,也不会与队列断开连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void add(T element) {
    if (element == null) {
        throw new NullPointerException();
    }

    Node<T> node = new Node<>(element);
    Node<T> currentTail;
    do {
        currentTail = tail.get();
        node.setPrevious(currentTail);
    } while(!tail.compareAndSet(currentTail, node));

    if(node.previous != null) {
        node.previous.next = node;
    }

    head.compareAndSet(null, node); // for inserting the first element
    size.incrementAndGet();
}

要注意的必要部分是突出显示的行。 我们尝试将新节点添加到队列中,直到CAS操作成功更新尾部为止,该尾部仍必须与我们将新节点附加到的尾部相同。

6.2。 无锁获取

与添加操作类似,无锁获取操作将确保我们返回最后一个元素并将尾部移动到当前位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public T get() {
    if(head.get() == null) {
        throw new NoSuchElementException();
    }

    Node<T> currentHead;
    Node<T> nextNode;
    do {
        currentHead = head.get();
        nextNode = currentHead.getNext();
    } while(!head.compareAndSet(currentHead, nextNode));

    size.decrementAndGet();
    return currentHead.getValue();
}

同样,要注意的必要部分是突出显示的行。 CAS操作确保只有在此期间没有其他节点被移除的情况下,我们才移动当前磁头。

Java已经提供了非阻塞队列ConcurrentLinkedQueue的实现。 这是本文所述的M. Michael和L. Scott的无锁队列的实现。 这里一个有趣的旁注是Java文档指出它是一个无等待队列,实际上是无锁的。 Java 8文档正确地调用了无锁实现。

7.无等待队列

如我们所见,以上实现是无锁的,但是并非无锁的。 如果有许多线程正在访问我们的队列,则add和get方法中的while循环可能会长时间循环(或者,虽然不可能,但可能永远循环)。

我们如何实现等待自由? 通常,免等待算法的实现非常棘手。 我们请感兴趣的读者阅读本文,该文章详细描述了免等待队列。 在本文中,让我们看一下如何处理队列的免等待实现的基本思想。

无等待队列要求每个线程(在有限数量的步骤之后)都保证有进度。 换句话说,我们的add和get方法中的while循环必须经过一定数量的步骤才能成功。

为了实现这一点,我们为每个线程分配了一个辅助线程。 如果该帮助线程成功将元素添加到队列中,它将帮助另一个线程在插入另一个元素之前插入其元素。

由于帮助程序线程本身具有一个帮助程序,并且在整个线程列表中,每个线程都有一个帮助程序,因此我们可以保证在每个线程完成一次插入之后,该线程成功完成了插入。 下图说明了这个想法:

 width=

当然,当我们可以动态添加或删除线程时,事情会变得更加复杂。

8.结论

在本文中,我们了解了非阻塞数据结构的基础。 我们介绍了不同的级别和基本操作,例如比较和交换。

然后,我们研究了Java中无锁队列的基本实现。 最后,我们概述了如何实现等待自由的想法。

本文所有示例的完整源代码都可以在GitHub上获得。