CPU快取一致性協議MESI,memory barrier和java volatile

JavaDog發表於2019-03-29

MESI協議

MESI協議是一個被廣泛使用的CPU快取一致性協議。我們都知道在CPU中存在著多級快取,快取級別越低,容量就越小,速度也越快。有了快取,CPU就不需要每次都向主存讀寫資料,這提高了CPU的執行速度。然而,在多核CPU中,低階別的快取是單個CPU獨佔的:
CPU_
如上圖所示,每個CPU核心分別擁有獨立的一、二級快取,共享了三級快取。這就帶來了快取一致性的問題:當同一份資料同時存在於多個CPU的獨立快取中時,如何保證快取資料的一致性?

MESI協議提供了一種方式,成功的解決了快取一致性問題。對於快取中的每一行,都設定一個狀態位,一共有四種狀態:

  • M(modified):表示快取行僅存在於當前的快取中,並且已經被更改。在該快取行寫回到主存之前,任何其他CPU都不能讀取該快取行的內容。
  • E(exclusive):表示快取行僅存在於當前的快取中,並且未被修改。如果有其他CPU讀取該行,則轉移到Shared狀態;如果修改該行,則轉移到Modified狀態。
  • S(shared):表示有多個CPU共享該快取行,且內容未被修改。
  • I(invalid):表示快取行已失效(未被使用)。

從上述狀態定義可以看出,MESI協議實際上定義了一個狀態機,其中狀態轉移規則保證了CPU在多級快取環境下的快取一致性。MESI定義的狀態轉移規則如下所述:

除Invalid狀態以外,所有狀態的快取行都可以進行讀操作

從狀態定義就可以看出,只有Invalid狀態的快取行內容是無效的,必須從主存讀取。

只有在狀態為M或E時才能進行寫操作,如果當前狀態為S,則其它CPU中的同一行必須轉移到狀態I,這是通過傳送RFO(request for ownership)廣播實現的

M或E狀態下,快取行都只存在於單個CPU中,S狀態下多個CPU共享一行,因此必須將其他CPU的狀態置為I,才能進行寫操作。

除M以外的任意狀態都可轉移到I,M狀態必須先寫回到主存再丟棄

只要內容未被修改,CPU可以再任意時刻丟棄一個快取行,否則則必須先把修改的內容寫回到主存

處於M狀態的快取必須攔截其他CPU對同一行的讀操作,並返回自身快取中的資料

這可以保證所有CPU讀到的都是最新的內容,這種攔截操作稱為snoop,資料不需要寫回到主存,直接由M狀態的快取返回,狀態由M轉移到S

處於S狀態的快取必須監聽RFO廣播,並轉移到I狀態

當一個CPU修改S狀態的快取時,其餘的快取必須先轉移到I狀態,防止併發的寫操作

處於E狀態的快取必須攔截其他CPU的讀操作,並轉移到S狀態

當有其他CPU讀E狀態的快取時,狀態必須由獨佔轉移到共享

上述過程可以用下面這幅圖來描述:
Diagrama_MESI

那麼問題來了

MESI很好的解決了快取一致性的問題,但是也不可避免的帶來了額外的開銷。考慮一個簡單的賦值操作a=1,假設變數所在的快取行不在當前CPU中,則CPU需要傳送"read invalidate"訊息,獲取快取行,並且告知其他CPU丟棄該快取行,然後該CPU必須等待,直到收到來自其他CPU的確認響應為止:
CPU_Stall
而實際上,無論a之前的值為何,在該條指令執行後都會被覆蓋,因此這段等待的開銷是完全沒有必要的。為此,CPU的設計者加入了store buffer,用於快取store指令對快取行的修改:
CPUwithStoreBuffer
如圖所示,對快取行的修改操作不會立刻執行到快取行上,而是先進入store buffer,這樣CPU的寫操作就不需要等待到從其他CPU得到快取行才能執行。CPU可以立即執行寫操作,等到得到快取行時,才將變更從store buffer寫入快取行。然而,這又立刻帶來了另外一個問題,由於store buffer的存在,在CPU中同一個變數可能存在兩份拷貝(當快取行到達CPU時,快取和store buffer中存在同一個變數的兩份拷貝),這無疑破壞了快取的一致性,若CPU在store buffer寫入快取之前load資料,就會拿到舊的資料。為了解決這個問題,CPU設計者又加入了store forwarding機制,簡單的講就是CPU會優先從store buffer中取變數,保證同一時刻一個變數在單個CPU中的一致性:
store_forwarding
然而,這樣做並不能解決另外一個問題,那就是隱式的資料依賴,考慮下面兩個

程式碼清單1:
foo(){
    a = 1;
    b = 1;
}

bar(){
   while(b == 0) continue;
   assert(a == 1);
}
複製程式碼

假設CPU0執行foo,CPU1執行bar,並且a處於CPU1的快取中。由於store buffer的存在,對a的寫操作會立刻執行,而不會等待其他CPU的invalidate響應。CPU0接著執行b=1,CPU1獲取到最新的b以後,執行assert語句,此時,CPU1有可能尚未收到來自CPU0的invalidate訊息,因而a有可能仍在CPU1的快取中,並且值未被改變,從而導致assert失敗。

記憶體屏障

引入store buffer帶來了效能的提升,卻導致MESI協議無法保障快取的一致性。從上一節中的例子可以看出,一致性問題的出現來源於資料之間的隱式依賴,也就是說必須保證某個操作在另外一個操作之前完成。比如a=1這個操作必須寫入到cache line(只有在cpu收到invalidate響應時,才會把資料從store buffer寫入cache line),才能執行b=1。但是CPU是無法探測到這種隱式相關性的,必須由程式設計師自己來進行控制。因此CPU提供了記憶體屏障指令,該指令使得屏障之前的寫操作都在屏障之後的寫操作之前完成:

程式碼清單2:
foo(){
    a = 1;
    smp_mb(); // 加入記憶體屏障
    b = 1;
}

bar(){
   while(b == 0) continue;
   assert(a == 1);
}
複製程式碼

smp_mb的實際功能是對store buffer中的變數標記,這樣當CPU0執行b=1時,發現store buffer中存在標記過的變數,就不能立刻將b=1寫入快取行,而是將其寫入store buffer(但不進行標記)。等到CPU0收到invalidate響應,將store buffer中的標記變數寫入快取行,b=1才會寫入到快取行。在此期間,由於標記變數的存在,所有對b的讀操作都只能讀到b的原始值,也就是0,導致CPU1無法執行到assert語句。

除了寫操作等待,invalidate操作的開銷也很大,因為它的存在,CPU不得不頻繁丟棄快取行,導致快取命中率低下。為了進一步提升效能,CPU中又加入了invalidate佇列(invalidate queue),CPU收到invalidate訊息以後會立刻傳送響應,但並不立刻處理,而是將該訊息放入佇列,等到適當的時候再處理。與store buffer類似,這麼做的副作用也是破壞了MESI協議,延遲響應的代價就是快取中可能存在過期的資料。這個問題同樣可以用記憶體屏障來解決:

程式碼清單3:
foo(){
    a = 1;
    smp_mb(); // 加入記憶體屏障
    b = 1;
}

bar(){
   while(b == 0) continue;
   smp_mb(); // 加入記憶體屏障
   assert(a == 1);
}
複製程式碼

bar()函式的記憶體屏障保證了屏障之前的invalidate訊息都會執行,然後才執行後面的指令。這樣CPU1執行assert時,發現a的快取行已經失效,只能嘗試讀取,此時CPU0會返回最新的資料a=1,assert執行成功。為了進一步提升效率,CPU還支援對store buffer和invalidate佇列單獨進行操作,這就是寫屏障和讀屏障。寫屏障保證屏障之前的寫操作對其他CPU都是可見的;讀屏障保證屏障之後的讀操作讀到的都是最新的資料。

Java中的記憶體屏障

Java中的volatile關鍵字可以用來修飾變數,它可以保證:

可見性 一個執行緒對volatile變數的寫操作可以立刻被其他執行緒看到
原子性 對volatile變數的單個讀/寫操作具有原子性

在某些jvm中,對longdouble的讀寫操作是不具有原子性的,而是會拆成兩部分:對高32位和低32位分別賦值。因此,假設執行緒a在讀long變數l時,執行緒b也在寫入,那麼執行緒a可能讀到的資料可能一半是新的,一半是舊的。如果將longdouble變數宣告為volatile,則可能保證變數的讀寫具有原子性。但是要注意,這個原子性只是對讀寫的單個操作而言的,對於複合操作則不能保證:

程式碼清單4:
volatile int a  = 0;

incrementAndGet(){
      a++;
      return a;
}
複製程式碼

如果指望程式碼清單4中的a變數能夠正確的增長,恐怕要失望了。因為a++這個操作實際上是由讀取-修改-寫入三個操作組成的,在併發環境中,這樣的操作不具有原子性,資料更新很有可能會丟失。

可見性又是怎麼一回事呢?這就涉及到了記憶體屏障,為了使得對volatile變數的修改對其他執行緒總是可見的,jvm會執行如下操作:

在volatile變數的寫操作之後插入寫屏障

插入寫屏障之後,屏障之前的寫操作對於其他CPU都是可見的,需要注意的是此處的可見性並不只針對標記為volatile的變數,而在所有在屏障之前執行了寫操作的共享變數(寫屏障是對store buffer中存在的所有變數進行標記)。

在對volatile變數的讀操作之前插入讀屏障

插入讀屏障之後,本地快取中所有被更改過的共享變數會立刻失效(通過執行invalidate佇列中的訊息實現)。這樣,在屏障之後讀取共享變數時,由於快取失效,只能向主存或其他CPU傳送讀取請求,從而保證了讀到的一定是最新的值。

程式碼清單5是一個簡單的例項

程式碼清單5:
class MBExample{
    int a = 0;
    volatile boolean flag = false;

    void foo(){
        a = 2;
        flag = true;
    }

    void bar(){
        if(flag)
            a ++;
        else
            a --;
    }
}
複製程式碼

假設執行緒t1和t2共享MBExample的一個例項,t1執行foo, t2執行bar。假設foo先於bar執行,此時我們一定期望a最終的值為3。但是,如果flag變數未被標記為volatile,根據之前的討論,由於store buffer和invalidate queue的存在,t2未必能獲得最新的a和flag的值(例如假設一開始a以S狀態存在於t1,t2的cache line中,而flag以E狀態存在與t1的cache line,最終的結果有可能為a=1)。如果flag被標記為volatile,程式碼清單5實際上變成了如程式碼清單6的情形:

程式碼清單6:
...
void foo(){
    a = 2; // ---------------------------1
    flag = false; // --------------------2
    smp_wmb(); // 寫屏障
}

void bar(){
    smp_rmb(); // 讀屏障
    if(flag) // -------------------------3
        a ++; // -----------------------4
    else
        a --;
}
...
複製程式碼

事實上,volatile的作用不止於此,對於JIT編譯器而言,volatile還是"指令屏障",如果編譯器出於效能優化的考慮對指令進行重排序,有可能破壞程式的原本意圖,volatile對這一行為進行了限制。Java記憶體模型針對volatile的指令重排序做了如下規定:

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

因此,當foo在bar之前執行時,實際上產生了一種偏序關係,如程式碼清單6所示1 >> 2 >> 3 >> 4,最終使得4中讀取到的a的值一定為2。這種指令重排的約束僅對JIT生效,因為java位元組碼直譯器的解釋執行是line by line的,指令的先後順序天然的得到保留。

參考資料

Memory Barriers: a Hardware View for Software Hackers
MESI protocol
深入理解Java記憶體模型(四)——volatile
Memory Barriers/Fences
Non-atomic Treatment of double and long


CPU快取一致性協議MESI,memory barrier和java volatile


相關文章