內存屏障究竟是個什麼鬼?




讀者朋友你好 hello

在開始閱讀之前我們假設讀者已經掌握了緩存一致性協議的MESI相關知識。如果沒有建議閱讀 帶你瞭解緩存一致性協議 MESI

   2020年

   3月13日





問題的產生





如上圖 CPU 0 執行了一次寫操作,但是此時 CPU 0 的 local cache 中沒有這個數據。於是 CPU 0 發送了一個 Invalidate 消息,其他所有的 CPU 在收到這個 Invalidate 消息之後,需要將自己 CPU local cache 中的該數據從 cache 中清除,並且發送消息 acknowledge 告知 CPU 0。CPU 0 在收到所有 CPU 發送的 ack 消息後會將數據寫入到自己的 local cache 中。這裏就產生了性能問題:當 CPU 0 在等待其他 CPU 的 ack 消息時是處於停滯的(stall)狀態,大部分的時間都是在等待消息。爲了提高性能就引入的 Store Buffer。




Store Buffer



store buffer 的目的是讓 CPU 不再操作之前進行漫長的等待時間,而是將數據先寫入到 store buffer 中,CPU 無需等待可以繼續執行其他指令,等到 CPU 收到了 ack 消息後,再從 store buffer 中將數據寫入到 local cache 中。


有了 store buffer 之後性能提高了許多,但常言道:“有一利必有一弊。”store buffer 雖然提高了性能但是卻引入了新的問題。

   
   
   
a = 0 , b = 0; a = 1; b = a + 1; assert(b == 2);

假設變量 a 在 CPU 1 的 cache line 中 , 變量 b 在 CPU 0 的 cache line 中。上述代碼的執行序列如下:
1. CPU 0 執行 a = 1。

2. CPU 0 local cache 中沒有 a ,發生 cache miss 。

3. CPU 0 發送 read invalidate 消息獲取 a ,同時讓其他 CPU local cache 中的 a 被清除。

4. CPU 0 把需要寫入 a 的值 1 放入了 store buffer 。

5. CPU 1 收到了 read invalidate 消息,迴應了 read response 和 acknowledge 消息,把自己 local cache 中的 a 清除了。

6. CPU 0 執行 b = a + 1 。

7. CPU 0 收到了 read response 消息得到了 a 的值是 0 。

8. CPU 0 從 cache line 中讀取了 a 值爲 0 。

9. CPU 0 執行 a + 1 , 並寫入 b ,b 被 CPU 0 獨佔所以直接寫入 cache line , 這時候 b 的值爲 1。

10. CPU 0 將 store buffer 中 a 的值寫入到 cache line , a 變爲 1。

11. CPU 0 執行 assert(b == 2) , 程序報錯。

導致這個問題是因爲 CPU 對內存進行操作的時候,順序和程序代碼指令順序不一致。在寫操作執行之前就先執行了讀操作。

另一個原因是在同一個 CPU 中同一個數據存在不一致的情況 , 在 store buffer 中是最新的數據, 在 cache line 中是舊的數據。爲了解決在同一個 CPU 的 store buffer 和 cache 之間數據不一致的問題,引入了 Store Forwarding。


store forwarding 就是當 CPU 執行讀操作時,會從 store buffer 和 cache 中讀取數據, 如果 store buffer 中有數據會使用 store buffer 中的數據,這樣就解決了同一個 CPU 中數據不一致的問題。但是由於 Memory Ordering 引起的問題還沒有解決。




內存操作順序
Memory Ordering

a = 0 , b = 0;void fun1() {     a = 1;     b = 1;}
void fun2() {   while (b == 0continue;   assert(a == 1);}

假設 CPU 0 執行 fun1() , CPU 1 執行 fun2() , a 變量在 CPU 1 cache 中 , b 變量在 CPU 0 cache 中。 上述代碼的執行序列如下:
  1.  CPU 0執行a=1的賦值操作,由於a不在local cache中,因此,CPU 0將a值放到store buffer中之後,發送了read invalidate命令到總線上去。
  2. CPU 1執行 while (b == 0) 循環,由於b不在CPU 1的cache中,因此,CPU發送一個read message到總線上,看看是否可以從其他cpu的local cache中或者memory中獲取數據。
  3.  CPU 0繼續執行b=1的賦值語句,由於b就在自己的local cache中(cacheline處於modified狀態或者exclusive狀態),因此CPU0可以直接操作將新的值1寫入cache line。
  4. CPU 0收到了read message,將最新的b值”1“回送給CPU 1,同時將b cacheline的狀態設定爲shared。
  5. CPU 1收到了來自CPU 0的read response消息,將b變量的最新值”1“值寫入自己的cacheline,狀態修改爲shared。
  6. 由於b值等於1了,因此CPU 1跳出while (b == 0)的循環,繼續執行。
  7.  CPU 1執行assert(a == 1),這時候CPU 1的local cache中還是舊的a值,因此assert(a == 1)失敗。
  8. CPU 1收到了來自CPU 0的read invalidate消息,以a變量的值進行迴應,同時清空自己的cacheline。
  9. CPU 0收到了read response和invalidate ack的消息之後,將store buffer中的a的最新值”1“數據寫入cacheline。


產生問題的原因是 CPU 0 對 a 的寫操作還沒有執行完,但是 CPU 1 對 a 的讀操作已經執行了。畢竟CPU並不知道哪些變量有相關性,這些變量是如何相關的。不過CPU設計者可以間接提供一些工具讓軟件工程師來控制這些相關性。這些工具就是 memory barrier 指令。要想程序正常運行,必須增加一些 memory barrier 的操作。





寫內存屏障
Store Memory Barrier



a = 0 , b = 0;void fun1() {     a = 1;     smp_mb();     b = 1;}
void fun2() {    while (b == 0continue;  assert(a == 1);}


smp_mb() 這個內存屏障的操作會在執行後續的store操作之前,首先flush store buffer(也就是將之前的值寫入到cacheline中)。smp_mb() 操作主要是爲了讓數據在local cache中的操作順序是符合program order的順序的,爲了達到這個目標有兩種方法:方法一就是讓CPU stall,直到完成了清空了store buffer(也就是把store buffer中的數據寫入cacheline了)。方法二是讓CPU可以繼續運行,不過需要在store buffer中做些文章,也就是要記錄store buffer中數據的順序,在將store buffer的數據更新到cacheline的操作中,嚴格按照順序執行,即便是後來的store buffer數據對應的cacheline已經ready,也不能執行操作,要等前面的store buffer值寫到cacheline之後才操作。增加smp_mb() 之後,操作順序如下:

1. CPU 0執行a=1的賦值操作,由於a不在local cache中,因此,CPU 0將a值放 store buffer中之後,發送了read invalidate命令到總線上去。

2. CPU 1執行 while (b == 0) 循環,由於b不在CPU 1的cache中,因此,CPU發送一個read message到總線上,看看是否可以從其他cpu的local cache中或者memory中獲取數據。

3. CPU 0執行smp_mb()函數,給目前store buffer中的所有項做一個標記(後面我們稱之marked entries)。當然,針對我們這個例子,store buffer中只有一個marked entry就是“a=1”。

4. CPU 0繼續執行b=1的賦值語句,雖然b就在自己的local cache中(cacheline處於modified狀態或者exclusive狀態),不過在store buffer中有marked entry,因此CPU0並沒有直接操作將新的值1寫入cache line,取而代之是b的新值”1“被寫入store buffer,當然是unmarked狀態。

5. CPU 0收到了read message,將b值”0“(新值”1“還在store buffer中)回送給CPU 1,同時將b cacheline的狀態設定爲shared。

6.  CPU 1收到了來自CPU 0的read response消息,將b變量的值(”0“)寫入自己的cacheline,狀態修改爲shared。

7. 完成了bus transaction之後,CPU 1可以load b到寄存器中了(local cacheline中已經有b值了),當然,這時候b仍然等於0,因此循環不斷的loop。雖然b值在CPU 0上已經賦值等於1,但是那個新值被安全的隱藏在CPU 0的store buffer中。

8. CPU 1收到了來自CPU 0的read invalidate消息,以a變量的值進行迴應,同時清空自己的cacheline。

9. CPU 0將store buffer中的a值寫入cacheline,並且將cacheline狀態修改爲modified狀態。

10. 由於store buffer只有一項marked entry(對應a=1),因此,完成step 9之後,store buffer的b也可以進入cacheline了。不過需要注意的是,當前b對應的cacheline的狀態是shared。

11.  CPU 0發送invalidate消息,請求b數據的獨佔權。

12.  CPU 1收到invalidate消息,清空自己的b cacheline,並回送acknowledgement給CPU 0。

13. CPU 1繼續執行while (b == 0),由於b不在自己的local cache中,因此 CPU 1發送read消息,請求獲取b的數據。

14. CPU 0收到acknowledgement消息,將b對應的cacheline修改成exclusive狀態,這時候,CPU 0終於可以將b的新值1寫入cacheline。

15. CPU 0收到read消息,將b的新值1回送給CPU 1,同時將其local cache中b對應的cacheline狀態修改爲shared。

16.  CPU 1獲取來自CPU 0的b的新值,將其放入cacheline中。

17. 由於b值等於1了,因此CPU 1跳出while (b == 0)的循環,繼續執行。

18. CPU 1執行assert(a == 1),不過這時候a值沒有在自己的cacheline中,因此需要通過cache一致性協議從CPU 0那裏獲得,這時候獲取的是a的最新值,也就是1值,因此assert成功。

通過上面的描述,我們可以看到,一個直觀上很簡單的給a變量賦值的操作,都需要那麼長的執行過程,而且每一步都需要芯片參與,最終完成整個複雜的賦值操作過程。



上述這個例子展示了 write memory barrier , 簡單來說在屏障之後的寫操作必須等待屏障之前的寫操作完成纔可以執行,讀操作則不受該屏障的影響。



順序寫操作導致了 CPU 的停頓
Store Sequences Result in Unnecessary Stalls

按照矛盾的角度來看解決了一個問題之後伴隨着又產生了一個新的問題:每個cpu的store buffer不能實現的太大,其entry的數目不會太多。當cpu以中等的頻率執行store操作的時候(假設所有的store操作導致了cache miss),store buffer會很快的被填滿。在這種狀況下,CPU只能又進入等待狀態,直到cache line完成invalidate和ack的交互之後,可以將store buffer的entry寫入cacheline,從而爲新的store讓出空間之後,CPU纔可以繼續執行。這種狀況也可能發生在調用了memory barrier指令之後,因爲一旦store buffer中的某個entry被標記了,那麼隨後的store都必須等待invalidate完成,因此不管是否cache miss,這些store都必須進入store buffer。爲了解決這個問題引入了 invalidate queues 可以緩解這個狀況。store buffer之所以很容易被填充滿,主要是其他CPU迴應invalidate acknowledge比較慢,如果能夠加快這個過程,讓store buffer儘快進入cacheline,那麼也就不會那麼容易填滿了。

invalidate acknowledge不能儘快回覆的主要原因是invalidate cacheline的操作沒有那麼快完成,特別是cache比較繁忙的時候,這時,CPU往往進行密集的loading和storing的操作,而來自其他CPU的,對本CPU local cacheline的操作需要和本CPU的密集的cache操作進行競爭,只要完成了invalidate操作之後,本CPU纔會發生invalidate acknowledge。此外,如果短時間內收到大量的invalidate消息,CPU有可能跟不上處理,從而導致其他CPU不斷的等待。

然而,CPU其實不需要完成invalidate操作就可以回送acknowledge消息,這樣,就不會阻止發生invalidate請求的那個CPU進入無聊的等待狀態。CPU可以buffer這些invalidate message(放入Invalidate Queues),然後直接回應acknowledge,表示自己已經收到請求,隨後會慢慢處理。當然,再慢也要有一個度,例如對a變量cacheline的invalidate處理必須在該CPU發送任何關於a變量對應cacheline的操作到bus之前完成。


有了Invalidate Queue的CPU,在收到invalidate消息的時候首先把它放入Invalidate Queue,同時立刻回送acknowledge 消息,無需等到該cacheline被真正invalidate之後再回應。當然,如果本CPU想要針對某個cacheline向總線發送invalidate消息的時候,那麼CPU必須首先去Invalidate Queue中看看是否有相關的cacheline,如果有,那麼不能立刻發送,需要等到Invalidate Queue中的cacheline被處理完之後再發送。一旦將一個invalidate(例如針對變量a的cacheline)消息放入CPU的Invalidate Queue,實際上該CPU就等於作出這樣的承諾:在處理完該invalidate消息之前,不會發送任何相關(即針對變量a的cacheline)的MESI協議消息。






讀內存屏障
Load Memory Barrier

a = 0 , b = 0;void fun1() {      a = 1;      smp_mb();      b = 1;}
void fun2() {       while (b == 0continue;   assert(a == 1);}

假設 a 存在於 CPU 0 和 CPU 1 的 local cache 中,b 存在於 CPU 0 中。CPU 0 執行 fun1() , CPU 1 執行 fun2() 。操作序列如下:

  1.  CPU 0執行a=1的賦值操作,由於a在CPU 0 local cache中的cacheline處於shared狀態,因此,CPU 0將a的新值“1”放入store buffer,並且發送了invalidate消息去清空CPU 1對應的cacheline。

   2. CPU 1執行while (b == 0)的循環操作,但是b沒有在local cache,因此發送read消息試圖獲取該值。

    3. CPU 1收到了CPU 0的invalidate消息,放入Invalidate Queue,並立刻回送Ack。

    4. CPU 0收到了CPU 1的invalidate ACK之後,即可以越過程序設定內存屏障(第四行代碼的smp_mb() ),這樣a的新值從store buffer進入cacheline,狀態變成Modified。

    5. CPU 0 越過memory barrier後繼續執行b=1的賦值操作,由於b值在CPU 0的local cache中,因此store操作完成並進入cache line。

    6. CPU 0收到了read消息後將b的最新值“1”回送給CPU 1,並修正該cacheline爲shared狀態。

    7.  CPU 1收到read response,將b的最新值“1”加載到local cacheline。

    8. 對於CPU 1而言,b已經等於1了,因此跳出while (b == 0)的循環,繼續執行後續代碼。

     9.  CPU 1執行assert(a == 1),但是由於這時候CPU 1 cache的a值仍然是舊值0,因此assert 失敗。

     10. Invalidate Queue中針對a cacheline的invalidate消息最終會被CPU 1執行,將a設定爲無效。

很明顯,在上面場景中,加速 ack 導致fun1()中的memory barrier失效了,因此,這時候對 ack 已經沒有意義了,畢竟程序邏輯都錯了。怎麼辦?其實我們可以讓memory barrier指令和Invalidate Queue進行交互來保證確定的memory order。具體做法是這樣的:當CPU執行memory barrier指令的時候,對當前Invalidate Queue中的所有的entry進行標註,這些被標註的項次被稱爲marked entries,而隨後CPU執行的任何的load操作都需要等到Invalidate Queue中所有marked entries完成對cacheline的操作之後才能進行。因此,要想保證程序邏輯正確,我們需要給 fun2() 增加內存屏障的操作,具體如下:

a = 0 , b = 0;void fun1() {      a = 1;      smp_mb();      b = 1;}
void fun2() {     while (b == 0continue;     smp_rmb(); assert(a == 1); }

當 CPU 1 執行完 while(b == 0) continue;  之後, 它必須等待 Invalidate Queues 中的 Invalidate 變量 a 的消息被處理完,將 a 從 CPU 1 local cache 中清除掉。然後才能執行 assert(a == 1)。CPU 1 在讀取 a 時發生 cache miss ,然後發送一個 read 消息讀取 a ,CPU 0 會迴應一個 read response 將 a 的值發送給 CPU 1。




許多CPU architecture提供了弱一點的memory barrier指令只mark其中之一。如果只mark invalidate queue,那麼這種memory barrier被稱爲read memory barrier。相應的,write memory barrier只mark store buffer。一個全功能的memory barrier會同時mark store buffer和invalidate queue。

我們一起來看看讀寫內存屏障的執行效果:對於read memory barrier指令,它只是約束執行CPU上的load操作的順序,具體的效果就是CPU一定是完成read memory barrier之前的load操作之後,纔開始執行read memory barrier之後的load操作。read memory barrier指令象一道柵欄,嚴格區分了之前和之後的load操作。同樣的,write memory barrier指令,它只是約束執行CPU上的store操作的順序,具體的效果就是CPU一定是完成write memory barrier之前的store操作之後,纔開始執行write memory barrier之後的store操作。全功能的memory barrier會同時約束load和store操作,當然只是對執行memory barrier的CPU有效。


掃碼關注

覺得有收穫

動動手指關注我們吧:)



本文分享自微信公衆號 - 黑帽子技術(SNJYYNJY2020)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章