Volatile如何保證執行緒可見性之匯流排鎖、快取一致性協議

等不到的口琴發表於2021-02-14

基礎知識回顧

下圖給出了假想機的基本設計。中央處理單元(CPU)是進行算術和邏輯操作的部件,包含了有限數量的儲存位置——暫存器(register),一個高頻時鐘、一個控制單元和一個算術邏輯單元。

時鐘 (clock) 對 CPU 內部操作與系統其他元件進行同步。
控制單元 (control unit, CU) 協調參與機器指令執行的步驟序列。
算術邏輯單元 (arithmetic logic unit, ALU) 執行算術運算,如加法和減法,以及邏輯運算,如 AND(與)、OR(或)和 NOT(非)。

CPU 通過主機板上 CPU 插座的引腳與計算機其他部分相連。大部分引腳連線的是資料匯流排、控制匯流排和地址匯流排。

記憶體儲存單元 (memory storage unit,圖中沒有畫出來) 用於在程式執行時儲存指令與資料。它接受來自 CPU 的資料請求,將資料從隨機儲存器 (RAM) 傳輸到 CPU,並從 CPU 傳輸到記憶體。

由於所有的資料處理都在 CPU 內進行,因此儲存在記憶體中的程式在執行前需要被複制到 CPU 中。程式指令在複製到 CPU 時,可以一次複製一條,也可以一次複製多條。

匯流排 (bus) 是一組並行線,用於將資料從計算機一個部分傳送到另一個部分。一個計算機系統通常包含四類匯流排:資料類、I/O 類、控制類和地址類。

資料匯流排 (data bus) 在 CPU 和記憶體之間傳輸指令和資料。I/O 匯流排在 CPU 和系統輸入 / 輸出裝置之間傳輸資料。控制匯流排 (control bus) 用二進位制訊號對所有連線在系統匯流排上裝置的行為進行同步。當前執行指令在 CPU 和記憶體之間傳輸資料時,地址匯流排 (address bus) 用於保持指令和資料的地址。

情景引入

有了前面的前置知識,我們都知道CPU和實體記憶體之間的通訊速度遠慢於CPU的處理速度,所以CPU有自己的內部快取,根據一些規則將記憶體中的資料讀取到內部快取中來,以加快頻繁讀取的速度。我們假設在一臺PC上只有一個CPU和一份內部快取,那麼所有程式和執行緒看到的數都是快取裡的數,不會存在問題;

但現在伺服器通常是多 CPU,更普遍的是,每塊CPU裡有多個核心,而每個核心都維護了自己的快取,那麼這時候多執行緒併發就會存在快取不一致性,這會導致嚴重問題。

以 i++為例,i的初始值是0.那麼在開始每塊快取都儲存了i的值0,當第一塊核心做i++的時候,其快取中的值變成了1,即使馬上回寫到主記憶體,那麼在回寫之後第二塊核心快取中的i值依然是0,其執行i++,回寫到記憶體就會覆蓋第一塊核心的操作,使得最終的結果是1,而不是預期中的2.

快取一致性協議

那麼怎麼解決整個問題呢?作業系統提供了匯流排鎖定的機制。前端匯流排(也叫CPU匯流排,Front Side Bus))是所有CPU與晶片組連線的主幹道,負責CPU與外界所有部件的通訊,包括快取記憶體、記憶體、北橋,其控制匯流排向各個部件傳送控制訊號、通過地址匯流排傳送地址訊號指定其要訪問的部件、通過資料匯流排雙向傳輸。在CPU1要做 i++操作的時候,其在匯流排上發出一個LOCK#訊號,其他處理器就不能操作快取了該共享變數記憶體地址的快取,也就是阻塞了其他CPU,使該處理器可以獨享此共享記憶體。

但我們只需要對此共享變數的操作是原子就可以了,而匯流排鎖定把CPU和記憶體的通訊給鎖住了,使得在鎖定期間,其他處理器不能操作其他記憶體地址的資料,從而開銷較大,所以後來的CPU都提供了快取一致性機制,Intel的奔騰486之後就提供了這種優化。

快取一致性:快取一致性機制就整體來說,是當某塊CPU對快取中的資料進行操作了之後,就通知其他CPU放棄儲存在它們內部的快取,或者從主記憶體中重新讀取, 用MESI闡述原理如下:

MESI協議:是以快取行(快取的基本資料單位,在Intel的CPU上一般是64位元組)的幾個狀態來命名的(全名是Modified、Exclusive、 Share or Invalid)。該協議要求在每個快取行上維護兩個狀態位,使得每個資料單位可能處於M、E、S和I這四種狀態之一,各種狀態含義如下:

M:被修改的。處於這一狀態的資料,只在本CPU中有快取資料,而其他CPU中沒有。同時其狀態相對於記憶體中的值來說,是已經被修改的,且沒有更新到記憶體中。
​ E:獨佔的。處於這一狀態的資料,只有在本CPU中有快取,且其資料沒有修改,即與記憶體中一致。
​ S:共享的。處於這一狀態的資料在多個CPU中都有快取,且與記憶體一致。
​ I:無效的。本CPU中的這份快取已經無效。

一個處於M狀態的快取行,必須時刻監聽所有試圖讀取該快取行對應的主存地址的操作,如果監聽到,則必須在此操作執行前把其快取行中的資料寫回記憶體。
一個處於S狀態的快取行,必須時刻監聽使該快取行無效或者獨享該快取行的請求,如果監聽到,則必須把其快取行狀態設定為I。
一個處於E狀態的快取行,必須時刻監聽其他試圖讀取該快取行對應的主存地址的操作,如果監聽到,則必須把其快取行狀態設定為S。

​ 當CPU需要讀取資料時,如果其快取行的狀態是I的,則需要從記憶體中讀取,並把自己狀態變成S,如果不是I,則可以直接讀取快取中的值,但在此之前,必須要等待其他CPU的監聽結果,如其他CPU也有該資料的快取且狀態是M,則需要等待其把快取更新到記憶體之後,再讀取。

​ 當CPU需要寫資料時,只有在其快取行是M或者E的時候才能執行,否則需要發出特殊的RFO指令(Read Or Ownership,這是一種匯流排事務),通知其他CPU置快取無效(I),這種情況下效能開銷是相對較大的。在寫入完成後,修改其快取狀態為M。

所以如果一個變數在某段時間只被一個執行緒頻繁地修改,則使用其內部快取就完全可以辦到,不涉及到匯流排事務,如果快取一會被這個CPU獨佔、一會被那個CPU 獨佔,這時才會不斷產生RFO指令影響到併發效能。這裡說的快取頻繁被獨佔並不是指執行緒越多越容易觸發,而是這裡的CPU協調機制,這有點類似於有時多執行緒並不一定提高效率,原因是執行緒掛起、排程的開銷比執行任務的開銷還要大,這裡的多CPU也是一樣,如果在CPU間排程不合理,也會形成RFO指令的開銷比任務開銷還要大。當然,這不是程式設計者需要考慮的事,作業系統會有相應的記憶體地址的相關判斷

MESI失效的情景

並非所有情況都會使用快取一致性,如被操作的資料不能被快取在CPU內部或運算元據跨越多個快取行(狀態無法標識),則處理器會呼叫匯流排鎖定;另外當CPU不支援快取鎖定時,自然也只能用匯流排鎖定了,比如說奔騰486以及更老的CPU。匯流排事務的競爭,雖然有很高的一致性但是效率非常低。

記憶體屏障

編譯器和CPU會在不影響結果(這兒主要是根據資料依賴性)的情況下對指令重排序,使效能得到優化,但是實際情況裡面有些指令雖然沒有前後依賴關係,但是重排序之後影響到輸出結果,這時候可以插入一個記憶體屏障,相當於告訴CPU和編譯器限於這個命令的必須先執行,後於這個命令的必須後執行。

記憶體屏障的另一個作用是強制更新一次不同CPU的快取,這意味著如果你對一個volatile欄位進行寫操作,你必須知道:

  1. 一旦你完成寫入,任何訪問這個欄位的執行緒將會得到最新的值;
  2. 在你寫入之前,會保證所有之前發生的事已經發生,並且任何更新過的資料值也是可見的,因為記憶體屏障會把之前的寫入值都重新整理到快取。

Volatile如何保證可見性?

加入volatile關鍵字時,會多出一個lock字首指令,lock字首指令實際上相當於一個記憶體屏障,它有三個功能:

  • 確保指令重排序時不會把其後面的指令重排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障後面,即在執行到記憶體屏障這句指令時,前面的操作已經全部完成;

  • 將當前處理器快取行的資料立即寫回系統記憶體(由volatile先行發生原則保證);

    先行發生(Happens-Before)是Java記憶體模型中定義的兩項操作之間的偏序關係,比如說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等

    下面是Java記憶體模型下一些“天然的”先行發生關係,這些先行發生關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來,則它們就沒有順序性保障,虛擬機器可以對它們隨意地進行重排序。

    • 程式次序規則(Program Order Rule):在一個執行緒內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。注意,這裡說的是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。
    • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是“同一個鎖”,而“後面”是指時間上的先後。
    • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的“後面”同樣是指時間上的先後。
    • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
    • 執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測執行緒是否已經終止執行。
    • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread::interrupted()方法檢測到是否有中斷髮生。
    • 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。
    • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
  • 這個寫回記憶體的操作會引起在其他CPU裡快取了該記憶體地址的資料無效。寫回操作時要經過匯流排傳播資料,而每個處理器通過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效狀態,當處理器要對這個值進行修改的時候,會強制重新從系統記憶體裡把資料讀到處理器快取(也是由volatile先行發生原則保證);

快取一致性協議有多種,但是日常處理的大多數計算機裝置都屬於”嗅探(snooping)”機制,它的基本思想是:
所有記憶體的傳輸都發生在一條共享的匯流排上,而所有的處理器都能看到這條匯流排:快取本身是獨立的,但是記憶體是共享資源,所有的記憶體訪問都要經過仲裁(同一個指令週期中,只有一個CPU快取可以讀寫記憶體)。
CPU快取不僅僅在做記憶體傳輸的時候才與匯流排打交道,而是不停在嗅探匯流排上發生的資料交換,跟蹤其他快取在做什麼。所以當一個快取代表它所屬的處理器去讀寫記憶體時,其它處理器都會得到通知,它們以此來使自己的快取保持同步。只要某個處理器一寫記憶體,其它處理器馬上知道這塊記憶體在它們的快取段中已失效。

可以得出lock指令的幾個作用:
1、鎖匯流排,其它CPU對記憶體的讀寫請求都會被阻塞,直到鎖釋放,不過實際後來的處理器都採用鎖快取替代鎖匯流排,因為鎖匯流排的開銷比較大,鎖匯流排期間其他CPU沒法訪問記憶體
2、lock後的寫操作會回寫已修改的資料,同時讓其它CPU相關快取行失效,從而重新從主存中載入最新的資料
3、不是記憶體屏障卻能完成類似記憶體屏障的功能,阻止屏障兩遍的指令重排序

由於效率問題,實際後來的處理器都採用鎖快取來替代鎖匯流排,這種場景下多快取的資料一致是通過快取一致性協議來保證的 。

MESI協議的問題

既然CPU有了MESI協議可以保證cache的一致性,那麼為什麼還需要volatile這個關鍵詞來保證可見性(記憶體屏障)?或者是隻有加了volatile的變數在多核cpu執行的時候才會觸發快取一致性協議?

兩個解釋結論:

  1. 多核情況下,所有的cpu操作都會涉及快取一致性的校驗,只不過該協議是弱一致性,不能保證一個執行緒修改變數後,其他執行緒立馬可見,也就是說雖然其他CPU狀態已經置為無效,但是當前CPU可能將資料修改之後又去做其他事情,沒有來得及將修改後的變數重新整理回主存,而如果此時其他CPU需要使用該變數,則又會從主存中讀取到舊的值。而volatile則可以保證可見性,即立即重新整理回主存,修改操作和寫回操作必須是一個原子操作;
  2. 正常情況下,系統操作並不會進行快取一致性的校驗,只有變數被volatile修飾了,該變數所在的快取行才被賦予快取一致性的校驗功能。

volatile的使用場景舉例

一句話來說就是保證執行緒可見性以及禁止指令重排序,具體就是三個場景:

  1. 狀態標誌(開關模式)
  2. 雙重檢查鎖定
  3. 需要利用順序性

舉個DCL的例子:

synchronized關鍵字是防止多個執行緒同時執行一段程式碼,那麼就會很影響程式執行效率,而volatile關鍵字在某些情況下效能要優於synchronized,但是volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件:

  • 對變數的寫操作不依賴於當前值;
  • 該變數沒有包含在具有其他變數的不變式中。

下面列舉兩個使用場景

  • 狀態標記量(本文中程式碼的列子)
  • 雙重檢查(單例模式)
Copyclass Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) { // 1
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();  //2
            }
        }
        return instance;
    }
}

上述的Instance類變數是沒有用volatile關鍵字修飾的,會導致這樣一個問題:

線上程執行到第1行的時候,程式碼讀取到instance不為null時,instance引用的物件有可能還沒有完成初始化(先賦值預設值,再賦值初始值),但是已經賦予了預設值。

造成這種現象主要的原因是重排序。重排序是指編譯器和處理器為了優化程式效能而對指令序列進行重新排序的一種手段。

第二行程式碼可以分解成以下幾步

Copyemory = allocate();  // 1:分配物件的記憶體空間
ctorInstance(memory); // 2:初始化物件
instance = memory;  // 3:設定instance指向剛分配的記憶體地址

根源在於程式碼中的2和3之間,可能會被重排序。例如:

Copy
memory = allocate();  // 1:分配物件的記憶體空間
instance = memory;  // 3:設定instance指向剛分配的記憶體地址
// 注意,此時物件還沒有被初始化!
ctorInstance(memory); // 2:初始化物件

這種重排序可能就會導致一個執行緒拿到的instance是非空的但是還沒初始化完全的物件。

相關文章