隊列淺談

 

在不僅一個線程訪問一個互斥的變量時,全部線程都必須使用同步,不然就可能會發生一些很是糟糕的事情。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 隊列淺談緩存

相關文章
相關標籤/搜索