1. 程式人生 > >記憶體屏障和 volatile 語義

記憶體屏障和 volatile 語義

背景

在閱讀java中volatile的關鍵詞語義時,發現很多書中都使用了重排序這個詞來描述,同時又講到了執行緒工作記憶體和主存等等相關知識。但是隻用那些書的抽象定義進行理解時總是感覺什麼地方說不通,最後發現,是那些書中使用的抽象遮蔽了一些對讀者的知識點,反而導致了理解上的困難。因此有了這篇文章。沒有任何虛構的理解抽象,從硬體的角度來理解什麼是記憶體屏障,以及記憶體屏障如何讓volatile工作。最後說明了在多執行緒中,如何使用volatile來提升效能。

儲存結構

在計算機之中,存在著多級的儲存結構。這是為了適應不同硬體速度帶來的差異。底層是主存(也就是記憶體),容量最大,速度最慢;中間是cpu的快取(現代cpu都有多級快取結構,級別越高速度越慢,但是可以將多級快取看成是一個整體),容量較小,但是速度很快;最上層是cpu自身的store buffer和Invalidate Queues,速度最快,容量非常少。其中主存和cpu快取的資料檢視對於每一個cpu都是相同的,也就是說在這個級別上,每個cpu都看到了相同的資料,而store buffer和Invalidate Queues是每一個cpu私有的。這就導致了一系列的程式設計問題,下文會詳細展開。

CPU快取

Cpu為了平衡自身處理速度過快和主存讀寫速度過慢這個問題,使用了快取來儲存處理中的熱點資料。cpu需要處理資料的時候(包含讀取和寫出),都是直接向快取發出讀寫指令。如果資料不在快取中,則會從主存中讀取資料到快取中,再做對應的處理。需要注意的是,cpu讀取資料到快取中,是固定長度的讀取。也就是說cpu快取是一行一行的載入資料進來。因為也稱之為cpu快取行,即cacheline。而快取中的資料,也會在合適的時候回寫到主存當中(這個時機可以抽象的認為是由cpu自行決定的)。

現在的cpu都是多核cpu,為了在處理資料的時候保持快取有效性,因此一個cpu需要資料而且該資料不在自身的cache中的時候,會同時向其他的cpu快取和主存求取。如果其他的cpu快取中有資料,則使用該資料,這樣就保證了不會使用到主存中的錯誤的尚未更新的舊資料。

而各個cpu的內部快取依靠MESI快取一致性協議來進行協調。以此保證各個Cpu看到的內容是一致的。

Store buffer

如果一個Cpu要寫出一個數據,但是此時這個資料不在自己的cacheline中,因為cpu要向其他的cpu快取發出read invalidate訊息。等待其他的cpu返回read response和invalidate ack訊息後,將資料寫入這個cacheline。這裡就存在著時間的浪費,因為不管其他的cpu返回的是什麼資料,本cpu都是要將它覆蓋的。而在等待的這段時間,cpu無事可幹,只能空轉。為了讓cpu不至於空閒,因為設計了store buffer元件。store buffer是每個cpu獨享的寫入快取空間,用於儲存對cacheline的寫入,而且速度比cacheline高一個數量級,但是容量非常少。

但是store buffer會產生在單核上的讀寫不一致問題。下面是模擬

a = 1;
b = a+1;
assert b==2;

假設a不在本cpu的cacheline中。在其他cpu的cacheline中,值為0.會有如下的步驟

序號 操作內容
1 發現a的地址不在本cpu的cacheline中,向其他的cpu傳送read invalidate訊息
2 將資料寫入store buffer中
3 收到其他cpu響應的read response和invalidate ack訊息
4 執行b=a+1,因為a這個時候已經在cacheline中,讀取到值為0,加1後為1,寫入到b中
5 執行assert b==2 失敗,因為b是1
6 store buffer中的值重新整理到a的cacheline中,修改a的值為1,但是已經太晚了

