基礎知識-指令重排序

指令重排序

對主存的一次訪問一般花費硬件的數百次時鐘週期。處理器通過緩存(caching)能夠從數量級上降低內存延遲的成本這些緩存爲了性能重新排列待定內存操作的順序。也就是說,程序的讀寫操作不一定會按照它要求處理器的順序執行。

重排序的背景

我們知道現代CPU的主頻越來越高,與cache的交互次數也越來越多。當CPU的計算速度遠遠超過訪問cache時,會產生cache wait,過多的cache wait就會造成性能瓶頸。
針對這種情況,多數架構(包括X86)採用了一種將cache分片的解決方案,即將一塊cache劃分成互不關聯地多個 slots (邏輯存儲單元,又名 Memory Bank 或 Cache Bank),CPU可以自行選擇在多個 idle bank 中進行存取。這種 SMP 的設計,顯著提高了CPU的並行處理能力,也迴避了cache訪問瓶頸。

重排序的種類

編譯期重排。編譯源代碼時,編譯器依據對上下文的分析,對指令進行重排序,以之更適合於CPU的並行執行。
運行期重排,CPU在執行過程中,動態分析依賴部件的效能,對指令做重排序優化。

Memory Bank的劃分

一般 Memory bank 是按cache address來劃分的。比如 偶數adress 0×12345000?分到 bank 0, 奇數address 0×12345100?分到 bank1。

實例講解指令重排序原理

爲了方便理解,我們先來看一張CPU內部結構圖。
在這裏插入圖片描述
從圖中可以看到,這是一臺配備雙CPU的計算機,cache 按地址被分成了兩塊 cache banks,分別是?cache bank0 和 cache bank1。

理想的內存訪問指令順序:
1,CPU0往 cache address 0×12345000 寫入一個數字 1。因爲address 0×12345000是偶數,所以值被寫入 bank0.
2,CPU1讀取 bank0 address 0×12345000 的值,即數字1。
3,CPU0往 cache 地址 0×12345100 ?寫入一個數字 2。因爲address 0×12345100是奇數,所以值被寫入 bank1.
4,CPU1讀取 bank1 address ?0×12345100 的值,即數字2。

重排序後的內存訪問指令順序:

1,CPU0 準備往 bank0 address 0×12345000 寫入數字 1。
2,CPU0檢查 bank0 的可用性。發現 bank0 處於 busy 狀態。
3, CPU0 爲了防止 cache等待,發揮最大效能,將內存訪問指令重排序。即先執行後面的 bank1 address 0×12345100 數字2的寫入請求。
4,CPU0檢查 bank1 可用性,發現bank1處於 idle 狀態。
5,CPU0 將數字2寫入 bank 1 address 0×12345100。
6,CPU1來讀取 ?0×12345000,未讀到 數字1,出錯。
7, CPU0 繼續檢查 bank0 的可用性,發現這次?bank0 可用了,然後將數字1寫入 0×12345000。
8, CPU1 讀取 0×12345100,讀到數字2,正確。

從上述觸發步驟中,可以看到第 3 步發生了指令重排序,並導致第 6步讀到錯誤的數據。

通過對指令重排,CPU可以獲得更快地響應速度,但也給編寫併發程序的程序員帶來了諸多挑戰。
內存屏障是用來防止CPU出現指令重排序的利器之一。
通過這個實例,不知道你對指令重排理解了沒有?

Java語言規範規定了JVM線程內部維持順序化語義,也就是說只要程序的最終結果等同於它在嚴格的順序化環境下的結果,那麼指令的執行順序就可能與代碼的順序不一致。這個過程通過叫做指令的重排序。指令重排序存在的意義在於:JVM能夠根據處理器的特性(CPU的多級緩存系統、多核處理器等)適當的重新排序機器指令,使機器指令更符合CPU的執行特點,最大限度的發揮機器的性能。

程序執行最簡單的模型是按照指令出現的順序執行,這樣就與執行指令的CPU無關,最大限度的保證了指令的可移植性。這個模型的專業術語叫做順序化一致性模型。但是現代計算機體系和處理器架構都不保證這一點(因爲人爲的指定並不能總是保證符合CPU處理的特性)。

我們來看最經典的一個案例。



package xylz.study.concurrency.atomic; 

public class ReorderingDemo { 

    static int x = 0, y = 0, a = 0, b = 0; 

    public static void main(String[] args) throws Exception { 

        for (int i = 0; i < 100; i++) {
            x=y=a=b=0;
            Thread one = new Thread() {
                public void run() {
                    a = 1;
                    x = b;
                }
            };
            Thread two = new Thread() {
                public void run() {
                    b = 1;
                    y = a;
                }
            };
            one.start();
            two.start();
            one.join();
            two.join();
            System.out.println(x + " " + y);
        }
    } 
}

在這個例子中one/two兩個線程修改區x,y,a,b四個變量,在執行100次的情況下,可能得到(0 1)或者(1 0)或者(1 1)。事實上按照JVM的規範以及CPU的特性有很可能得到(0 0)。當然上面的代碼大家不一定能得到(0 0),因爲run()裏面的操作過於簡單,可能比啓動一個線程花費的時間還少,因此上面的例子難以出現(0,0)。但是在現代CPU和JVM上確實是存在的。由於run()裏面的動作對於結果是無關的,因此裏面的指令可能發生指令重排序,即使是按照程序的順序執行,數據變化刷新到主存也是需要時間的。假定是按照a=1;x=b;b=1;y=a;執行的,x=0是比較正常的,雖然a=1在y=a之前執行的,但是由於線程one執行a=1完成後還沒有來得及將數據1寫回主存(這時候數據是在線程one的堆棧裏面的),線程two從主存中拿到的數據a可能仍然是0(顯然是一個過期數據,但是是有可能的),這樣就發生了數據錯誤。
在兩個線程交替執行的情況下數據的結果就不確定了,在機器壓力大,多核CPU併發執行的情況下,數據的結果就更加不確定了。

Happens-before法則

Java的內存結構如下
如果多線程之間不共享數據,這也表現得很好,但是如果多線程之間要共享數據,那麼這些亂序執行,數據在寄存器中這些行爲將導致程序行爲的不確定性,現在處理器已經是多核時代了,這些問題將會更加嚴重,每個線程都有自己的工作內存,多個線程共享主內存,如圖

如果共享數據,什麼時候同步到主內存讓別人的線程讀取數據呢?這又是不確定的,如果非要一致,那麼代價高昂,這將犧牲處理器的性能,所以現在的處理器會犧牲存儲一致性來換取性能,如果程序要確保共享數據的時候獲得一致性,處理器通常了提供了一些關卡指令,這個可以幫助程序員來實現,但是各種處理器都不一樣,如果要使程序能夠跨平臺是不可能的,怎麼辦?

使用Java,由JMM(Java Memeory Model Action)來屏蔽,我們只要和JMM的規定來使用一致性保證就搞定了,那麼JMM又提供了什麼保證呢?JMM的定義是通過動作的形式來描述的,所謂動作,包括變量的讀和寫,監視器加鎖和釋放鎖,線程的啓動和拼接,這就是傳說中的happen before,要想A動作看到B動作的結果,B和A必須滿足happen before關係,happen before法則如下:

1, 程序次序法則,如果A一定在B之前發生,則happen before,
2, 監視器法則,對一個監視器的解鎖一定發生在後續對同一監視器加鎖之前
3, Volatie變量法則:寫volatile變量一定發生在後續對它的讀之前
4, 線程啓動法則:Thread.start一定發生在線程中的動作
5, 線程終結法則:線程中的任何動作一定發生在括號中的動作之前(其他線程檢測到這個線程已經終止,從Thread.join調用成功返回,Thread.isAlive()返回false)
6, 中斷法則:一個線程調用另一個線程的interrupt一定發生在另一線程發現中斷。
7, 終結法則:一個對象的構造函數結束一定發生在對象的finalizer之前
8, 傳遞性:A發生在B之前,B發生在C之前,A一定發生在C之前。

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