佇列:佇列線上程池等有限資源池中的應用

liyf2發表於2020-10-12

我們知道,CPU 資源是有限的,任務的處理速度與執行緒個數並不是線性正相關。相反,過多的執行緒反而會導致 CPU 頻繁切換,處理效能下降。所以,執行緒池的大小一般都是綜合考慮要處理任務的特點和硬體環境,來事先設定的。

當我們向固定大小的執行緒池中請求一個執行緒時,如果執行緒池中沒有空閒資源了,這個時候執行緒池如何處理這個請求?是拒絕請求還是排隊請求?各種處理策略又是怎麼實現的呢?

實際上,這些問題並不複雜,其底層的資料結構就是我們今天要學的內容,佇列(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->next= new_node, tail = tail->next;出隊時,head = head->next。我將具體的程式碼放到 GitHub 上,你可以自己試著實現一下,然後再去 GitHub 上跟我實現的程式碼對比下,看寫得對不對。

在這裡插入圖片描述

迴圈佇列

我們剛才用陣列來實現佇列的時候,在 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 的迴圈佇列實現程式碼,關鍵要確定好隊空和隊滿的判定條件,具體的程式碼你要能寫出來。

除此之外,我們還講了幾種高階的佇列結構,阻塞佇列、併發佇列,底層都還是佇列這種資料結構,只不過在之上附加了很多其他功能。阻塞佇列就是入隊、出隊操作可以阻塞,併發佇列就是佇列的操作多執行緒安全。

相關文章