原始碼解析Synchronous Queue 這種特立獨行的佇列

華為雲開發者社群發表於2022-04-26
摘要:Synchronous Queue 是一種特立獨行的佇列,其本身是沒有容量的,比如呼叫者放一個資料到佇列中,呼叫者是不能夠立馬返回的,呼叫者必須等待別人把我放進去的資料消費掉了,才能夠返回。

本文分享自華為雲社群《Synchronous Queue 原始碼解析》,作者: JavaEdge。

1 簡介

Synchronous Queue 是一種特立獨行的佇列,其本身是沒有容量的,比如呼叫者放一個資料到佇列中,呼叫者是不能夠立馬返回的,呼叫者必須等待別人把我放進去的資料消費掉了,才能夠返回。Synchronous Queue 在 MQ 中被大量使用,本文就讓我們從原始碼來看下 Synchronous Queue 到底是如何實現這種功能的呢。

2 整體架構

不像ArrayBlockingQueue、LinkedBlockingDeque之類的阻塞佇列使用AQS實現併發,SynchronousQueue直接使用CAS操作實現資料的安全訪問,因此原始碼中充斥著大量的CAS程式碼。

SynchronousQueue 的整體設計比較抽象,在內部抽象出了兩種演算法實現,一種是先入先出的佇列,一種是後入先出的堆疊,兩種演算法被兩個內部類實現,而直接對外的 put,take 方法的實現就非常簡單,都是直接呼叫兩個內部類的 transfer 方法進行實現,整體的呼叫關係如下圖所示:

原始碼解析Synchronous Queue 這種特立獨行的佇列

2.1 類註釋

佇列不儲存資料,所以沒有大小,也無法迭代;插入操作的返回必須等待另一個執行緒完成對應資料的刪除操作,反之亦然;

佇列由兩種資料結構組成,分別是後入先出的堆疊和先入先出的佇列,堆疊是非公平的,佇列是公平的。

第二點是如何做到的?堆疊又是如何實現的呢?接下來我們一點一點揭曉。

2.2 類圖

原始碼解析Synchronous Queue 這種特立獨行的佇列

SynchronousQueue 整體類圖和 LinkedBlockingQueue 相似,都實現了 BlockingQueue 介面,但因為其不儲存資料結構,有一些方法是沒有實現的,比如說 isEmpty、size、contains、remove 和迭代等方法,這些方法都是預設實現,如下截圖:

2.3 結構細節

SynchronousQueue 底層結構和其它佇列完全不同,有著獨特的兩種資料結構:佇列和堆疊,我們一起來看下資料結構:

// 堆疊和佇列共同的介面
// 負責執行 put or take
abstract static class Transferer<E> {
    // e 為空的,會直接返回特殊值,不為空會傳遞給消費者
    // timed 為 true,說明會有超時時間
    abstract E transfer(E e, boolean timed, long nanos);
}

// 堆疊 後入先出 非公平
// Scherer-Scott 演算法
static final class TransferStack<E> extends Transferer<E> {
}

// 佇列 先入先出 公平
static final class TransferQueue<E> extends Transferer<E> {
}

private transient volatile Transferer<E> transferer;

// 無參構造器預設為非公平的
public SynchronousQueue(boolean fair) {
    transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}

從原始碼中我們可以得到幾點:

堆疊和佇列都有一個共同的介面,叫做 Transferer,該介面有個方法:transfer,該方法很神奇,會承擔 take 和 put 的雙重功能;

在我們初始化的時候,是可以選擇是使用堆疊還是佇列的,如果你不選擇,預設的就是堆疊,類註釋中也說明了這一點,堆疊的效率比佇列更高。

接下來我們來看下堆疊和佇列的具體實現。

3 非公平的堆疊

3.1 堆疊的結構

首先我們來介紹下堆疊的整體結構,如下:

原始碼解析Synchronous Queue 這種特立獨行的佇列

