無鎖資料結構:佇列

喬永琪發表於2017-01-12

佇列多種多樣,不同之處在於訊息生產者、消費的數量不同;在於是基於預先分配的buffer有界佇列,還是基於List的無界佇列;在於是否支援優先順序;在於是無鎖非阻塞,還是有鎖;在於嚴格遵守FIFO,公平還是非公平等等。更多細節參見Dmitry Vyukov的文章

眾所周知,更多特定的佇列需求,勢必需要更加有效的演算法。本文中,只考慮佇列最常見的版本,多個生產者對多個消費者,無界併發佇列,因此不考慮優先順序。

無鎖資料結構:佇列

我猜佇列想必是研究人員最喜歡的資料結構,因為它簡單,但卻比棧複雜,因為它有兩端而非一端。正是因為有兩端,那麼一個有趣的問題就出來了:如何在多執行緒環境下管理它們呢?各種版本的佇列演算法紛紛發表,想要做一個全面的描述是不可能的了。我提煉其中一些最流行的演算法簡要介紹一下,首先從經典佇列開始。

經典佇列

經典佇列是一個帶有兩端,即頭和尾的列表。從頭部讀取資料,從尾部寫入資料。

一個標準的簡單佇列

下面的程式碼拷貝自《無鎖資料結構:簡介》

這裡就不要過多糾結於此,它不適用於併發,列出來只是為了印證主題,說明該佇列有多簡單。本文會向大家展示,該佇列適用於併發場景時,其簡單演算法做了哪些變動。

Michael和Scott的演算法被認為是無鎖佇列的經典演算法。

以下程式碼來自libcds庫,它是經典演算法的一種簡單實現。若想檢視全部程式碼,請看cds::intrusive::MSQueue類。程式碼中包含有註釋,避免大家讀起來乏味:

正如大家所看到的,佇列由一個有頭有尾的單連結串列組成。

演算法的核心是什麼呢?通過常規的CAS控制兩個指標——這倆指標分別指向頭部的和尾部。實際上得到的佇列永遠不為空。檢視程式碼,是否有任何一處對頭和尾做了nullptr檢查?沒有吧。非空的佇列構造器中,新增啞元素(dummy element)給它,作為頭和尾。出隊返回一個元素,該元素作為一個新的頭啞元素,其前面的啞元素被移除。

(譯者注:所謂啞元素,僅是為了佔一個位置,讓連結串列永遠不為空,從而簡化判斷的邊緣條件,其資料部分沒有任何意義)

無鎖資料結構:佇列

在設計侵入式佇列時必須考慮,返回指標是佇列的一部分,僅在下一次出隊時可以移除它。

其次,演算法假定尾部指標不指向最後一個元素。每一次讀取尾部,需檢查尾部是否包含下一個m_pNext元素。倘若該指標不為nullptr說明tail位置不對,應該後移。但這裡有另外一個陷阱:或許tail會指向head前面的元素。為了避免這一點,出隊方法中對m_pTail->m_pNext做了隱式地檢查:先讀取head,m_pHead->m_pNext元素緊隨其後,確保pNext != nullptr。接著看到head等於tail,tail後面必然還有元素,即pNext,此時應該後移tail。這是一個典型的執行緒互助案例,它在無鎖程式設計中很常見。

2000年,小範圍的演算法優化被提出該觀點認為出隊方法中的MSQueue演算法,在每一次的迴圈迭代中,讀取tail是沒有必要的;只有在成功更新head時,才有必要讀取tail,驗證tail是否真的指向最後一個元素。因此,可以在某種程度上減少載入m_pTail的壓力。這個優化參見libcds庫中的cds::intrusive::MoirQueue類。

菜籃佇列

2007年,一個MSQueue有趣的變體被引入。無鎖領域久負盛名的研究者Nir Shavit和他的助理們,採用不同的方法優化了Michael和Scott經典的無鎖佇列。

Nir Shavit將佇列作為一組邏輯菜籃,短時間內,每一個都可以插入一個新元素。一旦這個時間點過了,一個新的菜籃就會被建立。
無鎖資料結構:佇列