為了避免這個問題,所以對於store buffer的設計中增加一個策略叫做store forwarding。就是說cpu在讀取資料的時候會先檢視store buffer,如果store buffer中有資料,直接用store buffer中的。這樣,也就避免了使用錯誤資料的問題了。 store forwarding可以解決在單執行緒中的資料不一致問題,但是store buffer所帶來的複雜性遠不止如此。在多執行緒環境下,會有其他的問題。下面是模擬程式碼

public void set(){
  a=1;
  b=1;
}
public void print(){
 while(b==0)
 ;
 assert a==1;
}

假設a和b的值都是0,其中b在cpu0中,a在cpu1中。cpu0執行set方法,cpu1執行print方法。

序號 cpu0的步驟(執行set) cpu1的步驟(執行print)
1 想寫入a=1,但是由於a不在自身的cacheline中,向cpu1傳送read invalidate訊息 執行while(b==0),由於b不在自身的cacheline中,向cpu0傳送read訊息
2 向store buffer中寫入a=1 等待cpu0響應的read response訊息
3 b在自身的cacheline中,並且此時狀態為M或者E,寫入b=1 等待cpu0響應的read response訊息
4 收到cpu1的read請求,將b=1的值用read response訊息傳遞,同時將b所在的cacheline修改狀態為s 等待cpu0響應的read response訊息
5 等待cpu1的read response和invalidate ack訊息 收到cpu0的read response訊息,將b置為1,因此程式跳出迴圈
6 等待cpu1的read response和invalidate ack訊息 因為a在自身的cacheline中,所以讀取後進行比對。assert a==1失敗。因為此時a在自身cacheline中的值還是0,而且該cacheline尚未失效
7 等待cpu1的read response和invalidate ack訊息 收到cpu0傳送的read invalidate訊息,將a所在的cacheline設定為無效,但是 為時已晚,錯誤的判斷結果已經產生了
8 收到cpu1響應的read response和invalidate ack訊息,將store buffer中的值寫入cacheline中

通過上面的例子可以看到,在多核系統中,store buffer的存在讓程式的結果與我們的預期不相符合。上面的程式中,由於store buffer的存在,所以在cacheline中的操作順序實際上先b=1然後a=1。就好像操作被重排序一樣(重排序這個詞在很多文章中都有,但是定義不詳,不好理解。實際上直接理解store buffer會簡單很多)。為了解決這樣的問題,cpu提供了一些操作指令,來幫助我們避免這樣的問題。這樣的指令就是記憶體屏障(英文fence,也翻譯叫做柵欄)。來看下面的程式碼

public void set(){
  a=1;
  smp_mb();
  b=1;
}
public void print(){
 while(b==0)
 ;
 assert a==1;
}

smb_mb()就是記憶體屏障指令,英文memory barries。它的作用,是在後續的store動作之前,將sotre buffer中的內容重新整理到cacheline。這個操作的效果是讓本地的cacheline的操作順序和程式碼的順序一致,也就是讓其他cpu觀察到的該cpu的cacheline操作順序被分為smp_mb()之前和之後。要達到這個目的有兩種方式

  • 遇到smp_mb()指令時,暫停cpu執行,將當前的store_buffer全部重新整理到cacheline中,完成後cpu繼續執行
  • 遇到smp_mb()指令時,cpu繼續執行,但是所有後續的store操作都進入到了store buffer中,直到store buffer之前的內容都被重新整理到cacheline,即使此時需要store的內容的cacheline是M或者E狀態,也只能先寫入store buffer中。這樣的策略,既可以提升cpu效率,也保證了正確性。當之前store buffer的內容被重新整理到cacheline完成後,後面新增加的內容也會有合適的時機重新整理到cacheline。把store buffer想象成一個FIFO的佇列就可以了。

下面來看,當有了smp_mb()之後,程式的執行情況。所有的初始假設與上面相同。

