硬件級多線程保障

存儲結構圖

在這裏插入圖片描述
對於現代CPU,廣告中我們經常可以聽到一個三級緩存的概念。其中第一級和第二級緩存,爲每個CPU核心獨享,第三級緩存,是所有CPU核心共享,緩存集成在CPU內部,讀取寫入速度非常快,但是容量很小
再往下一級便是我們的內存,容量大,讀寫速度相對較快,
再往下便是我們的磁盤, 容量可以非常大,但是讀寫速度比較一般。

那整個計算機存儲系統是如何工作的呢?假設我們CPU核心1執行到了某一條指令,需要讀入標識爲A的對象數據:

  • 核心1去自己的第一級緩存L0中嘗試獲取數據A,如果沒有則嘗試從L1中嘗試獲取,如果還是沒有,則依次往下進行嘗試查找,比如當在L3中找到數據A之後,將數據寫入L2,再讀入L1,再L0
  • 當下次還需要讀取數據A時,如果L0中還存在該數據,直接讀取,如果沒有了,重複步驟1

數據一致性

在這裏插入圖片描述
現代CPU一般都會多個核心,每個核心獨立工作,假設現有2個核心,分別開啓線程,將主內存中的變量X和Y分別讀進自己的一級二級緩存,執行對應的業務邏輯,如果程序運行中,CPU1將X值修改成了1,CPU2將X值修改成了2,當線程執行完畢,將X數據刷新進主存時,CPU1需要寫入的值是1,而CPU2需要寫入的值卻是2,產生數據不一致問題。

如何解決各個CPU核心之間的數據不一致問題呢?主要有以下兩種方式:

  • 總線鎖(bus lock)
  • 緩存一致性協議

總線鎖

概念:總線,也叫CPU總線,是CPU與芯片組成連接的主幹道,負責CPU與外界所有部件的通信,包括高速緩存,內存,向各個部件發送控制信號、尋址、數據傳輸等。

總線鎖:當CPU1需要執行某數據項時,其在總線上發出LOCK信號,這樣其他CPU,如CPU2將不能操作該數據項變量的內存地址緩存,以此阻塞住其他所有CPU核心,CPU1獨享此共享內存。

缺點:代價較大,在鎖定期間,其他CPU只能等待。

緩存一致性協議

概念:當CPU某一塊核心,修改了它自己的L1、L2緩存中的數據,通知其他CPU核心不要再使用它們內部的緩存,從主內存中重新讀取。

注意,它只是一個協議,每個CPU廠家的具體實現邏輯是不一樣的,有MESI,MSI等多種。以常用的Intel CPU爲例,使用的MESI協議,簡單介紹如下:
對緩存數據定義了以下這幾種狀態:

  • modified(修改):當前CPU對該緩存塊進行了修改,必須要寫回主存,其他CPU不能再緩存原有信息
  • exclusive(獨享):當前CPU獨享該緩存塊,其他CPU不能裝載該緩存塊
  • share(共享):該緩存塊同時被多個CPU裝載讀取,未被修改
  • invalid(無效):該緩存塊數據,已經被其他CPU修改了,當前CPU對該緩存塊的緩存失效,不能再使用

問題:

  1. 會涉及緩存行對齊的問題,下面會講
  2. 某些數據很大的時候,無法進行緩存,還是需要使用總線鎖

緩存行對齊

設想一下這樣一個問題,當CPU運行到某一條指令時,需要讀取一個變量a,那CPU讀取數據時,是否僅僅是讀取a這一條數據呢?CPU爲了提高效率,會在內存中一次性讀取一整行的數據(一個cache line),現在CPU一般是64個字節