每個菜籃包含一組無序元素,這種定義看似違反了佇列-FIFO的基本特性;也就是說佇列變成了非公平。FIFO是針對菜籃的,而非菜籃中的元素。倘若菜籃用來插入的時間段非常短暫,我們可以忽視菜籃中無序項。(譯者注:時間短意味著,沒放幾個就建立了新的菜籃,因此可以近似地看做是FIFO)

如何確定時間段的長短呢?菜籃佇列作者認為,實際上,無需確定該時間短長短。讓我們回頭看一眼MSQueue佇列,在入隊運算中(enqueue),當併發很高時,CAS改變尾部(tail)無法正常工作;這就是為什麼在MSQueue呼叫回退(back-off)的原因,在併發情況下加入元素,無法保證佇列中元素項的排序。就是這個邏輯菜籃。正好說明,抽象的邏輯菜籃就是一種回退策略。

在此,我不想過多地談論程式碼實現,因此就不提供具體程式碼了。MSQueue例子已經很好的向我闡述了,無鎖程式碼確實相當的簡潔。有計劃檢視程式碼實現的,請參看libcds庫中cds::intrusive::BasketQueue類,cds/intrusive/basket_queue.h檔案。同時,為了解釋本演算法,我從Nir Shavit及其同事的工作中拷貝了另一張圖:

無鎖資料結構:佇列

1、A、B、C三個執行緒打算往佇列中插入項。它們看到尾部(tail)在正確的位置,並試圖併發改變tail(還記得在MSQueue中,尾部(tail)可以不指向佇列中最後的元素)。
2、A執行緒獲勝,成功插入一個新項。B和C則失敗了——它們的tail的CAS運算沒有成功執行。因此它倆開始基於之前的讀到的tail舊值,往菜籃中插入各自的項。
3、B執行緒先一步成功插入,與此同時,D執行緒呼叫入隊(enqueue),成功完成項插入,並改變了尾部(tail)。
4、C執行緒此後也完成了插入,請看,它將項插入佇列中間位置。在這個插入過程中,C使用的指向舊tail的指標,線上程進入運算但未成功執行CAS時,就已經讀取此指標了。

需要注意的是,在插入過程中,插入項可能被放入佇列head前面。比如圖NR 4中C前面的項:當C執行緒執行入隊(enqueue)時,其它執行緒刪除C前面的項。(譯者注,舊的頭部被刪除了)
為了防止此類情形出現,建議採用邏輯刪除,即用一個特殊刪除標籤標記待刪除元素。這就要求指向項的標籤和指標必須為原子性讀取,我們在指向pNext項指標的最低有效位(lsb)中存入此標籤。這是可以接受的,現代系統中記憶體分配都是以4個位元組對齊,因為指標最低有效位的2個位元一直為零。所以我們創造了標記指標方法,該方法被廣泛地應用於無鎖資料結構中。當然未來我們會多次碰到此方法。應用邏輯刪除,即在CAS幫助下,將pNext最低位位元值設為1,這樣就可以避免插入項在head前面的情形出現。這樣插入依舊由CAS來完成,與此同時,待刪除項在最低位值為1.因此,CAS可能會失敗。(當然,在插入項時,我們無需獲取整個標記指標,只有最高有效位(msb)包含地址;我們假定最低有效位等於零)。

菜籃佇列最後一項改進是刪除項實體,據觀察,在每次成功呼叫出隊時,改變頭部令人不爽,因為CAS會被呼叫,正如你所知道的那樣,這個操作太笨重了。因此,我們僅在存在好幾個邏輯刪除元素之後,才會改變頭部。(在libcds庫中,預設值是三)。同樣,當佇列為空時,我們也可以改變頭部(head)。可以說,在菜籃佇列中,頭部是變化跳動的。

所有對經典無鎖佇列優化設計都是在高併發這個背景下展開的。

樂觀方式

2004年 Nir Shavit和Edya Ladan Mozes在MSQueue引入一種新的樂觀的優化方式

無鎖資料結構:佇列

他們注意到Michael和Scott的演算法中,出隊運算僅需要一個CAS,而入隊需要兩個CAS,如上圖所示。

