Why Memory Barrier?

要了解如何使用memory barrier,最好的方法是明白它爲什麼存在。CPU硬件設計爲了提高指令的執行速度,增設了兩個緩衝區(store buffer, invalidate queue)。這個兩個緩衝區可以避免CPU在某些情況下進行不必要的等待,從而提高速度,但是這兩個緩衝區的存在也同時帶來了新的問題。

要仔細分析這個問題需要先了解cache的工作方式。

目前CPU的cache的工作方式很像軟件編程所使用的hash表,書上說“N路組相聯(N-way set associative)”,其中的“組”就是hash表的模值,即hash鏈的個數,而常說的“N路”,就是每個鏈表的最大長度。鏈表的表項叫做 cache-line,是一段固定大小的內存塊。讀操作很直接,不再贅述。如果某個CPU要寫數據項,必須先將該數據項從其他CPU的cache中移出, 這個操作叫做invalidation。當invalidation結束,CPU就可以安全的修改數據了。如果數據項在該CPU的cache中,但是是隻 讀的,這個過程叫做”write miss”。一旦CPU將數據從其他CPU的cache中移除,它就可以重複的讀寫該數據項了。如果此時其他CPU試圖訪問這個數據項,將產生一 次”cache miss”,這是因爲第一個CPU已經使數據項無效了。這種類型的cache-miss叫做”communication miss”,因爲產生這種miss的數據項通常是做在CPU之間溝通之用,比如鎖就是這樣一種數據項。

爲了保證在多處理器的環境下cache仍然一致,需要一種協議來防止數據不一致和丟失。目前常用的協議是MESI協議。MESI是 Modified,Exclusive, Shared, Invalid這四種狀態的首字母的組合。使用該協議的cache,會在每個cache-line前加一個2位的tag,標示當前的狀態。

modified狀態:該cache-line包含修改過的數據,內存中的數據不會出現在其他CPU-cache中,此時該CPU的cache中包含的數據是最新的
exclusive狀態:與modified類似,但是數據沒有修改,表示內存中的數據是最新的。如果此時要從cache中剔除數據項,不需要將數據寫回內存
shared狀態:數據項可能在其他CPU中有重複,CPU必須在查詢了其他CPU之後纔可以向該cache-line寫數據
invalid狀態:表示該cache-line空

MESI使用消息傳遞的方式在上述幾種狀態之間切換,具體轉換過程參見[1]。如果CPU使用共享BUS,下面的消息足夠:

read: 包含要讀取的CACHE-LINE的物理地址
read response: 包含READ請求的數據,要麼由內存滿足要麼由cache滿足
invalidate: 包含要invalidate的cache-line的物理地址,所有其他cache必須移除相應的數據項
invalidate ack: 回覆消息
read invalidate: 包含要讀取的cache-line的物理地址,同時使其他cache移除該數據。需要read response和invalidate ack消息
writeback:包含要寫回的數據和地址,該狀態將處於modified狀態的lines寫回內存,爲其他數據騰出空間

引用[1]中的話:

Interestingly enough, a shared-memory multiprocessor system really is a message-passing computer under the covers. This means that clusters of SMP machines that use distributed shared memory are using message passing to implement shared memory at two different levels of the system architecture.

雖然該協議可以保證數據的一致性,但是在某種情況下並不高效。舉例來說,如果CPU0要更新一個處於CPU1-cache中的數據,那麼它必須等待 cache-line從CPU1-cache傳遞到CPU0-cache,然後再執行寫操作。cache之間的傳遞需要花費大量的時間,比執行一個簡單的 操作寄存器的指令高出幾個數量級。而事實上,花費這個時間根本毫無意義,因爲不論從CPU1-cache傳遞過來的數據是什麼,CPU0都會覆蓋它。爲了 解決這個問題,硬件設計者引入了store buffer,該緩衝區位於CPU和cache之間,當進行寫操作時,CPU直接將數據寫入store buffer,而不再等待另一個CPU的消息。但是這個設計會導致一個很明顯的錯誤情況。

試考慮如下代碼:

1 a = 1;
2 b = a + 1;
3 assert(b == 2);

假設初始時a和b的值都是0,a處於CPU1-cache中,b處於CPU0-cache中。如果按照下面流程執行這段代碼:

1 CPU0執行a=1;
2 因爲a在CPU1-cache中,所以CPU0發送一個read invalidate消息來佔有數據
3 CPU0將a存入store buffer
4 CPU1接收到read invalidate消息,於是它傳遞cache-line,並從自己的cache中移出該cache-line
5 CPU0開始執行b=a+1;
6 CPU0接收到了CPU1傳遞來的cache-line,即“a=0”
7 CPU0從cache中讀取a的值,即“0”
8 CPU0更新cache-line,將store buffer中的數據寫入,即“a=1”
9 CPU0使用讀取到的a的值“0”,執行加1操作,並將結果“1”寫入b(b在CPU0-cache中,所以直接進行)
10 CPU0執行assert(b == 2); 失敗

出現問題的原因是我們有兩份”a”的拷貝,一份在cache-line中,一份在store buffer中。硬件設計師的解決辦法是“store forwarding”,當執行load操作時,會同時從cache和store buffer裏讀取。也就是說,當進行一次load操作,如果store-buffer裏有該數據,則CPU會從store-buffer裏直接取出數 據,而不經過cache。因爲“store forwarding”是硬件實現,我們並不需要太關心。

還有一中錯誤情況,考慮下面的代碼:

void foo(void)
{
a = 1;
b = 1;
}

void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}

假設變量a在CPU1-cache中,b在CPU0-cache中。CPU0執行foo(),CPU1執行bar(),程序執行的順序如下:

1 CPU0執行 a = 1; 因爲a不在CPU0-cache中,所以CPU0將a的值放到store-buffer裏,然後發送read invalidate消息
2 CPU1執行while(b == 0) continue; 但是因爲b不再CPU1-cache中,所以它會發送一個read消息
3 CPU0執行 b = 1;因爲b在CPU0-cache中,所以直接存儲b的值到store-buffer中
4 CPU0收到 read 消息,於是它將更新過的b的cache-line傳遞給CPU1,並標記爲shared
5 CPU1接收到包含b的cache-line,並安裝到自己的cache中
6 CPU1現在可以繼續執行while(b == 0) continue;了,因爲b=1所以循環結束
7 CPU1執行assert(a == 1);因爲a本來就在CPU1-cache中,而且值爲0,所以斷言爲假
8 CPU1收到read invalidate消息,將並將包含a的cache-line傳遞給CPU0,然後標記cache-line爲invalid。但是已經太晚了

就是說,可能出現這類情況,b已經賦值了,但是a還沒有,所以出現了b = 1, a = 0的情況。對於這類問題,硬件設計者也愛莫能助,因爲CPU無法知道變量之間的關聯關係。所以硬件設計者提供了memory barrier指令,讓軟件來告訴CPU這類關係。解決方法是修改代碼如下:

void foo(void)
{
a = 1;
smp_mb();
b = 1;
}

smp_mb()指令可以迫使CPU在進行後續store操作前刷新store-buffer。以上面的程序爲例,增加memory barrier之後,就可以保證在執行b=1的時候CPU0-store-buffer中的a已經刷新到cache中了,此時CPU1-cache中的a 必然已經標記爲invalid。對於CPU1中執行的代碼,則可以保證當b==0爲假時,a已經不在CPU1-cache中,從而必須從CPU0- cache傳遞,得到新值“1”。具體過程見[1]。

上面的例子是使用memory barrier的一種環境,另一種環境涉及到另一個緩衝區,確切的說是一個隊列——“Invalidate Queues”。

store buffer一般很小,所以CPU執行幾個store操作就會填滿。這時候CPU必須等待invalidation ACK消息,來釋放緩衝區空間——得到invalidation ACK消息的記錄會同步到cache中,並從store buffer中移除。同樣的情形發生在memory barrier執行以後,這時候所有後續的store操作都必須等待invalidation完成,不論這些操作是否導致cache-miss。解決辦法 很簡單,即使用“Invalidate Queues”將invalidate消息排隊,然後馬上返回invalidate ACK消息。不過這種方法有問題。

考慮下面的情況:

void foo(void)
{
a = 1;
smp_mb();
b = 1;
}

void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}

a處於shared狀態,b在CPU0-cache內。CPU0執行foo(),CPU1執行函數bar()。執行操作如下:

1 CPU0執行a=1。因爲cache-line是shared狀態,所以新值放到store-buffer裏,並傳遞invalidate消息來通知CPU1
2 CPU1執行 while(b==0) continue;但是b不再CPU1-cache中,所以發送read消息
3 CPU1接受到CPU0的invalidate消息,將其排隊,然後返回ACK消息
4 CPU0接收到來自CPU1的ACK消息,然後執行smp_mb(),將a從store-buffer移到cache-line中
5 CPU0執行b=1;因爲已經包含了該cache-line,所以將b的新值寫入cache-line
6 CPU0接收到了read消息,於是傳遞包含b新值的cache-line給CPU1,並標記爲shared狀態
7 CPU1接收到包含b的cache-line
8 CPU1繼續執行while(b==0) continue;因爲爲假所以進行下一個語句
9 CPU1執行assert(a==1),因爲a的舊值依然在CPU1-cache中,斷言失敗
10 儘管斷言失敗了,但是CPU1還是處理了隊列中的invalidate消息,並真的invalidate了包含a的cache-line,但是爲時已晚

可以看出出現問題的原因是,當CPU排隊某個invalidate消息後,在它還沒有處理這個消息之前,就再次讀取該消息對應的數據了,該數據此時本應該已經失效的。

解決方法是在bar()中也增加一個memory barrier:

void bar(void)
{
while (b == 0) continue;
smp_mb();
assert(a == 1);
}

此處smp_mb()的作用是處理“Invalidate Queues”中的消息,於是在執行assert(a==1)時,CPU1中的包含a的cache-line已經無效了,新的值要重新從CPU0-cache中讀取。

memory bariier還可以細分爲“write memory barrier(wmb)”和“read memory barrier(rmb)”。rmb只處理Invalidate Queues,wmb只處理store buffer。

可以使用rmb和wmb重寫上面的例子:

void foo(void)
{
a = 1;
smp_wmb();
b = 1;
}

void bar(void)
{
while (b == 0) continue;
smp_rmb();
assert(a == 1);
}

最後提一下x86的mb。x86CPU會自動處理store順序,所以smp_wmb()原語什麼也不做,但是load有可能亂序,smp_rmb()和smp_mb()展開爲lock;addl。

[1] http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf
[2] http://en.wikipedia.org/wiki/Memory_barrier
[3] http://www.mjmwired.net/kernel/Documentation/memory-barriers.txt


發佈了53 篇原創文章 · 獲贊 12 · 訪問量 33萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章