1. 程式人生 > >剖析Disruptor:為什麼會這麼快?(一)鎖的缺點

剖析Disruptor:為什麼會這麼快?(一)鎖的缺點

作者:Trisha’s  譯者:張文灼,潘曦  整理和校對:方騰飛,丁一

Martin Fowler寫了一篇非常好的文章,裡面不僅提到了Disruptor,而且還解釋了Disruptor 如何應用在LMAX的架構裡。裡面有提及了一些目前沒有涉及的概念,但最經常問到的問題是 “Disruptor究竟是什麼?"。

目前我正準備在回答這個問題,但首先回答"為什麼它會這麼快?"

這些問題持續出現,但是我不能沒有說它是幹什麼的就說它為什麼會這麼快,不能沒有說它為什麼這樣做就說它是什麼東西。

所以我陷入了一個僵局,一個如何寫部落格的僵局。

要打破這個僵局,我準備以最簡單方式回答第一個問題,如果幸運的話,在以後博文裡,如果需要解釋的話我會重新提回:Disruptor提供了一種執行緒之間資訊交換的方式。

作為一個開發者,因為"執行緒"一詞的出現,我的警鐘已經敲響,它意味著併發,而併發是困難的。

併發 01

想象有兩個執行緒嘗試修改同一個變數value:

情況一:執行緒1先到達

  1. 變數value的值變為”blah”。
  2. 然後當執行緒2到達時,變數value的值變為”blahy”。

情況二:執行緒2先到達

  1. 變數value的值變為”fluffy”。
  2. 然後當執行緒1到達時,值變為”blah”。

情況三:執行緒1與執行緒2互動

  1. 執行緒2得到值"fluff"然後賦給本地變數myValue。
  2. 執行緒1改變value的值為”blah”。
  3. 然後執行緒2醒來並把變數value的值改為”fluffy”

情況三顯然是唯一一個是錯誤的,除非除非你認為wiki編輯的幼稚做法是正確的(

Google Code Wiki,我一直在關注你)。其他兩種情況主要是看你的意圖和想要達到的效果。執行緒2可能不會關心變數value的值是什麼,主要的意圖就是在後面加上字元 ‘y'而不管它原來的值是什麼,在這種前提下,情況一和情況二都是正確的。

但是如果執行緒2只是想把"fluff"改為”fluffy”,那麼情況二和三都不正確。假定執行緒2想把值設為”fluffy”,有幾種辦法可以解決這個問題:

辦法一:悲觀鎖

(“No Entry”的標誌對於在沒有在英國開車的人看得明白不?)

悲觀鎖和樂觀鎖這兩個詞通常在我們談論資料庫讀寫時經常會用到,但原理可以應用到在獲得一個物件的鎖的情況。

只要執行緒2一獲得Entry 的互斥鎖,它就會阻擊其它執行緒去改變它,然後它就可以隨意做它要做的事情,設定值,然後做其它事情。

你可以想象這裡非常耗效能的,因為其它執行緒在系統各處徘徊著準備要獲得鎖然後又阻塞。執行緒越多,系統的響應性就會越慢.

辦法二:樂觀鎖

在這種情況,當執行緒2需要去寫Entry時才會去鎖定它.它需要檢查Entry自從上次讀過後是否已經被改過了。如果執行緒1線上程2讀完後到達並把值改為”blah”,執行緒2讀到了這個新值,執行緒2不會把"fluffy"寫到Entry裡並把執行緒1所寫的資料覆蓋.執行緒2會重試(重新讀新的值,與舊值比較,如果相等則在變數的值後面附上’y’),這裡在執行緒2不會關心新的值是什麼的情況.或者執行緒2會丟擲一個異常,或者會返回一個某些欄位已更新的標誌,這是在期望把”fluff”改為”fluffy”的情況.舉一個第二種情況的例子,如果你和另外一個使用者同時更新一個Wiki的頁面,你會告訴另外一個使用者的執行緒 Thread 2,它們需要重新載入從Thread1來新的變化,然後再提交它們的內容。

潛在的問題:死鎖

鎖定會帶來各種各樣的問題,比如死鎖,想象有2個執行緒需要訪問兩個資源

如果你濫用鎖技術,兩個鎖都在獲得鎖的情況下嘗試去獲得另外一個鎖,那就是你應該重啟你的電腦的時候了。(校注:作者還挺幽默)

很明確的一個問題:鎖技術是慢的..

關於鎖就是它們需要作業系統去做裁定。執行緒就像兩姐妹在為一個玩具在爭吵,然後作業系統就是能決定他們誰能拿到玩具的父母,就像當你跑向你父親告訴他你的姐姐在你玩著的時候搶走了你的變形金剛-他還有比你們爭吵更大的事情去擔心,他或許在解決你們爭吵之前要啟動洗碗機並把它擺在洗衣房裡。如果你把你的注意力放在鎖上,不僅要花時間來讓作業系統來裁定。Disruptor論文中講述了我們所做的一個實驗。這個測試程式呼叫了一個函式,該函式會對一個64位的計數器迴圈自增5億次。當單執行緒無鎖時,程式耗時300ms。如果增加一個鎖(仍是單執行緒、沒有競爭、僅僅增加鎖),程式需要耗時10000ms,慢了兩個數量級。更令人吃驚的是,如果增加一個執行緒(簡單從邏輯上想,應該比單執行緒加鎖快一倍),耗時224000ms。使用兩個執行緒對計數器自增5億次比使用無鎖單執行緒慢1000倍。併發很難而鎖的效能糟糕。我僅僅是揭示了問題的表面,而且,這個例子很簡單。但重點是,如果程式碼在多執行緒環境中執行,作為開發者將會遇到更多的困難:

  • 程式碼沒有按設想的順序執行。上面的場景3表明,如果沒有注意到多執行緒訪問和寫入相同的資料,事情可能會很糟糕。
  • 減慢系統的速度。場景3中,使用鎖保護程式碼可能導致諸如死鎖或者效率問題。

這就是為什麼許多公司在面試時會多少問些併發問題(當然針對Java面試)。不幸的是,即使未能理解問題的本質或沒有問題的解決方案,也很容易學會如何回答這些問題。

Disruptor如何解決這些問題。

首先,Disruptor根本就不用鎖。

取而代之的是,在需要確保操作是執行緒安全的(特別是,在多生產者的環境下,更新下一個可用的序列號)地方,我們使用CAS(Compare And Swap/Set)操作。這是一個CPU級別的指令,在我的意識中,它的工作方式有點像樂觀鎖——CPU去更新一個值,但如果想改的值不再是原來的值,操作就失敗,因為很明顯,有其它操作先改變了這個值。

注意,這可以是CPU的兩個不同的核心,但不會是兩個獨立的CPU。

CAS操作比鎖消耗資源少的多,因為它們不牽涉作業系統,它們直接在CPU上操作。但它們並非沒有代價——在上面的試驗中,單執行緒無鎖耗時300ms,單執行緒有鎖耗時10000ms,單執行緒使用CAS耗時5700ms。所以它比使用鎖耗時少,但比不需要考慮競爭的單執行緒耗時多。

回到Disruptor,在我講生產者時講過ClaimStrategy。在這些程式碼中,你可以看見兩個策略,一個是SingleThreadedStrategy(單執行緒策略)另一個是MultiThreadedStrategy(多執行緒策略)。你可能會有疑問,為什麼在只有單個生產者時不用多執行緒的那個策略?它是否能夠處理這種場景?當然可以。但多執行緒的那個使用了AtomicLong(Java提供的CAS操作),而單執行緒的使用long,沒有鎖也沒有CAS。這意味著單執行緒版本會非常快,因為它只有一個生產者,不會產生序號上的衝突。

我知道,你可能在想:把一個數字轉成AtomicLong不可能是Disruptor速度快的唯一祕密。當然,它不是,否則它不可能稱為“為什麼這麼快(第一部分)”。

但這是非常重要的一點——在整個複雜的框架中,只有這一個地方出現多執行緒競爭修改同一個變數值。這就是祕密。還記得所有的訪問物件都擁有序號嗎?如果只有一個生產者,那麼系統中的每一個序列號只會由一個執行緒寫入。這意味著沒有競爭、不需要鎖、甚至不需要CAS。在ClaimStrategy中,如果存在多個生產者,唯一會被多執行緒競爭寫入的序號就是 ClaimStrategy 物件裡的那個。

這也是為什麼Entry中的每一個變數都只能被一個消費者寫。它確保了沒有寫競爭,因此不需要鎖或者CAS。

回到為什麼佇列不能勝任這個工作

因此你可能會有疑問,為什麼佇列底層用RingBuffer來實現,仍然在效能上無法與 Disruptor 相比。佇列和最簡單的ring buffer只有兩個指標——一個指向佇列的頭,一個指向隊尾:

如果有超過一個生產者想要往佇列裡放東西,尾指標就將成為一個衝突點,因為有多個執行緒要更新它。如果有多個消費者,那麼頭指標就會產生競爭,因為元素被消費之後,需要更新指標,所以不僅有讀操作還有寫操作了。

等等,我聽到你喊冤了!因為我們已經知道這些了,所以佇列常常是單生產者和單消費者(或者至少在我們的測試裡是)。
佇列的目的就是為生產者和消費者提供一個地方存放要互動的資料,幫助緩衝它們之間傳遞的訊息。這意味著緩衝常常是滿的(生產者比消費者快)或者空的(消費者比生產者快)。生產者和消費者能夠步調一致的情況非常少見。

所以,這就是事情的真面目。一個空的佇列:

一個滿的佇列:

(校對注:這應該是一個雙向佇列)

佇列需要儲存一個關於大小的變數,以便區分佇列是空還是滿。否則,它需要根據佇列中的元素的內容來判斷,這樣的話,消費一個節點(Entry)後需要做一次寫入來清除標記,或者標記節點已經被消費過了。無論採用何種方式實現,在頭、尾和大小變數上總是會有很多競爭,或者如果消費操作移除元素時需要使用一個寫操作,那元素本身也包含競爭。

基於以上,這三個變數常常在一個cache line裡面,有可能導致false sharing。因此,不僅要擔心生產者和消費者同時寫size變數(或者元素),還要注意由於頭指標尾指標在同一位置,當頭指標更新時,更新尾指標會導致快取不命中。這篇文章已經很長了,所以我就不再詳述細節了。

這就是我們所說的“分離競爭點問題”或者佇列的“合併競爭點問題”。通過將所有的東西都賦予私有的序列號,並且只允許一個消費者寫Entry物件中的變數來消除競爭,Disruptor 唯一需要處理訪問衝突的地方,是多個生產者寫入 Ring Buffer 的場景。

總結

Disruptor相對於傳統方式的優點:

  1. 沒有競爭=沒有鎖=非常快。
  2. 所有訪問者都記錄自己的序號的實現方式,允許多個生產者與多個消費者共享相同的資料結構。
  3. 在每個物件中都能跟蹤序列號(ring buffer,claim Strategy,生產者和消費者),加上神奇的cache line padding,就意味著沒有為偽共享和非預期的競爭。

校訂:需要注意Disruptor2.0使用了與本文中不一樣的名字。如果對類名感到困惑,請參考我的變更彙總