1. 程式人生 > >cache相關知識

cache相關知識

在併發程式設計過程中,我們大部分的焦點都放在如何控制共享變數的訪問控制上(程式碼層面),但是很少人會關注系統硬體及 JVM 底層相關的影響因素。前段時間學習了一個牛X的高效能非同步處理框架 Disruptor,它被譽為“最快的訊息框架”,其 LMAX 架構能夠在一個執行緒裡每秒處理 6百萬 訂單!在講到 Disruptor 為什麼這麼快時,接觸到了一個概念——偽共享( false sharing ),其中提到:快取行上的寫競爭是執行在 SMP 系統中並行執行緒實現可伸縮性最重要的限制因素。由於從程式碼中很難看出是否會出現偽共享,有人將其描述成無聲的效能殺手。

本文僅針對目前所學進行合併整理,目前並無非常深入地研究和實踐,希望對大家從零開始理解偽共享提供一些幫助。

偽共享的非標準定義為:快取系統中是以快取行(cache line)為單位儲存的,當多執行緒修改互相獨立的變數時,如果這些變數共享同一個快取行,就會無意中影響彼此的效能,這就是偽共享。

下面我們就來詳細剖析偽共享產生的前因後果。首先,我們要了解什麼是快取系統。

一、CPU 快取

CPU 快取的百度百科定義為:

CPU 快取(Cache Memory)是位於 CPU 與記憶體之間的臨時儲存器,它的容量比記憶體小的多但是交換速度卻比記憶體要快得多。】

快取記憶體的出現主要是為了解決 CPU 運算速度與記憶體讀寫速度不匹配的矛盾,因為 CPU 運算速度要比記憶體讀寫速度快很多,這樣會使 CPU 花費很長時間等待資料到來或把資料寫入記憶體。

在快取中的資料是記憶體中的一小部分,但這一小部分是短時間內 CPU 即將訪問的,當 CPU 呼叫大量資料時,就可避開記憶體直接從快取中呼叫,從而加快讀取速度。

CPU 和主記憶體之間有好幾層快取,因為即使直接訪問主記憶體也是非常慢的。如果你正在多次對一塊資料做相同的運算,那麼在執行運算的時候把它載入到離 CPU 很近的地方就有意義了。

按照資料讀取順序和與 CPU 結合的緊密程度,CPU 快取可以分為一級快取,二級快取,部分高階 CPU 還具有三級快取。每一級快取中所儲存的全部資料都是下一級快取的一部分,越靠近 CPU 的快取越快也越小。所以 L1 快取很小但很快(譯註:L1 表示一級快取),並且緊靠著在使用它的 CPU 核心。L2 大一些,也慢一些,並且仍然只能被一個單獨的 CPU 核使用。L3 在現代多核機器中更普遍,仍然更大,更慢,並且被單個插槽上的所有 CPU 核共享。最後,你擁有一塊主存,由全部插槽上的所有 CPU 核共享。擁有三級快取的的 CPU,到三級快取時能夠達到 95% 的命中率,只有不到 5% 的資料需要從記憶體中查詢。

多核機器的儲存結構如下圖所示:

圖圖1

當 CPU 執行運算的時候,它先去 L1 查詢所需的資料,再去 L2,然後是 L3,最後如果這些快取中都沒有,所需的資料就要去主記憶體拿。走得越遠,運算耗費的時間就越長。所以如果你在做一些很頻繁的事,你要確保資料在 L1 快取中。

Martin Thompson 給出了一些快取未命中的消耗資料,如下所示:

圖圖2

二、MESI 協議及 RFO 請求

從上一節中我們知道,每個核都有自己私有的 L1,、L2 快取。那麼多執行緒程式設計時, 另外一個核的執行緒想要訪問當前核內 L1、L2 快取行的資料, 該怎麼辦呢?

有人說可以通過第 2 個核直接訪問第 1 個核的快取行,這是當然是可行的,但這種方法不夠快。跨核訪問需要通過 Memory Controller(記憶體控制器,是計算機系統內部控制記憶體並且通過記憶體控制器使記憶體與 CPU 之間交換資料的重要組成部分),典型的情況是第 2 個核經常訪問第 1 個核的這條資料,那麼每次都有跨核的消耗.。更糟的情況是,有可能第 2 個核與第 1 個核不在一個插槽內,況且 Memory Controller 的匯流排頻寬是有限的,扛不住這麼多資料傳輸。所以,CPU 設計者們更偏向於另一種辦法: 如果第 2 個核需要這份資料,由第 1 個核直接把資料內容發過去,資料只需要傳一次。

那麼什麼時候會發生快取行的傳輸呢?答案很簡單:當一個核需要讀取另外一個核的髒快取行時發生。但是前者怎麼判斷後者的快取行已經被弄髒(寫)了呢?

下面將詳細地解答以上問題. 首先我們需要談到一個協議—— MESI 協議。現在主流的處理器都是用它來保證快取的相干性和記憶體的相干性。M、E、S 和 I 代表使用 MESI 協議時快取行所處的四個狀態:

M(修改,Modified):本地處理器已經修改快取行,即是髒行,它的內容與記憶體中的內容不一樣,並且此 cache 只有本地一個拷貝(專有); E(專有,Exclusive):快取行內容和記憶體中的一樣,而且其它處理器都沒有這行資料; S(共享,Shared):快取行內容和記憶體中的一樣, 有可能其它處理器也存在此快取行的拷貝; I(無效,Invalid):快取行失效, 不能使用。

下面說明這四個狀態是如何轉換的:

初始:一開始時,快取行沒有載入任何資料,所以它處於 I 狀態。

本地寫(Local Write):如果本地處理器寫資料至處於 I 狀態的快取行,則快取行的狀態變成 M。

本地讀(Local Read):如果本地處理器讀取處於 I 狀態的快取行,很明顯此快取沒有資料給它。此時分兩種情況:(1)其它處理器的快取裡也沒有此行資料,則從記憶體載入資料到此快取行後,再將它設成 E 狀態,表示只有我一家有這條資料,其它處理器都沒有;(2)其它處理器的快取有此行資料,則將此快取行的狀態設為 S 狀態。(備註:如果處於M狀態的快取行,再由本地處理器寫入/讀出,狀態是不會改變的)

遠端讀(Remote Read):假設我們有兩個處理器 c1 和 c2,如果 c2 需要讀另外一個處理器 c1 的快取行內容,c1 需要把它快取行的內容通過記憶體控制器 (Memory Controller) 傳送給 c2,c2 接到後將相應的快取行狀態設為 S。在設定之前,記憶體也得從總線上得到這份資料並儲存。

遠端寫(Remote Write):其實確切地說不是遠端寫,而是 c2 得到 c1 的資料後,不是為了讀,而是為了寫。也算是本地寫,只是 c1 也擁有這份資料的拷貝,這該怎麼辦呢?c2 將發出一個 RFO (Request For Owner) 請求,它需要擁有這行資料的許可權,其它處理器的相應快取行設為 I,除了它自已,誰不能動這行資料。這保證了資料的安全,同時處理 RFO 請求以及設定I的過程將給寫操作帶來很大的效能消耗。

狀態轉換由下圖做個補充:

圖圖3

我們從上節知道,寫操作的代價很高,特別當需要傳送 RFO 訊息時。我們編寫程式時,什麼時候會發生 RFO 請求呢?有以下兩種:

1. 執行緒的工作從一個處理器移到另一個處理器, 它操作的所有快取行都需要移到新的處理器上。此後如果再寫快取行,則此快取行在不同核上有多個拷貝,需要傳送 RFO 請求了。 2. 兩個不同的處理器確實都需要操作相同的快取行

接下來,我們要了解什麼是快取行。

三、快取行

在文章開頭提到過,快取系統中是以快取行(cache line)為單位儲存的。快取行通常是 64 位元組(譯註:本文基於 64 位元組,其他長度的如 32 位元組等不適本文討論的重點),並且它有效地引用主記憶體中的一塊地址。一個 Java 的 long 型別是 8 位元組,因此在一個快取行中可以存 8 個 long 型別的變數。所以,如果你訪問一個 long 陣列,當陣列中的一個值被載入到快取中,它會額外載入另外 7 個,以致你能非常快地遍歷這個陣列。事實上,你可以非常快速的遍歷在連續的記憶體塊中分配的任意資料結構。而如果你在資料結構中的項在記憶體中不是彼此相鄰的(如連結串列),你將得不到免費快取載入所帶來的優勢,並且在這些資料結構中的每一個項都可能會出現快取未命中。

如果存在這樣的場景,有多個執行緒操作不同的成員變數,但是相同的快取行,這個時候會發生什麼?。沒錯,偽共享(False Sharing)問題就發生了!有張 Disruptor 專案的經典示例圖,如下:

圖圖4

上圖中,一個執行在處理器 core1上的執行緒想要更新變數 X 的值,同時另外一個執行在處理器 core2 上的執行緒想要更新變數 Y 的值。但是,這兩個頻繁改動的變數都處於同一條快取行。兩個執行緒就會輪番傳送 RFO 訊息,佔得此快取行的擁有權。當 core1 取得了擁有權開始更新 X,則 core2 對應的快取行需要設為 I 狀態。當 core2 取得了擁有權開始更新 Y,則 core1 對應的快取行需要設為 I 狀態(失效態)。輪番奪取擁有權不但帶來大量的 RFO 訊息,而且如果某個執行緒需要讀此行資料時,L1 和 L2 快取上都是失效資料,只有 L3 快取上是同步好的資料。從前一篇我們知道,讀 L3 的資料非常影響效能。更壞的情況是跨槽讀取,L3 都要 miss,只能從記憶體上載入。

表面上 X 和 Y 都是被獨立執行緒操作的,而且兩操作之間也沒有任何關係。只不過它們共享了一個快取行,但所有競爭衝突都是來源於共享。