剖析Disruptor:爲何會這麼快?(二)神奇的緩存行填充

原文連接:http://mechanitis.blogspot.com/2011/07/dissecting-disruptor-why-its-so-fast_22.html 需FQhtml

計算機入門

     我喜歡在 LMAX工做的緣由之一是,在這裏工做讓我明白從大學和A Level Computing所學的東西實際上仍是有意義的。作爲一個開發者你能夠逃避不去了解CPU,數據結構或者 大O符號 —— 而我用了10年的職業生涯來忘記這些東西。可是如今看來,若是你知道這些知識並應用它,你能寫出一些很是巧妙和很是快速的代碼。
 
     所以,對在學校學過的人是種複習,對未學過的人是個簡單介紹。可是請注意,這篇文章包含了大量的過分簡化。
 
     CPU是你機器的心臟,最終由它來執行全部運算和程序。主內存(RAM)是你的數據(包括代碼行)存放的地方。本文將忽略硬件驅動和網絡之類的東西,由於 Disruptor的目標是儘量多的在內存中運行。
 
     CPU和主內存之間有好幾層緩存,由於即便直接訪問主內存也是很是慢的。若是你正在屢次對一塊數據作相同的運算,那麼在執行運算的時候把它加載到離CPU很近的地方就有意義了(好比一個循環計數-你不想每次循環都跑到主內存去取這個數據來增加它吧)。

 

     越靠近CPU的緩存越快也越小。因此L1緩存很小但很快,而且緊靠着在使用它的CPU內核。L2大一些,也慢一些,而且仍然只能被一個單獨的 CPU 核使用。L3在現代多核機器中更廣泛,仍然更大,更慢,而且被單個插槽上的全部 CPU 核共享。最後,你擁有一塊主存,由所有插槽上的全部 CPU 核共享。
 
     當CPU執行運算的時候,它先去L1查找所需的數據,再去L2,而後是L3,最後若是這些緩存中都沒有,所需的數據就要去主內存拿。走得越遠,運算耗費的時間就越長。因此若是你在作一些很頻繁的事,你要確保數據在L1緩存中。

Martin 和 Mike 的 QCon presentation 演講中給出了一些緩存未命中的消耗數據:數組

 
從CPU到 大約須要的 CPU 週期 大約須要的時間
主存   約60-80納秒
QPI 總線傳輸
(between sockets, not drawn) 
  約20ns
L3 cache 約40-45 cycles, 約15ns
L2 cache 約10 cycles, 約3ns
L1 cache 約3-4 cycles, 約1ns
寄存器 1 cycle  
 
 
若是你的目標是讓端到端的延遲只有 10毫秒,而其中花80納秒去主存拿一些未命中數據的過程將佔很重的一塊。

緩存行

     如今須要注意一件有趣的事情:數據在緩存中不是以獨立的項來存儲的。如不是一個單獨的變量,也不是一個單獨的指針。緩存是由緩存行組成的,一般是64字節(譯註:64位處理器),而且它有效地引用主內存中的一塊地址。一個Java的long類型是8字節,所以在一個緩存行中能夠存8個long類型的變量。(此處忽略了多級緩存)
 
 
 
     奇妙的是:若是你訪問一個long數組,當數組中的一個值被加載到緩存中,它會額外加載另外7個,所以你能很是快地遍歷這個數組。事實上,遍歷在內存中連續分配的任意數據結構都是很是快的,由於存在這樣的機制。
 
     所以若是你數據結構中的項在內存中不是彼此相鄰的(好比說:鏈表),你將得不到免費緩存加載所帶來的優點。而且在這些數據結構中的每個項均可能會出現緩存未命中。
 
     不過,這種免費加載存在弊端。設想:你的long類型的數據不是數組的一部分,它只是一個單獨的變量,暫時稱它爲 head;同時你的類中有另外一個變量緊挨着它,暫時稱它爲 tail。如今,當你加載 head到緩存的時候,你也免費加載了 tail
 

 

     如今看起來還不錯。直到生產者要將生產的內容放到tail,此時 head中保存的內容也正被消費者(譯註:和生產者不在同一個內核中)消費。這兩個變量並沒有直接聯繫,但須要被這兩個線程使用,且這兩個線程運行在不一樣的內核(譯註:這裏是指物理上的內核,即多核CPU)中。
 
     設想某一時刻消費者更新了 head的值。緩存中的值和內存中的值都被更新了,而其餘全部存儲 head的緩存行都會都會失效,由於其它緩存中 head不是最新值了。而咱們必須以整個緩存行做爲單位來處理,不能只把 head標記爲無效。
 

 

     假如此時生產者進程要訪問tail中的內容,就致使整個緩存行須要從主內存從新讀取,由於緩存未命中。一個和消費者無關的進程(生產者),想要訪問一個和head無關的數據(tail),卻被意外拖慢了速度。
 
     若是兩個獨立的線程同時寫這兩個值會更糟。由於每次線程對緩存行進行寫操做時,每一個內核都要把另外一個內核上的緩存塊無效掉並從新讀取裏面的數據(譯註:這裏假設每一個線程獨佔一個內核)。儘管它們寫入的是不一樣的變量,但幾乎等於兩個線程之間的寫衝突。
 
     這叫做「 僞共享」(False sharing),由於每次你訪問 head你也會獲得 tail,並且每次你訪問 tail,你也會獲得 head。這一切都在後臺發生,而且沒有任何編譯警告會告訴你,你正在寫一個併發訪問效率很低的代碼。

解決方案-神奇的緩存行填充

     Disruptor採用了緩存行填充的方法,來消除這個問題。這種作法適用於64字節(或更小)的處理器架構,經過增長補全來確保ring buffer的序列號不會和其餘數據同時存在於一個緩存行中。
 
public long p1, p2, p3, p4, p5, p6, p7; // cache line padding
private volatile long cursor = INITIAL_CURSOR_VALUE;
public long p8, p9, p10, p11, p12, p13, p14; // cache line padding

(譯註:先後各七位填充字段,保證cursor[1]在緩衝行中任意位置,其周圍都有足夠的填充字段)緩存

 
     所以沒有僞共享,就沒有和其它任何變量的意外衝突,沒有沒必要要的緩存未命中。 在你的 Entry類中也值得這樣作,若是你有不一樣的消費者往不一樣的字段寫入,你須要確保各個字段間不會出現僞共享。
 
 
譯者注:
[1]不一樣於傳統隊列的head、tail、size variables定義的隊列,Disruptor對外只有一個變量,那就是隊尾元素的下標,Disruptor稱其爲cursor。 
相關文章
相關標籤/搜索