一種高效無鎖內存隊列的實現

http://www.searchtb.com/2012/10/introduction_to_disruptor.html

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來表示依賴關係。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 場景描述:生產者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更是有幾百倍的提升。不管怎麼樣,現在大家都漸漸都這麼一個意識了:鎖是性能殺手。所以這些無鎖的數據結構和算法,可以嘗試借鑑來使用在合適的場景中。

標籤:disruptorlock-freenon-blocking

27 RESPONSES


  1. jack on 11 十 2012

    弱問,圖1~圖4用啥畫的?

    • 悟時 on 17 十 2012

      額 這個圖是從別的地方搞過來的

    • gy on 18 十 2012

      同問

    • Cnfn on 22 十 2012

      vim的插件DrawIt

    • Cnfn on 22 十 2012

      抱歉, 剛纔看錯了, 還以爲您說的是下面那幾幅ASCII碼圖呢…

      • E7 on 28 一 2013

        不是ascii碼圖,是上面那些,drawit畫的都是ascii碼的,同問

  2. byhh on 19 十 2012

    用戶態的鎖雖然避免了這些問題,但是其實它們只是在沒有真實的競爭時纔有效

    這裏是指什麼競爭? 在何種條件下,用戶態的鎖會出問題?謝謝

  3. egmkang on 19 十 2012

    如果消費者的消費能力很差,就不適用無鎖隊列,這個時候用有鎖隊列反而更簡單.

  4. Cnfn on 22 十 2012

    在您的文章中只有”消費者如何獲知有新的元素進來了”, 怎麼沒有”多個消費者是如何實現防止重複新進來的元素的呢?”, 這部分纔是最重要的吧?

    • Cnfn on 22 十 2012

      感謝您的文章!
      在上一條回覆之後我突然想到多個消費者也可以像生產者一樣通過CAS來同步, 於是問題解決了, 也寫了一個C++版, 謝謝…
      剛剛接觸多線程和無鎖隊列, 讓您見笑了~~~

  5. 崔冉 on 26 十 2012

    不錯,還需要多看

  6. Solrex on 02 十一 2012

    > 另外,爲了防止生產者生產過快,在環形隊列中覆蓋消費者的數據,生產者要對消費者的消費情況進行跟蹤,實現上就是去讀取一下每個消費者當前的消費位置。

    這是怎麼做到的?效率如何?

  7. 李 on 10 十一 2012

    無鎖的情況例如cas 操作,如果遇到高併發的時候,會不會就比較悲劇了,可能遇到很多衝突的情況,到時候就要佔用很多cpu,進行自旋

  8. itechdog on 18 十二 2012

    你的C版本發來看看

  9. Aden on 26 十二 2012

    請教一個問題:如果多個消費端消費同一份數據,消費端的消費能力有快有慢,怎麼平衡這些消費端?慢的影響快的這麼辦?

  10. [...] [1]  一種高效無鎖內存隊列的實現 [...]

  11. [...] [1]  一種高效無鎖內存隊列的實現 [...]

  12. 名字 on 04 三 2013

    寫的很好也很多,如果看過jdk代碼就知道,
    ArrayBlockingQueue 就是這麼回事,所以不用糾結於具體是什麼意思,看一下ArrayBlockingQueue 就什麼都懂了。

  13. qn on 13 三 2013

    cas 是鎖實現的一部分,實際上現在文章所說的無鎖,實際上,我覺的應該是拋棄了cas衝突後要處理的等待隊列的,並且線程並不直接進行掛起,應該是屬於更細的鎖吧

  14. dingliang on 26 三 2013

    有兩個問題,請指教,謝謝。
    1.正文裏好像忘了講隊列頭尾落入同一個cache line的問題了。

    2.還有disruptor的使用似乎還要求啓動的線程最好不要多於CPU核心數。目的大概是爲了讓每個核心執行一個線程以減少上下文切換所帶來的損失。
    那麼我的疑問是,disruptor是否有其他邏輯實現用於強化每個核心執行一個線程這個想法?僅僅保證啓動的線程數不多於CPU核心數就能夠減少上下文切換嗎?

  15. jiayy on 11 四 2013

    http://www.liblfds.org/ 不知道有沒有人用過這個無鎖數據結構庫,
    我在32核線程的機器上跑了一下 benchmark ,發現核心越多性能越差,影響對他的信心了,有沒有淘寶的大拿驗一下

    Benchmark Iteration 01
    ========================

    Release 6.1.1 Freelist Benchmark #1
    CPUs,total ops,mean ops/sec per CPU,standard deviation,scalability
    1,246081358,24608136,0,1.00
    2,199543112,9977156,11895333,0.41
    3,89551600,2985053,2899417,0.12
    4,72142158,1803554,1736669,0.07
    5,63174052,1263481,1231531,0.05
    6,56724522,945409,928488,0.04
    7,52423462,748907,741945,0.03
    8,49750856,621886,622482,0.03
    9,33216690,369074,885494,0.01
    10,29548254,295483,577371,0.01
    11,27488642,249897,349166,0.01
    12,27429622,228580,243030,0.01
    13,26364156,202801,198908,0.01
    14,26559260,189709,198769,0.01
    15,25467484,169783,200595,0.01
    16,25168734,157305,224077,0.01
    17,26750556,157356,224953,0.01
    18,26440556,146892,183104,0.01
    19,27767700,146146,182785,0.01
    20,28852032,144260,179046,0.01
    21,30215072,143881,177123,0.01
    22,31246522,142030,173322,0.01
    23,32409560,140911,168121,0.01
    24,33457132,139405,163690,0.01
    25,33074418,132298,197168,0.01
    26,32506334,125024,224112,0.01
    27,31940668,118299,256833,0.00
    28,31774470,113480,260138,0.00
    29,31559118,108825,259814,0.00
    30,31162320,103874,261511,0.00
    31,30791554,99328,265175,0.00
    32,30343526,94824,270157,0.00

    • superawesome on 28 五 2013

      基於lockfree的,也即是CAS之類同步的,在24核左右就應該達到瓶頸。這時候必須修正同步方法了。

  16. [...] 這裏我想採用對比的方式來講述。有鎖隊列,這可能是最簡單的一種隊列了,比如我們在多線程情況下使用標準STD的deque,那麼毫無疑問需要對其加鎖。加鎖其實是將協調過程交給了操作系統來管理,但無鎖隊列卻是在CPU層面就做到了協調,所以在效率上會高很多。更詳細的解釋請參見http://www.searchtb.com/2012/10/introduction_to_disruptor.html [...]

  17. yalung on 28 八 2013

    cas+busy wait這個東西我覺得就和自旋鎖沒啥區別嘛。實際性能未必比自旋鎖好。

    cas無非是降低了鎖的粒度,本質cas還是鎖,還不如直接使用自旋鎖(內部也是xchg、xadd之類原子操作實現,封裝性又好),也沒多鎖幾條語句。

    cas被過度濫用了。

  18. 姜戈船長 on 24 一 2014

    你好,我覺得這個producer barrier和consumer barrier是重點吧。我自己想實現一個C++的版本,但是做到一半,發現單純只是這些原理性的好像不太夠。樓主能不能在仔細講一講呢?我看了discruptor,不太理解。

  19. […] 一種高效無鎖內存隊列的實現 無鎖隊列的實現 鎖無關的(Lock-Free)數據結構 An Introduction to Lock-Free Programming […]

  20. 聖經 on 04 三 2014

    應用程序只要多與被造的世界連接起來,它就會越來越有效率。


發佈了5 篇原創文章 · 獲贊 1 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章