1. 程式人生 > >基於雙向鏈表實現無鎖隊列的正確姿勢(對之前博客中錯誤的修正)

基於雙向鏈表實現無鎖隊列的正確姿勢(對之前博客中錯誤的修正)

單向 reel 規範 特殊 線程 些許 github volatile 時間

目錄

  • 1. 前言
  • 2. 基於雙向鏈表實現的無鎖隊列
    • 2.1 入隊方法
    • 2.2 出隊方法
  • 3. 性能測試
  • 4.總結

1. 前言

如果你認真看過我前幾天寫的這篇博客自己動手構建無鎖的並發容器(棧和隊列)的隊列部分,那麽我要向你表示道歉。因為在實現隊列的出隊方法時我犯了一個低級錯誤:隊列的出隊方向是在隊列頭部,而我的實現是在隊列尾部。盡管代碼能夠正確執行,但明顯不符合隊列規範。所以那部分代碼寫作"基於雙向鏈表的無鎖隊列"其實讀作“基於雙向鏈表的無鎖棧”。當然,“隊列是從一端入隊而從另一端出隊的,在一邊進出的那是棧”這種常識我肯定是有的,至於為什麽會犯這種低級錯誤思來想去只能歸咎於連續高溫導致的倦怠。前段時間的我,就好像一只被困在土裏的非洲肺魚,人生的全部意義都在等待雨季的來臨。最近,久違的雨水帶來了些許涼意,也沖走了這種精神上的疲倦,趁這個機會要好好糾正下以前的錯誤。代碼見github上beautiful-concurrent

2. 基於雙向鏈表實現的無鎖隊列

鏈表節點的定義如下

/**
 * 鏈表節點的定義
 * @param <E>
 */
private static class Node<E> {

    //指向前一個節點的指針
    public volatile Node pre;

    //指向後一個結點的指針
    public volatile Node next;

    //真正要存儲在隊列中的值
    public E item;

    public Node(E item) {
        this.item = item;
    }

    @Override
    public String toString() {
        return "Node{" +
                "item=" + item +
                '}';
    }
}

基於雙向鏈表實現無鎖隊列時,結點指針不需要被原子的更新,只需要用volatile修飾保證可見性。

2.1 入隊方法

首先還是來看下隊列的入隊方法,這部分代碼參考了Doug Lea在AQS中對線程加入同步隊列這部分邏輯的實現,所以正確性是沒有問題的

/**
 * 將元素加入隊列尾部
 *
 * @param e 要入隊的元素
 * @return true:入隊成功 false:入隊失敗
 */
public boolean enqueue(E e) {

    //創建一個包含入隊元素的新結點
    Node<E> newNode = new Node<>(e);
    //死循環
    for (; ; ) {
        //記錄當前尾結點
        Node<E> taild = tail.get();
        //當前尾結點為null,說明隊列為空
        if (taild == null) {
            //CAS方式更新隊列頭指針
            if (head.compareAndSet(null, newNode)) {
                //非同步方式更新尾指針
                tail.set(newNode);
                return true;
            }

        } else {

            //新結點的pre指針指向原尾結點
            newNode.pre = taild;
            //CAS方式將尾指針指向新的結點
            if (tail.compareAndSet(taild, newNode)) {
                //非同步方式使原尾結點的next指針指向新加入結點
                taild.next = newNode;
                return true;
            }
        }
    }
}

這裏分了兩種情況來討論,隊列為空和隊列不為空,通過隊列尾指針所指向的元素進行判斷:

  • 1.隊列為空:隊列尾指針指向的結點為null,這部分邏輯在if分句中
    首先以CAS方式更新隊列頭指針指向新插入的結點,若執行成功則以非同步的方式將尾指針也指向該結點,結點入隊成功;若CAS更新頭指針失敗則要重新執行for循環,整個過程如下圖所示
    技術分享圖片

  • 2.隊列不為空:隊列尾指針指向的結點不為null。則分三步實現入隊邏輯,整個過程如下圖所示
    技術分享圖片

僅考慮入隊情形,整個過程是線程安全,盡管有些步驟沒有進行同步。我們分隊列為空和不為空兩種情況來進行論證:

  • 1.隊列為空時,執行流程將進入if分句,假設某線程執行head.compareAndSet(null, newNode)更新頭指針的操作成功,那麽tail.set(newNode)這句不管其何時執行,其他線程將因為tail為null只能進入該if分句中,並且更新頭指針的CAS操作必然失敗,因為此時head已經不為null。所以僅就入隊情形而言,隊列為空時的操作是線程安全的。
  • 2.隊列不為空時,只要更新尾指針的CAS操作即tail.compareAndSet(taild, newNode)執行成功,那麽此時結點已經成功加入隊列,taild.next = newNode;這步何時執行僅就入隊的情形而言沒有任何關系(但是會影響出隊的邏輯實現,這裏先賣個關子)。

2.2 出隊方法

 /**
     * 將隊列首元素從隊列中移除並返回該元素,若隊列為空則返回null
     *
     * @return
     */
    public E dequeue() {

        //死循環
        for (; ; ) {
            //當前頭結點
            Node<E> tailed = tail.get();
            //當前尾結點
            Node<E> headed = head.get();

            if (tailed == null) { //尾結點為null,說明隊列為空,直接返回null
                 return null;

            } else if (headed == tailed) { //尾結點和頭結點相同,說明隊列中只有一個元素,此時要更新頭尾指針
                //CAS方式更新尾指針為null
                if (tail.compareAndSet(tailed, null)) {
                    //頭指針更新為null
                    head.set(null);
                    return headed.item;
                }

            } else {
                //走到這一步說明隊列中元素結點的個數大於1,只要更新隊列頭指針指向原頭結點的下一個結點就行
                //但是要註意頭結點的下一個結點可能為null,所以要先確保新的隊列頭結點不為null

                //隊列頭結點的下一個結點
                Node headedNext = headed.next;
                if (headedNext != null && head.compareAndSet(headed, headedNext))
                    headedNext.pre=null;  //help gc
                    return headed.item;
            }
        }
    }

