队列浅谈

 

在不仅一个线程访问一个互斥的变量时,全部线程都必须使用同步,不然就可能会发生一些很是糟糕的事情。Java 语言中主要的同步手段就是 synchronized 关键字(也称为内在锁),它强制实行互斥,确保执行 synchronized 块的线程的动做,可以被后来执行受相同锁保护的 synchronized 块的其余线程看到。在使用得当的时候,内在锁可让程序作到线程安全,可是在使用锁定保护短的代码路径,并且线程频繁地争用锁的时候,锁定可能成为至关繁重的操做。 

在 “流行的原子” 一文中,咱们研究了原子变量,原子变量提供了原子性的读-写-修改操做,能够在不使用锁的状况下安全地更新共享变量。原子变量的内存语义与 volatile 变量相似,可是由于它们也能够被原子性地修改,因此能够把它们用做不使用锁的并发算法的基础。 

非阻塞的计数器

清单 1 中的 Counter 是线程安全的,可是使用锁的需求带来的性能成本困扰了一些开发人员。可是锁是必需的,由于虽然增长看起来是单一操做,但实际是三个独立操做的简化:检索值,给值加 1,再写回值。(在 getValue 方法上也须要同步,以保证调用 getValue 的线程看到的是最新的值。虽然许多开发人员勉强地使本身相信忽略锁定需求是能够接受的,但忽略锁定需求并非好策略。) 

在多个线程同时请求同一个锁时,会有一个线程获胜并获得锁,而其余线程被阻塞。JVM 实现阻塞的方式一般是挂起阻塞的线程,过一下子再从新调度它。由此形成的上下文切换相对于锁保护的少数几条指令来讲,会形成至关大的延迟。 


清单 1. 使用同步的线程安全的计数器

public final class Counter {
    private long value = 0;
    public synchronized long getValue() {
        return value;
    }
    public synchronized long increment() {
        return ++value;
    }
}
 


清单 2 中的 NonblockingCounter 显示了一种最简单的非阻塞算法:使用 AtomicInteger 的 compareAndSet() (CAS)方法的计数器。compareAndSet() 方法规定 “将这个变量更新为新值,可是若是从我上次看到这个变量以后其余线程修改了它的值,那么更新就失败”(请参阅 “流行的原子” 得到关于原子变量以及 “比较和设置” 的更多解释。) 


清单 2. 使用 CAS 的非阻塞算法