序號 cpu0的步驟(執行set) cpu1的步驟(執行print)
1 想寫入a=1,但是由於a不在自身的cacheline中,向cpu1傳送read invalidate訊息 執行while(b==0),由於b不在自身的cacheline中,向cpu0傳送read訊息
2 向store buffer中寫入a=1 等待cpu0響應的read response訊息
3 遇到smp_mb(),等待直到可以將store buffer中的內容重新整理到cacheline 等待cpu0響應的read response訊息
4 等待直到可以將store buffer中的內容重新整理到cacheline 收到cpu0發來的read invalidate訊息,傳送a=0的值,同時將自身a所在的cacheline修改為invalidate狀態
5 收到cpu1響應的read response和invalidate ack訊息,將a=0的值設定到cacheline,隨後store buffer中a=1的值重新整理到cacheline,設定cacheline狀態為M 等待cpu0響應的read response訊息
6 由於b就在自身的cacheline中,並且狀態為M或者E,設定值為b=1 等待cpu0響應的read response訊息
7 收到cpu1的read請求,將b=1的值傳遞回去,同時設定該cacheline狀態為s 等待cpu0響應的read response訊息
8 收到cpu0的read response資訊,將b設定為1,程式跳出迴圈
9 由於a所在的cacheline被設定為invalidate,因此向cpu0傳送read請求
10 收到cpu1的read請求,以a=1響應,並且將自身的cacheline狀態修改為s 等待cpu0的read response響應
11 收到read response請求,將a設定為1,執行程式判斷,結果為真

可以看到,在有了記憶體屏障之後,程式的真實結果就和我們的預期結果相同了。

invalidate queue

使用了store buffer後,cpu的store效能會提升很多。然後store buffer的容量是很小的(越快的東西,成本就越高,一定就越小),cpu以中等的頻率填充store buffer。如果不幸發生比較多的cache miss,那麼很快store buffer就被填滿了,cpu只能等待。又或者程式中呼叫了smp_mb()指令,這樣後續的操作都只能進入store buffer,而不管相關cacheline是否處於M或者E狀態。

store buffer很容易滿的原因是因為收到其他cpu的invalidate ack的速度太慢。而cpu傳送invalidate ack的速度太慢是因為cpu要等到將對應的cacheline設定為invalidate後才能傳送invalidate ack。有的時候太多invalidate請求,cpu的處理速度就跟不上。為了加速這個流程,硬體設計者設計了invaldate queue來加速這個過程。收到的invalidate請求先放入invalidate queue,然後之後立刻響應invalidate ack訊息。而cpu可以在隨後慢慢的處理這些invalidate訊息。當然,這裡必須不能太慢。也就是說,cpu實際上給出了一個承諾,如果一個invalidatge請求在invalidate queue中,那麼對於這個請求相關的cacheline,在該請求被處理完成前,cpu不會再發送任何與該cacheline相關的MESI訊息。在有了store buffer和invalidate queue後,cpu的處理速度又可以更高。下面是結構圖。 但是在引入了invalidate queue又會導致另外一個問題。下面先來看程式碼

public void set(){
  a=1;
  smp_mb();
  b=1;
}
public void print(){
 while(b==0)
 ;
 assert a==1;
}

程式碼與上面的例子相同,但是初始條件不同了。這次a同時存在於cpu0和cpu1之中,狀態為s。b是cpu0獨享,狀態為E或者M。

序號 cpu0的步驟(執行set) cpu1的步驟(執行print)
1 想寫入a=1,但是由於a的狀態是s,向cpu1傳送invalidate訊息 執行while(b==0),由於b不在自身的cacheline中,向cpu0傳送read訊息
2 向store buffer中寫入a=1 收到cpu0的invalidate訊息,放入invalidate queue,響應invalidate ack訊息。
3 遇到smp_mb(),等待直到可以將store buffer中的內容重新整理到cacheline。立刻收到cpu0的invalidate ack,將store buffer中的a=1寫入到cacheline,並且修改狀態為M 等待cpu0響應的read response訊息
4 由於b就在自己的cacheline中,寫入b=1,修改狀態為M 等待cpu0響應的read response訊息
5 收到cpu1響應的read請求,將b=1作為響應回傳,同時將cacheline的狀態修改為s。 等待cpu0響應的read response訊息
6 收到read response,將b=1寫入cacheline,程式跳出迴圈
7 由於a所在的cacheline還未失效,load值,進行比對,assert失敗
8 cpu處理invalidate queue的訊息,將a所在的cacheline設定為invalidate,但是已經太晚了

