1. 程式人生 > >Disruptor原始碼閱讀筆記

Disruptor原始碼閱讀筆記

Disruptor是什麼

關於 Disruptor,網路上有很多的解釋和說法。這裡簡單的概括下。Disruptor 是一個消費者生產者佇列框架,據官網介紹,可以提供非常強大的效能。Disruptor 與其說為我們帶來了一個框架,更多的是為我們帶來了一個獨特思路的程式設計實踐。總結來說大致有3點。

  • 使用迴圈陣列的方式代替佇列,使用預先填充資料的方式來避免 GC;
  • 使用 CPU 快取行填充的方式來避免極端情況下的資料爭用導致的效能下降;
  • 多執行緒程式設計中儘量避免鎖爭用的編碼技巧。

上面的三點是在 Disruptor 中帶來的一些技巧。有些是常用的,有些是實現起來比較獨特的。

使用迴圈陣列代替佇列

生產者消費者模型自然是離不開佇列的。但是使用傳統的佇列,面對併發等問題,在效能上是否已經足夠的高效?或者說是否有其他的辦法來進一步的提高效能。Disruptor 為我們提供了一個思路和實踐(這個思路不是 Disruptor 首創,但是他們提供了一個好的完整實踐)

基本的迴圈陣列實現

定義一個數組,長度為2的次方冪(因為計算機是二進位制的,所以2次方冪可以進行並運算來代替取模運算)。設定一個數字標誌表示當前的可用的位置(可以從0開始)。當這個數字標誌不斷增長到大於陣列長度時進行與陣列長度的並運算,得到的新數字依然在陣列的長度範圍內,就又可以插入。這樣就好像一直插入直到陣列末尾又再次從頭開始,故而稱之為迴圈陣列。

一般的迴圈陣列有頭尾兩個標誌位。這點和佇列很像。頭標誌位表示下一個可以插入的位置,尾標誌位表示下一個可以讀取的位置。頭標誌位不能大於尾標誌位一個數組長度(因為這樣就插入的位置和讀取的位置就重疊了會導致資料丟失),尾標誌位不能等於頭標誌位(因為這樣讀取的資料實際上是上一輪的舊資料)

預先填充提高效能

我們知道在java中如果創造大量的物件使用後棄用,JVM 會在適當的時候進行 GC 操作。大量的物件 GC 操作是很消耗時間的。所以如果能夠避免 GC 也可以提高效能,特別是在資料互動非常頻繁的時候。

在迴圈陣列中,可以事先在陣列中填充好資料。一旦有新資料的產生,要做的就是修改陣列中某一位中的一些屬性值。這樣可以避免頻繁建立資料和棄用資料導致的 GC。這點比起佇列是要好的。

只保留一個標誌位

多執行緒在佇列也好,迴圈陣列也好,必然存在對標誌位的競爭。無論是使用鎖來避免競爭,還是使用 CAS 來進行無鎖演算法。只要爭用的情況存在,並且執行緒較多,都會出現對資源的不斷消耗。爭用的物件越多,爭用中消耗掉的資源也就越多。為了避免這樣的情況,減少爭用的資源就是一個手段。比如在迴圈陣列中只保留一個標誌位,也就是下一個可以寫入資料位置的標誌位。而尾部標誌位則在各個消費者執行緒中儲存(具體的程式設計手法後續細講)。

迴圈陣列在單執行緒中的使用

如果確定只有一個生產者,也就是說只有一個寫執行緒。則在迴圈陣列中的使用會更加簡化。具體來說單執行緒更新陣列上的標誌位,那這種情況,標誌位就無需採用 CAS 寫的方式來確定下一個可寫入的位置,直接就是在單執行緒內進行普通的更新即可。

迴圈陣列在多執行緒中的使用

如果存在多個生產者,則可寫入的標誌位需要用 CAS 演算法來進行爭奪,避免鎖的使用。多個執行緒通過 CAS 得到唯一的不衝突的下一個可寫序號。由於需要獲得序號後才能進行寫入,而寫入完成才可以讓消費者執行緒進行消費。所以才獲得序號後,完成寫入前,必須有一種方式讓消費者檢測是否完成。以避免消費者拿到還未填入輸入的陣列位。

為了達到這個目標,存在簡單—效率低和複雜—效率高兩種方式。

簡單但是可能效率低的方式

使用兩個標誌位。

  • + prePut:表示下一個可以供生產者放入的位置;
  • + put:表示最後一個生產者已經放入的位置。

多個生產者通過 CAS 獲得 prePut 的不同的值。在獲得的序號並且完成資料寫入後,將 put 的值以 CAS 方式遞增(比如獲得的序號是7,只有 put 是6的時候才允許設定成功),稱之為釋出。這種方式存在一個缺點,如果多個執行緒併發寫入,獲取 prePut 的值不會堵塞,假設其中一個生產者在寫入資料的時候稍慢,則其他的執行緒寫入完畢也無法完成釋出。就會導致迴圈等待,浪費了 CPU 效能。

複雜但是可能效率高的方式

在上面的方式中,主要的爭奪環節集中在多執行緒釋出中,序號大的執行緒釋出需要等到序號小的執行緒釋出完成後才能釋出。那我們的優化的點也在這個地方。如果只有一個地方可以寫入完成資訊,必然需要爭奪。為了避免爭奪,我們可以使用標誌陣列(長度和內容陣列相同,每一位表示相同下標的內容陣列是否釋出)來表示每一個位置是否寫入。這樣就可以避免釋出的爭奪(大家的標誌位都不在一起了)。

