07 | 隊列:隊列在線程池等有限資源池中的應用

咱們知道,CPU 資源是有限的,任務的處理速度與線程個數並非線性正相關。相反,過多的線程反而會致使 CPU 頻繁切換,處理性能降低。因此,線程池的大小通常都是綜合考慮要處理任務的特色和硬件環境,來事先設置的。node

 

當咱們向固定大小的線程池中請求一個線程時,若是線程池中沒有空閒資源了,這個時候線程池如何處理這個請求?是拒絕請求仍是排隊請求?各類處理策略又是怎麼實現的呢?python

 

實際上,這些問題並不複雜,其底層的數據結構就是咱們今天要學的內容,隊列(queue)。算法

 

如何理解「隊列」?

 

隊列這個概念很是好理解。你能夠把它想象成排隊買票,先來的先買,後來的人只能站末尾,不容許插隊。先進者先出,這就是典型的「隊列」。數據庫

 

咱們知道,棧只支持兩個基本操做:入棧 push()和出棧 pop()。隊列跟棧很是類似,支持的操做也頗有限,最基本的操做也是兩個:入隊 enqueue(),放一個數據到隊列尾部;出隊 dequeue(),從隊列頭部取一個元素。數組

 

 

因此,隊列跟棧同樣,也是一種操做受限的線性表數據結構。緩存

 

隊列的概念很好理解,基本操做也很容易掌握。做爲一種很是基礎的數據結構,隊列的應用也很是普遍,特別是一些具備某些額外特性的隊列,好比循環隊列、阻塞隊列、併發隊列。它們在不少偏底層系統、框架、中間件的開發中,起着關鍵性的做用。好比高性能隊列+Disruptor、Linux+環形緩存,都用到了循環併發隊列;Java+concurrent+併發包利用+ArrayBlockingQueue+來實現公平鎖等。安全

 

順序隊列和鏈式隊列

 

咱們知道了,隊列跟棧同樣,也是一種抽象的數據結構。它具備先進先出的特性,支持在隊尾插入元素,在隊頭刪除元素,那究竟該如何實現一個隊列呢?數據結構

 

跟棧同樣,隊列能夠用數組來實現,也能夠用鏈表來實現。用數組實現的棧叫做順序棧,用鏈表實現的棧叫做鏈式棧。一樣,用數組實現的隊列叫做順序隊列,用鏈表實現的隊列叫做鏈式隊列。+咱們先來看下基於數組的實現方法。我用 Java 語言實現了一下,不過並不包含 Java 語言的高級語法,並且我作了比較詳細的註釋,你應該能夠看懂。多線程

 

 

// 用數組實現的隊列
public class ArrayQueue {
  // 數組:items,數組大小:n
  private String[] items;
  private int n = 0;
  // head 表示隊頭下標,tail 表示隊尾下標
  private int head = 0;
  private int tail = 0;

  // 申請一個大小爲 capacity 的數組
  public ArrayQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入隊
  public boolean enqueue(String item) {
    // 若是 tail == n 表示隊列已經滿了
    if (tail == n) return false;
    items[tail] = item;
    ++tail;
    return true;
  }

  // 出隊
  public String dequeue() {
    // 若是 head == tail 表示隊列爲空
    if (head == tail) return null;
    // 爲了讓其餘語言的同窗看的更加明確,把 -- 操做放到單獨一行來寫了
    String ret = items[head];
    ++head;
    return ret;
  }
}

  

比起棧的數組實現,隊列的數組實現稍微有點兒複雜,可是不要緊。我稍微解釋一下實現思路,你很容易就能明白了。併發

 

對於棧來講,咱們只須要一個棧頂指針就能夠了。可是隊列須要兩個指針:一個是 head 指針,指向隊頭;一個是 tail 指針,指向隊尾。

 

你能夠結合下面這幅圖來理解。當 a、b、c、d+依次入隊以後,隊列中的 head 指針指向下標爲 0 的位置,tail 指針指向下標爲 4 的位置。

 

 

當咱們調用兩次出隊操做以後,隊列中 head 指針指向下標爲 2 的位置,tail 指針仍然指向下標爲 4 的位置。

 

 

 

你確定已經發現了,隨着不停地進行入隊、出隊操做,head 和 tail 都會持續日後移動。當 tail 移動到最右邊,即便數組中還有空閒空間,也沒法繼續往隊列中添加數據了。這個問題該如何解決呢?

 

你是否還記得,在數組那一節,咱們也遇到過相似的問題,就是數組的刪除操做會致使數組中的數據不連續。你還記得咱們當時是怎麼解決的嗎?對,用數據搬移!可是,每次進行出隊操做都至關於刪除數組下標爲 0 的數據,要搬移整個隊列中的數據,這樣出隊操做的時間複雜度就會從原來的 O(1) 變爲 O(n)。能不能優化一下呢?

 