而入隊中第二個CAS甚至在低載入時。能顯著降低效能,因為在現代處理器中,CAS是一個相當重量級運算。是否在某種程度上可以處理掉這個不足呢?

試想MSQueue::enqueue中存在兩個CAS會怎樣?第一個CAS新增新項到tail使得pTail->pNext。第二個CAS將尾部向後移動。那麼可否用一個原子性記錄而非CAS改變pNext欄位呢?確實可以,假定單連結串列的方向與以往不一樣,並非從頭到尾,而是從尾到頭。因此可以採用原子性store(pNew->pNext = pTail)完成pNew->pNext任務,接著再通過CAS改變pTail。不過一旦改變了單連結串列方向,接下來如何進行出隊運算呢?因為方向改變,pHead->pNext 必然不會存在了。

樂觀佇列作者建議改用一個雙連結串列。

但問題是,針對CAS的無鎖雙連結串列有效演算法迄今還未可知。已知的演算法有DCAS,但沒有對應的硬體實現。針對CAS的MCAS(CAS for M unbounded memory cells)模擬演算法,但沒那麼有效(需要2M + 1 CAS),充其量就是一個理論的玩意。

作者給出了以下方法:連結串列從尾部到頭部的連結依舊是一致的(佇列中並不需要next連結,但它可以處理入隊第一個CAS)。正是由於從頭到尾相反的方向,最重要的連結-prev-並不能真正的一致,意味著允許出現這種違例的。找出此類違例,我們就可以重建正確的表,放在next引用後面。如何檢測此類違例了?事實上,這個相當簡單:pHead->prev->next != pHead。如果這個不等在出隊(dequeue)被發現, fix_list這個輔助處理過程就會被呼叫。

摘自libcds庫cds::intrusive::OptimisticQueue類

fix_list從佇列的尾查至頭,用正確的pNext引用,正確的pPrev。

列表從頭至尾的違例也是有可能的,更多的是因為延遲,而非重載入。延遲是因為作業系統替換或執行緒中斷。具體請看 OptimisticQueue::enqueue中的程式碼:

 

這段程式碼證明了我們所做出的優化:建立pPrev列表(對我們最重要了),希望能成功。倘若發現直接列表和反向列表之間有錯位,我們不得不花時間確認了(執行fix_list)。

那麼,底線在哪裡?入隊和出隊各自都有一個CAS。代價就是一旦列表被檢測出違例,就會執行fix_list。代價究竟有多大?實驗結果會告訴我們。

大家可以在cds/intrusive.optimistic_queue.h檔案,以及libcds庫中的cds::intrusive::OptimisticQueue類中找到原始碼。

無等待佇列

為了完整地闡述經典佇列,我們應該談談無等待佇列演算法。

無等待幾乎是演算法中最嚴格的,演算法的執行時間必須可限定並且可預測。在實踐中,無等待演算法通常比諸如無鎖、無干擾演算法效能要低。但它們數量眾多,實現起來也不復雜。

許多無等待演算法結構是相當標準:不是執行一運算(例如入隊/出隊),而是先宣告——儲存帶引數的運算描述於一些可公開訪問的共享儲存中接著不停地開啟併發執行緒。接著它們瀏覽儲存中的描述符,並試著執行該程式碼。結果,很多執行緒以很高的負載執行相同的任務,僅有一個執行緒成功。

諸如此類的C++演算法實現複雜度,主要涉及如何實現儲存,以及處理描述符的記憶體分配。

libcds庫沒有實現無等待佇列,是因為該佇列作者在其研究中,效能測試結果不盡人意。

測試結果

本文中,我決定提供以上演算法的測試結果。

測試是綜合性的,測試機為雙核處理器,Debian Linux,Intel Dual Xeon X5670 2.93 GHz, 6 cores per processor + hyperthreading,總共24邏輯處理器。測試過程中,機器閒置達百分之九十。

編譯器為GCC4.8.2,優化引數為-O3 -march=native -mtune=native。

測試佇列來自cds::container名稱空間,因此,它們是非侵入式的,即每個元素執行各種的記憶體分配。隨後我們會將佇列與採用互斥量(mutex)的std::queue<T, std::deque<T>>和std::queue<T, std::list<T>>標準同步實現做比較。

