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

基礎知識回顧

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

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

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和一分內部緩存,那麼全部進程和線程看到的數都是緩存裏的數,不會存在問題;app

但如今服務器一般是多 CPU,更廣泛的是,每塊CPU裏有多個內核,而每一個內核都維護了本身的緩存,那麼這時候多線程併發就會存在緩存不一致性,這會致使嚴重問題。ide

以 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指令的幾個做用:
一、鎖總線,其它CPU對內存的讀寫請求都會被阻塞,直到鎖釋放,不過實際後來的處理器都採用鎖緩存替代鎖總線,由於鎖總線的開銷比較大,鎖總線期間其餘CPU無法訪問內存
二、lock後的寫操做會回寫已修改的數據,同時讓其它CPU相關緩存行失效,從而從新從主存中加載最新的數據
三、不是內存屏障卻能完成相似內存屏障的功能,阻止屏障兩遍的指令重排序

因爲效率問題,實際後來的處理器都採用鎖緩存來替代鎖總線,這種場景下多緩存的數據一致是經過緩存一致性協議來保證的 。

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是非空的可是還沒初始化徹底的對象。

相關文章
相關標籤/搜索