上面的例子,看起來就好像第一個一樣,仍然是b=1先生效,a=1後生效。導致了cpu1執行的錯誤。就好像記憶體操作”重排序”一樣(個人不太喜歡記憶體操作重排序這個術語,因為實際上並不是重新排序的問題,而是是否可見的問題。但是用重排序這樣的詞語,反而不好理解。但是很多書都是用是了這個詞語,大家可以有自己的理解。但是還是推薦不要理會這些作者的抽象概念,直接瞭解核心)。其實這個問題的觸發,就是因為invalidate queue沒有在需要被處理的時候處理完成,造成了原本早該失效的cacheline仍然被cpu認為是有效,出現了錯誤的結果。那麼只要讓記憶體屏障增加一個讓invalidate queue全部處理完成的功能即可。

硬體的設計者也是這麼考慮的,請看下面的程式碼

public void set(){
  a=1;
  smp_mb();
  b=1;
}
public void print(){
 while(b==0)
 ;
 smp_mb();
 assert a==1;
}

a同時存在於cpu0和cpu1之中,狀態為s。b是cpu0獨享,狀態為E或者M。

序號 cpu0的步驟(執行set) cpu1的步驟(執行print)
1 想寫入a=1,但是由於a的狀態是s,向cpu1傳送invalidate訊息 執行while(b==0),由於b不在自身的cacheline中,向cpu0傳送read訊息
2 向store buffer中寫入a=1 收到cpu0的invalidate訊息,放入invalidate queue,響應invalidate ack訊息。
3 遇到smp_mb(),等待直到可以將store buffer中的內容重新整理到cacheline。立刻收到cpu0的invalidate ack,將store buffer中的a=1寫入到cacheline,並且修改狀態為M 等待cpu0響應的read response訊息
4 由於b就在自己的cacheline中,寫入b=1,修改狀態為M 等待cpu0響應的read response訊息
5 收到cpu1響應的read請求,將b=1作為響應回傳,同時將cacheline的狀態修改為s。 等待cpu0響應的read response訊息
6 收到read response,將b=1寫入cacheline,程式跳出迴圈
7 遇見smp_mb(),讓cpu將invalidate queue中的訊息全部處理完後,才能繼續向下執行。此時將a所在的cacheline設定為invalidate
8 由於a所在的cacheline已經無效,向cpu0傳送read訊息
9 收到read請求,以a=1傳送響應 收到cpu0傳送的響應,以a=1寫入cacheline,執行assert a==1.判斷成功

可以看到,由於記憶體屏障的加入,程式正確了。

記憶體屏障

通過上面的解釋和例子,可以看出,記憶體屏障是是因為有了store buffer和invalidate queue之後,被用來解決可見性問題(也就是在cacheline上的操作重排序問題)。記憶體屏障具備兩方面的作用

  • 強制cpu將store buffer中的內容寫入到cacheline中
  • 強制cpu將invalidate queue中的請求處理完畢

但是有些時候,我們只需要其中一個功能即可,所以硬體設計者們就將功能細化,分別是

  • 讀屏障: 強制cpu將invalidate queue中的請求處理完畢。也被稱之為smp_rmb
  • 寫屏障: 強制cpu將store buffer中的內容寫入到cacheline中或者將該指令之後的寫操作寫入store buffer直到之前的內容被寫入cacheline.也被稱之為smp_wmb
  • 讀寫屏障: 強制重新整理store buffer中的內容到cacheline,強制cpu處理完invalidate queue中的內容。也被稱之為smp_mb

JMM記憶體模型

