高效無鎖隊列

Disruptor是LMAX公司開源的一個高效的內存無鎖隊列。這兩天看了一下相關的設計文檔和博客,下面嘗試進行一下總結。

第一部分。引子
談到併發程序設計,有幾個概念是避免不了的。

1.鎖:鎖是用來做併發最簡單的方式,當然其代價也是最高的。內核態的鎖的時候需要操作系統進行一次上下文切換,等待鎖的線程會被掛起直至鎖釋放。在上下文切換的時候,cpu之前緩存的指令和數據都將失效,對性能有很大的損失。用戶態的鎖雖然避免了這些問題,但是其實它們只是在沒有真實的競爭時纔有效。下面是一個計數實驗中不加鎖、使用鎖、使用CAS及定義volatile變量之間的性能對比。

2. CAS: CAS的涵義不多介紹了。使用CAS時不像上鎖那樣需要一次上下文切換,但是也需要處理器鎖住它的指令流水線來保證原子性,並且還要加上Memory Barrier來保證其結果可見。

3. Memory Barrier: 大家都知道現代CPU是亂序執行的,也就是程序順序與實際的執行順序很可能是不一致的。在單線程執行時這不是個問題,但是在多線程環境下這種亂序就可能會對執行結果產生很大的影響了。memory barrier提供了一種控制程序執行順序的手段, 關於其更多介紹,可以參考 http://en.wikipedia.org/wiki/Memory_barrier

4. Cache Line:cache line解釋起來其實很簡單,就是CPU在做緩存的時候有個最小緩存單元,在同一個單元內的數據被同時被加載到緩存中,充分利用 cache line可以大大降低數據讀寫的延遲,錯誤利用cache line也會導致緩存不同替換,反覆失效。

好,接下來談一談設計併發內存隊列時需要考慮的問題。一就是數據結構的問題,是選用定長的數組還是可變的鏈表,二是併發控問題,是使用鎖還是CAS操作,是使用粗粒度的一把鎖還是將隊列的頭、尾、和容量三個變量分開控制,即使分開,能不能避免它們落入同一個Cache line中呢。
我們再回過頭來思考一下隊列的使用場景。通常我們的處理會形成一條流水線或者圖結構,隊列被用來作爲這些流程中間的銜接表示它們之間的依賴關係,同時起到一個緩衝的作用。但是使用隊列並不是沒有代價的,實際上數據的入隊和出隊都是很耗時的,尤其在性能要求極高的場景中,這種消耗更顯得奢侈。如果這種依賴能夠不通過在各個流程之間放一個隊列來表示那就好啦!
第二部分 正文
現在開始來介紹我們的Disruptor啦,有了前面這麼多的鋪墊,我想可以直入主題了。接下來我們就從隊列的三種基本問題來細細分析下disruptor吧。

1.列隊中的元素如何存儲?
Disruptor的中心數據結構是一個基於定長數組的環形隊列,如圖1。
在數組創建時可以預先分配好空間,插入新元素時只要將新元素數據拷貝到已經分配好的內存中即可。對數組的元素訪問對CPU cache 是非常友好的。關於數組的大小選擇有一個講究,大家都知道環形隊列中會用到取餘操作, 在大部分處理器上,取餘操作並不高效。因此可以將數組大小設定爲2的指數倍,這樣計算餘數只需要通過位操作 index & ( size -1 )就能夠得到實際的index。
Disruptor對外只有一個變量,那就是隊尾元素的下標:cursor,這也避免了對head/tail這兩個變量的操作和協同。生產者和消費者對disruptor的訪問分別需要通過producer barrier和consumer barrier來協調。關於這兩個barrier是啥,後面會介紹。
ring buffer
圖1. RingBuffer,當前的隊尾元素位置爲18