但是又來帶來一個問題,用什麼數字來表示是否已經發布完成?如果只是0和1,那麼寫過1輪以後,標誌陣列位上就都是1了。又無法區分。所以標誌陣列上的數字應該在迴圈陣列的每一輪迴圈的值都不同。比如一開始都是-1,第一輪中是0的表示已釋出,第二輪中是0表示沒釋出,是1的表示已釋出。下面的是釋出的演算法步驟:

  1. 將序號除以標誌陣列長度(因為長度是2的次方冪,這一步可以通過右移來完成)得到填入值 x;
  2. 將序號和標誌陣列長度減一進行並運算得到填入位置 index;
  3. 將index位置寫入 x。

CPU快取行填充技術

一般在軟體程式設計中,很少有工程師會關注一些硬體的資訊。不過如果追求效能達到極致,那麼對於一些硬體知識的瞭解就成了必要。這其中CPU 快取的知識會神奇的提高我們的程式效能。

CPU快取行

在程式設計上,網路關於 CPU 快取的知識介紹很多。這裡簡單說下。在硬體中,CPU 存在著多級快取的結果,越接近 CPU 的快取容量越小,速度越快。每一個物理核心都有自己的快取體系。不同的 CPU 之間通過快取嗅探協議來確定快取中的資料是否已經失效。如果失效了,CPU 會去記憶體中讀取資料,並且將最新的資料在特定指令的幫助下寫入到記憶體中。

CPU 快取是以行為單位進行存取的。以前的 CPU 是32個位元組一行,現在則是64個位元組一行。因為這種行存取的方式,所以稱之為快取行。如果一個物件中不同屬性在多執行緒中被頻繁更新,會導致一個問題:由於在同一個快取行中的不相關變數的更新導致整個快取行失效。快取行失效後 CPU 就只好到主存中重新讀取資料。這個問題在併發佇列中特別明顯。為了修正這個問題,JDK 7 中特意提供了 transferqueue 來解決這個問題。

快取行填充

既然問題的發生是因為同一個快取行中有不相關的變數被更新導致快取行需要的資料一起失效,那麼解決的辦法就是讓這個頻繁被更新的變數獨佔一個快取行即可。也就是剩下的位置就用無關資料填充。這樣就保證了關鍵變數不會因為其他變數的更新而失效。具體的填充方式,就是在一個 Java 物件中設定無意義的變數,根據變數的長度來計算需要的個數。以下是示例程式碼:

//現在一般的cpu架構都是64個位元組的快取行,針對這個情況,快取行填充可以如下進行
class LeftPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

class RealValue extends LeftPad
{
    protected volatile long point = -1; // 前後都有7個元素填充,可以保證該核心變數獨自在一個快取行中
}

class RightPad extends RealValue
{
    protected long p9, p10, p11, p12, p13, p14, p15;
}

public class CpuCachePadingValue extends RightPad
{
}

多執行緒程式設計中減少對鎖的使用

Disruptor 整個框架的實現過程都在儘量的減少對鎖的使用。比如生產者消費者中最容易出現爭奪的,其實就是其中的訊息佇列。那麼對於這個訊息佇列,我們可以採用的優化手段包括

  • 使用迴圈陣列代替佇列,使用 CAS 演算法來代替鎖爭奪;
  • 消費者各自儲存自己當前已經處理過的序號,而不是將這個序號的資訊在佇列中來儲存,避免多執行緒爭用。

針對上面的第二點詳細展開說一下。一般來說,佇列中資訊的處理有兩種不同的形式,第一種是這個訊息需要所有消費者都處理完畢,才能認為是被使用好了。第二種是爭奪到使用權的消費者執行緒進行消費,其他消費者執行緒爭奪下一個。無論哪一種,都可以將消費者已經處理的序號儲存在消費者執行緒內。而如果資訊只允許被一個執行緒消費,可以在內部使用 CAS 來爭奪。而生產者執行緒則需要持有消費者的類的資訊,好用來判斷所有消費者中消費的最小的序號,以避免在資料寫入時覆蓋了某個消費者尚未處理的資料資訊。

指定消費者不同的處理順序

Disruptor 可以讓不同的消費者按照一定的順序進行訊息處理。比如一個訊息,必須先經過日誌處理 A1 儲存日誌,資料轉換處理器 A2 清理才能最終被業務處理器 A3 進行實際的業務處理。而 A1 和 A2 並沒有任何前後關係,但是 A3 必須等 A1 和 A2 都完成後才能進行。那麼在實際編碼時,可以讓 A3 追蹤 A1 和 A2 的處理序號。所有的消費者都在等待佇列中可用序號達到自己需要的序號,一旦到達,排位靠後的處理器就迴圈檢測排位靠前的處理器是否已經將資料處理完畢,處理完畢之後自己開始對資料的處理。

總結

Disruptor 這個框架在整個的編碼過程中一直都在體現本地快取資料,使用 CAS 來代替鎖,儘可能無鎖甚至無 CAS 這樣的一種程式設計思想。根據官網的說明,這樣的編碼思想是在他們追求多執行緒以提高效能遇到失敗後(專案複雜性、可測試性、維護性等),回過頭思考在單執行緒下的效能可能性(單執行緒無鎖必然是效能最高的,但是吞吐量就有待商榷)。