您當前的位置:首頁 > 文化

理解Disruptor(上):帶你體會CPU快取記憶體的風馳電掣

作者:由 記得要讓著本寶寶 發表于 文化時間:2022-06-22

讓我一起帶你來看一看,CPU 到底能有多快。在接下來的兩篇文章中,我會帶你一起來看一個開源專案 Disruptor。看看我們怎麼利用 CPU 和快取記憶體的硬體特性,來設計一個對於效能有極限追求的系統。

我們今天講解的 Disruptor 就是由一家專門做高頻交易的公司 LMAX 開源出來的。

有意思的是,Disruptor 的開發語言,並不是很多人心目中最容易做到效能極限的 C/C++,而是效能受限於 JVM 的 Java。這到底是怎麼一回事呢?那透過這一講,你就能體會到,其實只要通曉硬體層面的原理,即使是像 Java 這樣的高階語言,也能夠把 CPU 的效能發揮到極限。

Padding Cache Line,體驗快取記憶體的威力

我們先來看看 Disruptor 裡面一段神奇的程式碼。這段程式碼裡,Disruptor 在 RingBufferPad 這個類裡面定義了 p1,p2 一直到 p7 這樣 7 個 long 型別的變數。

abstract class RingBufferPad

{

protected long p1, p2, p3, p4, p5, p6, p7;

}

我在看到這段程式碼的第一反應是,變數名取得不規範,p1-p7 這樣的變數名沒有明確的意義啊。不過,當我深入瞭解了 Disruptor 的設計和原始碼,才發現這些變數名取得恰如其分。因為這些變數就是沒有實際意義,只是幫助我們進行

快取行填充

(Padding Cache Line),使得我們能夠儘可能地用上 CPU 快取記憶體(CPU Cache)。那麼快取行填充這個黑科技到底是什麼樣的呢?我們接著往下看。

不知道你還記不記得。如果訪問內建在 CPU 裡的 L1 Cache 或者 L2 Cache,訪問延時是記憶體的 1/15 乃至 1/100。而記憶體的訪問速度,其實是遠遠慢於 CPU 的。想要追求極限效能,需要我們儘可能地多從 CPU Cache 裡面拿資料,而不是從記憶體裡面拿資料。

理解Disruptor(上):帶你體會CPU快取記憶體的風馳電掣

CPU Cache 裝載記憶體裡面的資料,不是一個一個欄位載入的,而是載入一整個快取行。舉個例子,如果我們定義了一個長度為 64 的 long 型別的陣列。那麼資料從記憶體載入到 CPU Cache 裡面的時候,不是一個一個數組元素載入的,而是一次性載入固定長度的一個快取行。

我們現在的 64 位 Intel CPU 的計算機,快取行通常是 64 個位元組(Bytes)。一個 long 型別的資料需要 8 個位元組,所以我們一下子會載入 8 個 long 型別的資料。也就是說,一次載入數組裡面連續的 8 個數值。這樣的載入方式使得我們遍歷陣列元素的時候會很快。因為後面連續 7 次的資料訪問都會命中快取,不需要重新從記憶體裡面去讀取資料。這個效能層面的好處

但是,在我們不是使用陣列,而是使用單獨的變數的時候,這裡就會出現問題了。在 Disruptor 的 RingBuffer(環形緩衝區)的程式碼裡面,定義了一個單獨的 long 型別的變數。這個變數叫作 INITIAL_CURSOR_VALUE ,用來存放 RingBuffer 起始的元素位置。

理解Disruptor(上):帶你體會CPU快取記憶體的風馳電掣

CPU 在載入資料的時候,自然也會把這個資料從記憶體載入到快取記憶體裡面來。不過,這個時候,快取記憶體裡面除了這個資料,還會載入這個資料前後定義的其他變數。這個時候,問題就來了。Disruptor 是一個多執行緒的伺服器框架,在這個資料前後定義的其他變數,可能會被多個不同的執行緒去更新資料、讀取資料。這些寫入以及讀取的請求,會來自於不同的 CPU Core。於是,為了保證資料的同步更新,我們不得不把 CPU Cache 裡面的資料,重新寫回到記憶體裡面去或者重新從記憶體裡面載入資料。

而我們剛剛說過,這些 CPU Cache 的寫回和載入,都不是以一個變數作為單位的。這些動作都是以整個 Cache Line 作為單位的。所以,當 INITIAL_CURSOR_VALUE 前後的那些變數被寫回到記憶體的時候,這個欄位自己也寫回到了記憶體,這個常量的快取也就失效了。當我們要再次讀取這個值的時候,要再重新從記憶體讀取。這也就意味著,讀取速度大大變慢了。

……

abstract class RingBufferPad

{

protected long p1, p2, p3, p4, p5, p6, p7;

}

abstract class RingBufferFields extends RingBufferPad

{

……

}

public final class RingBuffer extends RingBufferFields implements Cursored, EventSequencer, EventSink

