CAS的基本使用模式:在更新某個值時存在不確定性,以及在更新失敗時重新嘗試。構建非阻塞算法的技巧在於:將執行原子修改的範圍縮小到單個變量上。
鏈接隊列比棧更爲複雜,因爲它必須支持對頭節點和尾節點的快速訪問。因此,它需要單獨維護的頭指針和尾指針。有兩個指針指向尾部的節點:當前最後一個元素的next指針,以及尾節點。當成功地插入一個新元素時,這兩個指針都需要採用原子操作來更新。
這裏需要一些技巧來完成,第一個技巧是,即使在一個包含多個步驟的更新操作中,也要確保數據結構總是處於一致的狀態。這樣,當線程B到達時,如果發現線程A正在執行更新,那麼線程B就可以知道有一個操作已部分完成,並且不能立即開始執行自己的更新操作。然後,B可以等待(通過反覆檢查隊列的狀態)並直到A完成更新,從而使兩個線程不會相互干擾。
雖然這種方法能夠使不同的線程“輪流”訪問呢數據結構,並且不會造成破壞,但如果一個線程在更新操作中失敗了,那麼其他的線程都無法在訪問隊列。要使得該算法成爲一個非阻塞算法,必須確保當一個線程失敗時不會妨礙其他線程繼續執行下去。因此,第二個技巧是,如果當B到達時發現A正在修改數據結構,那麼在數據結構中應該有足夠多的信息,使得B能完成A的更新操作。如果B“幫助”A完成了更新操作,那麼B可以執行自己的操作,而不用等到A的操作完成。當A恢復後再試圖完成其操作時,會發現B已經替它完成了。
在下面的程序中,給出了 Michael-Scott 提出的非阻塞連界隊列算法中的插入部分,它是由 ConcurrentLinkedQueue
實現的。在許多隊列算法中,空隊列通常都包含一個“哨兵節點”或者“啞(Dummy)節點”,並且頭節點和尾節點在初始化時都指向該哨兵節點。尾節點通常要麼指向哨兵節點(如果隊列爲空),即隊列的最後一個元素,要麼(當有操作正在執行更新時)指向倒數第二個元素。下圖1給出了一個處於正常狀態(或者說穩定狀態)的包含兩個元素的隊列。
Michael-Scott 非阻塞隊列算法中的插入:
1 @ThreadSafe 2 public class LinkedQueue<E> { 3 private static class Node <E> { 4 final E item; 5 final AtomicReference<LinkedQueue.Node<E>> next; 6 7 public Node(E item, LinkedQueue.Node<E> next) { 8 this.item = item; 9 this.next = new AtomicReference<LinkedQueue.Node<E>>(next); 10 } 11 } 12 13 private final LinkedQueue.Node<E> dummy = new LinkedQueue.Node<E>(null, null); 14 private final AtomicReference<LinkedQueue.Node<E>> head 15 = new AtomicReference<LinkedQueue.Node<E>>(dummy); 16 private final AtomicReference<LinkedQueue.Node<E>> tail 17 = new AtomicReference<LinkedQueue.Node<E>>(dummy); 18 19 public boolean put(E item) { 20 LinkedQueue.Node<E> newNode = new LinkedQueue.Node<E>(item, null); 21 while (true) { 22 LinkedQueue.Node<E> curTail = tail.get(); 23 LinkedQueue.Node<E> tailNext = curTail.next.get(); 24 if (curTail == tail.get()) { 25 if (tailNext != null) { // A 26 // 隊列處於中間狀態,推進尾節點 27 tail.compareAndSet(curTail, tailNext); // B 28 } else { 29 // 處於穩定狀態,嘗試插入新節點 30 if (curTail.next.compareAndSet(null, newNode)) { // C 31 // 插入操作成功,嘗試推進尾節點 32 tail.compareAndSet(curTail, newNode); // D 33 return true; 34 } 35 } 36 } 37 } 38 } 39 }
圖1 處於穩定狀態幷包含兩個元素的對立
當插入一個新的元素時,需要更新兩個指針。首先更新當前最後一個元素的next 指針,將新節點鏈接到隊列隊尾,然後更新尾節點,將其指向這個新元素。在兩個操作之間,隊列處於一種中間狀態,如圖2。在等二次更新完成後,隊列將再次處於穩定狀態,如圖3所示。
實現這兩個技巧的關鍵在於:當隊列處於穩定狀態時,尾節點的next域將爲空,如果隊列處於中間狀態,那麼tail.next 將爲非空。因此,任何線程都能夠通過檢查tail.next 來獲取隊列當前的狀態。而且,當隊列處於中間狀態時,可以通過將尾節點移動一個節點,從而結束其他線程正在執行的插入元素操作,並使得隊列恢復爲穩定狀態。
圖2 在插入過程中處於中間狀態的對立
圖3 在插入操作完成後,隊列再次處於穩定狀態
LinkedQueue.put 方法在插入新元素之前,將首先檢查隊列是否處於中間狀態(步驟A)。如果是,那麼有另一個線程正在插入元素(在步驟C和D之間)。此時當前線程不會等待其他線程執行完成,而是幫助它完成操作,並將尾節點向前推進一個節點(步驟B)。然後,它將重複執行這種檢查,以免另一個線程已經開始插入新元素,並繼續推進尾節點,直到它發現隊列處於穩定狀態之後,纔會開始執行自己的插入操作。
由於步驟C中的CAS將把新節點鏈接到隊列尾部,因此如果兩個線程同時插入元素,那麼這個CAS將失敗。在這樣的情況下,並不會造成破壞:不會發生任何變化,並且當前的線程只需要重新讀取尾節點並再次重試。如果步驟C成功了,那麼插入操作將生效,第二個CAS(步驟D)被認爲是一個“清理操作”,因爲它既可以由執行插入操作的線程來執行,也可以由其他任何線程來執行。如果步驟D失敗,那麼執行插入操作的線程將返回,而不是重新執行CAS,因爲不再需要重試——另一個線程已經在步驟B中完成了這個工作。
這種方式能夠工作,因爲在任何線程嘗試將一個新節點插入到隊列之前,都會首先通過檢查tail.next是否非空來判斷是否需要清理隊列。如果是,它首先會推薦尾節點(可能需要執行多次),直到隊列處於穩定狀態。