實際上,咱們在出隊時能夠不用搬移數據。若是沒有空閒空間了,咱們只須要在入隊時,再集中觸發一次數據的搬移操做。藉助這個思想,出隊函數 dequeue() 保持不變,咱們稍加改造一下入隊函數 enqueue() 的實現,就能夠輕鬆解決剛纔的問題了。下面是具體的代碼:

 

   // 入隊操做,將 item 放入隊尾
  public boolean enqueue(String item) {
    // tail == n 表示隊列末尾沒有空間了
    if (tail == n) {
      // tail ==n && head==0,表示整個隊列都佔滿了
      if (head == 0) return false;
      // 數據搬移
      for (int i = head; i < tail; ++i) {
        items[i-head] = items[i];
      }
      // 搬移完以後從新更新 head 和 tail
      tail -= head;
      head = 0;
    }
    
    items[tail] = item;
    ++tail;
    return true;
  }

  

從代碼中咱們看到,當隊列的 tail 指針移動到數組的最右邊後,若是有新的數據入隊,咱們能夠將 head 到 tail 之間的數據,總體搬移到數組中 0 到 tail-head 的位置。

 

 

 

這種實現思路中,出隊操做的時間複雜度仍然是 O(1),但入隊操做的時間複雜度仍是 O(1) 嗎?你能夠用咱們第 3 節、第 4 節講的算法複雜度分析方法,本身試着分析一下。

 

接下來,咱們再來看下基於鏈表的隊列實現方法。

 

基於鏈表的實現,咱們一樣須要兩個指針:head+指針和+tail+指針。它們分別指向鏈表的第一個結點和最後一個結點。如圖所示,入隊時,tail->=new_node,tail=ail->next;出隊時,head=head->next。

 

 

循環隊列

 

咱們剛纔用數組來實現隊列的時候,在 tail==n 時,會有數據搬移操做,這樣入隊操做性能就會受到影響。那有沒有辦法可以避免數據搬移呢?咱們來看看循環隊列的解決思路。

 

循環隊列,顧名思義,它長得像一個環。本來數組是有頭有尾的,是一條直線。如今咱們把首尾相連,扳成了一個環。我畫了一張圖,你能夠直觀地感覺一下。

 

 

咱們能夠看到,圖中這個隊列的大小爲+8,當前 head=4,tail=7。當有一個新的元素 a 入隊時,咱們放入下標爲 7 的位置。但這個時候,咱們並不把 tail 更新爲 8,而是將其在環中後移一位,到下標爲 0 的位置。當再有一個元素 b 入隊時,咱們將 b 放入下標爲 0 的位置,而後 tail 加 1 更新爲 1。因此,在 a,b 依次入隊以後,循環隊列中的元素就變成了下面的樣子:

 

 

 

經過這樣的方法,咱們成功避免了數據搬移操做。看起來不難理解,可是循環隊列的代碼實現難度要比前面講的非循環隊列難多了。要想寫出沒有 bug 的循環隊列的實現代碼,我我的以爲,最關鍵的是,肯定好隊空和隊滿的斷定條件。

 

在用數組實現的非循環隊列中,隊滿的判斷條件是 tail==+n,隊空的判斷條件是 head==tail。那針對循環隊列,如何判斷隊空和隊滿呢?

 

隊列爲空的判斷條件仍然是 head==tail。但隊列滿的判斷條件就稍微有點複雜了。我畫了一張隊列滿的圖,你能夠看一下,試着總結一下規律。

 

 

就像我圖中畫的隊滿的狀況,tail=3,head=4,n=8,因此總結一下規律就是:(3+1)%8=4。多畫幾張隊滿的圖,你就會發現,當隊滿時,(tail+1)%n=head。

 

你有沒有發現,當隊列滿時,圖中的 tail 指向的位置其實是沒有存儲數據的。因此,循環隊列會浪費一個數組的存儲空間。 Talk is cheap,若是仍是沒怎麼理解,那就 show you code 吧。

 

public class CircularQueue {
  // 數組:items,數組大小:n
  private String[] items;
  private int n = 0;
  // head 表示隊頭下標,tail 表示隊尾下標
  private int head = 0;
  private int tail = 0;

  // 申請一個大小爲 capacity 的數組
  public CircularQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入隊
  public boolean enqueue(String item) {
    // 隊列滿了
    if ((tail + 1) % n == head) return false;
    items[tail] = item;
    tail = (tail + 1) % n;
    return true;
  }

  // 出隊
  public String dequeue() {
    // 若是 head == tail 表示隊列爲空
    if (head == tail) return null;
    String ret = items[head];
    head = (head + 1) % n;
    return ret;
  }
}

  

阻塞隊列和併發隊列

 

