僞共享(false sharing)

僞共享(false sharing),併發編程無聲的性能殺手

在併發編程過程中,我們大部分的焦點都放在如何控制共享變量的訪問控制上(代碼層面),但是很少人會關注系統硬件及 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% 的數據需要從內存中查詢。

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

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

Martin Thompson 給出了一些緩存未命中的消耗數據,如下所示:

二、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的過程將給寫操作帶來很大的性能消耗。

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

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

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

接下來,我們要了解什麼是緩存行。

三、緩存行

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

如果存在這樣的場景,有多個線程操作不同的成員變量,但是相同的緩存行,這個時候會發生什麼?。沒錯,僞共享(False Sharing)問題就發生了!有張 Disruptor 項目的經典示例圖,如下:

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

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

四、遭遇僞共享

好的,那麼接下來我們就用 code 來進行實驗和佐證。

四個線程修改一數組不同元素的內容。元素的類型是 VolatileLong,只有一個長整型成員 value 和 6 個沒用到的長整型成員。value 設爲 volatile 是爲了讓 value 的修改對所有線程都可見。程序分兩種情況執行,第一種情況爲不屏蔽倒數第三行(見"屏蔽此行"字樣),第二種情況爲屏蔽倒數第三行。爲了"保證"數據的相對可靠性,程序取 10 次執行的平均時間。執行情況如下(執行環境:32位 windows,四核,8GB 內存):

         

(不屏蔽)                                                          (屏蔽)

兩個邏輯一模一樣的程序,前者的耗時大概是後者的 2.5 倍,這太不可思議了!那麼這個時候,我們再用僞共享(False Sharing)的理論來分析一下。前者 longs 數組的 4 個元素,由於 VolatileLong 只有 1 個長整型成員,所以整個數組都將被加載至同一緩存行,但有4個線程同時操作這條緩存行,於是僞共享就悄悄地發生了。

基於此,我們有理由相信,在一定線程數量範圍內(注意思考:爲什麼強調是一定線程數量範圍內),隨着線程數量的增加,僞共享發生的頻率也越大,直觀體現就是執行時間越長。爲了證實這個觀點,本人在同樣的機器上分別用單線程、2、4、8個線程,對有填充和無填充兩種情況進行測試。執行場景是取 10 次執行的平均時間,結果如下所示:

五、如何避免僞共享?

其中一個解決思路,就是讓不同線程操作的對象處於不同的緩存行即可。

那麼該如何做到呢?其實在我們註釋的那行代碼中就有答案,那就是緩存行填充(Padding) 。現在分析上面的例子,我們知道一條緩存行有 64 字節,而 Java 程序的對象頭固定佔 8 字節(32位系統)或 12 字節( 64 位系統默認開啓壓縮, 不開壓縮爲 16 字節),所以我們只需要填 6 個無用的長整型補上6*8=48字節,讓不同的 VolatileLong 對象處於不同的緩存行,就避免了僞共享( 64 位系統超過緩存行的 64 字節也無所謂,只要保證不同線程不操作同一緩存行就可以)。

僞共享在多核編程中很容易發生,而且非常隱蔽。例如,在 JDK 的 LinkedBlockingQueue 中,存在指向隊列頭的引用 head 和指向隊列尾的引用 tail 。而這種隊列經常在異步編程中使有,這兩個引用的值經常的被不同的線程修改,但它們卻很可能在同一個緩存行,於是就產生了僞共享。線程越多,核越多,對性能產生的負面效果就越大。

由於某些 Java 編譯器的優化策略,那些沒有使用到的補齊數據可能會在編譯期間被優化掉,我們可以在程序中加入一些代碼防止被編譯優化。如下:

public static long preventFromOptimization(VolatileLong v) { return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;
}

另外一種技術是使用編譯指示,來強制使每一個變量對齊。

下面的代碼顯式了編譯器使用__declspec( align(n) ) 此處 n=64,按照 cache line 邊界對齊。

__declspec (align(64)) int thread1_global_variable; __declspec (align(64)) int thread2_global_variable;

當使用數組時,在 cache line 尾部填充 padding 來保證數據元素在 cache line 邊界開始。如果不能夠保證數組按照 cache line 邊界對齊,填充數據結構【數組元素】使之是 cache line 大小的兩倍。下面的代碼顯式了填充數據結構使之按照 cache line 對齊。並且通過 __declspec( align(n) ) 語句來保證數組也是對齊的。如果數組是動態分配的,你可以增加分配的大小,並調整指針來對其到 cache line 邊界。

struct ThreadParams { // For the following 4 variables: 4*4 = 16 bytes unsigned long thread_id; unsigned long v; // Frequent read/write access variable unsigned long start; unsigned long end; // expand to 64 bytes to avoid false-sharing // (4 unsigned long variables + 12 padding)*4 = 64 int padding[12]; };

除此之外,在網上還有很多對僞共享的研究,提出了一些基於數據融合的方案,有興趣的同學可以瞭解下。

六、對於僞共享,我們在實際開發中該怎麼做?

通過上面大篇幅的介紹,我們已經知道僞共享的對程序的影響。那麼,在實際的生產開發過程中,我們一定要通過緩存行填充去解決掉潛在的僞共享問題嗎?

其實並不一定。

首先就是多次強調的,僞共享是很隱蔽的,我們暫時無法從系統層面上通過工具來探測僞共享事件。其次,不同類型的計算機具有不同的微架構(如 32 位系統和 64 位系統的 java 對象所佔自己數就不一樣),如果設計到跨平臺的設計,那就更難以把握了,一個確切的填充方案只適用於一個特定的操作系統。還有,緩存的資源是有限的,如果填充會浪費珍貴的 cache 資源,並不適合大範圍應用。最後,目前主流的 Intel 微架構 CPU 的 L1 緩存,已能夠達到 80% 以上的命中率。

綜上所述,並不是每個系統都適合花大量精力去解決潛在的僞共享問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章