併發程式設計之 SynchronousQueue 核心原始碼分析

莫那·魯道發表於2018-04-30

前言

SynchronousQueue 是一個普通使用者不怎麼常用的佇列,通常在建立無界執行緒池(Executors.newCachedThreadPool())的時候使用,也就是那個非常危險的執行緒池 ^_^

它是一個非常特殊的阻塞佇列,他的模式是:在 offer的時候,如果沒有另一個執行緒在 take 或者 poll 的話,就會失敗,反之,如果在 take或者 poll的時候,沒有執行緒在offer ,則也會失敗,而這種特性,則非常適合用來做高響應並且執行緒不固定的執行緒池的Queue。所以,在很多高效能伺服器中,如果併發很高,這時候,普通的 LinkedQueue就會成為瓶頸,效能就會出現毛刺,當換上 SynchronousQueue後,效能就會好很多。

今天就看看這個特殊的 Queue 是怎麼實現的。友情提示:程式碼有些小複雜。。。請做好心理準備。

原始碼實現

SynchronousQueue 內部分為公平(佇列)和非公平(棧),佇列的效能相對而言會好點。構造方法中,就看出來了。預設是非公平的,通常非公平(棧 FIFO)的效能會高那麼一點點。

構造方法

public SynchronousQueue(boolean fair) {
    transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}
複製程式碼

offer 方法

該方法我們通常建議使用帶有超時機制的 offer方法。

public boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException {
    if (e == null) throw new NullPointerException();
    if (transferer.transfer(e, true, unit.toNanos(timeout)) != null)
        return true;
    if (!Thread.interrupted())
        return false;
    throw new InterruptedException();
}

複製程式碼

從上面的程式碼中,可以看到核心方法就是 transfer方法。如果該方法返回 true,表示,插入成功,如果失敗,就返回 false

poll 方法

public E poll(long timeout, TimeUnit unit) throws InterruptedException {
    E e = transferer.transfer(null, true, unit.toNanos(timeout));
    if (e != null || !Thread.interrupted())
        return e;
    throw new InterruptedException();
}
複製程式碼

同樣的該方法也是呼叫了 transfer 方法。結果返回得到的值或者null。區別在於,offer方法的 e 引數是實體的。而 poll 方法 e 引數是 null,我們猜測,方法內部肯定根據這個做了判斷。所以,重點在於transfer方法的實現。

而 transferer 有 2 種,佇列和棧,我們就研究一種,知曉其原理,另一種有時間在看。

TransferQueue 原始碼實現

構造方法:

TransferQueue() {
    QNode h = new QNode(null, false); // initialize to dummy node.
    head = h;
    tail = h;
}
複製程式碼

構造一個 Node 節點,註釋說這是一個加的 node。並賦值給 head 和 tail 節點。形成一個初始化的連結串列。

看看這個 node:

/** Node class for TransferQueue. */
static final class QNode {
    volatile QNode next;          // next node in queue
    volatile Object item;         // CAS'ed to or from null
    volatile Thread waiter;       // to control park/unpark
    final boolean isData;
}
複製程式碼

node 持有佇列中下一個 node,node 對應的值 value,持有該 node 的執行緒,擁有 park 或者 unpark,這裡用的是 JUC 的工具類 LockSupport,還有一個布林型別,isData,這個非常重要,需要好好理解,到後面我們會好好講解。

我們更關注的是這個類的 transfer 方法,該方法是 SynchronousQueue 的核心。

該方法介面定義如下:

/**
 * Performs a put or take. put 或者 take
 *
 * @param e if non-null, the item to be handed to a consumer;
 *          if null, requests that transfer return an item
 *          offered by producer. 
 * @param timed if this operation should timeout
 * @param nanos the timeout, in nanoseconds
 * @return if non-null, the item provided or received; if null,
 *         the operation failed due to timeout or interrupt --
 *         the caller can distinguish which of these occurred
 *         by checking Thread.interrupted.
 */
