1. 程式人生 > >從Java視角理解系統結構(三)偽共享

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

從Java視角理解系統結構連載, 關注我的微博(連結)瞭解最新動態

從我的前一篇博文中, 我們知道了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的例子, 稍做修改,程式碼如下:

public final class FalseSharing implements Runnable {
	public static int NUM_THREADS = 4; // change
	public final static long ITERATIONS = 500L * 1000L * 1000L;
	private final int arrayIndex;
	private static VolatileLong[] longs;

	public FalseSharing(final int arrayIndex) {
		this.arrayIndex = arrayIndex;
	}

	public static void main(final String[] args) throws Exception {
		Thread.sleep(10000);
		System.out.println("starting....");
		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();
		System.out.println("duration = " + (System.nanoTime() - start));
	}

	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 FalseSharing(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; // 註釋
	}
}

程式碼的邏輯是預設4個執行緒修改一陣列不同元素的內容.  元素的型別是VolatileLong, 只有一個長整型成員value和6個沒用到的長整型成員. value設為volatile是為了讓value的修改所有執行緒都可見. 在一臺Westmere(Xeon E5620 8core*2)機器上跑一下看

$ java FalseSharing
starting....
duration = 9316356836

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

$ java FalseSharing
starting....
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事件就可以證明了,步驟如下:

# 設定捕捉L2快取IN事件
$ sudo  opcontrol --setup --event=L2_LINES_IN:100000
# 清空工作區
$ sudo opcontrol --reset
# 開始捕捉
$ sudo opcontrol --start
# 執行程式
$ java FalseSharing
# 程式跑完後, dump捕捉到的資料
$ sudo opcontrol --dump
# 停止捕捉
$ sudo opcontrol -h
# 報告結果
$ opreport -l `which java`

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

$ opreport -l `which java`
CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
samples  %        image name               symbol name
34085    99.8447  anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000) anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000)
51        0.1494  anon (tgid:16054 range:0x7fa485722000-0x7fa485992000) anon (tgid:16054 range:0x7fa485722000-0x7fa485992000)
2         0.0059  anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000)
快的版本:
$ opreport -l `which java`
CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
samples  %        image name               symbol name
22       88.0000  anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000) anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000)
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個長整型在編譯時優化掉, 可以在程式中加入一些程式碼防止被編譯優化。

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

另外, 由於Java的GC問題. 資料在記憶體和對應的CPU快取行的位置有可能發生變化, 所以在使用pad的時候應該注意GC的影響.

最後感謝同事撒迦長仁在Java物件記憶體佈局及Profile工具上給予的幫助.

2012年4月19日更新:
發現netty和grizzly的程式碼中的LinkedTransferQueue中都使用了PaddedAtomicReference<QNode>來代替原來的Node, 使用了補齊的辦法解決了佇列偽共享的問題. 不知道是不是JSR-166的人開發的, 看來他們早就意識到這個問題了. 但是從Doug Lea JSR-166的cvs看不到這個變化, 不知道究竟是誰改的? 他們的repository到底是在哪?

2012年5月19日更新:
為了區別Cache Coherence和Cache Consistency兩個概念, 不讓讀者混淆, 這裡把Cache Coherence改翻譯成快取相干性.


周忱。阿里巴巴技術專家,曾經負責淘寶Hadoop,Hive研發, Hive Contributor, 目前在做分散式實時計算