佇列:佇列線上程池等有限資源池中的應用
我們知道,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 的迴圈佇列實現程式碼,關鍵要確定好隊空和隊滿的判定條件,具體的程式碼你要能寫出來。
除此之外,我們還講了幾種高階的佇列結構,阻塞佇列、併發佇列,底層都還是佇列這種資料結構,只不過在之上附加了很多其他功能。阻塞佇列就是入隊、出隊操作可以阻塞,併發佇列就是佇列的操作多執行緒安全。
相關文章
- 陣列模擬佇列 以及佇列的複用(環形佇列)陣列佇列
- 訊息佇列系列一:訊息佇列應用佇列
- netcore下RabbitMQ佇列、死信佇列、延時佇列及小應用NetCoreMQ佇列
- uva 11995 棧,佇列,優先佇列,等基本資料結構的應用與理解佇列資料結構
- 佇列、資源與鎖佇列
- 佇列、阻塞佇列佇列
- Redis 應用-非同步訊息佇列與延時佇列Redis非同步佇列
- 佇列-單端佇列佇列
- Java版-資料結構-佇列(陣列佇列)Java資料結構佇列陣列
- 【資料結構】佇列(順序佇列、鏈佇列)的JAVA程式碼實現資料結構佇列Java
- 佇列 和 迴圈佇列佇列
- 【佇列】【懶排序】佇列Q佇列排序
- Team Queue (佇列的一種應用)佇列
- java軟引用在佇列的應用Java佇列
- ModStart排程和佇列佇列
- Laravel Queues 佇列應用實戰Laravel佇列
- 處理線上RabbitMQ佇列阻塞MQ佇列
- C語言 簡單的佇列(陣列佇列)C語言佇列陣列
- 佇列 手算到機算 入門 佇列 迴圈佇列佇列
- 圖解--佇列、併發佇列圖解佇列
- 單調佇列雙端佇列佇列
- 阻塞佇列一——java中的阻塞佇列佇列Java
- synchronized 中的同步佇列與等待佇列synchronized佇列
- 三、資料結構演算法-棧、佇列、優先佇列、雙端佇列資料結構演算法佇列
- Java版-資料結構-佇列(迴圈佇列)Java資料結構佇列
- 佇列佇列
- 鏈式佇列—用連結串列來實現佇列佇列
- 執行緒池的阻塞佇列的理解執行緒佇列
- RabbitMQ 訊息佇列之佇列模型MQ佇列模型
- Kafka 延時佇列&重試佇列Kafka佇列
- 佇列的一種實現:迴圈佇列佇列
- redis訊息佇列簡單應用Redis佇列
- laravel的佇列Laravel佇列
- 前端資料結構(2)之佇列及其應用前端資料結構佇列
- 稀疏陣列、佇列陣列佇列
- 07-主佇列和全域性佇列佇列
- 佇列(楊輝三角)——鏈式佇列佇列
- java執行緒池-工作佇列workQueueJava執行緒佇列