abstract E transfer(E e, boolean timed, long nanos);
複製程式碼

註釋說道 e 引數的作用:

如果 e 不是 null(說明是生產者呼叫) ,將 item 交給消費者,並返回 e;反之,如果是 null(說明是消費者呼叫),將生產者提供的 item 返回給消費者。

看看 TransferQueue 類的 transfer 方法實現,樓主寫了很多的註釋嘗試解讀:

QNode s = null; // constructed/reused as needed
boolean isData = (e != null);// 當輸入的是資料時,isData 就是 ture,表明這個操作是一個輸入資料的操作;同理,當呼叫者輸入的是 null,則是在消費資料。

for (;;) {
    QNode t = tail;
    QNode h = head;
    if (t == null || h == null)         // 如果併發導致未"來得及"初始化
        continue;                       // 自旋重來

    // 以下分成兩個部分進行

    // 1. 如果當前操作和 tail 節點的操作是一樣的;或者頭尾相同(表明佇列中啥都沒有)。
    if (h == t || t.isData == isData) { 
        QNode tn = t.next;
        if (t != tail)                  // 如果 t 和 tail 不一樣,說明,tail 被其他的執行緒改了,重來
            continue;
        if (tn != null) {               // 如果 tail 的 next 不是空。就需要將 next 追加到 tail 後面了。
            advanceTail(t, tn); // 使用 CAS 將 tail.next 變成 tail,        
            continue;
        }
        if (timed && nanos <= 0)        // 時間到了,不等待,返回 null,插入失敗,獲取也是失敗的。
            return null;
        if (s == null) // 如果能走到這裡,說明 tail 的 next 是 null,這裡的判斷是避免重複建立 Qnode 物件。
            s = new QNode(e, isData);// 建立一個新的節點。
        if (!t.casNext(null, s))        // 嘗試 CAS 將這個剛剛建立的節點追加到 tail 的 next 節點上.
            continue;// 如果失敗,則重來

        advanceTail(t, s); // 當新的節點成功追加到 tail 節點的 next 上了, 就嘗試將 tail.next 節點覆蓋 tail 節點,稱之為推進。
        // s == 新節點,“可能”是新的 tail;e 是實際資料。
        Object x = awaitFulfill(s, e, timed, nanos);// 該方法作用就是,讓當前執行緒等待。排除意外情況和超時的話,就是等待其他執行緒拿走資料並替換成 isData 不同的資料。
        if (x == s) { // x == s 是什麼意思呢? 表明在 awaitFulfill 方法中,這個資料被取消了,tryCancel 方法就是將 item 覆蓋了 QNode。說明這次操作失敗了。
            clean(t, s);// 操作失敗則需要清理資料,並返回 null。
            return null;
        }

        // 如果一切順利,確實被其他執行緒喚醒了,其他執行緒也交換了資料。
        // 這個判斷:next != this,說明了什麼?當這個 tail 節點的 next 不再指向自己,說明了
        if (!s.isOffList()) {           // not already unlinked
            // 這一步是將 S 節點設定為 Head,並且將新 Head 的 next 指向自己,讓 Head 和之前的 next 斷開。
            advanceHead(t, s);          // unlink if head     
            // 當 x 不是 null,表明對方執行緒是存放資料的。     
            if (x != null)              // and forget fields
                // 這一步操作將自己的 item 設定成自己。
                s.item = s;
            // 將 S 節點的持有執行緒變成 null。
            s.waiter = null;
        }
        // x 不是 null 表明,對方執行緒是生產者,返回他生產的資料;如果是 null,說明對方執行緒是消費者,那他自己就是生產者,返回自己的資料,表示成功。
        return (x != null) ? (E)x : e;

    } 
    // 2. 如果當前的操作型別和 tail 的操作不一樣。稱之為互補。
    else {                            // complementary-mode
        QNode m = h.next;               // node to fulfill
        // 如果下方這些判斷沒過,說明併發修改了,自旋重來。 
        if (t != tail || m == null || h != head)
            continue;                   // inconsistent read

        Object x = m.item;
        // 如果 head 節點的 isData 和當前操作相同,
        // 如果 操作不同,但 head 的 item 就是自身,也就是發生了取消操作,tryCancel 方法會做這件事情。
        // 如果上面2個都不滿足,嘗試使用 CAS 將 e 覆蓋 item。 
        if (isData == (x != null) ||    // m already fulfilled
            x == m ||                   // m cancelled
            !m.casItem(x, e)) {         // lost CAS
            // CAS 失敗了,Head 的操作型別和當前型別相同,item 被取消了,都會走這裡。
            // 將 h.next 覆蓋 head。重來。
            advanceHead(h, m);          // dequeue and retry
            continue;
        }
        // 這裡也是將 h.next 覆蓋 head。能夠走到這裡,說明,上面的 CAS 操作成功了,當前執行緒已經將 e 覆蓋了 next 的 item 。
        advanceHead(h, m);              // successfully fulfilled
        // 喚醒 next 的 執行緒。提醒他可以取出資料,或者“我”已經拿到資料了。
        LockSupport.unpark(m.waiter);
        // 如果 x 不是 null,表明這是一次消費資料的操作,反之,這是一次生產資料的操作。
        return (x != null) ? (E)x : e;
    }
}
複製程式碼

