阻塞隊列的首選之隊列超集LinkedTransferQueue

前言

這次討論的議題是 JDK 1.7 新增的一個阻塞隊列LinkedTransferQueue,那麼它與上一篇我們討論的那3個基本的阻塞隊列有什麼不同呢?爲什麼JDK1.7的時候需要在JUC包中加入這個隊列的實現呢?首先可以看看Doug Lea對增加LTQ(LinkedTransferQueue,下文都如此描述)這個隊列的動機描述:

資料:http://cs.oswego.edu/pipermail/concurrency-interest/2009-February/005888.html

The simplest answer is that we realized that we should
have had this in the first place in Java5 rather than
some others, but didn’t know it. Better late than never.

The capabilities are a superset of those in

  • ConcurrentLinkedQueue,
  • SynchronousQueue, in “fair” mode,
  • LinkedBlockingQueues that are not capacity bounded.

And better not only because you can now mix these capabilities,
but also because the implementation includes better techniques
we’ve discovered. So it seems like a good choice to place
in java.util.concurrent.

  • 首先,doug表示這個隊列的實現早該在Java5中出現在juc中,但是也沒關係,遲來總比沒來好。

  • 其次,此隊列的能力是以下隊列的超集

    • ConcurrentLinkedQueue:非阻塞的高吞吐量的線程安全隊列
    • SynchronousQueue:阻塞隊列,不存儲元素,是hand-off的,吞吐量高
    • LinkedBlockingQueues:基本的阻塞隊列實現,可以存儲元素
  • 最後,此隊列不僅混合了以上能力,還附加了一些更好的技術(性能更好),綜上所述,此隊列似乎是juc中的一個很好的選擇

這裏再引入Pure Danger Tech這位小哥的博客的一段話

資料:https://puredanger.github.io/tech.puredanger.com/2009/02/28/java-7-transferqueue/

Joe Bowbeer helpfully provided a link to a paper by William Scherer, Doug Lea, and Michael Scott that lays out the LinkedTransferQueue algorithms and performance tests showing their improvements over the existing Java 5 alternatives. LinkedTransferQueue outperforms SynchronousQueue by a factor of 3x in unfair mode and 14x in fair mode. Because SynchronousQueue is used as the heart of task handoff in something like ThreadPoolExecutor, this can result in similar kinds of performance improvements there. Given the importance of executors in concurrent programming, you start to see the importance of adding this new implementation.

  • 這裏表示,論文中表明LTQ的算法和性能測試表現了其改善了Java5中的隊列選擇,LTQ在非公平模式下優於SynchronousQueue 3個因子(不知道是不是3倍的意思,這裏因子是3,應該是3倍吧?反正性能優),在公平模式下優於SynchronousQueue 14個因子

綜上所述,LTQ性能更優,並且集合了(無界的)非阻塞隊列(正常的線程安全隊列FIFO)、阻塞隊列(擁有阻塞特性的線程安全隊列FIFO)、SIZE 0 (hand-off傳輸)的特性,在不失hand-off的特性的語義的同時可以靈活的在阻塞與非阻塞中切換,在需要線程安全的隊列選擇上應當優先被選擇。

隊列的語義切換

線程安全的隊列

TransferQueue<Integer> tlq = new LinkedTransferQueue<>();

// ConcurrentLinkedQueue
tlq.offer(1);
tlq.put(2);

// [1, 2]
System.out.println(tlq);
// 1
System.out.println(tlq.poll());
// 2
System.out.println(tlq.poll());
// null
System.out.println(tlq.poll());

如果我們需要將其用作普通線程安全的隊列,其沒有阻塞的語義,有高吞吐量、伸縮性的保證

Hand-off(SynchronousQueue語義)

// SynchronousQueue

// blocking
//tlq.transfer(1);

// blocking
//tlq.take()

// 類似SynchronousQueue#offer
// non-blocking,沒人接收就丟棄了
tlq.tryTransfer(1);
// empty -> []
System.out.println(tlq);

無界的阻塞隊列

// 無界的 LinkedBlockingQueue
tlq.put(1);

// 1
System.out.println(tlq.poll());
// size == 0 阻塞
System.out.println(tlq.take());

LTQ原理分析

那麼其底層又是怎麼做到同時可以有這麼多的語義的呢?首先來看看幾個API

public void transfer(E e) throws InterruptedException {
  if (xfer(e, true, SYNC, 0) != null) {
    Thread.interrupted(); // failure possible only due to interrupt
    throw new InterruptedException();
  }
}
public boolean tryTransfer(E e) {
  return xfer(e, true, NOW, 0) == null;
}
public boolean add(E e) {
  xfer(e, true, ASYNC, 0);
  return true;
}
// ...

其中使用了以下枚舉來體現每個存取方法的語義

/*
 * Possible values for "how" argument in xfer method.
 */
private static final int NOW   = 0; // for untimed poll, tryTransfer
private static final int ASYNC = 1; // for offer, put, add
private static final int SYNC  = 2; // for transfer, take
private static final int TIMED = 3; // for timed poll, tryTransfer

可以看到,所有的存取api都調用了xfer這個方法,所以關鍵就在xfer方法中。這裏我羅列了幾個重要api調用xfer方法的參數列表:

語義 隊列存取方法 xfer方法參數列表
普通隊列存取(non-blocking) put/offer/add xfer(e, true, ASYNC, 0)
非阻塞,隊列空返回null poll xfer(null, false, NOW, 0)
hand-off(阻塞傳球,隊列不存放元素) transfer xfer(e, true, SYNC, 0)
類似於SynchronousQueue的offer tryTransfer xfer(e, true, TIMED, unit.toNanos(timeout))
若隊列空,阻塞取 take xfer(null, false, SYNC, 0)

由於是無界的,所以添加元素不會阻塞,而阻塞取包含在take方法中,所以這裏沒有列出阻塞隊列的語義。

假設阻塞特性

上一篇文章中我們使用了假設法推敲SynchronousQueue的代碼,這次我們依然這樣做。在這一小節,我們假設在阻塞特性下模擬的場景。

1. 來一個線程存數據

假設有一個線程阻塞存數據,首先調用了transfer(item)方法阻塞,直到另一個線程調用了take方法

// e, true, SYNC 0
private E xfer(E e, boolean haveData, int how, long nanos) {
  // 如果是存模式,元素又爲空,必然是不正確的
  if (haveData && (e == null))
    throw new NullPointerException();
  Node s = null;                        // the node to append, if needed

  retry:
  for (;;) {                            // restart on append race

    // head此時爲空,不進入分支
    for (Node h = head, p = h; p != null;) { // find & match first node
      // ...
    }

    // 如果是立即的,可以看出這裏就直接返回了
    // 這裏是SYNC同步模式,進入分支
    if (how != NOW) {                 // No matches available
      // 進入分支
      if (s == null)
        // 構造新節點
        // Node.item = e,Node.haveData = true體現爲存數據節點
        s = new Node(e, haveData);
      // 嘗試將節點添加
      Node pred = tryAppend(s, haveData);
      // 從下面的tryAppend方法分析可以看出此時pred爲s節點
      // 不進入此分支
      if (pred == null)
        continue retry;           // lost race vs opposite mode
			// 由於不是ASYNC異步模式,所以會進入awaitMatch方法
      if (how != ASYNC)
        return awaitMatch(s, pred, e, (how == TIMED), nanos);
    }
    return e; // not waiting
  }
}

這裏進入tryAppend方法

// s = 剛剛構造的新節點,havaData = true
private Node tryAppend(Node s, boolean haveData) {
  // tail = null
  for (Node t = tail, p = t;;) {        // move p to last node and append
    Node n, u;                        // temps for reads of next & tail
    // 進入此分支
    if (p == null && (p = head) == null) {
      // 將新節點設置爲head
      if (casHead(null, s))
        // 返回
        return s;                 // initialize
    }
    //...
  }
}

由於是SYNC模式,此時會進入awaitMatch方法

// s, s, e, timed = false, 0
private E awaitMatch(Node s, Node pred, E e, boolean timed, long nanos) {
  // 計算到期的時間
  final long deadline = timed ? System.nanoTime() + nanos : 0L;
  Thread w = Thread.currentThread();
  int spins = -1; // initialized after first item and cancel checks
  ThreadLocalRandom randomYields = null; // bound if needed

  for (;;) {
    Object item = s.item;
    // s == s 不進入分支
    // 這裏的意思是,如果節點s中的item變化了,表示有節點接收了此時存放的數據
    // 所以這就是退出for循環也就是有人來接收的標誌
    if (item != e) {                  // matched
      // assert item != s;
      s.forgetContents();           // avoid garbage
      return LinkedTransferQueue.<E>cast(item);
    }
    // 檢測是否被中斷,若被中斷則返回
    if ((w.isInterrupted() || (timed && nanos <= 0)) &&
        s.casItem(e, s)) {        // cancel
      unsplice(pred, s);
      return e;
    }

    // 初始化一個自旋的值
    if (spins < 0) {                  // establish spins at/near front
      // 若是多核處理器,則會初始化一個自旋次數
      if ((spins = spinsFor(pred, s.isData)) > 0)
        randomYields = ThreadLocalRandom.current();
    }
    // 接下來的幾次for循環都會進入這裏,自旋
    // 由於阻塞掛起喚醒線程需要切換到內核態調用系統函數,有一定的開銷
    // 爲了不輕易掛起線程,所以會自旋
    else if (spins > 0) {             // spin
      --spins;
      if (randomYields.nextInt(CHAINED_SPINS) == 0)
        // 這裏似乎會隨機讓出CPU時間片
        Thread.yield();           // occasionally yield
    }
    else if (s.waiter == null) {
      // 因爲之後需要被其他線程喚醒,這裏將線程信息保存在節點中
      s.waiter = w;                 // request unpark then recheck
    }
    // 沒有時間限制,不進入此分支
    else if (timed) {
      nanos = deadline - System.nanoTime();
      if (nanos > 0L)
        // 可以看出,如果是有時間限制的,這裏就會阻塞一個特定的時間
        LockSupport.parkNanos(this, nanos);
    }
    else {
      // 無限阻塞
      LockSupport.park(this);
    }
  }
}

看到這裏,應該已經發現了,大致的流程如下所示:

  1. 將此時模式封裝爲Node,設置爲head作爲頭節點
  2. 自旋一會
  3. 阻塞自己
  4. 上述流程中,只要節點中的item改變了,就代表着有線程來接收,標誌可以退出了

如果有看過我上一篇阻塞隊列文章的讀者,就會發現其實這裏原理跟SynchronousQueue差不多,但論文中說的性能提升,筆者在這裏猜想,是否是因爲使用到了一些算法,類似於ConcurrentLinkedQueue的高吞吐算法,其他線程會協助遷移節點(這一點在下面會體現),因爲除此之外,與SynchronousQueue並沒有什麼差別

2. 又來一個線程存數據

整個存元素的流程走完了,這裏假設此時又有一個線程進來存元素,事情又會變成怎麼樣呢?

// e, true, SYNC 0
private E xfer(E e, boolean haveData, int how, long nanos) {
  // 如果是存模式,元素又爲空,必然是不正確的
  if (haveData && (e == null))
    throw new NullPointerException();
  Node s = null;                        // the node to append, if needed

  retry:
  for (;;) {                            // restart on append race

    // head此時不爲空,循環直到 p指針爲空
    for (Node h = head, p = h; p != null;) { // find & match first node
      // isData = true
      boolean isData = p.isData;
      // 我們剛剛節點的item
      Object item = p.item;
      // 表示節點還沒被處理,且 模式爲存元素
      if (item != p && (item != null) == isData) { // unmatched
        // 是一樣的存模式,退出循環
        if (isData == haveData)   // can't match
          break;
        // ...
      }
      // ...
    }

    // 如果是立即的,可以看出這裏就直接返回了
    // 這裏是SYNC同步模式,進入分支
    if (how != NOW) {                 // No matches available
      // 進入分支
      if (s == null)
        // 構造新節點
        // Node.item = e,Node.haveData = true體現爲存數據節點
        s = new Node(e, haveData);
      // 嘗試將節點添加
      Node pred = tryAppend(s, haveData);
      // 從下面的tryAppend方法分析可以看出此時pred爲s節點
      // 不進入此分支
      if (pred == null)
        continue retry;           // lost race vs opposite mode
			// 由於不是ASYNC異步模式,所以會進入awaitMatch方法
      if (how != ASYNC)
        return awaitMatch(s, pred, e, (how == TIMED), nanos);
    }
    return e; // not waiting
  }
}

這裏的tryAppend方法的結果就和上面的不一樣了

// haveData = true
private Node tryAppend(Node s, boolean haveData) {
  // tail = null
  for (Node t = tail, p = t;;) {        // move p to last node and append
    Node n, u;                        // temps for reads of next & tail
    // 這裏head就不爲null了,表示隊列此時是有元素在的
    if (p == null && (p = head) == null) {
      if (casHead(null, s))
        return s;                 // initialize
    }
    // 主要是判斷此時的模式是否與p節點的匹配
    // 由於此時模式匹配,返回false不進入分支
    else if (p.cannotPrecede(haveData))
      return null;                  // lost race vs opposite mode
    // head節點的next=null
    else if ((n = p.next) != null)    // not last; keep traversing
      p = p != t && t != (u = tail) ? (t = u) : // stale tail
    (p != n) ? n : null;      // restart if off list
    // 將本節點設置爲head的next節點
    // 然後繼續循環
    else if (!p.casNext(null, s))
      p = p.next;                   // re-read on CAS failure
    // 第二次循環會走到這裏
    else {
      // 此時 t = null,進入分支
      if (p != t) {                 // update if slack now >= 2
        // 這裏會執行casTail(t, s),將此時節點設置爲tail
        while ((tail != t || !casTail(t, s)) &&
               (t = tail)   != null &&
               (s = t.next) != null && // advance and retry
               (s = s.next) != null && s != t);
      }
      return p;
    }
  }
}