{

public static final long INITIAL_CURSOR_VALUE = Sequence。INITIAL_VALUE;

protected long p1, p2, p3, p4, p5, p6, p7;

……

面臨這樣一個情況,Disruptor 裡發明了一個神奇的程式碼技巧,這個技巧就是快取行填充。

Disruptor 在 INITIAL_CURSOR_VALUE 的前後,分別定義了 7 個 long 型別的變數。

前面的 7 個來自繼承的 RingBufferPad 類,後面的 7 個則是直接定義在 RingBuffer 類裡面。

這 14 個變數沒有任何實際的用途。我們既不會去讀他們,也不會去寫他們。

而 INITIAL_CURSOR_VALUE 又是一個常量,也不會進行修改。所以,一旦它被載入到 CPU Cache 之後,只要被頻繁地讀取訪問,就不會再被換出 Cache 了。這也就意味著,對於這個值的讀取速度,會是一直是 CPU Cache 的訪問速度,而不是記憶體的訪問速度。

使用 RingBuffer,利用快取和分支預測

其實這個利用 CPU Cache 的效能的思路,貫穿了整個 Disruptor。Disruptor 整個框架,其實就是一個高速的生產者 - 消費者模型(Producer-Consumer)下的佇列。生產者不停地往佇列裡面生產新的需要處理的任務,而消費者不停地從佇列裡面處理掉這些任務。

理解Disruptor(上):帶你體會CPU快取記憶體的風馳電掣

如果你熟悉演算法和資料結構,那你應該非常清楚,如果要實現一個佇列,最合適的資料結構應該是連結串列。我們只要維護好連結串列的頭和尾,就能很容易實現一個佇列。生產者只要不斷地往連結串列的尾部不斷插入新的節點,而消費者只需要不斷從頭部取出最老的節點進行處理就好了。我們可以很容易實現生產者 - 消費者模型。實際上,Java 自己的基礎庫裡面就有 LinkedBlockingQueue 這樣的佇列庫,可以直接用在生產者 - 消費者模式上。

理解Disruptor(上):帶你體會CPU快取記憶體的風馳電掣

不過,Disruptor 裡面並沒有用 LinkedBlockingQueue,而是使用了一個 RingBuffer 這樣的資料結構,這個 RingBuffer 的底層實現則是一個固定長度的陣列。比起連結串列形式的實現,陣列的資料在記憶體裡面會存在空間區域性性。

就像上面我們看到的,陣列的連續多個元素會一併載入到 CPU Cache 裡面來,所以訪問遍歷的速度會更快。而連結串列裡面各個節點的資料,多半不會出現在相鄰的記憶體空間,自然也就享受不到整個 Cache Line 載入後資料連續從快取記憶體裡面被訪問到的優勢。

除此之外,資料的遍歷訪問還有一個很大的優勢,就是 CPU 層面的分支預測會很準確。這可以使得我們更有效地利用了 CPU 裡面的多級流水線,我們的程式就會跑得更快。

總結延伸

好了,不知道講完這些,你有沒有體會到 Disruptor 這個框架的神奇之處呢?

CPU 從記憶體載入資料到 CPU Cache 裡面的時候,不是一個變數一個變數載入的,而是載入固定長度的 Cache Line。如果是載入數組裡面的資料,那麼 CPU 就會載入到數組裡面連續的多個數據。所以,陣列的遍歷很容易享受到 CPU Cache 那風馳電掣的速度帶來的紅利。

對於類裡面定義的單獨的變數,就不容易享受到 CPU Cache 紅利了。因為這些欄位雖然在記憶體層面會分配到一起,但是實際應用的時候往往沒有什麼關聯。於是,就會出現多個 CPU Core 訪問的情況下,資料頻繁在 CPU Cache 和記憶體裡面來來回回的情況。而 Disruptor 很取巧地在需要頻繁高速訪問的常量 INITIAL_CURSOR_VALUE 前後,各定義了 7 個沒有任何作用和讀寫請求的 long 型別的變數。

這樣,無論在記憶體的什麼位置上,這個 INITIAL_CURSOR_VALUE 所在的 Cache Line 都不會有任何寫更新的請求。我們就可以始終在 Cache Line 裡面讀到它的值,而不需要從記憶體裡面去讀取資料,也就大大加速了 Disruptor 的效能。

這樣的思路,其實滲透在 Disruptor 這個開源框架的方方面面。作為一個生產者 - 消費者模型,Disruptor 並沒有選擇使用連結串列來實現一個佇列,而是使用了 RingBuffer。RingBuffer 底層的資料結構則是一個固定長度的陣列。這個陣列不僅讓我們更容易用好 CPU Cache,對 CPU 執行過程中的分支預測也非常有利。更準確的分支預測,可以使得我們更好地利用好 CPU 的流水線,讓程式碼跑得更快。

推薦閱讀

今天講的是 Disruptor,推薦的閱讀內容自然是 Disruptor 的官方文件。作為一個開源專案,Disruptor 在自己GitHub上有很詳細的設計文件,推薦你好好閱讀一下。

這裡面不僅包含了怎麼用好 Disruptor,也包含了整個 Disruptor 框架的設計思路,是一份很好的閱讀學習材料。另外,Disruptor 的官方文件裡,還有很多文章、演講,詳細介紹了這個框架,很值得深入去看一看。Disruptor 的原始碼其實並不複雜,很適合用來學習怎麼閱讀開源框架程式碼。

標簽: CPU  Disruptor  cache  載入  記憶體