簡述偽共享和快取一致性MESI

huansky發表於2022-01-10

什麼是偽共享

計算機系統中為了解決主記憶體與CPU執行速度的差距,在CPU與主記憶體之間新增了一級或者多級高速緩衝儲存器(Cache),這個Cache一般是整合到CPU內部的,所以也叫 CPU Cache,如下圖是兩級cache結構:

  

Cache內部是按行儲存的,其中每一行稱為一個cache行,cache行是Cache與主記憶體進行資料交換的單位,cache行的大小一般為2的冪次數字節。

 

當 CPU 訪問某一個變數時候,首先會去看 CPU Cache 內是否有該變數,如果有則直接從中獲取,否者就去主記憶體裡面獲取該變數,然後把該變數所在記憶體區域的一個Cache行大小的記憶體拷貝到 Cache(cache行是Cache與主記憶體進行資料交換的單位)。由於存放到 Cache 行的的是記憶體塊而不是單個變數,所以可能會把多個變數存放到了一個cache行。當多個執行緒同時修改一個快取行裡面的多個變數時候,由於同時只能有一個執行緒操作快取行,所以相比每個變數放到一個快取行效能會有所下降,這就是偽共享。

如上圖變數x,y同時被放到了CPU的一級和二級快取,當執行緒1使用CPU1對變數x進行更新時候,首先會修改cpu1的一級快取變數x所在快取行,這時候快取一致性協議會導致cpu2中變數x對應的快取行失效,那麼執行緒2寫入變數x的時候就只能去二級快取去查詢,這就破壞了一級快取,而一級快取比二級快取更快。更壞的情況下如果cpu只有一級快取,那麼會導致頻繁的直接訪問主記憶體。

為何會出現偽共享

偽共享的產生是因為多個變數被放入了一個快取行,並且多個執行緒同時去寫入快取行中不同變數。那麼為何多個變數會被放入一個快取行那。其實是因為Cache與記憶體交換資料的單位就是Cache,當CPU要訪問的變數沒有在Cache命中時候,根據程式執行的區域性性原理會把該變數在記憶體中大小為Cache行的記憶體放如快取行。

long a;
long b;
long c;
long d;

如上程式碼,宣告瞭四個long變數,假設cache行的大小為32個位元組,那麼當cpu訪問變數a時候發現該變數沒有在cache命中,那麼就會去主記憶體把變數a以及記憶體地址附近的b,c,d放入快取行。也就是地址連續的多個變數才有可能會被放到一個快取行中,當建立陣列時候,陣列裡面的多個元素就會被放入到同一個快取行。那麼單執行緒下多個變數放入快取行對效能有影響?其實正常情況下單執行緒訪問時候由於陣列元素被放入到了一個或者多個cache行對程式碼執行是有利的,因為資料都在快取中,程式碼執行會更快,可以對比下面程式碼執行:

程式碼(1):

public class TestForContent {

    static final int LINE_NUM = 1024;
    static final int COLUM_NUM = 1024;
    public static void main(String[] args) {
        
        long [][] array = new long[LINE_NUM][COLUM_NUM];
        
        long startTime = System.currentTimeMillis();
        for(int i =0;i<LINE_NUM;++i){
            for(int j=0;j<COLUM_NUM;++j){
                array[i][j] = i*2+j;
            }
        }
        long endTime = System.currentTimeMillis();
        long cacheTime = endTime - startTime;
        System.out.println("cache time:" + cacheTime);
    }
}

程式碼(2):

public class TestForContent2 {

    static final int LINE_NUM = 1024;
    static final int COLUM_NUM = 1024;
    public static void main(String[] args) {
        
        long [][] array = new long[LINE_NUM][COLUM_NUM];

        long startTime = System.currentTimeMillis();
        for(int i =0;i<COLUM_NUM;++i){
            for(int j=0;j<LINE_NUM;++j){
                array[j][i] = i*2+j;
            }
        }
        long endTime = System.currentTimeMillis();
        System.out.println("no cache time:" + (endTime - startTime));
    }
}

筆者mac電腦上執行程式碼(1)多次耗時均在10ms一下,執行程式碼(2)多次耗時均在10ms以上。總結下來是說程式碼(1)比程式碼(2)執行的快,這是因為陣列內陣列元素之間記憶體地址是連續的,當訪問陣列第一個元素時候,會把第一個元素後續若干元素一塊放入到cache行,這樣順序訪問陣列元素時候會在cache中直接命中,就不會去主記憶體讀取,後續訪問也是這樣。總結下也就是當順序訪問陣列裡面元素時候,如果當前元素在cache沒有命中,那麼會從主記憶體一下子讀取後續若干個元素到cache,也就是一次訪問記憶體可以讓後面多次直接在cache命中。而程式碼(2)是跳躍式訪問陣列元素的,而不是順序的,這破壞了程式訪問的區域性性原理,並且cache是有容量控制的,cache滿了會根據一定淘汰演算法替換cache行,會導致從記憶體置換過來的cache行的元素還沒等到讀取就被替換掉了。

所以單個執行緒下順序修改一個cache行中的多個變數,是充分利用了程式執行區域性性原理,會加速程式的執行,而多執行緒下併發修改一個cache行中的多個變數而就會進行競爭cache行,降低程式執行效能。

