程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> JAVA綜合教程 >> 偽共享(false sharing),並發編程無聲的性能殺手,falsesharing

偽共享(false sharing),並發編程無聲的性能殺手,falsesharing

編輯:JAVA綜合教程

偽共享(false sharing),並發編程無聲的性能殺手,falsesharing


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

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

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

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

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

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

緩存行

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

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

遭遇偽共享

好的,那麼接下來我們就用 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個線程同時操作這條緩存行,於是偽共享就悄悄地發生了。

基於此,我們有理由相信,在一定線程數量范圍內(注意思考:為什麼強調是一定線程數量范圍內),隨著線程數量的增加,偽共享發生的頻率也越大,直觀體現就是執行時間越長。為了證實這個觀點,本人在同樣的機器上分別用單線程、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% 以上的命中率。

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

 

 

附錄

參考文章一:《從Java視角理解偽共享(False Sharing)》

參考文章二:《【翻譯】線程間偽共享的避免和識別》

參考文章三:《一種利用數據融合來提高局部性和減少偽共享的方法》

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved