從Java視角理解系統結構(三)僞共享

      轉自 轉載自併發編程網 – ifeve.com http://ifeve.com/from-javaeye-false-sharing/

從我的前一篇博文中, 我們知道了CPU緩存及緩存行的概念, 同時用一個例子說明了編寫單線程Java代碼時應該注意的問題. 下面我們討論更爲複雜, 而且更符合現實情況的多核編程時將會碰到的問題. 這些問題更容易犯, 連j.u.c包作者Doug Lea大師的JDK代碼裏也存在這些問題.

MESI協議及RFO請求
前一篇我們知道, 典型的CPU微架構有3級緩存, 每個核都有自己私有的L1, L2緩存. 那麼多線程編程時, 另外一個核的線程想要訪問當前核內L1, L2 緩存行的數據, 該怎麼辦呢?
有人說可以通過第2個核直接訪問第1個核的緩存行. 這是可行的, 但這種方法不夠快. 跨核訪問需要通過Memory Controller(見上一篇的示意圖), 典型的情況是第2個核經常訪問第1個核的這條數據, 那麼每次都有跨核的消耗. 更糟的情況是, 有可能第2個核與第1個核不在一個插槽內.況且Memory Controller的總線帶寬是有限的, 扛不住這麼多數據傳輸. 所以, CPU設計者們更偏向於另一種辦法: 如果第2個核需要這份數據, 由第1個核直接把數據內容發過去, 數據只需要傳一次。

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

下面將詳細地解答以上問題. 首先我們需要談到一個協議–MESI協議(鏈接). 現在主流的處理器都是用它來保證緩存的相干性和內存的相干性. M,E,S和I代表使用MESI協議時緩存行所處的四個狀態:

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


上圖源自於內核開發者Ulrich Drepper著名的What Every Programmer Should Know About Memory一書(下載), 簡要地展示了緩存行的四種狀態轉換. 不過他的書中沒有說明白這四個狀態是怎麼轉換的, 下面我用小段文字來說明一下.

初始 一開始時, 緩存行沒有加載任何數據, 所以它處於I狀態.
本地寫(Local Write)如果本地處理器寫數據至處於I狀態的緩存行, 則緩存行的狀態變成M.
本地讀(Local Read) 如果本地處理器讀取處於I狀態的緩存行, 很明顯此緩存沒有數據給它. 此時分兩種情況: (1)其它處理器的緩存裏也沒有此行數據, 則從內存加載數據到此緩存行後, 再將它設成E狀態, 表示只有我一家有這條數據, 其它處理器都沒有 (2)其它處理器的緩存有此行數據, 則將此緩存行的狀態設爲S狀態.

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

以上只是列舉了一些狀態轉換, 爲下文做鋪墊. 如果全部描述,需要非常大量的文字, 大家參考這張圖就知道原因了, 可以通過此圖瞭解MESI協議更詳細的信息.

僞共享
我們從上節知道, 寫操作的代價很高, 特別當需要發送RFO消息時. 我們編寫程序時, 什麼時候會發生RFO請求呢? 有以下兩種:
1. 線程的工作從一個處理器移到另一個處理器, 它操作的所有緩存行都需要移到新的處理器上. 此後如果再寫緩存行, 則此緩存行在不同核上有多個拷貝, 需要發送RFO請求了.
2. 兩個不同的處理器確實都需要操作相同的緩存行

由上一篇我們知道, 在Java程序中,數組的成員在緩存中也是連續的. 其實從Java對象的相鄰成員變量也會加載到同一緩存行中. 如果多個線程操作不同的成員變量, 但是相同的緩存行, 僞共享(False Sharing)問題就發生了. 下面引用Disruptor項目Lead的博文中的示例圖和實驗例子(偷會懶,但會加上更詳細的profile方法).

一個運行在處理器core 1上的線程想要更新變量X的值, 同時另外一個運行在處理器core 2上的線程想要更新變量Y的值. 但是, 這兩個頻繁改動的變量都處於同一條緩存行. 兩個線程就會輪番發送RFO消息, 佔得此緩存行的擁有權. 當core 1取得了擁有權開始更新X, 則core 2對應的緩存行需要設爲I狀態. 當core 2取得了擁有權開始更新Y, 則core 1對應的緩存行需要設爲I狀態(失效態). 輪番奪取擁有權不但帶來大量的RFO消息, 而且如果某個線程需要讀此行數據時, L1和L2緩存上都是失效數據, 只有L3緩存上是同步好的數據.從前一篇我們知道, 讀L3的數據非常影響性能. 更壞的情況是跨槽讀取, L3都要miss,只能從內存上加載.
表面上X和Y都是被獨立線程操作的, 而且兩操作之間也沒有任何關係.只不過它們共享了一個緩存行, 但所有競爭衝突都是來源於共享.

實驗及分析
引用Martin的例子, 稍做修改,代碼如下:

01 public final class FalseSharing implements Runnable {
02     public static int NUM_THREADS = 4// change
03     public final static long ITERATIONS = 500L * 1000L * 1000L;
04     private final int arrayIndex;
05     private static VolatileLong[] longs;
06  
07     public FalseSharing(final int arrayIndex) {
08         this.arrayIndex = arrayIndex;
09     }
10  
11     public static void main(final String[] args) throws Exception {
12         Thread.sleep(10000);
13         System.out.println("starting....");
14         if (args.length == 1) {
15             NUM_THREADS = Integer.parseInt(args[0]);
16         }
17  
18         longs = new VolatileLong[NUM_THREADS];
19         for (int i = 0; i < longs.length; i++) {
20             longs[i] = new VolatileLong();
21         }
22         final long start = System.nanoTime();
23         runTest();
24         System.out.println("duration = " + (System.nanoTime() - start));
25     }
26  
27     private static void runTest() throws InterruptedException {
28         Thread[] threads = new Thread[NUM_THREADS];
29         for (int i = 0; i < threads.length; i++) {
30             threads[i] = new Thread(new FalseSharing(i));
31         }
32         for (Thread t : threads) {
33             t.start();
34         }
35         for (Thread t : threads) {
36             t.join();
37         }
38     }
39  
40     public void run() {
41         long i = ITERATIONS + 1;
42         while (0 != --i) {
43             longs[arrayIndex].value = i;
44         }
45     }
46  
47     public final static class VolatileLong {
48         public volatile long value = 0L;
49         public long p1, p2, p3, p4, p5, p6; // 註釋
50     }
51 }

代碼的邏輯是默認4個線程修改一數組不同元素的內容.  元素的類型是VolatileLong, 只有一個長整型成員value和6個沒用到的長整型成員. value設爲volatile是爲了讓value的修改所有線程都可見. 在一臺Westmere(Xeon E5620 8core*2)機器上跑一下看

1 $ java FalseSharing
2 starting....
3 duration = 9316356836

把以上代碼49行註釋掉, 看看結果:

1 $ java FalseSharing
2 starting....
3 duration = 59791968514

兩個邏輯一模一樣的程序, 前者只需要9秒, 後者跑了將近一分鐘, 這太不可思議了! 我們用僞共享(False Sharing)的理論來分析一下. 後面的那個程序longs數組的4個元素,由於VolatileLong只有1個長整型成員, 所以整個數組都將被加載至同一緩存行, 但有4個線程同時操作這條緩存行, 於是僞共享就悄悄地發生了. 讀者可以測試一下2,4,8, 16個線程分別操作時分別是什麼效果, 什麼樣的趨勢.

那麼怎麼避免僞共享呢? 我們未註釋的代碼就告訴了我們方法. 我們知道一條緩存行有64字節, 而Java程序的對象頭固定佔8字節(32位系統)或12字節(64位系統默認開啓壓縮, 不開壓縮爲16字節), 詳情見 鏈接. 我們只需要填6個無用的長整型補上6*8=48字節, 讓不同的VolatileLong對象處於不同的緩存行, 就可以避免僞共享了(64位系統超過緩存行的64字節也無所謂,只要保證不同線程不要操作同一緩存行就可以). 這個辦法叫做補齊(Padding).

如何從系統層面觀察到這種優化是切實有效的呢? 很可惜, 由於很多計算機的微架構不同, 我們沒有工具來直接探測僞共享事件(包括Intel Vtune和Valgrind). 所有的工具都是從側面來發現的, 下面通過Linux利器OProfile來證明一下. 上面的程序的數組只是佔64 * 4 = 256字節, 而且在連續的物理空間, 照理來說數據會在L1緩存上就命中, 肯定不會傳入到L2緩存中, 只有在僞共享發生時纔會出現. 於是, 我們可以通過觀察L2緩存的IN事件就可以證明了,步驟如下:

01 # 設置捕捉L2緩存IN事件
02 $ sudo  opcontrol --setup --event=L2_LINES_IN:100000
03 # 清空工作區
04 $ sudo opcontrol --reset
05 # 開始捕捉
06 $ sudo opcontrol --start
07 # 運行程序
08 $ java FalseSharing
09 # 程序跑完後, dump捕捉到的數據
10 $ sudo opcontrol --dump
11 # 停止捕捉
12 $ sudo opcontrol -h
13 # 報告結果
14 $ opreport -l `which java`

比較一下兩個版本的結果, 慢的版本:

1 $ opreport -l `which java`
2 CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
3 Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
4 samples  %        image name               symbol name
5 34085    99.8447  anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000) anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000)
6 51        0.1494  anon (tgid:16054 range:0x7fa485722000-0x7fa485992000) anon (tgid:16054 range:0x7fa485722000-0x7fa485992000)
7 2         0.0059  anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000)
快的版本:
1 $ opreport -l `which java`
2 CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
3 Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
4 samples  %        image name               symbol name
5 22       88.0000  anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000) anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000)
6 3        12.0000  anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000)

慢的版本由於False Sharing引發的L2緩存IN事件達34085次, 而快版本的爲0次.

總結
僞共享在多核編程中很容易發生, 而且比較隱蔽. 例如, 在JDK的LinkedBlockingQueue中, 存在指向隊列頭的引用head和指向隊列尾的引用last. 而這種隊列經常在異步編程中使有,這兩個引用的值經常的被不同的線程修改, 但它們卻很可能在同一個緩存行, 於是就產生了僞共享. 線程越多, 核越多,對性能產生的負面效果就越大.
某些Java編譯器會將沒有使用到的補齊數據, 即示例代碼中的6個長整型在編譯時優化掉, 可以在程序中加入一些代碼防止被編譯優化。

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

另外, 由於Java的GC問題. 數據在內存和對應的CPU緩存行的位置有可能發生變化, 所以在使用pad的時候應該注意GC的影響.


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