從上圖中我們可以看到,我們有一個大的堆疊池,池的開口叫做堆疊頭,put 的時候,就往堆疊池中放資料。take 的時候,就從堆疊池中拿資料,兩者操作都是在堆疊頭上運算元據,從圖中可以看到,越靠近堆疊頭,資料越新,所以每次 take 的時候,都會拿到堆疊頭的最新資料,這就是我們說的後入先出,也就是非公平的。

圖中 SNode 就是原始碼中棧元素的表示,我們看下原始碼:

原始碼解析Synchronous Queue 這種特立獨行的佇列
  • volatile SNode next
    棧的下一個,就是被當前棧壓在下面的棧元素
  • volatile SNode match
    節點匹配,用來判斷阻塞棧元素能被喚醒的時機
  • 比如我們先執行 take,此時佇列中沒有資料,take 被阻塞了,棧元素為 SNode1
    當有 put 操作時,會把當前 put 的棧元素賦值給 SNode1 的 match 屬性,並喚醒 take 操作
    當 take 被喚醒,發現 SNode1 的 match 屬性有值時,就能拿到 put 進來的資料,從而返回
  • volatile Thread waiter
    棧元素的阻塞是通過執行緒阻塞來實現的,waiter 為阻塞的執行緒
  • Object item
    未投遞的訊息,或者未消費的訊息

3.2 入棧和出棧

  • 入棧
    使用 put 等方法,將資料放到堆疊池中
  • 出棧
    使用 take 等方法,把資料從堆疊池中拿出來

操作的物件都是堆疊頭,雖然兩者的一個是從堆疊頭拿資料,一個是放資料,但底層實現的方法卻是同一個,原始碼如下:

transfer 方法思路比較複雜,因為 take 和 put 兩個方法都揉在了一起A

@SuppressWarnings("unchecked")
E transfer(E e, boolean timed, long nanos) {
    SNode s = null; // constructed/reused as needed
 
    // e 為空: take 方法,非空: put 方法
    int mode = (e == null) ? REQUEST : DATA;
 
    // 自旋
    for (;;) {
        // 頭節點情況分類
        // 1:為空,說明佇列中還沒有資料
        // 2:非空,並且是 take 型別的,說明頭節點執行緒正等著拿資料
        // 3:非空,並且是 put 型別的,說明頭節點執行緒正等著放資料
        SNode h = head;
 
        // 棧頭為空,說明佇列中還沒有資料。
        // 棧頭非空且棧頭的型別和本次操作一致
        //    比如都是 put,那麼就把本次 put 操作放到該棧頭的前面即可,讓本次 put 能夠先執行
        if (h == null || h.mode == mode) {  // empty or same-mode
            // 設定了超時時間,並且 e 進棧或者出棧要超時了,
            // 就會丟棄本次操作,返回 null 值。
            // 如果棧頭此時被取消了,丟棄棧頭,取下一個節點繼續消費
            if (timed && nanos <= 0) {      // 無法等待
                // 棧頭操作被取消
                if (h != null && h.isCancelled())
                    // 丟棄棧頭,把棧頭的後一個元素作為棧頭
                    casHead(h, h.next);     // 將取消的節點彈棧
                // 棧頭為空,直接返回 null
                else
                    return null;
            // 沒有超時,直接把 e 作為新的棧頭
            } else if (casHead(h, s = snode(s, e, h, mode))) {
                // e 等待出棧,一種是空佇列 take,一種是 put
                SNode m = awaitFulfill(s, timed, nanos);
                if (m == s) {               // wait was cancelled
                    clean(s);
                    return null;
                }
                // 本來 s 是棧頭的,現在 s 不是棧頭了,s 後面又來了一個數,把新的資料作為棧頭
                if ((h = head) != null && h.next == s)
                    casHead(h, s.next);     // help s's fulfiller
                return (E) ((mode == REQUEST) ? m.item : s.item);
            }
        // 棧頭正在等待其他執行緒 put 或 take
        // 比如棧頭正在阻塞,並且是 put 型別,而此次操作正好是 take 型別,走此處
        } else if (!isFulfilling(h.mode)) { // try to fulfill
            // 棧頭已經被取消,把下一個元素作為棧頭
            if (h.isCancelled())            // already cancelled
                casHead(h, h.next);         // pop and retry
            // snode 方法第三個引數 h 代表棧頭,賦值給 s 的 next 屬性
            else if (casHead(h, s=snode(s, e, h, FULFILLING|mode))) {
                for (;;) { // loop until matched or waiters disappear
                    // m 就是棧頭,通過上面 snode 方法剛剛賦值
                    SNode m = s.next;       // m is s's match
                    if (m == null) {        // all waiters are gone
                        casHead(s, null);   // pop fulfill node
                        s = null;           // use new node next time
                        break;              // restart main loop
                    }
                    SNode mn = m.next;
                     // tryMatch 非常重要的方法,兩個作用:
                     // 1 喚醒被阻塞的棧頭 m,2 把當前節點 s 賦值給 m 的 match 屬性
                     // 這樣棧頭 m 被喚醒時,就能從 m.match 中得到本次操作 s
                     // 其中 s.item 記錄著本次的操作節點,也就是記錄本次操作的資料
                    if (m.tryMatch(s)) {
                        casHead(s, mn);     // pop both s and m
                        return (E) ((mode == REQUEST) ? m.item : s.item);
                    } else                  // lost match
                        s.casNext(m, mn);   // help unlink
                }
            }
        } else {                            // help a fulfiller
            SNode m = h.next;               // m is h's match
            if (m == null)                  // waiter is gone
                casHead(h, null);           // pop fulfilling node
            else {
                SNode mn = m.next;
                if (m.tryMatch(h))          // help match
                    casHead(h, mn);         // pop both h and m
                else                        // lost match
                    h.casNext(m, mn);       // help unlink
            }
        }
    }
}

總結一下操作思路:

  1. 判斷是 put 方法還是 take 方法
  2. 判斷棧頭資料是否為空,如果為空或者棧頭的操作和本次操作一致,是的話走 3,否則走 5
  3. 判斷操作有無設定超時時間,如果設定了超時時間並且已經超時,返回 null,否則走 4
  4. 如果棧頭為空,把當前操作設定成棧頭,或者棧頭不為空,但棧頭的操作和本次操作相同,也把當前操作設定成棧頭,並看看其它執行緒能否滿足自己,不能滿足則阻塞自己。比如當前操作是 take,但佇列中沒有資料,則阻塞自己
  5. 如果棧頭已經是阻塞住的,需要別人喚醒的,判斷當前操作能否喚醒棧頭,可以喚醒走 6,否則走 4
  6. 把自己當作一個節點,賦值到棧頭的 match 屬性上,並喚醒棧頭節點
  7. 棧頭被喚醒後,拿到 match 屬性,就是把自己喚醒的節點的資訊,返回。

在整個過程中,有一個節點阻塞的方法,原始碼如下:

當一個 節點/執行緒 將要阻塞時,它會設定其 waiter 欄位,然後在真正 park 之前至少再檢查一次狀態,從而涵蓋了競爭與實現者的關係,並注意到 waiter 非空,因此應將其喚醒。

當由出現在呼叫點位於堆疊頂部的節點呼叫時,對停放的呼叫之前會進行旋轉,以避免在生產者和消費者及時到達時阻塞。 這可能只足以在多處理器上發生。

從主迴圈返回的檢查順序反映了這樣一個事實,即優先順序: 中斷 > 正常的返回 > 超時。 (因此,在超時時,在放棄之前要進行最後一次匹配檢查。)除了來自非定時SynchronousQueue的呼叫。{poll / offer}不會檢查中斷,根本不等待,因此陷入了轉移方法中 而不是呼叫awaitFulfill。

/**
 * 旋轉/阻止,直到節點s通過執行操作匹配。
 * @param s 等待的節點
 * @param timed true if timed wait
 * @param nanos 超時時間
 * @return 匹配的節點, 或者是 s 如果被取消
 */