T型別為兩個整型的資料結構。所有無鎖佇列都基於Hazard Pointer。

永續性測試

該測試相當特殊,有一千萬對入隊/出隊運算。第一部分,測試執行一千萬入隊,75%為入隊運算,剩餘25%為出隊運算,即一千萬的入隊運算,二百五十萬的出隊運算)。第二部分,出隊運算執行七百五十萬次,直到佇列為空。

測試遵循以下理念:減小快取分配器的不利影響,當然前提是分配器含有快取。

測試時間的絕對值:

無鎖資料結構:佇列

大家看到了什麼?

首先映入眼簾的是,有鎖std::queue<T, std::deque<T>>被證明是最快的。怎麼可能呢?我認為這個跟記憶體有關:std::deque以N元素的塊來分配記憶體,而非每個元素。這暗示我們應該移除測試中分配器的影響,這會帶來相當長的延遲,另外,還有互斥量。當然, libcds的所有侵入式容器版本,沒有為元素分配記憶體。理應對它們進行測試。

顯而易見,無鎖佇列,針對MSQueue所有縝密的優化開出了豐碩的果實,即便不是那麼完美。

生產者和消費者測試

這個測試相當切合實際,佇列中包含N個生產者,N個消費者,分別執行百萬條寫運算,百萬條讀運算。圖表中的執行緒數,為生產者和消費者的執行緒數之和。

測試時間的絕對值:

無鎖資料結構:佇列

此處可以看出無鎖佇列是相當優雅,勝出者為OpimisticQueue。這就是說該無鎖資料結構的所有假設被證明都是正確的。

本測試接近實際情形,佇列中沒有出現大量元素堆積現象。為什麼呢?個人認為,分配器內在的優化在起作用。確實如此,在最後階段,佇列的存在不是為了大量元素堆積,而是削峰,通常佇列中是不存在元素的。

關於棧的補充說明

既然談到測試,就來談談棧。

本文以及前文所涉及的無鎖棧,針對Treiber棧,我移除了回退(back-off)。不論實現,亦或者偽碼描述、C++完整產品實現,理應單獨作為一篇文章。不過我可能永遠不會寫,因為其中所涉及的程式碼是在太多。實際上,你會發現移除回退(back-off)之後,若你檢視原始碼,完全不同。截止目前,只有libcds庫裡有。

同樣,我也提供了綜合測試結果,測試機器和前面的一樣。

生產者和消費者測試:一些執行緒會寫入棧中即壓棧,而另一些執行緒會讀取棧即彈棧。一組相同數量的生產者和消費者,生產者和消費者的執行緒總數都是百萬級。標準的棧,其同步由互斥量完成。

測試時間的絕對值:

無鎖資料結構:佇列

顯而易見,圖表本身就可以很好地說明事實。

移除回退(back-off)之後,什麼促使效能的顯著增加?好像是因為壓棧、彈棧相互抵消。然而,我們檢視內部統計,就會發現百萬個執行僅有十萬到十五萬個壓棧、彈棧相互抵消,大約為0.1%。而移除回退整個進入數大約為三十五萬。這說明移除回退最大的優勢就是一些執行緒在負載高的時候休眠,進而自動降低了整個棧的負載。現實的例子,移除回退(back-off)的休眠執行緒會持續大約5毫秒。

總結

本文闡述了經典無鎖佇列,展示了列表元素。該對列顯著的特點就是存在兩個併發端-頭部和尾部。接著縝密地闡述了Michael和Scott經典演算法的一些改進。我希望你會對此感興趣,並能在每天的生活中用到它。

從測試結果看,儘管佇列是無鎖的,但神奇的CAS並沒有帶來任何特別的效能提升。因此,很有必要尋找其它一些方法消除瓶頸即頭部和尾部,在某種程度上實現佇列並行工作。

這就是接下來我們要探討的。

無鎖資料結構

引言

基礎篇

 原子性和原子性原語

 記憶體柵障

 記憶體模型

機制篇

記憶體管理規則

RCU

棧的演進

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

無鎖資料結構:佇列 無鎖資料結構:佇列

相關文章