還是以上面的圖示爲例:
在這裏插入圖片描述
現有x,y兩個數據在內存同一行,CPU有CPU1和CPU2兩個核心分別執行不同的計算任務,CPU1任務中只需要用到x,CPU2任務重只需要用到y。
下面就會有這樣的一種場景,CPU1和CPU2會同時將x,y兩條數據都緩存進自己的L1、L2,然後CPU1不停的修改x數據,根據緩存一致性協議,需要不停的通知CPU2,讓CPU2不要再使用x的緩存,需要到主存中重新獲取。但是實際上CPU2裏的任務根本就不要用到x,還不得不不間斷的從主存中加載x和y。返回來當CPU2修改了Y值,CPU1同樣也是有這樣的問題。

因緩存行問題,導致CPU的高速緩存區失去了作用,需要不停的讀寫主存。

小demo

在這裏插入圖片描述
在這裏插入圖片描述
現在有一個class A,內部定義一個變x,有兩個線程分別負責不停的修改A1和A2。兩個程序唯一不同的點就是class A,在第二次運行時,在變量x之前,加了7個long類型的變量。對比下兩次運行時間,可以看到第二次耗時明顯小於第一次。這是爲什麼呢?

我們看下第一次運行時內存情況:
在這裏插入圖片描述
數組合計佔16個字節,A1和A2這兩個對象會在一個緩存行裏,CPU1在讀取A1時會順帶着將A2頁讀取,CPU2在讀取A2時會順帶着將A1頁讀取。當CPU1不停修改A1時,需要通知CPU2去主存中讀取新的A1,同理當CPU2不停修改A2時,需要通知CPU1去主存中讀取新的A2,直接導致兩個CPU的高速緩存失效。

我們再來看下第二次運行時的情況:
在這裏插入圖片描述
第二次運行時,對象中合計有8個long類型字段,佔64字節,如此CPU不能同時讀取A1和A2,也即A1和A2在兩個緩存行中。
CPU1在讀取A1時,不會讀取到A2,同理CPU2在讀取A2時,不會讀取到A1,後續每次對變量X的修改,都在各個CPU的內部高速緩存中進行,所以執行效率大幅提升。

WCBuffer(write combining buffer)

當CPU執行存儲指令時,會優先試圖將數據寫入離CPU最近的L1,如果在L1上沒有命中緩存,則會依次去訪問L2,L3,主存,性能呈指數級下降。這種情況下該怎麼辦呢?這個時候就涉及到另一個緩存區:WCBuffer。

WCBuffer,也叫合併寫緩衝區。CPU會在L0緩衝區沒有命中,在請求L2緩衝區所有權過程中,將待寫入的數據,先寫入WCBuffer,其大小與一個cache line相同,64字節。該緩衝區允許CPU在訪問該緩衝區數據時同時執行其他指令。

後續對一個緩存行的寫操作,在寫操作執行提交到L2之前,會在WCBuffer合併寫。比如先將緩存行數據修改爲1,再修改爲2,再修改爲3,那提交到L2的數據將直接是最終數據3。

public class Main {

    private static final int ITERATIONS = Integer.MAX_VALUE;
    private static final int ITEMS = 1 << 24;
    private static final int MASK = ITEMS - 1;

    private static final byte[] arrayA = new byte[ITEMS];
    private static final byte[] arrayB = new byte[ITEMS];
    private static final byte[] arrayC = new byte[ITEMS];
    private static final byte[] arrayD = new byte[ITEMS];
    private static final byte[] arrayE = new byte[ITEMS];
    private static final byte[] arrayF = new byte[ITEMS];
    private static final byte[] arrayG = new byte[ITEMS];
    private static final byte[] arrayH = new byte[ITEMS];

    public static void main(final String[] args) {
        System.out.println("case1 運行時間 = " + runCaseOne());
        System.out.println("case2 運行時間 = " + runCaseTwo());
    }

    public static long runCaseOne() {
        long start = System.nanoTime();
        int i = ITERATIONS;

        while (--i != 0) {
            int slot = (i & MASK);
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
            arrayG[slot] = b;
            arrayH[slot] = b;
        }
        return System.nanoTime() - start;
    }

