1. 程式人生 > >內存屏障和 volatile 語義

內存屏障和 volatile 語義

只需要 date 開發 多核 bool 計算 java 例子 說明

背景

在閱讀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會產生在單核上的讀寫不一致問題。下面是模擬

1

2

3

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是16store buffer中的值刷新到a的cacheline中,修改a的值為1,但是已經太晚了

為了避免這個問題,所以對於store buffer的設計中增加一個策略叫做store forwarding。就是說cpu在讀取數據的時候會先查看store buffer,如果store buffer中有數據,直接用store buffer中的。這樣,也就避免了使用錯誤數據的問題了。

store forwarding可以解決在單線程中的數據不一致問題,但是store buffer所帶來的復雜性遠不止如此。在多線程環境下,會有其他的問題。下面是模擬代碼

1

2

3

4

5

6

7

8

9

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消息3b在自身的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,也翻譯叫做柵欄)。來看下面的代碼

1

2

3

4

5

6

7

8

9

10

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又會導致另外一個問題。下面先來看代碼

1

2

3

4

5

6

7

8

9

10

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全部處理完成的功能即可。

硬件的設計者也是這麽考慮的,請看下面的代碼

1

2

3

4

5

6

7

8

9

10

11

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設置為invalidate8無由於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種,如下。

程序次序法則,如果A一定在B之前發生,則happen before
監視器法則,對一個監視器的解鎖一定發生在後續對同一監視器加鎖之前
Volatie變量法則:寫volatile變量一定發生在後續對它的讀之前
線程啟動法則:Thread.start一定發生在線程中的動作前
線程終結法則:線程中的任何動作一定發生在線程終結之前(其他線程檢測到這個線程已經終止,從Thread.join調用成功返回,Thread.isAlive()返回false)
中斷法則:一個線程調用另一個線程的interrupt一定發生在另一線程發現中斷之前。
終結法則:一個對象的構造函數結束一定發生在對象的finalizer之前
傳遞性:A發生在B之前,B發生在C之前,A一定發生在C之前。
使用HB關系,在多線程開發時就可以盡量少的避免使用鎖,而是直接利用hb關系和volatile關鍵字來達到信息傳遞並且可見的目的。

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

1

2

3

4

5

6

7

8

9

10

11

12

13

14

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.

內存屏障和 volatile 語義