SNode awaitFulfill(SNode s, boolean timed, long nanos) {
 
    // deadline 死亡時間,如果設定了超時時間的話,死亡時間等於當前時間 + 超時時間,否則就是 0
    final long deadline = timed ? System.nanoTime() + nanos : 0L;
    Thread w = Thread.currentThread();
    // 自旋的次數,如果設定了超時時間,會自旋 32 次,否則自旋 512 次。
    // 比如本次操作是 take 操作,自旋次數後,仍無其他執行緒 put 資料
    // 就會阻塞,有超時時間的,會阻塞固定的時間,否則一致阻塞下去
    int spins = (shouldSpin(s) ?
                 (timed ? maxTimedSpins : maxUntimedSpins) : 0);
    for (;;) {
        // 當前執行緒有無被打斷,如果過了超時時間,當前執行緒就會被打斷
        if (w.isInterrupted())
            s.tryCancel();

        SNode m = s.match;
        if (m != null)
            return m;
        if (timed) {
            nanos = deadline - System.nanoTime();
            // 超時了,取消當前執行緒的等待操作
            if (nanos <= 0L) {
                s.tryCancel();
                continue;
            }
        }
        // 自選次數減1
        if (spins > 0)
            spins = shouldSpin(s) ? (spins-1) : 0;
        // 把當前執行緒設定成 waiter,主要是通過執行緒來完成阻塞和喚醒
        else if (s.waiter == null)
            s.waiter = w; // establish waiter so can park next iter
        else if (!timed)
            // 通過 park 進行阻塞,這個我們在鎖章節中會說明
            LockSupport.park(this);
        else if (nanos > spinForTimeoutThreshold)
            LockSupport.parkNanos(this, nanos);
    }
}

可以發現其阻塞策略,並不是一上來就阻塞住,而是在自旋一定次數後,仍然沒有其它執行緒來滿足自己的要求時,才會真正的阻塞。

佇列的實現策略通常分為公平模式和非公平模式,本文我們重點介紹公平模式。

4 公平佇列

4.1元素組成

原始碼解析Synchronous Queue 這種特立獨行的佇列原始碼解析Synchronous Queue 這種特立獨行的佇列
  • volatile QNode next
    當前元素的下一個元素
  • volatile Object item // CAS’ed to or from null
    當前元素的值,如果當前元素被阻塞住了,等其他執行緒來喚醒自己時,其他執行緒會把自己 set 到 item 裡面
  • volatile Thread waiter // to control park/unpark
    可以阻塞住的當前執行緒
  • final boolean isData
    true 是 put,false 是 take

公平佇列主要使用的是 TransferQueue 內部類的 transfer 方法,看原始碼:

E transfer(E e, boolean timed, long nanos) {

    QNode s = null; // constructed/reused as needed
    // true : put false : get
    boolean isData = (e != null);

    for (;;) {
        // 佇列頭和尾的臨時變數,佇列是空的時候,t=h
        QNode t = tail;
        QNode h = head;
        // tail 和 head 沒有初始化時,無限迴圈
        // 雖然這種 continue 非常耗cpu,但感覺不會碰到這種情況
        // 因為 tail 和 head 在 TransferQueue 初始化的時候,就已經被賦值空節點了
        if (t == null || h == null)
            continue;
        // 首尾節點相同,說明是空佇列
        // 或者尾節點的操作和當前節點操作一致
        if (h == t || t.isData == isData) {
            QNode tn = t.next;
            // 當 t 不是 tail 時,說明 tail 已經被修改過了
            // 因為 tail 沒有被修改的情況下,t 和 tail 必然相等
            // 因為前面剛剛執行賦值操作: t = tail
            if (t != tail)
                continue;
            // 隊尾後面的值還不為空,t 還不是隊尾,直接把 tn 賦值給 t,這是一步加強校驗。
            if (tn != null) {
                advanceTail(t, tn);
                continue;
            }
            //超時直接返回 null
            if (timed && nanos <= 0)        // can't wait
                return null;
            //構造node節點
            if (s == null)
                s = new QNode(e, isData);
            //如果把 e 放到隊尾失敗,繼續遞迴放進去
            if (!t.casNext(null, s))        // failed to link in
                continue;

            advanceTail(t, s);              // swing tail and wait
            // 阻塞住自己
            Object x = awaitFulfill(s, e, timed, nanos);
            if (x == s) {                   // wait was cancelled
                clean(t, s);
                return null;
            }

            if (!s.isOffList()) {           // not already unlinked
                advanceHead(t, s);          // unlink if head
                if (x != null)              // and forget fields
                    s.item = s;
                s.waiter = null;
            }
            return (x != null) ? (E)x : e;
        // 佇列不為空,並且當前操作和隊尾不一致
        // 也就是說當前操作是隊尾是對應的操作
        // 比如說隊尾是因為 take 被阻塞的,那麼當前操作必然是 put
        } else {                            // complementary-mode
            // 如果是第一次執行,此處的 m 代表就是 tail
            // 也就是這行程式碼體現出佇列的公平,每次操作時,從頭開始按照順序進行操作
            QNode m = h.next;               // node to fulfill
            if (t != tail || m == null || h != head)
                continue;                   // inconsistent read

            Object x = m.item;
            if (isData == (x != null) ||    // m already fulfilled
                x == m ||                   // m cancelled
                // m 代表棧頭
                // 這裡把當前的操作值賦值給阻塞住的 m 的 item 屬性
                // 這樣 m 被釋放時,就可得到此次操作的值
                !m.casItem(x, e)) {         // lost CAS
                advanceHead(h, m);          // dequeue and retry
                continue;
            }
            // 當前操作放到隊頭
            advanceHead(h, m);              // successfully fulfilled
            // 釋放隊頭阻塞節點
            LockSupport.unpark(m.waiter);
            return (x != null) ? (E)x : e;
        }
    }
}

執行緒被阻塞住後,當前執行緒是如何把自己的資料傳給阻塞執行緒的。

假設執行緒 1 從佇列中 take 資料 ,被阻塞,變成阻塞執行緒 A 然後執行緒 2 開始往佇列中 put 資料 B,大致的流程如下:

  • 執行緒 1 從佇列 take 資料,發現佇列內無資料,於是被阻塞,成為 A
  • 執行緒 2 往隊尾 put 資料,會從隊尾往前找到第一個被阻塞的節點,假設此時能找到的就是節點 A,然後執行緒 B 把將 put 的資料放到節點 A 的 item 屬性裡面,並喚醒執行緒 1
  • 執行緒 1 被喚醒後,就能從 A.item 裡面拿到執行緒 2 put 的資料了,執行緒 1 成功返回。

在這個過程中,公平主要體現在,每次 put 資料的時候,都 put 到隊尾上,而每次拿資料時,並不是直接從堆頭拿資料,而是從隊尾往前尋找第一個被阻塞的執行緒,這樣就會按照順序釋放被阻塞的執行緒。

4.2 圖解公平佇列模型

公平模式下,底層實現使用的是 TransferQueue 佇列,它有一個head和tail指標,用於指向當前正在等待匹配的執行緒節點。

初始化時,TransferQueue的狀態如下:

原始碼解析Synchronous Queue 這種特立獨行的佇列

1.執行緒put1執行 put(1)操作,由於當前沒有配對的消費執行緒,所以put1執行緒入佇列,自旋一小會後睡眠等待,這時佇列狀態如下:

原始碼解析Synchronous Queue 這種特立獨行的佇列

2.接著,執行緒put2執行了put(2)操作,跟前面一樣,put2執行緒入佇列,自旋一小會後睡眠等待,這時佇列狀態如下:

原始碼解析Synchronous Queue 這種特立獨行的佇列

3.這時候,來了一個執行緒take1,執行了 take操作,由於tail指向put2執行緒,put2執行緒跟take1執行緒配對了(一put一take),這時take1執行緒不需要入隊,但是請注意了,這時候,要喚醒的執行緒並不是put2,而是put1。

