圖解--佇列、併發佇列

K戰神發表於2018-12-20

提到佇列,我們會在很多地方聽到或者看到,

那我們來看一下這位不太說話的老朋友,

從棧很容易聯想到佇列的實現

  • 棧是先進後出的資料結構,佇列而言它是先進先出。
  • 對棧而言,在棧頂有一個指標即可。
  • 佇列是需要兩個指標,一個在隊頭,一個在隊尾。對應著入隊操作和出隊操作。
  • 基於陣列實現的是順序佇列,基於連結串列實現的是鏈式佇列。

 

一個陣列實現的順序佇列,在 入隊了 AA 、BB 、CC 後,

隊頭指標 head=0,隊尾指標 tail=3。如下圖:

 

緊接著,又有兩次出隊,同樣,對於出隊head指標往後移動兩個:

 

以上兩個圖對應的如隊出隊操作,也是很容易看出問題所在:

隨著入隊出隊一波操作,tail指標很容易移動到最後的位置,表面上不能再入隊了。

但是極有可能如圖二一樣,頭指標head前面有大片空地。

怎麼辦?搬!我在出隊之後,後面的資料往前挪,我們可以稱之為移動補位。

 

但是每一次出隊操作都去搬資料,時間複雜度想想就會很高 O(n)

怎麼優化?

tail指標抵達末尾,同時head指標不在隊頭。也就是tail到了最後,且head前面有空。

此時觸發資料搬移,過程如下:

 

人的思想不斷進步,並且思考如何做得更加輕巧靈活。

我們會思考,可不可以不用搬移資料呢?

可以,接下來輪到迴圈佇列登場了。。。。。。

迴圈佇列,顧名思義。首尾相連形成環。噥,就是這個樣子:

 

長得這麼好看,一定要對得起我們對它的期望。

經過一番出隊入隊,頭部索引=2,尾部指標指向最後一個位置,即將接受FF入隊,

 

此時看上去又到了挪動陣列的時候了?

環形的存在就是為了避免佇列的資料搬移,我想你已經想到了它的靈巧之處。

對,就是將資料FF填充到索引=5處,tail指標移動到下一個,也就是索引=0處,就成了這樣:

 

佇列在平時工作時用的機會場景比較少,但是在一些偏底層系統中確實應用比較廣泛。

比如:阻塞佇列、併發佇列

阻塞佇列,就是在隊空時,取資料會被直接拒絕。直到有資料才會允許被訪問。

這種模型類似於 生產-消費關係,對的,這也是很多的訊息佇列的思想和應用。

這種阻塞佇列可以協調生產和消費的關係。當然,也可以生產的i訊息被多個消費。

 

這又產生了一個執行緒併發問題,我們如何保證執行緒安全呢?這就需要併發佇列。

基於陣列的迴圈佇列+CAS原子操作,可以很好的實現無鎖併發佇列。

 

基於以上,微軟給我們所提供的這些原始碼:

  • 佇列 Queue ;
  • 泛型佇列 Queue<T>;
  • 阻塞泛型集合 BlockingCollection<T>
  • 以及微軟強大的並行庫中的併發泛型佇列 ConcurrentQueue<T>

 我們著重看一下泛型佇列和併發泛型佇列


佇列 Queue 、泛型佇列 Queue<T>

我們直接看一下泛型版本的:

0、註釋說明:這是一個基於陣列實現的環形佇列,也就是迴圈佇列

1、初始定義

 

2、重要的私有變數

 

3、入隊:分為兩塊主邏輯,一個是隊滿,一個是正常插入。

 

 第0步已經註釋說明這是一個迴圈佇列,所以我們藉此機會分析一下這個迴圈佇列。

  • 隊滿 
    if (_size == _array.Length)  2倍擴容並且有最小裝載量判斷。
  • 正常
   _tail = (_tail + 1) % _array.Length; 下面我們來看看這句話怎麼來的。

 對於非迴圈佇列,頭尾指標和陣列的關係好確認。

 而迴圈佇列,因為是一個環,所以怎樣定位移動後的指標位置才是關鍵的。

 

陣列長度=6

當我入隊FF,原來尾部指標=5,當前尾部指標=0;

接著入隊GG,  原來尾部指標=0,當前尾部指標=1;

當我入隊HH,原來尾部指標=1,當前尾部指標=2;

規律:當前指標 = (原來指標 +1) % 陣列長度 

4、出隊同3

 

ConcurrentQueue<T>

註釋說的很明白,這是一個無鎖併發佇列

我們在看原始碼之前先來了解一些定義

對於現在的多CPU、以及超執行緒概念的作業系統來說,CPU和記憶體之前存在處理速度上的差距,所以中間加了暫存器和快取記憶體來緩衝。

多執行緒併發情況下,多核計算機,一個CPU讀取的是在暫存器中的值,另一個CPU讀取的是記憶體中的值,這就造成了資料不同步。

對於產生的併發問題,我們來看看併發佇列對這些的處理。

我們先來理解接下程式碼中涉及到的名詞:

1、易失結構 volatile : 告訴編譯器和CLR不需要優化程式碼順序,使得程式碼可控。不用將欄位快取到暫存器,快取早記憶體中就行。

2、互鎖結構  Interlocked : CAS保證原子性讀取操作

3、自旋鎖 :原地打轉,直到達到條件才離開。對於執行緒來講,一直持有資源不撒手。

4、執行緒類提供了幾個方法:

  • Thread.Sleep(0):掛起自身,讓出剩餘的時間片,強迫系統排程其他同級或者更高階的執行緒。
  • Thread.Sleep(1):強迫進行一次上下文切換
  • Thread.Ylied():提前結束剩餘的時間片,使得同級或者低階執行緒可能被排程。
  • Thread.SpinWait():超執行緒CPU模式下,強迫自身暫停,允許CPU排程其他執行緒。

5、CAS理論:compare and swap 比較並交換。該操作通過將記憶體中的值與指定資料進行比較,當數值一樣時將記憶體中的資料替換為新的值。

 

天也不早了,人也不少了,讓我們乾點正事。簡單看看入隊和出隊操作。

入隊:

需求是怎樣保證入隊的原子性?

通過 Interlocked 宣告同步塊,只允許一個執行緒搶佔資源進行入隊,其他執行緒使用自旋鎖進行原地等待。

等當前執行緒釋放同步塊,其他執行緒再次搶佔同步塊,然後入隊。直到隊滿跳出。 

 

  • 下面這是宣告瞭自旋鎖,執行緒進行入隊搶佔。

 

  • m_high =-1 

 

  • m_high 通過 Interlicked CAS原子操作,遞增。進行入隊或者隊滿判斷。

 

出隊:也是類似,通過自旋鎖,搶佔同步塊進行原子性出隊操作。

 

最後我們再來悄悄看看 自旋鎖自旋邏輯:

自旋至少10次,然後進行相應的自旋等待,並且相應的讓出自己的時間片,讓其他低階別執行緒可以得到排程。

 

總體來說,併發佇列通過CAS進行原子性入隊和出隊,並結合自旋鎖進行搶佔資源。

也就是很多的執行緒併發入隊或者出隊,同一時刻只有一個可以進行原子性入隊出隊。

 

相關文章