出隊的邏輯實現主要分三種情況討論:隊列為空,隊列中剛好一個元素結點和隊列中元素結點個數大於1。
其實上次代碼中出錯的部分主要是隊列中結點個數大於1這種情況,而其他兩種情況不管從哪邊出隊操作都是一樣的。下面就分情況討論下出隊實現中需要註意的點

  • 1.隊列為空,判斷標準是tail即尾指針是否指向null,因為入隊的時候就是以tail指針來判斷隊列狀態的,所以這裏要保持一致性,哪怕空隊列的入隊過程中頭指針已經成功指向新結點但沒來得及更新尾指針,此時出隊也就會返回null。

  • 2.隊列中剛好只有一個元素:頭尾指針剛好指向同一個結點。首先以CAS方式更新尾指針指向null,執行成功再以正常方式設置頭指針為null,這麽做會有並發問題嗎?考慮這種極端情形:剛好CAS更新尾指針為null然後失去了CPU執行權,如下圖所示:
    技術分享圖片

分兩種情況討論:
1.出隊情形
因為tail已經為null,程序會判斷隊列為空,所以之後執行出隊的線程將返回null
2.入隊情形
因為tail為null,所以執行入隊邏輯的線程會進入if分句,因為此時head不為null,所以執行圖示的CAS操作時會失敗並不斷自旋
技術分享圖片

綜上所示,隊列中恰好只有一個元素結點的出隊邏輯是線程安全的。

  • 3.隊列中元素結點的個數大於1
    這時候只要將頭指針以CAS方式更新為頭結點的下一個結點就行了,但是要註意在這之前要執行
    headed.next != null確保頭結點的下一個結點不為null。你可能會問:等等,執行這部分代碼的前提是隊列中元素結點的個數至少為2,那麽頭結點的下一個結點肯定不為null啊。如果只考慮出隊的情況,這麽想沒錯,但是此時可能處於隊列入隊的中間狀態,如下圖所示
    技術分享圖片

如上圖所示,隊列中有3個元素結點,但是負責第二個結點入隊的線程已經成功執行尾指針的更新操作但沒來得及更新前一個節點的next指針便失去了CPU執行權,回想下入隊的流程,其實這種情況是可能存在並且允許的。如果此時沒有通過headed.next != null進行判斷便更新head指針指向頭結點的下一個結點,那麽就會出現下面這種情況
技術分享圖片

此時出隊線程還是會執行最後一個else分句這部分代碼,但因為head此時為null,對其執行CAS更新操作將會拋出空指針異常。所以在對head指針進行CAS更新前要獲得所記錄頭結點的下一個結點headedNext,並通過headedNext !=null保證更新後的頭結點不為null。如果這種情況發生,出隊線程將通過自旋等待,直到造成這種情況的入隊線程成功執行
taild.next = newNode;,此時當前出隊線程的出隊過程才能執行成功,並正確設置頭指針指向原隊列頭結點的下一個結點。

完整的代碼見githubbeautiful-concurrent

3. 性能測試

開啟200個線程,每個線程混合進行10000次入隊和出隊操作,將上述流程重復進行100次統計出執行的平均時間(毫秒),完整的測試代碼已經放到github上beautiful-concurrent。測試結果如下圖所示
技術分享圖片

最後的測試結果真是出人意料。修復原來的隊列在一端進出的bug後,性能竟然也有了很大的提高。基於雙向鏈表實現的無鎖隊列LockFreeLinkedQueue在並發環境下的性能排在了第二位,超出了我們自己實現的基於單向鏈的無鎖隊列LockFreeSingleLinkedQueue很多,甚至接近於ConcurrentLinkedQueue的表現,要知道後者實現比我們的復雜了很多,經過了很多優化。原來的錯誤實現因為出隊和入隊在一端進行,所以平白無故增加了不必要的CSA競爭,導致並發性能降低這個好理解;那為什麽比基於單向鏈表的隊列表現還要好。畢竟後者沒有prev指針,少了很多指針操作。關於這點,可能是因為單向鏈表實現時的CAS競爭過多,導致對CPU的有效利用率不高。而雙向鏈表因其結構的特殊性,反而一定程度減少了CAS競爭。所以這也是個教訓,如果能保證線程安全,盡量不要使用任何同步操作,如果不得不進行同步,那麽越輕量級越好,volatile就比CAS"輕"得多。在拓寬下思路,如果我們對其進行類似於ConcurrentLinkedQueue的優化,比如不需要每次入隊都更新隊列尾指針,性能是否還會有飛躍,甚至超出ConcurrentLinkedQueue本身?這可能是個有意思的嘗試,先挖個坑好了,以後有時間再填。

4.總結

這篇文章是對前面文章錯誤的修正,之所以獨立成篇也是希望那些原來被我"誤導"過的同學更有機會看到。這次對隊列的出隊過程進行了詳細的圖文分析,而沒有像上次那樣偷懶,只講了個大概,不然也不會出現"隊列在一端進出"這種低級錯誤,不知道上篇文章被人踩了一腳是不是這個原因,如果能在發現錯誤的時候在下面留言給我指出來就太感謝了。畢竟寫技術博客的好處在於不僅是系統梳理技術知識自我提高的過程,也是一個和他人分享討論共同進步的過程。而這一過程不僅需要作者自己努力,也需要讀者共同參與。

基於雙向鏈表實現無鎖隊列的正確姿勢(對之前博客中錯誤的修正)