為何? 大家應該知道我們現在講的是公平策略,所謂公平就是誰先入隊了,誰就優先被喚醒,我們的例子明顯是put1應該優先被喚醒。有的同學可能會有一個疑問,明明是take1執行緒跟put2執行緒匹配上了,結果是put1執行緒被喚醒消費,怎麼確保take1執行緒一定可以和次首節點(head.next)也是匹配的呢?其實大家可以拿個紙畫一畫,就會發現真的就是這樣的。

公平策略總結下來就是:隊尾匹配隊頭出隊。

執行後put1執行緒被喚醒,take1執行緒的 take()方法返回了1(put1執行緒的資料),這樣就實現了執行緒間的一對一通訊,這時候內部狀態如下:

原始碼解析Synchronous Queue 這種特立獨行的佇列

4.最後,再來一個執行緒take2,執行take操作,這時候只有put2執行緒在等候,而且兩個執行緒匹配上了,執行緒put2被喚醒,take2執行緒take操作返回了2(執行緒put2的資料),這時候佇列又回到了起點,如下所示:

原始碼解析Synchronous Queue 這種特立獨行的佇列

以上便是公平模式下,SynchronousQueue的實現模型。總結下來就是:隊尾匹配隊頭出隊,先進先出,體現公平原則。

5 非公平模式

5.1 元素組成

  • 棧頂
原始碼解析Synchronous Queue 這種特立獨行的佇列原始碼解析Synchronous Queue 這種特立獨行的佇列
  • volatile SNode next
    棧中的下一個元素
  • volatile Object item // data; or null for REQUESTs
    當前元素的值,如果當前元素被阻塞住了,等其他執行緒來喚醒自己時,其他執行緒會把自己 set 到 item 裡面
  • volatile Thread waiter
    可以阻塞住的當前執行緒

5.2 圖解非公平模型

還是使用跟公平模式下一樣的操作流程,對比兩種策略下有何不同。

非公平模式底層的實現使用的是TransferStack,一個棧,實現中用head指標指向棧頂,接著我們看看它的實現模型:

1.執行緒put1執行 put(1)操作,由於當前沒有配對的消費執行緒,所以put1執行緒入棧,自旋一小會後睡眠等待,這時棧狀態如下

原始碼解析Synchronous Queue 這種特立獨行的佇列

2.接著,執行緒put2再次執行了put(2)操作,跟前面一樣,put2執行緒入棧,自旋一小會後睡眠等待,這時棧狀態如下:

原始碼解析Synchronous Queue 這種特立獨行的佇列

3.這時候,來了一個執行緒take1,執行了take操作,這時候發現棧頂為put2執行緒,匹配成功,但是實現會先把take1執行緒入棧,然後take1執行緒迴圈執行匹配put2執行緒邏輯,一旦發現沒有併發衝突,就會把棧頂指標直接指向 put1執行緒

原始碼解析Synchronous Queue 這種特立獨行的佇列

4.最後,再來一個執行緒take2,執行take操作,這跟步驟3的邏輯基本是一致的,take2執行緒入棧,然後在迴圈中匹配put1執行緒,最終全部匹配完畢,棧變為空,恢復初始狀態,如下圖所示:

原始碼解析Synchronous Queue 這種特立獨行的佇列

可以從上面流程看出,雖然put1執行緒先入棧了,但是卻是後匹配,這就是非公平的由來。

5 總結

SynchronousQueue 原始碼比較複雜,建議大家進行原始碼的 debug 來學習原始碼,為大家準備了除錯類:SynchronousQueueDemo,大家可以下載原始碼自己除錯一下,這樣學起來應該會更加輕鬆一點。

  • SynchronousQueue內沒有容器為什麼能夠儲存一個元素?
    內部沒有容器指的是沒有像陣列那樣的記憶體空間存多個元素,但是是有單地址記憶體空間,用於交換資料

SynchronousQueue由於其獨有的執行緒一一配對通訊機制,在大部分平常開發中,可能都不太會用到,但執行緒池技術中會有所使用,由於內部沒有使用AQS,而是直接使用CAS,所以程式碼理解起來會比較困難,但這並不妨礙我們理解底層的實現模型,在理解了模型的基礎上,再翻閱原始碼,就會有方向感,看起來也會比較容易!

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章