讀者看到這裏就會發現,此時如果有一個線程也是存元素,入隊的時候並不會將其直接設置爲tail,而是先設置爲tail的next,在下一次循環纔會設置爲tail,其實這裏的算法思想參考了ConcurrentLinkedQueue,這樣會使得每個線程存取的吞吐量有一定的提升,感興趣的讀者可以瞭解一下這裏的算法。

到這裏我們發現一個規律,如果有線程頻頻不斷的來存元素(是在hand-off特性下的假設),那麼會先依次進入LTQ中的鏈表隊列,然後自旋一會,如果還沒人來處理自己,就會阻塞自身線程。

3. 來一個線程取數據

那麼,如果有一個線程來 take/poll ,事情又會是怎樣呢?

// null, false, SYNC/NOW, 0
// 其中 poll 爲 NOW立即模式,take 爲 SYNC同步模式,其實在這種情況下兩者沒什麼分別
private E xfer(E e, boolean haveData, int how, long nanos) {
  if (haveData && (e == null))
    throw new NullPointerException();
  Node s = null;                        // the node to append, if needed

  retry:
  for (;;) {                            // restart on append race
		// p = head,爲第一個線程的Node對象
    // 當 p 爲 null 時退出循環
    for (Node h = head, p = h; p != null;) { // find & match first node
      // true
      boolean isData = p.isData;
      // 第一個線程想要傳遞的對象item
      Object item = p.item;
      // 進入分支
      if (item != p && (item != null) == isData) { // unmatched
        // 因爲此時是取模式,所以不會像以前那樣退出循環
        if (isData == haveData)   // can't match
          break;
        // 將當時的null與第一個線程Node中的item交換
        // 第一個線程喚醒之後就會發現item改變,其就會退出了
        if (p.casItem(item, e)) { // match
          // 此時p還是head,不進入循環
          for (Node q = p; q != h;) {
            Node n = q.next;  // update by 2 unless singleton
            if (head == h && casHead(h, n == null ? q : n)) {
              h.forgetNext();
              break;
            }                 // advance and retry
            if ((h = head)   == null ||
                (q = h.next) == null || !q.isMatched())
              break;        // unless slack < 2
          }
          // 將第一個線程喚醒
          LockSupport.unpark(p.waiter);
          // 返回拿到的元素
          return LinkedTransferQueue.<E>cast(item);
        }
      }
      // 如果上面的cas操作失敗了,相當於取元素的時候被別的線程先取了
      // 那麼拿到head的next節點,也就是第二個進來存的線程對應的Node
      // 繼續上面操作,取元素喚醒線程
      Node n = p.next;
      p = (p != n) ? n : (h = head); // Use head if p offlist
    }

    // 如果走到這裏,證明隊列中已經沒有元素了
    // 此時的take和poll纔有區別
    // 如果是NOW,也就是poll,就會直接返回了,不阻塞
    if (how != NOW) {                 // No matches available
      // 如果是take,就會繼續往下走
      if (s == null)
        s = new Node(e, haveData);
      // 加入到隊列中
      Node pred = tryAppend(s, haveData);
      if (pred == null)
        continue retry;           // lost race vs opposite mode
      // 不是異步模式,所以這裏會等待
      if (how != ASYNC)
        return awaitMatch(s, pred, e, (how == TIMED), nanos);
    }
    return e; // not waiting
  }
}

到這裏,已經足夠看出規律了,不需要繼續舉例了。

  • 隊列爲公平模式(因爲要體現非阻塞隊列的語義,其爲FIFO),先進先出的模式,其體現在存元素時會往tail最後插入,取元素時會從head中取
  • 當模式爲ASYNC或NOW,此時都不會阻塞,區別在於存的情況,如果是NOW模式,將不會存元素,直接返回了(類似SynchronousQueue的offer方法),如果是ASYNC模式,會像普通隊列那樣,往隊列中塞元素進去
  • 當模式爲SYNC,此時操作將都會阻塞
    • 存的情況:阻塞直到有線程來取
    • 取的情況:如果隊列沒有元素,阻塞直到有線程來存元素
  • 當模式爲TIMED,此時操作類似阻塞(SYNC)的情況,但只會阻塞一段限定時間

總結

綜上所述,我們可以發現,LTQ使用了4種模式靈活的切換阻塞隊列與非阻塞隊列的語義,並且使用了更先進的算法改善了性能,但其大致實現原理跟SYnchronousQueue、ConcurrentLinkedQueue很像,內部維護一個鏈表結構,FIFO的存取元素,可以不阻塞的存取,也可以阻塞的存取(利用自旋和LockSupport#park方法阻塞線程),保留了hand-off的特性的同時還能支持線程安全的非阻塞隊列,實屬線程安全隊列之首選。其底層原理和算法可能不需要過多深究,但瞭解其API的使用和異同點還是很有必要的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章