在併發編程過程當中,咱們大部分的焦點都放在如何控制共享變量的訪問控制上(代碼層面),可是不多人會關注系統硬件及 JVM 底層相關的影響因素。前段時間學習了一個牛X的高性能異步處理框架 Disruptor,它被譽爲「最快的消息框架」,其 LMAX 架構可以在一個線程裏每秒處理 6百萬 訂單!在講到 Disruptor 爲何這麼快時,接觸到了一個概念——僞共享( false sharing ),其中提到:緩存行上的寫競爭是運行在 SMP 系統中並行線程實現可伸縮性最重要的限制因素。因爲從代碼中很難看出是否會出現僞共享,有人將其描述成無聲的性能殺手。html
本文僅針對目前所學進行合併整理,目前並沒有很是深刻地研究和實踐,但願對你們從零開始理解僞共享提供一些幫助。java
僞共享的非標準定義爲:緩存系統中是以緩存行(cache line)爲單位存儲的,當多線程修改互相獨立的變量時,若是這些變量共享同一個緩存行,就會無心中影響彼此的性能,這就是僞共享。編程
下面咱們就來詳細剖析僞共享產生的來龍去脈。首先,咱們要了解什麼是緩存系統。windows
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 給出了一些緩存未命中的消耗數據,以下所示:架構
從上一節中咱們知道,每一個核都有本身私有的 L1,、L2 緩存。那麼多線程編程時, 另一個核的線程想要訪問當前核內 L一、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 來進行實驗和佐證。
public class FalseShareTest implements Runnable { public static int NUM_THREADS = 4; public final static long ITERATIONS = 500L * 1000L * 1000L; private final int arrayIndex; private static VolatileLong[] longs; public static long SUM_TIME = 0l; public FalseShareTest(final int arrayIndex) { this.arrayIndex = arrayIndex; } public static void main(final String[] args) throws Exception { Thread.sleep(10000); for(int j=0; j<10; j++){ System.out.println(j); if (args.length == 1) { NUM_THREADS = Integer.parseInt(args[0]); } longs = new VolatileLong[NUM_THREADS]; for (int i = 0; i < longs.length; i++) { longs[i] = new VolatileLong(); } final long start = System.nanoTime(); runTest(); final long end = System.nanoTime(); SUM_TIME += end - start; } System.out.println("平均耗時:"+SUM_TIME/10); } private static void runTest() throws InterruptedException { Thread[] threads = new Thread[NUM_THREADS]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new FalseShareTest(i)); } for (Thread t : threads) { t.start(); } for (Thread t : threads) { t.join(); } } public void run() { long i = ITERATIONS + 1; while (0 != --i) { longs[arrayIndex].value = i; } } public final static class VolatileLong { public volatile long value = 0L; public long p1, p2, p3, p4, p5, p6; //屏蔽此行
} }
上述代碼的邏輯很簡單,就是四個線程修改一數組不一樣元素的內容。元素的類型是 VolatileLong,只有一個長整型成員 value 和 6 個沒用到的長整型成員。value 設爲 volatile 是爲了讓 value 的修改對全部線程均可見。程序分兩種狀況執行,第一種狀況爲不屏蔽倒數第三行(見"屏蔽此行"字樣),第二種狀況爲屏蔽倒數第三行。爲了"保證"數據的相對可靠性,程序取 10 次執行的平均時間。執行狀況以下(執行環境:32位 windows,四核,8GB 內存):
(不屏蔽) (屏蔽)
兩個邏輯如出一轍的程序,前者的耗時大概是後者的 2.5 倍,這太難以想象了!那麼這個時候,咱們再用僞共享(False Sharing)的理論來分析一下。前者 longs 數組的 4 個元素,因爲 VolatileLong 只有 1 個長整型成員,因此整個數組都將被加載至同一緩存行,但有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% 以上的命中率。
綜上所述,並非每一個系統都適合花大量精力去解決潛在的僞共享問題。
附錄
參考文章一:《從Java視角理解僞共享(False Sharing)》
參考文章二:《【翻譯】線程間僞共享的避免和識別》
參考文章三:《一種利用數據融合來提升局部性和減小僞共享的方法》