在上面描述中可以看到硬體為我們提供了很多的額外指令來保證程式的正確性。但是也帶來了複雜性。JMM為了方便我們理解和使用,提供了一些抽象概念的記憶體屏障。注意,下文開始討論的記憶體屏障都是指的是JMM的抽象記憶體屏障,它並不代表實際的cpu操作指令,而是代表一種效果。

  • LoadLoad Barriers 該屏障保證了在屏障前的讀取操作效果先於屏障後的讀取操作效果發生。在各個不同平臺上會插入的編譯指令不相同,可能的一種做法是插入也被稱之為smp_rmb指令,強制處理完成當前的invalidate queue中的內容
  • StoreStore Barriers 該屏障保證了在屏障前的寫操作效果先於屏障後的寫操作效果發生。可能的做法是使用smp_wmb指令,而且是使用該指令中,將後續寫入資料先寫入到store buffer的那種處理方式。因為這種方式消耗比較小
  • LoadStore Barriers 該屏障保證了屏障前的讀操作效果先於屏障後的寫操作效果發生。
  • StoreLoad Barriers 該屏障保證了屏障前的寫操作效果先於屏障後的讀操作效果發生。該屏障兼具上面三者的功能,是開銷最大的一種屏障。可能的做法就是插入一個smp_mb指令來完成。

記憶體屏障在volatile關鍵中的使用

記憶體屏障在很多地方使用,這裡主要說下對於volatile關鍵字,記憶體屏障的使用方式。

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。
  • 在每個volatile讀操作的後面插入一個LoadStore屏障。

上面的記憶體屏障方式主要是規定了在處理器級別的一些重排序要求。而JMM本身,對於volatile變數在編譯器級別的重排序也制定了相關的規則。可以用下面的圖來表示 volatile變數除了在編譯器重排序方面的語義以外,還存在一條約束保證。如果cpu硬體上存在類似invalidate queue的東西,可以在進行變數讀取操作之前,會先處理完畢queue上的內容。這樣就能保證volatile變數始終是讀取最新的最後寫入的值。

Happen-before

JMM為了簡化對程式設計複雜的理解,使用了HB來表達不同操作之間的可見性。HB關係在不同的書籍中有不同的表達。這裡推薦一種比較好理解的。

A Happen before B,說明A操作的效果先於B操作的效果發生。這種偏序關係在單執行緒中是沒有什麼作用的,因為單執行緒中,執行效果要求和程式碼順序一致。但是在多執行緒中,其可見性作用就非常明顯了。舉個例子,線上程1中進行進行a,b操作,操作存在hb關係。那麼當執行緒2觀察到b操作的效果時,必然也能觀察到a操作的效果,因為a操作Happen before b操作。

在java中,存在HB關係的操作一共有8種,如下。

  1. 程式次序法則,如果A一定在B之前發生,則happen before
  2. 監視器法則,對一個監視器的解鎖一定發生在後續對同一監視器加鎖之前
  3. Volatie變數法則:寫volatile變數一定發生在後續對它的讀之前
  4. 執行緒啟動法則:Thread.start一定發生線上程中的動作前
  5. 執行緒終結法則:執行緒中的任何動作一定發生線上程終結之前(其他執行緒檢測到這個執行緒已經終止,從Thread.join呼叫成功返回,Thread.isAlive()返回false)
  6. 中斷法則:一個執行緒呼叫另一個執行緒的interrupt一定發生在另一執行緒發現中斷之前。
  7. 終結法則:一個物件的建構函式結束一定發生在物件的finalizer之前
  8. 傳遞性:A發生在B之前,B發生在C之前,A一定發生在C之前。

使用HB關係,在多執行緒開發時就可以儘量少的避免使用鎖,而是直接利用hb關係和volatile關鍵字來達到資訊傳遞並且可見的目的。

比如很常見的一個執行緒處理一些資料並且修改標識位後,另外的執行緒檢測到標識位發生改變,就接手後續的流程。此時如何保證前一個執行緒對資料做出的更改後一個執行緒全部可見呢。先來看下面的程式碼例子

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;     //1
        flag = true;    //2
    }

    public void reader() {
        while(flag==false); //3
        int i=a; //4
    }
}

有兩個不同的執行緒分別執行writer和reader方法,根據Hb規則,有如下的順序執行圖。 這樣的順序,i讀取到的a的值就是最新的,也即是1.

參考文獻