說實話,程式碼還是比較複雜的。JDK 中註釋是這麼說的:

基本演算法是死迴圈採取 2 種方式中的其中一種。 1 如果佇列是空的,或者持有相同的模式節點(isData 相同),就嘗試新增節點到佇列中,並讓當前執行緒等待。 2 如果佇列中有執行緒在等待,那麼就使用一種互補的方式,使用 CAS 和等待者交換資料。並返回。

什麼意思呢?

首先明確一點,佇列中,資料有 2 種情況(但同時只存在一種),要麼QNode 中有實際資料(offer 的時候,是有資料的,但沒有“人”來取),要麼沒有實際資料(poll 的時候,佇列中沒有資料,執行緒只好等待)。佇列在哪一種狀態取決於他為空後,第一個插入的是什麼型別的資料

樓主畫了點圖來表示:

  1. 佇列初始化的時候,只有一個空的Node

image.png

  1. 此時,一個執行緒嘗試 offer 或者 poll資料,都會插入一個 Node 插入到節點中。

image.png

  1. 假設剛剛發生的是 offer 操作,這個時候,另一個執行緒也來 offer,這時就會有 2 個節點。

image.png

  1. 這個時候,佇列中有 2 個有真實資料(offer 操作)的節點了,注意,這個時候,那 2 個執行緒都是 wait的,因為沒有人接受他們的資料。此時,又來一個執行緒,做 poll 操作。

image.png

從上圖可以看出,poll 執行緒從head 開始取資料,因為它的 isDatatail 節點的 isData 不同,那麼就會從 head 開始找節點,並嘗試將自己的 null 值和節點中的真實資料進行交換。並喚醒等待中的執行緒。

這 4 幅圖就是 SynchronousQueue的精華。

既然叫做同步佇列,一定是 A 執行緒生產資料的時候,有 B 執行緒在消費,否則 A 執行緒就需要等待,反之,如果 A 執行緒準備消費資料,但佇列中沒有資料,執行緒也會等待,直到有 B 執行緒存放資料。

而 JDK 的實現原理則是:使用一個佇列,佇列中的用一個 isData 來區分生產還是消費,所有新操作都根據 tail 節點的模式來決定到底是追加到 tail節點還是和 tail節點(從 head 開始)交換資料。

而所謂的交換是從head開始,取出節點的實際資料,然後使用 CAS 和匹配到的節點進行交換。從而完成兩個執行緒直接交換資料的操作。

為什麼他在某些情況下,比LinkedBlockingQueue效能高呢?其中有個原因就是沒有使用鎖,減少了執行緒上下文切換。第二則是執行緒之間交換資料的方式更加的高效。

