中文字幕在线观看,亚洲а∨天堂久久精品9966,亚洲成a人片在线观看你懂的,亚洲av成人片无码网站,亚洲国产精品无码久久久五月天

內存屏障和 volatile 語義

2018-09-17    來源:importnew

容器云強勢上線!快速搭建集群,上萬Linux鏡像隨意使用

背景

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

存儲結構

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

CPU緩存

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

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

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

Store buffer

如果一個Cpu要寫出一個數據,但是此時這個數據不在自己的cacheline中,因為cpu要向其他的cpu緩存發(fā)出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 發(fā)現a的地址不在本cpu的cacheline中,向其他的cpu發(fā)送read invalidate消息
2 將數據寫入store buffer中
3 收到其他cpu響應的read response和invalidate ack消息
4 執(zhí)行b=a+1,因為a這個時候已經在cacheline中,讀取到值為0,加1后為1,寫入到b中
5 執(zhí)行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所帶來的復雜性遠不止如此。在多線程環(huán)境下,會有其他的問題。下面是模擬代碼

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

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

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

通過上面的例子可以看到,在多核系統(tǒng)中,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。它的作用,是在后續(xù)的store動作之前,將sotre buffer中的內容刷新到cacheline。這個操作的效果是讓本地的cacheline的操作順序和代碼的順序一致,也就是讓其他cpu觀察到的該cpu的cacheline操作順序被分為smp_mb()之前和之后。要達到這個目的有兩種方式

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

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

序號 cpu0的步驟(執(zhí)行set) cpu1的步驟(執(zhí)行print)
1 想寫入a=1,但是由于a不在自身的cacheline中,向cpu1發(fā)送read invalidate消息 執(zhí)行while(b==0),由于b不在自身的cacheline中,向cpu0發(fā)送read消息
2 向store buffer中寫入a=1 等待cpu0響應的read response消息
3 遇到smp_mb(),等待直到可以將store buffer中的內容刷新到cacheline 等待cpu0響應的read response消息
4 等待直到可以將store buffer中的內容刷新到cacheline 收到cpu0發(fā)來的read invalidate消息,發(fā)送a=0的值,同時將自身a所在的cacheline修改為invalidate狀態(tài)
5 收到cpu1響應的read response和invalidate ack消息,將a=0的值設置到cacheline,隨后store buffer中a=1的值刷新到cacheline,設置cacheline狀態(tài)為M 等待cpu0響應的read response消息
6 由于b就在自身的cacheline中,并且狀態(tài)為M或者E,設置值為b=1 等待cpu0響應的read response消息
7 收到cpu1的read請求,將b=1的值傳遞回去,同時設置該cacheline狀態(tài)為s 等待cpu0響應的read response消息
8 收到cpu0的read response信息,將b設置為1,程序跳出循環(huán)
9 由于a所在的cacheline被設置為invalidate,因此向cpu0發(fā)送read請求
10 收到cpu1的read請求,以a=1響應,并且將自身的cacheline狀態(tài)修改為s 等待cpu0的read response響應
11 收到read response請求,將a設置為1,執(zhí)行程序判斷,結果為真

可以看到,在有了內存屏障之后,程序的真實結果就和我們的預期結果相同了。

invalidate queue

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

store buffer很容易滿的原因是因為收到其他cpu的invalidate ack的速度太慢。而cpu發(fā)送invalidate ack的速度太慢是因為cpu要等到將對應的cacheline設置為invalidate后才能發(fā)送invalidate ack。有的時候太多invalidate請求,cpu的處理速度就跟不上。為了加速這個流程,硬件設計者設計了invaldate queue來加速這個過程。收到的invalidate請求先放入invalidate queue,然后之后立刻響應invalidate ack消息。而cpu可以在隨后慢慢的處理這些invalidate消息。當然,這里必須不能太慢。也就是說,cpu實際上給出了一個承諾,如果一個invalidatge請求在invalidate queue中,那么對于這個請求相關的cacheline,在該請求被處理完成前,cpu不會再發(fā)送任何與該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之中,狀態(tài)為s。b是cpu0獨享,狀態(tài)為E或者M。

序號 cpu0的步驟(執(zhí)行set) cpu1的步驟(執(zhí)行print)
1 想寫入a=1,但是由于a的狀態(tài)是s,向cpu1發(fā)送invalidate消息 執(zhí)行while(b==0),由于b不在自身的cacheline中,向cpu0發(fā)送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,并且修改狀態(tài)為M 等待cpu0響應的read response消息
4 由于b就在自己的cacheline中,寫入b=1,修改狀態(tài)為M 等待cpu0響應的read response消息
5 收到cpu1響應的read請求,將b=1作為響應回傳,同時將cacheline的狀態(tài)修改為s。 等待cpu0響應的read response消息
6 收到read response,將b=1寫入cacheline,程序跳出循環(huán)
7 由于a所在的cacheline還未失效,load值,進行比對,assert失敗
8 cpu處理invalidate queue的消息,將a所在的cacheline設置為invalidate,但是已經太晚了

上面的例子,看起來就好像第一個一樣,仍然是b=1先生效,a=1后生效。導致了cpu1執(zhí)行的錯誤。就好像內存操作”重排序”一樣(個人不太喜歡內存操作重排序這個術語,因為實際上并不是重新排序的問題,而是是否可見的問題。但是用重排序這樣的詞語,反而不好理解。但是很多書都是用是了這個詞語,大家可以有自己的理解。但是還是推薦不要理會這些作者的抽象概念,直接了解核心)。其實這個問題的觸發(fā),就是因為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之中,狀態(tài)為s。b是cpu0獨享,狀態(tài)為E或者M。

序號 cpu0的步驟(執(zhí)行set) cpu1的步驟(執(zhí)行print)
1 想寫入a=1,但是由于a的狀態(tài)是s,向cpu1發(fā)送invalidate消息 執(zhí)行while(b==0),由于b不在自身的cacheline中,向cpu0發(fā)送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,并且修改狀態(tài)為M 等待cpu0響應的read response消息
4 由于b就在自己的cacheline中,寫入b=1,修改狀態(tài)為M 等待cpu0響應的read response消息
5 收到cpu1響應的read請求,將b=1作為響應回傳,同時將cacheline的狀態(tài)修改為s。 等待cpu0響應的read response消息
6 收到read response,將b=1寫入cacheline,程序跳出循環(huán)
7 遇見smp_mb(),讓cpu將invalidate queue中的消息全部處理完后,才能繼續(xù)向下執(zhí)行。此時將a所在的cacheline設置為invalidate
8 由于a所在的cacheline已經無效,向cpu0發(fā)送read消息
9 收到read請求,以a=1發(fā)送響應 收到cpu0發(fā)送的響應,以a=1寫入cacheline,執(zhí)行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
    該屏障保證了在屏障前的讀取操作效果先于屏障后的讀取操作效果發(fā)生。在各個不同平臺上會插入的編譯指令不相同,可能的一種做法是插入也被稱之為smp_rmb指令,強制處理完成當前的invalidate queue中的內容
  • StoreStore Barriers
    該屏障保證了在屏障前的寫操作效果先于屏障后的寫操作效果發(fā)生?赡艿淖龇ㄊ鞘褂smp_wmb指令,而且是使用該指令中,將后續(xù)寫入數據先寫入到store buffer的那種處理方式。因為這種方式消耗比較小
  • LoadStore Barriers
    該屏障保證了屏障前的讀操作效果先于屏障后的寫操作效果發(fā)生。
  • StoreLoad Barriers
    該屏障保證了屏障前的寫操作效果先于屏障后的讀操作效果發(fā)生。該屏障兼具上面三者的功能,是開銷最大的一種屏障。可能的做法就是插入一個smp_mb指令來完成。

內存屏障在volatile關鍵中的使用

內存屏障在很多地方使用,這里主要說下對于volatile關鍵字,內存屏障的使用方式。

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

上面的內存屏障方式主要是規(guī)定了在處理器級別的一些重排序要求。而JMM本身,對于volatile變量在編譯器級別的重排序也制定了相關的規(guī)則?梢杂孟旅娴膱D來表示

volatile變量除了在編譯器重排序方面的語義以外,還存在一條約束保證。如果cpu硬件上存在類似invalidate queue的東西,可以在進行變量讀取操作之前,會先處理完畢queue上的內容。這樣就能保證volatile變量始終是讀取最新的最后寫入的值。

Happen-before

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

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

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

  1. 程序次序法則,如果A一定在B之前發(fā)生,則happen before
  2. 監(jiān)視器法則,對一個監(jiān)視器的解鎖一定發(fā)生在后續(xù)對同一監(jiān)視器加鎖之前
  3. Volatie變量法則:寫volatile變量一定發(fā)生在后續(xù)對它的讀之前
  4. 線程啟動法則:Thread.start一定發(fā)生在線程中的動作前
  5. 線程終結法則:線程中的任何動作一定發(fā)生在線程終結之前(其他線程檢測到這個線程已經終止,從Thread.join調用成功返回,Thread.isAlive()返回false)
  6. 中斷法則:一個線程調用另一個線程的interrupt一定發(fā)生在另一線程發(fā)現中斷之前。
  7. 終結法則:一個對象的構造函數結束一定發(fā)生在對象的finalizer之前
  8. 傳遞性:A發(fā)生在B之前,B發(fā)生在C之前,A一定發(fā)生在C之前。

使用HB關系,在多線程開發(fā)時就可以盡量少的避免使用鎖,而是直接利用hb關系和volatile關鍵字來達到信息傳遞并且可見的目的。

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

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
    }
}

有兩個不同的線程分別執(zhí)行writer和reader方法,根據Hb規(guī)則,有如下的順序執(zhí)行圖。

這樣的順序,i讀取到的a的值就是最新的,也即是1.

參考文獻

  • jsr133文檔

標簽: 代碼

版權申明:本站文章部分自網絡,如有侵權,請聯系:west999com@outlook.com
特別注意:本站所有轉載文章言論不代表本站觀點!
本站所提供的圖片等素材,版權歸原作者所有,如需使用,請與原作者聯系。

上一篇:SpringBoot | 第二十二章:定時任務的使用

下一篇:SpringBoot | 番外:使用小技巧合集