Lock-Free数据结构的介绍以及Java实现

1,497 阅读8分钟

介绍

本文将介绍什么是非阻塞数据结构以及它们为什么能替代基于锁的并发数据结构。

首先,我们将先熟悉一些术语,比如obstruction-free,lock-free,和wait-free。其次,我们会了解构建非阻塞算法的基本方法,比如CAS。最后,我们会实现一个无锁队列,并在此基础上实现wait-freedom。

锁与饥饿

关于这两个概念的介绍,可以参考之前的文章:饥饿

首先,来看阻塞线程与饥饿线程之间的区别。

threads_lock-1024x504-1.png 在上图中,线程2拿到了锁,因此当线程1试图获取锁的时候,需要等待线程2释放锁,否则线程将会被阻塞。如果线程2被挂起,线程2会一直保持着锁不释放,而线程1则陷入了无休止的等待。

下图描述了线程饥饿的场景:

threads_lockfree-1024x482-1.png 线程2访问数据时不再需要获取到锁,当线程1并发访问数据时,会检测到并发请求从而立即返回,并通知线程不能完成操作(如图中的红线)。线程1会隔一段时间再次尝试访问数据,直到成功。

这个方法的优点就是我们不需要锁,然而如果线程2访问数据太频繁,导致线程1可能要请求很多次才能成功。

之后我们会讨论CAS操作如何能够实现非阻塞访问。

非阻塞数据结构类型

我们可以将非阻塞数据结构分为三个类型

无障碍(Obstruction-Free)

无障碍是非阻塞数据结构的最弱形式,只要求保证线程在其他线程挂起的情况下继续执行。

更确切来说,如果其他线程都挂起,线程就不会处于饥饿状态。这与加锁不同,如果线程正在等待锁并且持有锁的线程被挂起,那么线程将永远等待。

无锁(Lock-Free)

无锁的概念是在如何时候至少有一个线程可以继续,所有其他的线程可能都处于在饥饿状态。与无障碍的区别在于即使没有挂起线程,也至少有一个非饥饿线程

免等待(Wait-Free)

免等待是基于无锁的,无锁结构保证线程能在有限步数后继续运行,那该结构也是免等待的,也就是说线程不会因为『不合理的大步数』而一直处于饥饿状态。

总结

用图示来形容这三种类型:

threads_summary-1-1024x334.png 图左描述了无障碍类型,只要我们暂停其他线程(用黄色表示),那么线程1(顶部线程)就可以继续运行。中间描述了无锁类型,至少线程1可以推进,此时其他线程处于饥饿状态。图右描述了免等待,我们保证线程1在饥饿一段时间(红色箭头)后继续运行。

非阻塞原语

在这一节中,我们将了解三种实现无锁的数据结构的基本操作

CAS

Compare and Swap是用来规避锁的基础操作。

CAS的思想是,只在变量与一开始从主存中取得的值相同时才对其进行更新。CAS是原子操作,取值与更新要么同时发生要么同时不发生。

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

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

CAS的核心代码如下:

volatile int value;

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

然而,这可能会使某个线程陷入饥饿状态,比如其他线程同时对同一变量执行CAS则可能发生这种情况,因为对于某个线程来说可能会一直执行CAS。尽管如此,一个线程的CAS失败意味着另外一个线程CAS成功,因此也保证了全局进度,这是Lock—Free所要求的。

当然需要注意的是,硬件必须支持CAS,实现真正的原子操作而不是加锁。

负载链接/存储条件

CAS的替代方法是负载链接和存储条件。尽管CAS只在变量值与期望值相同时才更新,但CAS总是会成功的,如果该值已更改,并且同时已更改回其先前值,则CAS也会成功。

下图说明了这种情况:

threads_aba-1024x571-1.png

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

这称为 A-B-A 问题。 当然,此行为可能不是问题。但是其他人可能不希望这样做。

取 & 加

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

Java实现的Lock-free队列

背景

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

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

linkedQueue-768x265-1.png

要添加新元素,线程需要执行三个步骤:

  • 创建新元素(N和M),并将指向下一个元素的指针设置为空;
  • 使对前一个元素的引用指向L,对L的下一个元素的引用指向N(或M);
  • 尾点指向队尾

linkedQueue_threads-768x648.png

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

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

linkedQueue_threads_result-2-1024x417-1-768x313.png

实现

这里我们参考一篇关于Lock-Free的论文,论文中介绍了无锁队列的伪代码实现:

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}

initialize(Q: pointer to queue_t)
    node = new node()                                     # Allocate a free
    node node–>next.ptr = NULL                    # Make it the only node in the linked list
    Q–>Head = Q–>Tail = node                      # Both Head and Tail point to it
    