    public static long runCaseTwo() {
        long start = System.nanoTime();
        int i = ITERATIONS;
        while (--i != 0) {
            int slot = (i & MASK);
            byte b = (byte) i;
            arrayA[slot] = b;
            arrayB[slot] = b;
            arrayC[slot] = b;
            arrayG[slot] = b;
        }
        i = ITERATIONS;
        while (--i != 0) {
            int slot = (i & MASK);
            byte b = (byte) i;
            arrayD[slot] = b;
            arrayE[slot] = b;
            arrayF[slot] = b;
            arrayH[slot] = b;
        }
        return System.nanoTime() - start;
    }
}

本人電腦iMac,3 GHz Intel Core i5,輸出結果:
case1 運行時間 = 7358440377
case2 運行時間 = 5989994707

注:網上其他文檔裏面的數據差距可以達到1.5倍,但是case1和case2的單個運行時間是我電腦耗時的2倍,可能是我CPU運行比較快,差距沒那麼明顯。還有一些例子合計是6個數組,也是不對的。

看上面的代碼,是不是很困惑,明明case2的循環次數是case1的2倍,爲什麼case2的執行時間反而比case1短呢?

在這裏插入圖片描述
一個WCBuffer內64字節的緩衝區維護了一個64位的字段,每更新一個字節就會設置對應的位,來表示將緩衝區交換到外部緩存時哪些數據是有效的。

一個intel的CPU,在同一個時刻只能拿到4個WCBuffer緩衝區,在case1中需要連續寫入8個不同位置的內存,那麼連續更新數據寫滿緩衝區時,CPU就需要等待,將緩衝區的內容寫入L2,此時CPU是處於強制暫停狀態。一個循環內的剩餘數據,需要等待下一次填滿緩衝區。

而case2中,每一次循環正好是4個不同位置的內存,因合併寫緩衝區滿到引起的cpu暫停的次數會大大減少,實現了執行效率的提示。

這意味着,在一個循環中,我們不應該同時寫超過4個不同的內存位置,否則我們將可能不能享受到合併寫(write combining)的好處。

CPU亂序執行

處理器基本上會按照程序中書寫的機器指令的順序執行。按照書寫順序執行稱爲按序執行(In-Order )。按照書寫順序執行時,如果從內存讀取數據的加載指令、除法運算指令等延遲(等待結果的時間)較長的指令後面緊跟着使用該指令結果的指令,就會陷入長時間的等待。儘管這種情況無可奈何,但有時,再下一條指令並不依賴於前面那條延遲較長的指令,只要有了操作數就能執行。這種情況就會產生輸出結果和預計不符。

CPU 內存屏障

爲了避免在某些情況下出現CPU指令的亂序執行,便出現了CPU的內存屏障技術,注意這裏說的是CPU的內存屏障技術,不是JVM的內存屏障。各個品牌的CPU對屏障的定義和處理不一樣,還是以intel爲例:
intel有3中內存屏障:

  • sfence:store fence,寫屏障,在sfence指令前的寫操作必須在sfence指令後的寫操作前完成。
  • lfence:load fence,讀屏障,在lfence指令前的讀屏障操作必須在lfence指令後的讀操作前完成。
  • mfence:讀寫屏障,在mfence指令前的讀寫操作必須在mfence指令後的讀寫操作前完成。

是不是沒看懂啥意思?以sfence屏障指令爲例:
在這裏插入圖片描述
目前有兩條寫指令1和2,中間插入一條sfence,表示寫指令1必須先執行,然後再執行寫指令2,兩者不可以換順序。lfence和mfence同理可得。
彙編指令,“Lock”就是一個讀寫屏障,相信很多人反編譯Class文件後會看到有Lock指令,其實這就是JVM幫我們加的CPU內存屏障指令。

JVM內存屏障

JVM的內存屏障,一定是基於CPU內存屏障來的。JVM對內存讀寫屏障進行了組合:

  • LoadLoad屏障
  • StoreStore屏障
  • LoadStore屏障
  • StoreLoad屏障

以LoadLoad屏障爲例,其他幾個類似:
在這裏插入圖片描述
LoadLoad指令要求,必須Load1先執行,然後再執行Load2

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章