2.生產者如何向隊列中插入元素?
生產者插入元素分爲兩個步驟,第一步申請一個空的slot, 每個slot只會被一個生產者佔用,申請到空的slot的生產者將新元素的數據拷貝到該slot;第二步是發佈,發佈之後,新元素才能爲消費者所見。如果只有一個生產者,第一步申請操作無需同步即可完成。如果有多個生產者,那麼會有一個變量:claimSequence來記錄申請位置,申請操作需要通過CAS來同步,例如圖二中,如果兩個生產者都想申請第19號slot, 則它們會同時執行CAS(&claimSequence, 18, 19),執行成功的人得到該slot,另一個則需要繼續申請下一個可用的slot。在disruptor中,發佈成功的順序與申請的順序是嚴格保持一致的,在實現上,發佈事件實際上就是修改cursor的值,操作等價於CAS(&cursor, myslot-1, myslot),從此操作也可以看出,發佈執行成功的順序必定是slot, slot 1, slot 2 ….嚴格有序的。另外,爲了防止生產者生產過快,在環形隊列中覆蓋消費者的數據,生產者要對消費者的消費情況進行跟蹤,實現上就是去讀取一下每個消費者當前的消費位置。例如一個環形隊列的大小是8,有兩個消費者的分別消費到第13和14號元素,那麼生產者生產的新元素是不能超過20的。插入元素的過程圖示如下:
publisher1
圖2. RingBuffer當前的隊尾位置序號爲18.生產者提出申請。

圖3. 生產者申請得到第19號位置,並且19號位置是獨佔的,可以寫入生產元素。此時19號元素對消費者是不可見的。

圖4,生產者成功寫入19號位置後,將cursor修改爲19,從而完成發佈,之後消費者可以消費19號元素。

3.消費者如何獲知有新的元素進來了?
消費者需要等待有新元素進入方能繼續消費,也就是說cursor大於自己當前的消費位置。等待策略有多種。可以選擇sleep wait, busy spin等等,在使用disruptor時,可以根據場景選擇不同的等待策略。

4.批量
如果消費者發現cursor相比其最後的一次消費位置前進了不止一個位置,它就可以選擇批量消費這區段的元素,而不是一次一個的向前推進。這種做法在提高吞吐量的同時還可以使系統的延遲更加平滑。

5.依賴圖
前面也提過,在傳統的系統中,通常使用隊列來表示多個處理流程之間的依賴,並且一步依賴就需要多添加一個隊列。在Disruptor中,由於生產者和消費者是分開考慮和控制的,因此有可能能夠通過一個核心的環形隊列來表示全部的依賴關係,可以大大提高吞吐,降低延遲。當然,要達到這個目的,還需要用戶細心地去設計。下面舉一個簡單的例子來說明如何使用disruptor來表示依賴關係。

/**
* 場景描述:生產者p1生產出來的數據需要經過消費者ep1和ep2的處理,然後傳遞給消費者ep3
*
*            -----
*     ----->| EP1 |------
*    |       -----       |
*    |                   v
*  ----                -----
* | P1 |              | EP3 |
*  ----                -----
*    |                   ^
*    |       -----       |
*     ----->| EP2 |------
*            -----
*
*
* 基於隊列的解決方案
* ============
*                 take       put
*     put    ====      -----      ====   take
*     ----->| Q1 |<---| EP1 |--->| Q3 |<------
*    |       ====      -----      ====        |
*    |                                        |
*  ----      ====      -----      ====      -----
* | P1 |--->| Q2 |<---| EP2 |--->| Q4 |<---| EP3 |
*  ----      ====      -----      ====      -----
*
* 使用Disruptor的解決方案:
* 以一個RingBuffer爲中心,生產者p1生產事件寫到ringbuffer中,
* 消費者ep1和ep2僅需要根據隊尾位置來進行判斷是否有可消費事件即可,
* 消費者ep3則需要根據消費者ep1和ep2的位置來判斷是否有可消費事件。生產者需要跟蹤ep3的位置,防止覆蓋未消費事件。
* ==========
*                    track to prevent wrap
*               -------------------------------
*              |                               |
*              |                               v
*  ----      ====                 =====      -----
* | P1 |--->| RB |<--------------| SB2 |<---| EP3 |
*  ----      ====                 =====      -----
*      claim   ^  get               |   waitFor
*              |                    |
*            =====      -----       |
*           | SB1 |<---| EP1 |<-----
*            =====      -----       |
*              ^                    |
*              |        -----       |
*               -------| EP2 |<-----
*             waitFor   -----
*/

第三部分 結束語
disruptor本身是用java寫的,但是筆者認爲在c 中更能體現其優點,自己也山寨了一個c 版本。在一個生產者和一個消費者的場景中測試表明,無鎖隊列相比有鎖隊列,qps有大約10倍的提升,latency更是有幾百倍的提升。不管怎麼樣,現在大家都漸漸都這麼一個意識了:鎖是性能殺手。所以這些無鎖的數據結構和算法,可以嘗試借鑑來使用在合適的場景中。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章