如何避免偽共享

JDK8之前一般都是通過位元組填充的方式來避免,也就是建立一個變數的時候使用填充欄位填充該變數所在的快取行,這樣就避免了多個變數存在同一個快取行,如下程式碼:

      public final static class FilledLong {
            public volatile long value = 0L;
            public long p1, p2, p3, p4, p5, p6;     
        }

假如Cache行為64個位元組,那麼我們在FilledLong類裡面填充了6個long型別變數,每個long型別佔用8個位元組,加上value變數的8個位元組總共56個位元組,另外這裡FilledLong是一個類物件,而類物件的位元組碼的物件頭佔用了8個位元組,所以當new一個FilledLong物件時候實際會佔用64個位元組的記憶體,這個正好可以放入Cache的一個行。

在JDK8中提供了一個sun.misc.Contended註解,用來解決偽共享問題,上面程式碼可以修改為如下:

    @sun.misc.Contended 
      public final static class FilledLong {
            public volatile long value = 0L;
        }

上面是修飾類的,當然也可以修飾變數,比如Thread類中的使用:

    /** The current seed for a ThreadLocalRandom */
    @sun.misc.Contended("tlr")
    long threadLocalRandomSeed;

    /** Probe hash value; nonzero if threadLocalRandomSeed initialized */
    @sun.misc.Contended("tlr")
    int threadLocalRandomProbe;

    /** Secondary seed isolated from public ThreadLocalRandom sequence */
    @sun.misc.Contended("tlr")
    int threadLocalRandomSecondarySeed;

多核CPU多級快取一致性協議MESI

多核CPU的情況下有多個一級快取,如何保證快取內部資料的一致,不讓系統資料混亂。這裡就引出了一個一致性的協議MESI。

MESI協議快取狀態

MESI 是指4中狀態的首字母。每個Cache line有4個狀態,可用2個bit表示,它們分別是:

快取行(Cache line):快取儲存資料的單元。

狀態描述監聽任務
M 修改 (Modified) 該Cache line有效,資料被修改了,和記憶體中的資料不一致,資料只存在於本Cache中。 快取行必須時刻監聽所有試圖讀該快取行相對就主存的操作,這種操作必須在快取將該快取行寫回主存並將狀態變成S(共享)狀態之前被延遲執行。
E 獨享、互斥 (Exclusive) 該Cache line有效,資料和記憶體中的資料一致,資料只存在於本Cache中。 快取行也必須監聽其它快取讀主存中該快取行的操作,一旦有這種操作,該快取行需要變成S(共享)狀態。
S 共享 (Shared) 該Cache line有效,資料和記憶體中的資料一致,資料存在於很多Cache中。 快取行也必須監聽其它快取使該快取行無效或者獨享該快取行的請求,並將該快取行變成無效(Invalid)。
I 無效 (Invalid) 該Cache line無效。

注意:

對於M和E狀態而言總是精確的,他們在和該快取行的真正狀態是一致的,而S狀態可能是非一致的。如果一個快取將處於S狀態的快取行作廢了,而另一個快取實際上可能已經獨享了該快取行,但是該快取卻不會將該快取行升遷為E狀態,這是因為其它快取不會廣播他們作廢掉該快取行的通知,同樣由於快取並沒有儲存該快取行的copy的數量,因此(即使有這種通知)也沒有辦法確定自己是否已經獨享了該快取行。

從上面的意義看來E狀態是一種投機性的優化:如果一個CPU想修改一個處於S狀態的快取行,匯流排事務需要將所有該快取行的copy變成invalid狀態,而修改E狀態的快取不需要使用匯流排事務。

假使有一個資料 int a = 1,這個資料被兩個執行緒讀取到了,執行緒1在 cpu 核心1上面執行,執行緒 2 在 cpu核心2上面執行,此時資料a的狀態在cup核心1和cpu核心2上面就是S(Shared)共享的,執行緒1執行指 “a=a+1”,此時資料 a 在 cpu 核心1中的狀態就是 M(Modified)修改的,資料a在cpu核心2上面的狀態就變成了I(Invalid)失效的,此時如果cpu核心2再去讀取a的資料,會發現a資料的狀態是Invalid,那麼就會直接去記憶體讀取。

如果資料 a,只在 cpu 核心1的快取記憶體裡面,而在cpu核心2的快取記憶體裡面沒有,此時資料 a 在cpu核心1中就是E(Exclusive)獨佔的。cpu是怎麼更新這4種狀態的呢?

如果每個cpu核心都要與其他 cpu 核心互動這樣的複雜度就是N2,而cpu核心不止與其他cpu核心通訊還要與一些記憶體等等資料通訊,這樣複雜度會很高。

如果有一根匯流排,所有的 cpu 都與這根匯流排通訊,複雜度就會降低很多,而真實的cpu的核心也是這樣的,最新的Intel處理器中,有一種快速通道互聯的技術(如果你是搞軟體的,我覺得了解到這裡就夠了,沒必要再去研究什麼是快速通道互聯技術)。

 

參考文章

CPU快取一致性協議MESI

偽共享

相關文章