何爲內存重排序?

前言

我們知道對於我們所編寫的代碼通過計算機如何順序執行以源代碼編寫的指令,程序只是處理器自上而下執行的文本文件中列出的操作列表,其實這是錯誤的理解,計算機能夠根據需要更改某些低級操作的順序,尤其是在讀取和寫入內存時,出於性能原因,會進行內存重排序,內存重排序是一種利用指令來進行對應操作,通過這種操作極大地提高了程序的速度,但是,另一方面,它可能對無鎖多線程造成嚴重破壞性,本節我們來分析何爲重排序。

何爲重排序

程序被加載到主內存中以便執行,CPU的任務是運行存儲在其中的指令,並在必要時讀取和寫入數據,那麼具體CPU具體是如何操作的呢?獲取指令、解碼從主存儲器中加載所有所需數據的指令、執行指令、將生成的結果寫回並存儲到主內存中。現代CPU能夠每納秒執行十條指令,但是需要數十納秒才能從主內存中獲取一些數據,與處理器相比,這種類型的內存變得非常慢,爲了減少加載和存儲操作中的延遲,因此操作系統爲CPU配備了一個很小但又非常快的特殊內存塊,稱爲緩存,所以CPU將使用寄存器-緩存,高速緩存是處理器存儲其最常使用的數據的地方,以避免與主內存的緩慢交互,當處理器需要讀取或寫入主內存時,它首先檢查該數據的副本在其自己的緩存中是否可用,如果是這樣,則處理器直接讀取或寫入高速緩存,而不必等待較慢的主內存響應,現代的CPU由多個內核組成—執行實際計算的組件,每個內核都有自己的緩存塊,該緩存塊又連接到主內存,如下圖所示:

具體地說,解碼模塊可以具有一個派遣隊列,在該隊列中,提取的指令將保留,直到其請求的數據從主內存加載到緩存中或它們的從屬指令完成爲止,當一些指令正在等待(或停頓)時,就緒的指令會同時解碼並下推到管道中,如果舊數據尚未在高速緩存中,則回寫模塊會將存儲請求放入存儲緩衝區中(高速緩存控制器按高速緩存行存儲和加載數據,每條高速緩存行通常大於單個內存訪問),並開始處理下一條獨立指令。在將舊數據放入緩存後,或者如果它已經在緩存中,指令將使用新結果覆蓋緩存,最終,新數據將最終根據不同的策略異步刷新到主內存(例如,當必須從高速緩存中爲新的高速緩存行或與其他數據一起以批處理方式處理數據時),總而言之,通過加入緩存使計算機運行速度更快, 或者說它可以使處理器始終保持忙碌和高效的狀態,從而幫助處理器因等待主內存響應避免浪費不必要的時間。 

 

很顯然,這種緩存機制會增加多核操作系統複雜性,有了緩存我們將需要詳細的規則來確定數據如何在不同的緩存之間流動,也就是使得各個緩存副本中的數據一致性以此確保每個內核都具有最新的版本,它們被稱爲緩存一致性協議(高速緩存一致性是最重要的問題,它由於增加了芯片多處理器上的內核數量以及將在這些處理器上運行的共享內存程序而迅速影響了多核處理器的性能。 “窺探協議”和“基於目錄的協議”是用於實現緩存之間一致性的兩種協議,這些協議的主要目的是實現多核處理器的高速緩存中數據值的一致性和驗證,以便通過任何高速緩存讀取存儲器地址都將返回寫入該地址的最新數據),可能會導致巨大的性能損失,因此,操作系統則使用內存重排序技巧,以充分利用每個內核,內存重新排序可能有幾個原因,例如,考慮兩個被指令訪問主內存中相同數據塊的內核,內核A從內存中讀取,內核B對其進行寫入,可能會迫使內核A等待,而內核B將其本地緩存的數據寫回到主內存中,以便內核A可以讀取到最新信息,等待中的內核可能會選擇提前運行其他內存指令,而不是浪費寶貴的時間而不做任何事情,啓用某些優化後,編譯器和虛擬機也可以自由地重新排序指令,這些更改在編譯時發生,可以通過查看彙編代碼或字節碼知道,軟件內存重排序以利用基礎硬件可能提供的任何功能,只是爲了使代碼運行更快。我們來看如下代碼:
class ReadWriteDemo {
    int A = 0;
    boolean B = false;

    //CPU1 (thread1) runs this method
    void writer() {
        A = 10;  
        B = true;
    }

    //CPU2 (thread2) runs this method
    void reader() {
        while (!B)
            continue;
        System.out.println(A == 10);
    }
}

編寫上述代碼後,我們會假設write方法將在reader方法執行之前完成,在理想情況下這種假設正確無疑,但是,如果使用CPU寄存器的緩存和緩衝,這種假設將可能是錯誤的,例如,如果字段B已經在高速緩存中,而A不在,則B可以早於A存入主內存,即使A和B都在高速緩存中,B仍有可能早於A存入主內存或者A從主內存中先加載到B之前或者A在B存儲前加載之前等類似多種可能性結果,簡而言之,將語句在原始代碼中的排序方式稱爲程序順序,單個內存引用(加載或存儲)完成的順序稱爲執行順序,由於CPU高速緩存,緩衝區和推測性執行在指令完成時間上增加了太多的異步性,因此執行順序不一定與其程序順序相同,這就是CPU中執行重排序的方式。如果程序是單線程或者方法writer中的字段A和B僅由一個線程訪問,我們實際上並不用關心重排序,因爲方法writer中的兩個存儲區是獨立的,即使兩個存儲被重排序。但是,如果程序爲多線程,那麼可能需要考慮執行順序,例如,CPU1執行方法writer,而CPU2執行方法reader,由於線程使用共享的主內存進行通信,並且由於CPU緩存一致性協議,緩存對訪問是透明的,因此當從內存中加載數據時,如果從未從任何CPU加載過數據,則從主內存中獲取,如果該CPU擁有數據,則爲來自另一個CPU的高速緩存,如果擁有數據,則爲來自其自身的高速緩存,如果CPU1無序執行方法writer,則上述打印出false,即使CPU1按照程序順序執行了方法writer,打印結果仍有可能爲false,因爲CPU2可以在執行while語句時之前執行打印結果,因爲從邏輯上講,在完成while語句之後才應該打印結果(這稱爲控制依賴),但是,CPU2可以自由地先推測性地執行打印結果,一般來講,當CPU看到諸如if或while語句之類的分支時,直到該分支指令完成之前,它才知道在哪裏獲取下一條指令,但是,如果它等待分支指令而又找不到足夠的獨立指令,則會降低CPU性能,因此,CPU1可以根據其預測推測性地執行打印結果,稍後可以批准其預測路徑正確時,它將提交執行,在reader方法情況下,這意味着在打印結果之後,CPU1在while語句中找到了B == true,由於CPU並不知道我們關心A和B的執行順序,因此必須使用所謂的內存屏障來告知它們順序必須使用同步構造以強制執行的排序語義。如果兩個CPU都引用相同的內存位置,說明它們具有數據依賴性,則沒有一個CPU將對存儲的給定操作進行重排序,否則將違反程序語義,基於以上分析,我們得出結論:單線程程序在順序化語義as-if-serial下運行,重排序的效果僅對多線程程序可見(或者一個線程中的重新排序僅對其他線程可見/對其他線程很重要),當CPU本質上執行給不了我們實際想要的排序語義時,程序必須使用同步機制。

指令調度說明 

只要編譯器不違反程序語義(這裏的編譯指代的是JIT編譯器)就可以自由地根據其優化對代碼進行物理或邏輯重新排序,現代編譯器具有許多強大的代碼轉換,如下:

public class Main {
    public static void main(String[] args) {

        int A = 10;
        int B = A + 10;
        int C = 20;
    }
}

假設編譯器通過複雜的分析發現A不在緩存中,而C在緩存中,因此,A=10將觸發多週期的數據加載,而C=20則可以在單個週期內完成,編譯器可以直接跳過對A=10和B=A+10進行賦值操作而執行C=20,以將停頓減少1,如果編譯器可以找到更多獨立的指令,則可以通過減少更多的停頓來進行相同的重排序。由上述我們知道在單核計算機上,硬件內存的重排序並不是問題,線程是操作系統控制的軟件結構,CPU僅接收連續的存儲指令流,它們仍然可以重排序,但是要遵循一個基本規則:給定內核的內存訪問在該內核中似乎是在程序中編寫的,因此,可能會發生內存重排序,但前提是它不會破壞最終結果。接下來我們再來看一個例子(源於java併發實戰)

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null)
            resource = new Resource();
        return resource;
    }
}

如上使用先檢查後操作模式實例化Resource,不用多講,很有可能兩個線程可以在該方法中同時到達,都將resource視爲null並初始化變量。這裏還涉及到我們上一節所講解的部分初始化對象問題,導致對象無法正確安全發佈,當我們初始化一個對象具體會進行5步操作:分配內存、創建對象、使用默認值初始化字段(比如int、boolean等)、運行構造函數、將對象的引用分配給變量,但是這裏在進行第4步操作之前就運行第5步操作,所以getInstance方法將返回一個非空但不一致的對象(具有未初始化字段)的引用。但是上述方法也很有可能返回null,因爲JMM對此允許, 要了解爲什麼這樣做是可行的,我們需要詳細分析讀寫,並評估它們之間是否存在事先發生聯繫(happens-before),我們將上述代碼進行如下重寫,以清楚地顯示讀取和寫入:

 

在此示例中,允許21和24都遵守10或13,並且合法執行了該程序,假設線程1看到resource爲空並對其進行了初始化,線程2看到x不爲空,應該會實例化Resource,但是結果可能返回null的resource,這是爲何呢?前面我們講解過CPU緩存一致性協議,當線程1執行實例化Resource時,此時需要寫入到主內存,同時線程2剛好要獲取resource將進行等待,爲了解決緩存一致性引起的等待問題,JIT通過指令進行重排序,接下來將跳過實例化resource並寫入,直接返回resource,此時結果就爲null。我們將重排序操作改造成如下,相信能更好理解一點
public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        Resource temp = resource;
        if (resource == null) 
            resource = temp = new Resource(); 
        return temp; 
    }
}

通過聲明一個Resource的臨時變量temp,此時在線程1和線程2都爲null,接下來將在線程1中爲null,而在線程2中不爲null,因爲它已由線程1初始化,最終線程1返回實例,而線程2返回null。

總結

本節我們詳細講解了重排序的概念以及引入重排序的原因,下一節我們進入到內存模型,感謝您的閱讀,我們下節見。

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