public class NonblockingCounter {
    private AtomicInteger value;
    public int getValue() {
        return value.get();
    }
    public int increment() {
        int v;
        do {
            v = value.get();
        while (!value.compareAndSet(v, v + 1));
        return v + 1;
    }
}
 


原子变量类之因此被称为原子的,是由于它们提供了对数字和对象引用的细粒度的原子更新,可是在做为非阻塞算法的基本构造块的意义上,它们也是原子的。非阻塞算法做为科研的主题,已经有 20 多年了,可是直到 Java 5.0 出现,在 Java 语言中才成为可能。 

现代的处理器提供了特殊的指令,能够自动更新共享数据,并且可以检测到其余线程的干扰,而 compareAndSet() 就用这些代替了锁定。(若是要作的只是递增计数器,那么 AtomicInteger 提供了进行递增的方法,可是这些方法基于 compareAndSet(),例如 NonblockingCounter.increment())。 

非阻塞版本相对于基于锁的版本有几个性能优点。首先,它用硬件的原生形态代替 JVM 的锁定代码路径,从而在更细的粒度层次上(独立的内存位置)进行同步,失败的线程也能够当即重试,而不会被挂起后从新调度。更细的粒度下降了争用的机会,不用从新调度就能重试的能力也下降了争用的成本。即便有少许失败的 CAS 操做,这种方法仍然会比因为锁争用形成的从新调度快得多。 

NonblockingCounter 这个示例可能简单了些,可是它演示了全部非阻塞算法的一个基本特征 —— 有些算法步骤的执行是要冒险的,由于知道若是 CAS 不成功可能不得不重作。非阻塞算法一般叫做乐观算法,由于它们继续操做的假设是不会有干扰。若是发现干扰,就会回退并重试。在计数器的示例中,冒险的步骤是递增 —— 它检索旧值并在旧值上加一,但愿在计算更新期间值不会变化。若是它的但愿落空,就会再次检索值,并重作递增计算。 


--------------------------------------------------------------------------------
回页首
非阻塞堆栈

非阻塞算法稍微复杂一些的示例是清单 3 中的 ConcurrentStack。ConcurrentStack 中的 push() 和 pop() 操做在结构上与 NonblockingCounter 上类似,只是作的工做有些冒险,但愿在 “提交” 工做的时候,底层假设没有失效。push() 方法观察当前最顶的节点,构建一个新节点放在堆栈上,而后,若是最顶端的节点在初始观察以后没有变化,那么就安装新节点。若是 CAS 失败,意味着另外一个线程已经修改了堆栈,那么过程就会从新开始。 


清单 3. 使用 Treiber 算法的非阻塞堆栈

public class ConcurrentStack<E> {
    AtomicReference<Node<E>> head = new AtomicReference<Node<E>>();
    public void push(E item) {
        Node<E> newHead = new Node<E>(item);
        Node<E> oldHead;
        do {
            oldHead = head.get();
            newHead.next = oldHead;
        } while (!head.compareAndSet(oldHead, newHead));
    }
    public E pop() {
        Node<E> oldHead;
        Node<E> newHead;
        do {
            oldHead = head.get();
            if (oldHead == null) 
                return null;
            newHead = oldHead.next;
        } while (!head.compareAndSet(oldHead,newHead));
        return oldHead.item;
    }
    static class Node<E> {
        final E item;
        Node<E> next;
        public Node(E item) { this.item = item; }
    }
}
 


性能考虑

在轻度到中度的争用状况下,非阻塞算法的性能会超越阻塞算法,由于 CAS 的多数时间都在第一次尝试时就成功,而发生争用时的开销也不涉及线程挂起和上下文切换,只多了几个循环迭代。没有争用的 CAS 要比没有争用的锁便宜得多(这句话确定是真的,由于没有争用的锁涉及 CAS 加上额外的处理),而争用的 CAS 比争用的锁获取涉及更短的延迟。 

在高度争用的状况下(即有多个线程不断争用一个内存位置的时候),基于锁的算法开始提供比非阻塞算法更好的吞吐率,由于当线程阻塞时,它就会中止争用,耐心地等候轮到本身,从而避免了进一步争用。可是,这么高的争用程度并不常见,由于多数时候,线程会把线程本地的计算与争用共享数据的操做分开,从而给其余线程使用共享数据的机会。(这么高的争用程度也代表须要从新检查算法,朝着更少共享数据的方向努力。)“流行的原子” 中的图在这方面就有点儿让人困惑,由于被测量的程序中发生的争用极其密集,看起来即便对数量不多的线程,锁定也是更好的解决方案。 


--------------------------------------------------------------------------------
回页首
非阻塞的链表

目前为止的示例(计数器和堆栈)都是很是简单的非阻塞算法,一旦掌握了在循环中使用 CAS,就能够容易地模仿它们。对于更复杂的数据结构,非阻塞算法要比这些简单示例复杂得多,由于修改链表、树或哈希表可能涉及对多个指针的更新。CAS 支持对单一指针的原子性条件更新,可是不支持两个以上的指针。因此,要构建一个非阻塞的链表、树或哈希表,须要找到一种方式,能够用 CAS 更新多个指针,同时不会让数据结构处于不一致的状态。 

在链表的尾部插入元素,一般涉及对两个指针的更新:“尾” 指针老是指向列表中的最后一个元素,“下一个” 指针从过去的最后一个元素指向新插入的元素。由于须要更新两个指针,因此须要两个 CAS。在独立的 CAS 中更新两个指针带来了两个须要考虑的潜在问题:若是第一个 CAS 成功,而第二个 CAS 失败,会发生什么?若是其余线程在第一个和第二个 CAS 之间企图访问链表,会发生什么? 

对于非复杂数据结构,构建非阻塞算法的 “技巧” 是确保数据结构总处于一致的状态(甚至包括在线程开始修改数据结构和它完成修改之间),还要确保其余线程不只可以判断出第一个线程已经完成了更新仍是处在更新的中途,还可以判断出若是第一个线程走向 AWOL,完成更新还须要什么操做。若是线程发现了处在更新中途的数据结构,它就能够 “帮助” 正在执行更新的线程完成更新,而后再进行本身的操做。当第一个线程回来试图完成本身的更新时,会发现再也不须要了,返回便可,由于 CAS 会检测到帮助线程的干预(在这种状况下,是建设性的干预)。 

这种 “帮助邻居” 的要求,对于让数据结构免受单个线程失败的影响,是必需的。若是线程发现数据结构正处在被其余线程更新的中途,而后就等候其余线程完成更新,那么若是其余线程在操做中途失败,这个线程就可能永远等候下去。即便不出现故障,这种方式也会提供糟糕的性能,由于新到达的线程必须放弃处理器,致使上下文切换,或者等到本身的时间片过时(而这更糟)。 

清单 4 的 LinkedQueue 显示了 Michael-Scott 非阻塞队列算法的插入操做,它是由 ConcurrentLinkedQueue 实现的: 


清单 4. Michael-Scott 非阻塞队列算法中的插入

public class LinkedQueue <E> {
    private static class Node <E> {
        final E item;
        final AtomicReference<Node<E>> next;
        Node(E item, Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<Node<E>>(next);
        }
    }
    private AtomicReference<Node<E>> head
        = new AtomicReference<Node<E>>(new Node<E>(null, null));
    private AtomicReference<Node<E>> tail = head;
    public boolean put(E item) {
        Node<E> newNode = new Node<E>(item, null);
        while (true) {
            Node<E> curTail = tail.get();
            Node<E> residue = curTail.next.get();
            if (curTail == tail.get()) {
                if (residue == null) /* A */ {
                    if (curTail.next.compareAndSet(null, newNode)) /* C */ {
                        tail.compareAndSet(curTail, newNode) /* D */ ;
                        return true;
                    }
                } else {
                    tail.compareAndSet(curTail, residue) /* B */;
                }
            }
        }
    }
}
 


像许多队列算法同样,空队列只包含一个假节点。头指针老是指向假节点;尾指针总指向最后一个节点或倒数第二个节点。图 1 演示了正常状况下有两个元素的队列: 


图 1. 有两个元素,处在静止状态的队列
 

如 清单 4 所示,插入一个元素涉及两个指针更新,这两个更新都是经过 CAS 进行的:从队列当前的最后节点(C)连接到新节点,并把尾指针移动到新的最后一个节点(D)。若是第一步失败,那么队列的状态不变,插入线程会继续重试,直到成功。一旦操做成功,插入被当成生效,其余线程就能够看到修改。还须要把尾指针移动到新节点的位置上,可是这项工做能够当作是 “清理工做”,由于任何处在这种状况下的线程均可以判断出是否须要这种清理,也知道如何进行清理。 

队列老是处于两种状态之一:正常状态(或称静止状态,图 1 和 图 3)或中间状态(图 2)。在插入操做以前和第二个 CAS(D)成功以后,队列处在静止状态;在第一个 CAS(C)成功以后,队列处在中间状态。在静止状态时,尾指针指向的连接节点的 next 字段总为 null,而在中间状态时,这个字段为非 null。任何线程经过比较 tail.next 是否为 null,就能够判断出队列的状态,这是让线程能够帮助其余线程 “完成” 操做的关键。 


图 2. 处在插入中间状态的队列,在新元素插入以后,尾指针更新以前
 

插入操做在插入新元素(A)以前,先检查队列是否处在中间状态,如 清单 4 所示。若是是在中间状态,那么确定有其余线程已经处在元素插入的中途,在步骤(C)和(D)之间。没必要等候其余线程完成,当前线程就能够 “帮助” 它完成操做,把尾指针向前移动(B)。若是有必要,它还会继续检查尾指针并向前移动指针,直到队列处于静止状态,这时它就能够开始本身的插入了。 

第一个 CAS(C)可能由于两个线程竞争访问队列当前的最后一个元素而失败;在这种状况下,没有发生修改,失去 CAS 的线程会从新装入尾指针并再次尝试。若是第二个 CAS(D)失败,插入线程不须要重试 —— 由于其余线程已经在步骤(B)中替它完成了这个操做! 


图 3. 在尾指针更新后,队列从新处在静止状态
 

幕后的非阻塞算法

若是深刻 JVM 和操做系统,会发现非阻塞算法无处不在。垃圾收集器使用非阻塞算法加快并发和平行的垃圾搜集;调度器使用非阻塞算法有效地调度线程和进程,实现内在锁。在 Mustang(Java 6.0)中,基于锁的 SynchronousQueue 算法被新的非阻塞版本代替。不多有开发人员会直接使用 SynchronousQueue,可是经过 Executors.newCachedThreadPool() 工厂构建的线程池用它做为工做队列。比较缓存线程池性能的对比测试显示,新的非阻塞同步队列实现提供了几乎是当前实现 3 倍的速度。在 Mustang 的后续版本(代码名称为 Dolphin)中,已经规划了进一步的改进。 


--------------------------------------------------------------------------------
回页首
结束语

非阻塞算法要比基于锁的算法复杂得多。开发非阻塞算法是至关专业的训练,并且要证实算法的正确也极为困难。可是在 Java 版本之间并发性能上的众多改进来自对非阻塞算法的采用,并且随着并发性能变得愈来愈重要,能够预见在 Java 平台的将来发行版中,会使用更多的非阻塞算法。

http://code.google.com,java

http://elf8848.iteye.com/blog/875830算法

http://hellosure.iteye.com/blog/1126541 队列浅谈缓存

相关文章
相关标签/搜索