好,重點部分講完了,再看看其中執行緒是如何等待的。邏輯在 awaitFulfill 方法中:

// 自旋或者等待,直到填充完畢
// 這裡的策略是什麼呢?如果自旋次數不夠了,通常是 16 次,但還有超過 1 秒的時間,就阻塞等待被喚醒。
// 如果時間到了,就取消這次的入隊行為。
// 返回的是 Node 本身
// s.item 就是 e 
Object awaitFulfill(QNode s, E e, boolean timed, long nanos) {
    final long deadline = timed ? System.nanoTime() + nanos : 0L;
    Thread w = Thread.currentThread();
    int spins = ((head.next == s) ?// 如果成功將 tail.next 覆蓋了 tail,如果有超時機制,則自旋 32 次,如果沒有超時機制,則自旋 32 *16 = 512次
                 (timed ? maxTimedSpins : maxUntimedSpins) : 0);
    for (;;) {
        if (w.isInterrupted())// 當前執行緒被中斷
            s.tryCancel(e);// 嘗試取消這個 item
        Object x = s.item;// 獲取到這個 tail 的 item
        if (x != e) // 如果不相等,說明 node 中的 item 取消了,返回這個 item。
            // 這裡是唯一停止迴圈的地方。當 s.item 已經不是當初的哪個 e 了,說明要麼是時間到了被取消了,要麼是執行緒中斷被取消了。
            // 當然,不僅僅只有這2種 “意外” 情況,還有一種情況是:當另一個執行緒拿走了這個資料,並修改了 item,也會通過這個判斷,返回被“修改”過的 item。
            return x;
        if (timed) {// 如果有時間限制
            nanos = deadline - System.nanoTime();
            if (nanos <= 0L) {// 如果時間到了
                s.tryCancel(e);// 嘗試取消 item,供上面的 x != e 判斷
                continue;// 重來
            }
        }
        if (spins > 0)// 如果還有自旋次數
            --spins;// 減一
        else if (s.waiter == null)// 如果自旋不夠,且 tail 的等待執行緒還沒有賦值
            s.waiter = w;// 當前執行緒賦值給 tail 的等待執行緒
        else if (!timed)// 如果自旋不夠,且如果執行緒賦值過了,且沒有限制時間,則 wait,(危險操作)
            LockSupport.park(this);
        else if (nanos > spinForTimeoutThreshold)// 如果自旋不夠,且如果限制了時間,且時間還剩餘超過 1 秒,則 wait 剩餘時間。
            // 主要目的就是等待,等待其他執行緒喚醒這個節點所在的執行緒。
            LockSupport.parkNanos(this, nanos);
    }
}
複製程式碼

該方法邏輯如下:

  1. 預設自旋 32 次,如果沒有超時機制,則 512 次。
  2. 如果時間到了,或者執行緒被中斷,則取消這次的操作,將item設定成自己。供後面判斷。
  3. 如果自旋結束,且剩餘時間還超過 1 秒,則阻塞等待至剩餘時間。
  4. 當執行緒被其他的執行緒喚醒,說明資料被交換了。則 return,返回的是交換後的資料。

總結

好了,關於 SynchronousQueue的核心原始碼分析就到這裡了,樓主沒有分析這個類的所有原始碼,只研究了核心部分程式碼,這足夠我們理解這個 Queue 的內部實現了。

總結下來就是:

JDK 使用了佇列或者棧來實現公平或非公平模型。其中,isData 屬性極為重要,標識這這個執行緒的這次操作,決定了他到底應該是追加到佇列中,還是從佇列中交換資料。

每個執行緒在沒有遇到自己的另一半時,要麼快速失敗,要麼進行阻塞,阻塞等待自己的另一半來,至於對方是給資料還是取資料,取決於她自己,如果她是消費者,那麼他就是生產者。

good luck!!!!

相關文章