enqueue(Q: pointer to queue_t, value: data type)
    node = new node()                             # Allocate a new node from the free list
    node–>value = value                           # Copy enqueued value into node
    node–>next.ptr = NULL                         # Set next pointer of node to NULL
    loop                                          # Keep trying until Enqueue is done
        tail = Q–>Tail                            # Read Tail.ptr and Tail.count together
        next = tail.ptr–>next                     # Read next ptr and count fields together
        if tail == Q–>Tail                        # Are tail and next consistent?
            if next.ptr == NULL                   # Was Tail pointing to the last node?
                if CAS(&tail.ptr–>next, next, )   # Try to link node at the end of the linked list
                    break                         # Enqueue is done. Exit loop
                endif
            else                                  # Tail was not pointing to the last node
                CAS(&Q–>Tail, tail, )             # Try to swing Tail to the next node
            endif
        endif
    endloop
    CAS(&Q–>Tail, tail, )                                              # Enqueue is done. Try to swing Tail to the inserted node

dequeue(Q: pointer to queue_t, pvalue: pointer to data type): boolean 
    loop                                                               # Keep trying until Dequeue is done
        head = Q–>Head                                                 # Read Head
        tail = Q–>Tail                                                 # Read Tail
        next = head–>next                                              # Read Head.ptr–>next
        if head == Q–>Head                                             # Are head, tail, and next consistent? 
            if head.ptr == tail.ptr                                    # Is queue empty or Tail falling behind? 
                if next.ptr == NULL                                    # Is queue empty? 
                    return FALSE                                       # Queue is empty, couldn’t dequeue 
                endif 
                CAS(&Q–>Tail, tail, )                                  # Tail is falling behind. Try to advance it 
            else                                                       # No need to deal with Tail 
                # Read value before CAS, otherwise another dequeue might free the next node 
                *pvalue = next.ptr–>value 
                if CAS(&Q–>Head, head, )                               # Try to swing Head to the next node 
                    break                                              # Dequeue is done. Exit loop 
                endif 
            endif 
        endif 
    endloop 
    free(head.ptr)                                                     # It is safe now to free the old dummy node 
    return TRUE                                                        # Queue was not empty, dequeue succeeded

实现细节上,Java提供了AtomicReference类来提供原子操作以及CAS,因此基本上按行复现伪代码即可。

package queue;

import java.util.concurrent.atomic.AtomicReference;

public class LockFreeQueue<E> implements IQueue<E> {

    static class Node<E> {
        E value;
        AtomicReference<Node<E>> next = new AtomicReference<>();
    }

    private final AtomicReference<Node<E>> head, tail;

    public LockFreeQueue() {
        Node<E> node = new Node<>();
        head = new AtomicReference<>();
        tail = new AtomicReference<>();
        head.set(node);
        tail.set(node);
    }

    @Override
    public void enqueue(E value) {
        // Create node to enqueue
        Node<E> node = new Node<>();
        node.value = value;
        node.next.set(null);
        Node<E> tail;
        while (true) {
            // Read tail and tail.next
            tail = this.tail.get();
            Node<E> next = tail.next.get();
            // If tail hasn't changed, and tail appears to point to the last node...
            if (tail == this.tail.get()) {
                if (next == null) {
                    // Attempt to enqueue the node onto the tail node
                    if (tail.next.compareAndSet(null, node)) {
                        // Success!
                        break;
                    }
                } else {
                    // Tail isn't pointing to the last node, attempt to update it
                    this.tail.compareAndSet(tail, next);
                }
            }
        }
        // Attempt to set tail to the enqueued node
        this.tail.compareAndSet(tail, node);
    }

    @Override
    public E dequeue() {
        while (true) {
            Node<E> head = this.head.get();
            Node<E> tail = this.tail.get();
            Node<E> next = head.next.get();
            if (head == this.head.get()) {
                if (head == tail) {
                    if (next == null) {
                        // Queue was observed to be empty
                        return null;
                    }
                    // A node was appended, but tail hasn't been updated yet,
                    // so try to update it
                    this.tail.compareAndSet(tail, next);
                } else {
                    E value = next.value;
                    if (this.head.compareAndSet(head, next)) {
                        // Successfully dequeued a node

                        // At this point it is safe to remove the reference
                        // to the value (to prevent an unnecessary reference to
                        // it from being kept by the queue)
                        next.value = null;

                        return value;
                    }
                }
            }
        }
    }
}

我们来用一段简单的代码测试:

public class Main {

    public static void main(String[] args) {
        LockFreeQueue<String> queue = new LockFreeQueue<>();
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            Thread thread = new Thread(() -> {
                System.out.println("start thread " + finalI);
                queue.enqueue("insert value " + finalI);
            });
            thread.start();
        }
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> System.out.println(queue.dequeue()));
            thread.start();
        }
    }
}

可以看到输出结果为:

start thread 0
start thread 3
start thread 2
start thread 1
start thread 6
start thread 4
start thread 5
start thread 8
start thread 9
start thread 7
insert value 0
insert value 3
insert value 1
insert value 2
insert value 6
insert value 4
insert value 5
insert value 8
insert value 9
insert value 7

而且输出结果顺序并不固定,这也印证了之前的说法,无锁数据结构只是保证至少有一个线程正在运行,而不保证最终结果的完成相同,是一种独特意义的最终一致性,即最终这些线程都会被执行,但不会保证执行的顺序。