數據結構與算法之美 - 09 | 隊列:隊列在線程池等有限資源池中的應用

這系列相關博客,參考 數據結構與算法之美

數據結構與算法之美 - 09 | 隊列:隊列在線程池等有限資源池中的應用

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

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

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

如何理解”隊列” ?

隊列這個概念非常好理解。你可以把它想象成排隊買票,先來的先買,後來的人只能站末尾,不允許插隊。先進者先出,這就是典型的"隊列”。

我們知道,棧只支持兩個基本操作:入棧push()和出棧pop()。隊列跟棧非常相似,支持的操作也很有限,最基本的操作也是兩個:入隊enqueue(),放一個數據到隊列尾部;出隊dequeue(),從隊列頭部取一個元素。
在這裏插入圖片描述
所以,隊列跟棧一樣,也是一種操作受限的線性表數據結構。

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

順序隊列和鏈式隊列

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

跟棧一樣,隊列可以用數組來實現,也可以用鏈表來實現。用數組實現的棧叫作順序棧,用鏈表實現的棧叫作鏈式棧。同樣,用數組實現的隊列叫作順序隊列,用鏈表實現的隊列叫作鏈式隊列。

我們先來看下基於數組的實現方法。我用Java語言實現了一下,不過並不包含Java語言的高級語法,而且我做了 比較詳細的註釋,你應該可以看懂。

//用數組實現的隊列public class ArrayQueue { //數組:items,數組大小:n

比起棧的數組實現,隊列的數組實現稍微有點兒複雜,但是沒關係。我稍微解釋一下實現思路,你很容易就能明白了。

對於棧來說,我們只需要一個棧頂指針就可以了。但是隊列需要兩個指針:一個是head指針,指向隊頭;一個是tail指針,指向隊尾。

你可以結合下面這幅圖來理解。當a、b、c、d依次入隊之後,隊列中的head指針指向下標爲0的位置,tail指針指向下標爲4的位置。
在這裏插入圖片描述
當我們調用兩次出隊操作之後,隊列中head指針指向下標爲2的位置,tail指針仍然指向下標爲4的位置。
在這裏插入圖片描述
你肯定已經發現了,隨着不停地進行入隊、出隊操作,head和tail都會持續往後移動。當tail移動到最右邊,即使數組中還有空閒空間,也無法繼續往隊列中添加數據了。這個問題該如何解決呢?

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

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

// 入隊操作,將item放入隊尾 public boolean enqueue (String item) { // tail == n表示隊列末尾沒有空間了 if (tail == n) { // ta

從代碼中我們看到,當隊列的tail指針移動到數組的最右邊後,如果有新的數據入隊,我們可以將head到tail之間的數據,整體搬移到數組中0到tail-head的位置。
在這裏插入圖片描述
這種實現思路中,出隊操作的時間複雜度仍然是O⑴,但入隊操作的時間複雜度還是O⑴嗎?你可以用我們第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的循環隊列的實現代碼,我個人覺得,最關鍵的是,確定好隊空和隊滿的判定條件。

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

隊列爲空的判斷條件仍然是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表示隊尾T

阻塞隊列和併發隊列

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

阻塞隊列其實就是在隊列基礎上增加了阻塞操作。簡單來說,就是在隊列爲空的時候,從隊頭取數據會被阻塞。 因爲此時還沒有數據可取,直到隊列中有了數據才能返回;如果隊列已經滿了,那麼插入數據的操作就會被阻塞,直到隊列中有空閒位置後再插入數據,然後再返回。
在這裏插入圖片描述
你應該已經發現了,上述的定義就是一個”生產者-消費者模型"!是的,我們可以使用阻塞隊列,輕鬆實現一個”生產者-消費者模型”!

這種基於阻塞隊列實現的”生產者-消費者模型”,可以有效地協調生產和消費的速度。當"生產者”生產數據的速度過快,"消賽者”來不及消賽時,存儲數據的隊列很快就會滿了。這個時候,生產者就阻塞等待,直到”消費者”消費了數據,"生產者”纔會被喚醒繼續”生產”。

而且不僅如此,基於阻塞隊列,我們還可以通過協調”生產者”和”消費者”的個數,來提高數據的處理效率。 比如前面的例子,我們可以多配置幾個”消費者”,來應對一個”生產者”。
在這裏插入圖片描述
前面我們講了阻塞隊列,在多線程情況下,會有多個線程同時操作隊列,這個時候就會存在線程安全問題,那如何實現一個線程安全的隊列呢?

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

解答開篇

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

我們一般有兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閒線程時,取出排隊的請求繼續處理。那如何存儲排隊的請求呢?

我們希望公平地處理每個排隊的請求,先進者先服務,所以隊列這種數據結構很適合來存儲排隊請求。我們前面說過,隊列有基於鏈表和基於數組這兩種實現方式。這兩種實現方式對於排隊請求又有什麼區別呢?

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

而基於數組實現的有界隊列(bounded queue),隊列的大小有限,所以線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設置一個合理的隊列大小,也是非常有講究的。隊列太大導致等待的請求太多,隊列太小會導致無法充分利用系統資源、發揮最大性能。

除了前面講到隊列應用在線程池請求排隊的場景之外,隊列可以應用在任何有限資源池中,用於排隊請求,比如數據庫連接池等。實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過"隊列”這種數據結構來實現請求排隊。

內容小結

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

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

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

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

課後思考

  • 除了線程池這種池結構會用到隊列排隊請求,你還知道有哪些類似的池結構或者場景中會用到隊列的排隊請求呢?
  • 今天講到併發隊列,關於如何實現無鎖併發隊列,網上有非常多的討論。對這個問題,你怎麼看呢?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章