佇列:佇列線上程池等有限資源池中的應用
本文是學習演算法的筆記,《資料結構與演算法之美》,極客時間的課程
電腦的CPU資源是有限的,任務的處理速度與執行緒數量之間並不是正相關。當執行緒數量過多,CPU要頻繁的在不同執行緒切換,反而會引起處理效能的下降。執行緒池中最大的執行緒數,是考慮多種因素來事先設定的,比如硬體的條件,業務的型別等等。
當我們向一個固定大小的的執行緒池中請求一個執行緒時,當執行緒池中沒有空閒資源了,這個時候執行緒池如何處理這個請求?是拒絕請求還是排隊請求?各種策略又是如何實現的呢?
實際上,這些問題的處理並不複雜,底層的資料結構,就是今天要說的佇列(queue)
佇列這個概念很好理解,典型的先進先出,好比排隊買票,先來的先買,後來的後買,不允許插隊。最基本的操作是入隊 enqueue() 和出隊 dequeue() 。會不會覺得這和棧有點類似呢!
佇列作為一種基本的資料結構,應用很廣泛,特別是具有某些額外功能的佇列,比如迴圈佇列、阻塞佇列、併發佇列等等。它們在很多偏底層系統、框架、中介軟體的開發中,起著關鍵作用。比如高效能佇列Disruptor 、Linux環形快取,都用到了迴圈併發佇列;Java concurrent 併發包利用ArrayBlockingQueue來實現公平鎖等。
順序佇列和鏈式佇列
與棧類似,用陣列實現的佇列叫作順序佇列,用連結串列實現的佇列叫作鏈式佇列。
public class ArrayQueue{ private String[] items; // 宣告一個數組 private int n; // 陣列大小 private int head = 0; // 隊頭下標 private int tail = 0; // 隊尾下標 public ArrayQueue(int capacity) { items = new String[capacity]; n = capacity; } // 入隊 public boolean enqueue(String item) { if (tail == n) { // tail == n 表示佇列已經滿了 return false; } items[tail] = item; ++tail; return true; } // 出隊 public String dequeue() { if(head == tail) { // head == tail 表示佇列為空 return null; } String ret = items[head]; ++head; return ret; } }
可以結合下圖來理解
當我們兩次調用出隊操作之後,佇列中的head指標指向下標為2的位置,tail指標仍然指向下標為4的位置。
你肯定可以看出來,隨著不停的出隊入隊操作,兩個指標都會往後移動,當tail指標移動到最右邊,即使陣列中有空閒空間,也無法繼續往佇列中新增資料了。這個問題該如何解決呢?
當然每次出隊操作後,進行陣列移動就可以解決問題了,但時間複雜度就由O(1)變為O(n)。要怎樣優化呢?每次出隊操作時,不進行資料搬移,只有當沒有tail指標到最右邊時,集中進行一次資料搬移。只需要改動下入隊的程式碼即可。
// 入隊 public boolean enqueue(String item) { if (tail == n) { if (head == 0) { return false; } // 資料搬移 for (int i = head; i < tail; i++) { items[i-head] = items[i]; } // 搬移後,重置指標位置 tail -= head; head = 0; } items[tail] = item; ++tail; return true; }
迴圈佇列
剛才,我們用陣列實現的佇列,當tail == n 時,會有搬移資料的操作,這樣的入隊操作會影響效能,有沒有辦法避免資料移動呢?我們看看迴圈佇列的解決思路。
迴圈佇列,就像一個環,如下圖,可以直觀的感受下。
如圖,當前 head = 4, tail = 7。當把一個新元素 a 入隊時,就把它放在下標為7的位置,此時 tail 往後移一位,到下標為 0 的位置。同理再放入新元素 b 時,下標更新為1。如下圖的樣子
迴圈佇列中,tail指向的位置實際上是沒有儲存資料的,會浪費一個數組的儲存空間。
判斷隊空的條件是 head == tail ,判斷隊滿的條件有點不好想 (tail+1)%n = head。可以直接看程式碼來理解
public class CircularQueue {
private String[] items; // 宣告一個數組
private int n; // 陣列大小
private int head = 0; // 隊頭下標
private int tail = 0; // 隊尾下標
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入隊
public boolean enqueue(String item) {
if (head == (tail + 1) % n) {
return false;
}
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出隊
public String dequeue() {
if (head == tail) { // head == tail 表示佇列為空
return null;
}
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
阻塞佇列和併發佇列
阻塞佇列,是在佇列的基礎上增加了阻塞操作。簡單來說,當佇列為空時,從隊頭取資料會被阻塞,直到佇列中有資料才返回。同樣,當隊滿時,插入資料的操作會被阻塞,直到佇列中的空閒位置後再插入資料,然後返回。
執行緒安全佇列又叫作併發佇列,最簡單直接的實現方法是在 enqueue()、dequeue() 方法上加鎖,這樣的鎖的粒度較大。實際上,基於陣列的迴圈佇列,利用CAS原子操作,可以實現非常高效的併發佇列。這也是是迴圈佇列比鏈式佇列應用更加廣泛的原因。這個在之後的篇章中再細說。
最後我們說說開篇提出的問題。當執行緒池中沒有空閒執行緒時,有兩種處理策略,一個是直接拒絕請求,另一個是請求排隊,當有空閒的執行緒時,再響應請求。
當請求排隊的時候,我們當然希望遵循先來後到的原則, 這時用佇列就可以很好的實現這個需求。佇列的實現又有兩種,其一是以連結串列來實現的無界佇列(unbounded queue),另外一個是以陣列實現的有界佇列(bounded queue)。這兩種適應不同的場景。比如對於響應時間很敏感的系統來說,使用無界佇列,可能會有無限多個請求等待,這顯然不合適。此時用有界佇列來實現,當隊滿時就拒絕請求。設定合適的佇列大小,過小會浪費資源,過大可能有長時間的等待。