Volatile如何保證線程可見性之總線鎖、緩存一致性協議


基礎知識回顧

下圖給出了假想機的基本設計。中央處理單元(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是非空的但是還沒初始化完全的對象。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM