一段程式碼,兩倍時差,直擊併發程式設計偽共享

jstarseven發表於2021-01-29

一、前言

【閒話開篇】:這段時間專案接近尾聲,我終於閒了一點,又拿起了早先未看完的書《JAVA高併發程式設計》,強迫自己學習。看到其中介紹《無鎖的快取框架:Disruptor》時,接觸到了一個概念——偽共享(false sharing),說是會影響併發程式的執行效能,被很多人描述成無聲的效能殺手,突然感覺到了自己知識的匱乏,罪過啊。

偽共享(false sharing),究竟是怎樣一回事呢?不急,我們先倒杯水邊喝邊回顧,以前上學時丟下的計算機組成原理相關知識點。

偽共享

二、概念解析

CPU 快取(三級)

CPU 快取(Cache Memory)是位於CPU與記憶體之間的臨時儲存器,它的容量比記憶體小很多,但是交換速度卻比記憶體要快得多。CPU和主記憶體之間有好幾級快取,CPU快取可以分為一級快取,二級快取,部分高階CPU還具有三級快取。每一級快取中所儲存的全部資料都是下一級快取的一部分,越靠近 CPU 的快取越快也越小。

快取記憶體的出現主要是為了解決CPU運算速度與記憶體讀寫速度不匹配的矛盾,因為CPU運算速度要比記憶體讀寫速度快很多,這樣會使 CPU 花費很長時間等待資料到來或把資料寫入記憶體。在快取中的資料是記憶體中的一小部分,但這一小部分是短時間內CPU即將訪問的,當CPU呼叫大量資料時,就可避開記憶體直接從快取中呼叫,從而加快讀取速度。

如果我們的程式正在多次對同一資料塊做相同的運算,那麼在執行運算的時候把它載入到離 CPU 很近的快取中就能大大的提高程式執行速度。

我們以L1、L2、L3分別表示一級快取、二級快取、三級快取,按照資料讀取順序和與CPU結合的緊密程度,速度是L1 >L2 > L3 >主存,容量是L1< L2< L3< 主存。

L1 快取很小但是很快,並且緊靠著在使用它的 CPU 核心,L2 大一些,也慢一些,並且仍然只能被一個單獨的 CPU 核使用,L3 更大,更慢,並且被單個插槽上的所有 CPU 核共享,最後是主存,由全部插槽上的所有 CPU 核共享。擁有三級快取的的 CPU,到三級快取時能夠達到95%的命中率,只有不到5%的資料需要從記憶體中查詢。
三級快取示意圖:

偽共享

快取行

快取行 (Cache Line) 是 CPU 快取中的最小單位,CPU 快取由若干快取行組成,一個快取行的大小通常是 64 位元組(備註:取決於 CPU,本文基於64位元組,其他長度的如32位元組等本文不作討論),並且它有效地引用主記憶體中的一塊地址。一個 Java 的 long 型別是 8 位元組,因此在一個快取行中可以存 8 個 long 型別的變數。
所以,如果你訪問一個 long 陣列,當陣列中的一個值被載入到快取中,它會額外載入另外 7 個,以致你能非常快地遍歷這個陣列。事實上,你可以非常快速的遍歷在連續的記憶體塊中分配的任意資料結構。而如果你在資料結構中的項在記憶體中不是彼此相鄰的(如連結串列),你將得不到快取載入所帶來的優勢,並且在這些資料結構中的每一項都可能會出現快取未命中的情況。

MESI 協議

MESI 協議是基於Invalidate的快取記憶體一致性協議,並且是支援回寫快取記憶體的最常用協議之一。

快取行狀態

CPU 的快取是以快取行(cache line)為單位的,MESI協議描述了多核處理器中一個快取行的狀態。(現在主流的處理器都是用它來保證快取的相干性和記憶體的相干性。)

在MESI協議中,每個快取行有4個狀態,分別是:

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

狀態轉換

在 MESI 協議中,每個Cache的Cache控制器不僅知道自己的讀寫操作,而且也監聽其它Cache的讀寫操作。每個Cache line所處的狀態根據本核和其它核的讀寫操作在4個狀態間進行遷移。MESI 協議狀態遷移圖如下:

偽共享

初始:一開始時,快取行沒有載入任何資料,所以它處於 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的過程將給寫操作帶來很大的效能消耗。

偽共享

瞭解了上述一些概念之後,我們們提出一個疑問?如果有多個執行緒操作不同的成員變數,但它們是相同的快取行,這個時候會發生什麼?

偽共享

沒錯,偽共享(False Sharing)問題就發生了!我們們來看一張經典的CPU 快取行示意圖:

偽共享

註釋:一個執行在處理器 core1上的執行緒想要更新變數 X 的值,同時另外一個執行在處理器 core2 上的執行緒想要更新變數 Y 的值。
但是,這兩個頻繁改動的變數都處於同一條快取行。兩個執行緒就會輪番傳送 RFO (Request For Owner) 訊息,佔得此快取行的擁有
權。當 core1 取得了擁有權開始更新 X,則 core2 對應的快取行需要設為 I 狀態(失效態)。當 core2 取得了擁有權開始更新 Y,
則core1	對應的快取行需要設為 I 狀態(失效態)。輪番奪取擁有權不但帶來大量的 RFO 訊息,而且如果某個執行緒需要讀此行資料時,
L1 和 L2 快取上都是失效資料,只有L3快取上是同步好的資料。從前面的內容我們知道,讀L3的資料會影響效能,更壞的情況是跨槽
讀取,L3 都出現快取未命中,只能從主存上載入。

舉例說明:

我們們以Java裡面的ArrayBlockingQueue為例採用生產消費模型說明,ArrayBlockingQueue有三個成員變數:

	- takeIndex:需要被取走的元素下標 
	- putIndex:可被元素插入的位置的下標 
	- count:佇列中元素的數量

這三個變數很容易放到一個快取行中,但是修改並沒有太多的關聯。所以每次修改,都會使之前快取的資料失效,從而不能完全達到共享的效果。

偽共享

當生產者執行緒put一個元素到ArrayBlockingQueue時,putIndex會修改,從而導致消費者執行緒的快取中的快取行無效,需要向上重新讀取,這種無法充分使用快取行特性的現象,稱為偽共享。

看到此處,我們可以自行總結,關於偽共享給出一個非標準的定義
CPU 快取系統中是以快取行為單位儲存的,當多執行緒修改互相獨立的變數時,如果這些變數共享同一個快取行,就會無意中影響彼此的效能,這就是偽共享。

三、程式模擬

程式用四個執行緒修改一陣列不同元素的內容,元素型別為 VolatileLong,包含一個長整型成員 value 和 6 個沒用到的長整型成員,value 設為 volatile 是為了讓 value 的修改對所有執行緒都可見。主要程式碼如下:

public class FalseShare 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 FalseShare(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    private static void exeTest() throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseShare(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;     //快取行填充
    }

    public static void main(final String[] args) throws Exception {
        for (int j = 0; j < 10; j++) {
            System.out.println("第" + j + "次...");
            longs = new VolatileLong[NUM_THREADS];
            for (int i = 0; i < longs.length; i++) {
                longs[i] = new VolatileLong();
            }
            long start = System.nanoTime();
            exeTest();
            long end = System.nanoTime();
            SUM_TIME += end - start;
        }
        System.out.println("平均耗時:" + SUM_TIME / 10);
    }
}

第一次執行:

//        public long p1, p2, p3, p4, p5, p6;     //快取行填充

第二次執行:

          public long p1, p2, p3, p4, p5, p6;     //快取行填充

程式每次執行,迴圈10次,取平均耗時,耗時結果如下:

第一次:
平均耗時:28305116160

第二次:
平均耗時:14071204270

【例項說明】:一個快取行有 64 位元組,一個long佔8個位元組,而 Java 程式的物件頭固定佔 8 位元組(32位系統)或 12 位元組( 64 位系統預設開啟壓縮, 不開壓縮為 16 位元組),所以我們只需要填 6個無用的長整型補上6*8=48位元組,讓不同的 VolatileLong 物件處於不同的快取行,就避免了偽共享( 64 位系統超過快取行的 64 位元組也無所謂,只要保證不同執行緒不操作同一快取行就可以了)。

四、偽共享避免

1.快取行填充(讓不同執行緒操作的物件處於不同的快取行)

1)快取行手動填充

2)使用Contended註解自動進行填充

2.使用編譯指示,來強制每一個變數對齊(略)

五、總結

1)CPU 快取是以快取行為單位進行操作的,產生偽共享問題的根源在於不同的CPU核同時操作同一個快取行;
2)可以通過快取行填充來解決偽共享問題,且Java8 中引入了@sun.misc.Contended註解來自動填充;
3)一個快取行有四種狀態,分別為:M(修改)E(專有)S(共享)I(無效);
4)每個快取行所處的狀態根據本核和其它核的讀寫操作在4個狀態間進行遷移;
5)不是所有的場景都需要解決偽共享問題,因為CPU快取是有限的,填充會犧牲掉一部分快取;

over

偽共享

相關文章