前面講的內容理論比較多,看起來很難跟實際的項目開發扯上關係。確實,隊列這種數據結構很基礎,平時的業務開發不大可能從零實現一個隊列,甚至都不會直接用到。而一些具備特殊特性的隊列應用卻比較普遍,好比阻塞隊列和併發隊列。

 

阻塞隊列其實就是在隊列基礎上增長了阻塞操做。簡單來講,就是在隊列爲空的時候,從隊頭取數據會被阻塞。由於此時尚未數據可取,直到隊列中有了數據才能返回;若是隊列已經滿了,那麼插入數據的操做就會被阻塞,直到隊列中有空閒位置後再插入數據,而後再返回。

 

 

 

你應該已經發現了,上述的定義就是一個「生產者 - 消費者模型」!是的,咱們可使用阻塞隊列,輕鬆實現一個「生產者  消費者模型」!

 

這種基於阻塞隊列實現的「生產者 - 消費者模型」,能夠有效地協調生產和消費的速度。當「生產者」生產數據的速度過快,「消費者」來不及消費時,存儲數據的隊列很快就會滿了。這個時候,生產者就阻塞等待,直到「消費者」消費了數據,「生產者」纔會被喚醒繼續「生產」。+並且不只如此,基於阻塞隊列,咱們還能夠經過協調「生產者」和「消費者」的個數,來提升數據的處理效率。好比前面的例子,咱們能夠多配置幾個「消費者」,來應對一個「生產者」。

 

 

 

前面咱們講了阻塞隊列,在多線程狀況下,會有多個線程同時操做隊列,這個時候就會存在線程安全問題,那如何實現一個線程安全的隊列呢?

 

線程安全的隊列咱們叫做併發隊列。最簡單直接的實現方式是直接在 enqueue()、dequeue() 方法上加鎖,可是鎖粒度大併發度會比較低,同一時刻僅容許一個存或者取操做。實際上,基於數組的循環隊列,利用 CAS 原子操做,能夠實現很是高效的併發隊列。這也是循環隊列比鏈式隊列應用更加普遍的緣由。在實戰篇講 Disruptor 的時候,我會再詳細講併發隊列的應用。

 

解答開篇

 

隊列的知識就講完了,咱們如今回過來看下開篇的問題。線程池沒有空閒線程時,新的任務請求線程資源時,線程池該如何處理?各類處理策略又是如何實現的呢?

 

咱們通常有兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另外一種是阻塞的處理方式,將請求排隊,等到有空閒線程時,取出排隊的請求繼續處理。那如何存儲排隊的請求呢?+咱們但願公平地處理每一個排隊的請求,先進者先服務,因此隊列這種數據結構很適合來存儲排隊請求。咱們前面說過,隊列有基於鏈表和基於數組這兩種實現方式。這兩種實現方式對於排隊請求又有什麼區別呢?

 

基於鏈表的實現方式,能夠實現一個支持無限排隊的無界隊列(unbounded queue),可是可能會致使過多的請求排隊等待,請求處理的響應時間過長。因此,針對響應時間比較敏感的系統,基於鏈表實現的無限排隊的線程池是不合適的。

 

而基於數組實現的有界隊列(bounded queue),隊列的大小有限,因此線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來講,就相對更加合理。不過,設置一個合理的隊列大小,也是很是有講究的。隊列太大致使等待的請求太多,隊列過小會致使沒法充分利用系統資源、發揮最大性能。+除了前面講到隊列應用在線程池請求排隊的場景以外,隊列能夠應用在任何有限資源池中,用於排隊請求,好比數據庫鏈接池等。實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上均可以經過「隊列」這種數據結構來實現請求排隊。

 

內容小結

 

今天咱們講了一種跟棧很類似的數據結構,隊列。關於隊列,你能掌握下面的內容,這節就沒問題了。

 

隊列最大的特色就是先進先出,主要的兩個操做是入隊和出隊。跟棧同樣,它既能夠用數組來實現,也能夠用鏈表來實現。用數組實現的叫順序隊列,用鏈表實現的叫鏈式隊列。特別是長得像一個環的循環隊列。在數組實現隊列的時候,會有數據搬移操做,要想解決數據搬移的問題,咱們就須要像環同樣的循環隊列。

 

循環隊列是咱們這節的重點。要想寫出沒有 bug 的循環隊列實現代碼,關鍵要肯定好隊空和隊滿的斷定條件,具體的代碼你要能寫出來。

 

除此以外,咱們還講了幾種高級的隊列結構,阻塞隊列、併發隊列,底層都仍是隊列這種數據結構,只不過在之上附加了不少其餘功能。阻塞隊列就是入隊、出隊操做能夠阻塞,併發隊列就是隊列的操做多線程安全。

 

思考

 

除了線程池這種池結構會用到隊列排隊請求,你還知道有哪些相似的池結構或者場景中會用到隊列的排隊請求呢?

相關文章
相關標籤/搜索