摘要:從視角理解系統結構連載關注我的微博鏈接了解最新動態從我的前一篇博文中我們知道了緩存及緩存行的概念同時用一個例子說明了編寫單線程代碼時應該注意的問題下面我們討論更為復雜而且更符合現實情況的多核編程時將會碰到的問題這些問題更容易犯連包作者大師的
從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狀態的緩存行, 很明顯此緩存沒有數據給它. 此時分兩種情況:
其它處理器的緩存里也沒有此行數據, 則從內存加載數據到此緩存行后,
再將它設成E狀態, 表示只有我一家有這條數據, 其它處理器都沒有
其它處理器的緩存有此行數據, 則將此緩存行的狀態設為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請求呢? 有以下兩種:
線程的工作從一個處理器移到另一個處理器, 它操作的所有緩存行都需要移到新的處理器上. 此后如果再寫緩存行, 則此緩存行在不同核上有多個拷貝, 需要發送RFO請求了.
兩個不同的處理器確實都需要操作相同的緩存行
由上一篇我們知道, 在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
$ 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工具上給予的幫助.
更新:
發現netty和grizzly的代碼中的LinkedTransferQueue中都使用了PaddedAtomicReference
by MinZhou via ifeve
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/64051.html
摘要:從視角理解系統結構連載關注我的微博鏈接了解最新動態眾所周知是計算機的大腦它負責執行程序的指令內存負責存數據包括程序自身數據同樣大家都知道內存比慢很多其實在年前的頻率和內存總線的頻率在同一個級別訪問內存只比訪問寄存器慢一點兒由于內存的發展受到 從Java視角理解系統結構連載, 關注我的微博(鏈接)了解最新動態 眾所周知, CPU是計算機的大腦, 它負責執行程序的指令; 內存負責存數據,...
摘要:本文是從視角理解系統結構連載文章在高性能編程時經常接觸到多線程起初我們的理解是多個線程并行地執行總比單個線程要快就像多個人一起干活總比一個人干要快然而實際情況是多線程之間需要競爭設備或者競爭鎖資源,導致往往執行速度還不如單個線程在這里有一個 本文是從Java視角理解系統結構連載文章 在高性能編程時,經常接觸到多線程. 起初我們的理解是, 多個線程并行地執行總比單個線程要快, 就像多個...
摘要:多線程技術是個很龐大的課題,編程思想這本書英文版,以下簡稱中也用了頁介紹的多線程體系。一個線程歸屬于唯一的進程,線程無法脫離進程而存在。五線程內數據線程的私有數據僅歸屬于一個線程,不在線程之間共享,例如,,。 多線程技術是個很龐大的課題,《Java編程思想》這本書(英文版,以下簡稱TIJ)中也用了136頁介紹Java的多線程體系。的確,Java語言發展到今天,多線程機制相比其他的語言從...
摘要:前半句是指線程內表現為串行的語義,后半句是指指令重排序現象和工作內存和主內存同步延遲現象。關于內存模型的講解請參考死磕同步系列之。目前國內市面上的關于內存屏障的講解基本不會超過這三篇文章,包括相關書籍中的介紹。問題 (1)volatile是如何保證可見性的? (2)volatile是如何禁止重排序的? (3)volatile的實現原理? (4)volatile的缺陷? 簡介 volatile...
摘要:前半句是指線程內表現為串行的語義,后半句是指指令重排序現象和工作內存和主內存同步延遲現象。關于內存模型的講解請參考死磕同步系列之。目前國內市面上的關于內存屏障的講解基本不會超過這三篇文章,包括相關書籍中的介紹。問題 (1)volatile是如何保證可見性的? (2)volatile是如何禁止重排序的? (3)volatile的實現原理? (4)volatile的缺陷? 簡介 volatile...
閱讀 2373·2021-11-22 14:56
閱讀 1179·2019-08-30 15:55
閱讀 3211·2019-08-29 13:29
閱讀 1358·2019-08-26 13:56
閱讀 3498·2019-08-26 13:37
閱讀 566·2019-08-26 13:33
閱讀 3354·2019-08-26 13:33
閱讀 2235·2019-08-26 13:33