Linux內核同步 - memory barrier
一、前言
我記得以前上學的時候大家經常說的一個詞匯叫做所見即所得,有些編程工具是所見即所得的,給程序員帶來極大的方便。對於一個c程序員,我們的編寫的代碼能所見即所得嗎?我們看到的c程序的邏輯是否就是最後CPU運行的結果呢?很遺憾,不是,我們的“所見”和最後的執行結果隔著:
1、編譯器
2、CPU取指執行
編譯器將符合人類思考的邏輯(c代碼)翻譯成了符合CPU運算規則的匯編指令,編譯器了解底層CPU的思維模式,因此,它可以在將c翻譯成匯編的時候進行優化(例如內存訪問指令的重新排序),讓產出的匯編指令在CPU上運行的時候更快。然而,這種優化產出的結果未必符合程序員原始的邏輯,因此,作為程序員,作為c程序員,必須有能力了解編譯器的行為,並在通過內嵌在c代碼中的memory barrier來指導編譯器的優化行為(這種memory barrier又叫做優化屏障,Optimization barrier),讓編譯器產出即高效,又邏輯正確的代碼。
CPU的核心思想就是取指執行,對於in-order的單核CPU,並且沒有cache(這種CPU在現實世界中還存在嗎?),匯編指令的取指和執行是嚴格按照順序進行的,也就是說,匯編指令就是所見即所得的,匯編指令的邏輯被嚴格的被CPU執行。然而,隨著計算機系統越來越復雜(多核、cache、superscalar、out-of-order),使用匯編指令這樣貼近處理器的語言也無法保證其被CPU執行的結果的一致性,從而需要程序員(看,人還是最不可以替代的)告知CPU如何保證邏輯正確。
綜上所述,memory barrier是一種保證內存訪問順序的一種方法,讓系統中的HW block(各個cpu、DMA controler、device等)對內存有一致性的視角。
二、不使用memory barrier會導致問題的場景
1、編譯器的優化
我們先看下面的一個例子:
preempt_disable()
臨界區
preempt_enable
有些共享資源可以通過禁止任務搶占來進行保護,因此臨界區代碼被preempt_disable和preempt_enable給保護起來。其實,我們知道所謂的preempt enable和disable其實就是對當前進程的struct thread_info中的preempt_count進行加一和減一的操作。具體的代碼如下:
#define preempt_disable() \
do { \
preempt_count_inc(); \
barrier(); \
} while (0)
linux kernel中的定義和我們的想像一樣,除了barrier這個優化屏障。barrier就象是c代碼中的一個柵欄,將代碼邏輯分成兩段,barrier之前的代碼和barrier之後的代碼在經過編譯器編譯後順序不能亂掉。也就是說,barrier之後的c代碼對應的匯編,不能跑到barrier之前去,反之亦然。之所以這麽做是因為在我們這個場景中,如果編譯為了榨取CPU的performace而對匯編指令進行重排,那麽臨界區的代碼就有可能位於preempt_count_inc之外,從而起不到保護作用。
現在,我們知道了增加barrier的作用,問題來了,barrier是否夠呢?對於multi-core的系統,只有當該task被調度到該CPU上執行的時候,該CPU才會訪問該task的preempt count,因此對於preempt enable和disable而言,不存在多個CPU同時訪問的場景。但是,即便這樣,如果CPU是亂序執行(out-of-order excution)的呢?其實,我們也不用擔心,正如前面敘述的,preempt count這個memory實際上是不存在多個cpu同時訪問的情況,因此,它實際上會本cpu的進程上下文和中斷上下文訪問。能終止當前thread執行preempt_disable的只有中斷。為了方便描述,我們給代碼編址,如下:
地址 | 該地址的匯編指令 | CPU的執行順序 |
a | preempt_disable() | 臨界區指令1 |
a+4 | 臨界區指令1 | preempt_disable() |
a+8 | 臨界區指令2 | 臨界區指令2 |
a+12 | preempt_enable | preempt_enable |
當發生中斷的時候,硬件會獲取當前PC值,並精確的得到了發生指令的地址。有兩種情況:
(1)在地址a發生中斷。對於out-of-order的CPU,臨界區指令1已經執行完畢,preempt_disable正在pipeline中等待執行。由於是在a地址發生中斷,也就是preempt_disable地址上發生中斷,對於硬件而言,它會保證a地址之前(包括a地址)的指令都被執行完畢,並且a地址之後的指令都沒有執行。因此,在這種情況下,臨界區指令1的執行結果被拋棄掉,因此,實際臨界區指令不會先於preempt_disable執行
(2)在地址a+4發生中斷。這時候,雖然發生中斷的那一刻的地址上的指令(臨界區指令1)已經執行完畢了,但是硬件會保證地址a+4之前的所有的指令都執行完畢,因此,實際上CPU會執行完preempt_disable,然後跳轉的中斷異常向量執行。
上面描述的是優化屏障在內存中的變量的應用,下面我們看看硬件寄存器的場景。一般而言,串口的驅動都會包括控制臺部分的代碼,例如:
static struct console xx_serial_console = {
……
.write = xx_serial_console_write,
……
};
如果系統enable了串口控制臺,那麽當你的驅動調用printk的時候,實際上最終是通過console的write函數輸出到了串口控制臺。而這個console write的函數可能會包含下面的代碼:
do {
獲取TX FIFO狀態寄存器
barrier();
} while (TX FIFO沒有ready);
寫TX FIFO寄存器;
對於某些CPU archtecture而言(至少ARM是這樣的),外設硬件的IO地址也被映射到了一段內存地址空間,對編譯器而言,它並不知道這些地址空間是屬於外設的。因此,對於上面的代碼,如果沒有barrier的話,獲取TX FIFO狀態寄存器的指令可能和寫TX FIFO寄存器指令進行重新排序,在這種情況下,程序邏輯就不對了,因為我們必須要保證TX FIFO ready的情況下才能寫TX FIFO寄存器。
對於multi core的情況,上面的代碼邏輯也是OK的,因為在調用console write函數的時候,要獲取一個console semaphore,確保了只有一個thread進入,因此,console write的代碼不會在多個CPU上並發。和preempt count的例子一樣,我們可以問同樣的問題,如果CPU是亂序執行(out-of-order excution)的呢?barrier只是保證compiler輸出的匯編指令的順序是OK的,不能確保CPU執行時候的亂序。 對這個問題的回答來自ARM architecture的內存訪問模型:對於program order是A1-->A2的情況(A1和A2都是對Device或是Strongly-ordered的memory進行訪問的指令),ARM保證A1也是先於A2執行的。因此,在這樣的場景下,使用barrier足夠了。 對於X86也是類似的,雖然它沒有對IO space采樣memory mapping的方式,但是,X86的所有操作IO端口的指令都是被順執行的,不需要考慮memory access order。
2、cpu architecture和cache的組織
註:本章節的內容來自對Paul E. McKenney的Why memory barriers文檔理解,更細致的內容可以參考該文檔。這個章節有些晦澀,需要一些耐心。作為一個c程序員,你可能會抱怨,為何設計CPU的硬件工程師不能屏蔽掉memory barrier的內容,讓c程序員關註在自己需要關註的程序邏輯上呢?本章可以展開敘述,或許能解決一些疑問。
(1)基本概念
在The Memory Hierarchy文檔中,我們已經了解了關於cache一些基礎的知識,一些基礎的內容,這裏就不再重復了。我們假設一個多核系統中的cache如下:
我們先了解一下各個cpu cache line狀態的遷移過程:
(a)我們假設在有一個memory中的變量為多個CPU共享,那麽剛開始的時候,所有的CPU的本地cache中都沒有該變量的副本,所有的cacheline都是invalid狀態。
(b)因此當cpu 0 讀取該變量的時候發生cache miss(更具體的說叫做cold miss或者warmup miss)。當該值從memory中加載到chache 0中的cache line之後,該cache line的狀態被設定為shared,而其他的cache都是Invalid。
(c)當cpu 1 讀取該變量的時候,chache 1中的對應的cache line也變成shared狀態。其實shared狀態就是表示共享變量在一個或者多個cpu的cache中有副本存在。既然是被多個cache所共享,那麽其中一個CPU就不能武斷修改自己的cache而不通知其他CPU的cache,否則會有一致性問題。
(d)總是read多沒勁,我們讓CPU n對共享變量來一個load and store的操作。這時候,CPU n發送一個read invalidate命令,加載了Cache n的cache line,並將狀態設定為exclusive,同時將所有其他CPU的cache對應的該共享變量的cacheline設定為invalid狀態。正因為如此,CPU n實際上是獨占了變量對應的cacheline(其他CPU的cacheline都是invalid了,系統中就這麽一個副本),就算是寫該變量,也不需要通知其他的CPU。CPU隨後的寫操作將cacheline設定為modified狀態,表示cache中的數據已經dirty,和memory中的不一致了。modified狀態和exclusive狀態都是獨占該cacheline,但是modified狀態下,cacheline的數據是dirty的,而exclusive狀態下,cacheline中的數據和memory中的數據是一致的。當該cacheline被替換出cache的時候,modified狀態的cacheline需要write back到memory中,而exclusive狀態不需要。
(e)在cacheline沒有被替換出CPU n的cache之前,CPU 0再次讀該共享變量,這時候會怎麽樣呢?當然是cache miss了(因為之前由於CPU n寫的動作而導致其他cpu的cache line變成了invalid,這種cache miss叫做communiction miss)。此外,由於CPU n的cache line是modified狀態,它必須響應這個讀得操作(memory中是dirty的)。因此,CPU 0的cacheline變成share狀態(在此之前,CPU n的cache line應該會發生write back動作,從而導致其cacheline也是shared狀態)。當然,也可能是CPU n的cache line不發生write back動作而是變成invalid狀態,CPU 0的cacheline變成modified狀態,這和具體的硬件設計相關。
(2)Store buffer
我們考慮另外一個場景:在上一節中step e中的操作變成CPU 0對共享變量進行寫的操作。這時候,寫的性能變得非常的差,因為CPU 0必須要等到CPU n上的cacheline 數據傳遞到其cacheline之後,才能進行寫的操作(CPU n上的cacheline 變成invalid狀態,CPU 0則切換成exclusive狀態,為後續的寫動作做準備)。而從一個CPU的cacheline傳遞數據到另外一個CPU的cacheline是非常消耗時間的,而這時候,CPU 0的寫的動作只是hold住,直到cacheline的數據完成傳遞。而實際上,這樣的等待是沒有意義的,因此,這時候cacheline的數據仍然會被覆蓋掉。為了解決這個問題,多核系統中的cache修改如下:
這樣,問題解決了,寫操作不必等到cacheline被加載,而是直接寫到store buffer中然後歡快的去幹其他的活。在CPU n的cacheline把數據傳遞到其cache 0的cacheline之後,硬件將store buffer中的內容寫入cacheline。
雖然性能問題解決了,但是邏輯錯誤也隨之引入,我們可以看下面的例子:
我們假設a和b是共享變量,初始值都是0,可以被cpu0和cpu1訪問。cpu 0的cache中保存了b的值(exclusive狀態),沒有a的值,而cpu 1的cache中保存了a的值,沒有b的值,cpu 0執行的匯編代碼是(用的是ARM匯編,沒有辦法,其他的都不是那麽熟悉):
ldr r2, [pc, #28] -------------------------- 取變量a的地址
ldr r4, [pc, #20] -------------------------- 取變量b的地址
mov r3, #1
str r3, [r2] --------------------------a=1
str r3, [r4] --------------------------b=1
CPU 1執行的代碼是:
ldr r2, [pc, #28] -------------------------- 取變量a的地址
ldr r3, [pc, #20] -------------------------- 取變量b的地址
start: ldr r3, [r3] -------------------------- 取變量b的值
cmp r3, #0 ------------------------ b的值是否等於0?
beq start ------------------------ 等於0的話跳轉到startldr r2, [r2] -------------------------- 取變量a的值
當cpu 1執行到--取變量a的值--這條指令的時候,b已經是被cpu0修改為1了,這也就是說a=1這個代碼已經執行了,因此,從匯編代碼的邏輯來看,這時候a值應該是確定的1。然而並非如此,cpu 0和cpu 1執行的指令和動作描述如下:
cpu 0執行的指令 | cpu 0動作描述 | cpu 1執行的指令 | cpu 1動作描述 |
str r3, [r2] (a=1) |
1、發生cache miss 2、將1保存在store buffer中 3、發送read invalidate命令,試圖從cpu 1的cacheline中獲取數據,並invalidate其cache line 註:這裏無需等待response,立刻執行下一條指令 |
ldr r3, [r3] (獲取b的值) |
1、發生cache miss 2、發送read命令,試圖加載b對應的cacheline 註:這裏cpu必須等待read response,下面的指令依賴於這個讀取的結果 |
str r3, [r4] (b=1) |
1、cache hit 2、cacheline中的值被修改為1,狀態變成modified |
||
響應cpu 1的read命令,發送read response(b=1)給CPU 0。write back,將狀態設定為shared | |||
cmp r3, #0 | 1、cpu 1收到來自cpu 0的read response,加載b對應的cacheline,狀態為shared 2、b等於1,因此不必跳轉到start執行 |
||
ldr r2, [r2] (獲取a的值) |
1、cache hit 2、獲取了a的舊值,也就是0 |
||
響應CPU 0的read invalid命令,將a對應的cacheline設為invalid狀態,發送read response和invalidate ack。但是已經釀成大錯了。 | |||
收到來自cpu 1的響應,將store buffer中的1寫入cache line。 |
對於硬件,CPU不清楚具體的代碼邏輯,它不可能直接幫助軟件工程師,只是提供一些memory barrier的指令,讓軟件工程師告訴CPU他想要的內存訪問邏輯順序。這時候,cpu 0的代碼修改如下:
ldr r2, [pc, #28] -------------------------- 取變量a的地址
ldr r4, [pc, #20] -------------------------- 取變量b的地址
mov r3, #1
str r3, [r2] --------------------------a=1確保清空store buffer的memory barrier instruction
str r3, [r4] --------------------------b=1
這種情況下,cpu 0和cpu 1執行的指令和動作描述如下:
cpu 0執行的指令 | cpu 0動作描述 | cpu 1執行的指令 | cpu 1動作描述 |
str r3, [r2] (a=1) |
1、發生cache miss 2、將1保存在store buffer中 3、發送read invalidate命令,試圖從cpu 1的cacheline中獲取數據,並invalidate其cache line 註:這裏無需等待response,立刻執行下一條指令 |
ldr r3, [r3] (獲取b的值) |
1、發生cache miss 2、發送read命令,試圖加載b對應的cacheline 註:這裏cpu必須等待read response,下面的指令依賴於這個讀取的結果 |
memory barrier instruction | CPU收到memory barrier指令,知道軟件要控制訪問順序,因此不會執行下一條str指令,要等到收到read response和invalidate ack後,將store buffer中所有數據寫到cacheline之後才會執行後續的store指令 | ||
cmp r3, #0 beq start |
1、cpu 1收到來自cpu 0的read response,加載b對應的cacheline,狀態為shared 2、b等於0,跳轉到start執行 |
||
響應CPU 0的read invalid命令,將a對應的cacheline設為invalid狀態,發送read response和invalidate ack。 | |||
收到來自cpu 1的響應,將store buffer中的1寫入cache line。 | |||
str r3, [r4] (b=1) |
1、cache hit,但是cacheline狀態是shared,需要發送invalidate到cpu 1 2、將1保存在store buffer中 註:這裏無需等待invalidate ack,立刻執行下一條指令 |
||
… | … | … | … |
由於增加了memory barrier,保證了a、b這兩個變量的訪問順序,從而保證了程序邏輯。
(3)Invalidate Queue
我們先回憶一下為何出現了stroe buffer:為了加快cache miss狀態下寫的性能,硬件提供了store buffer,以便讓CPU先寫入,從而不必等待invalidate ack(這些交互是為了保證各個cpu的cache的一致性)。然而,store buffer的size比較小,不需要特別多的store命令(假設每次都是cache miss)就可以將store buffer填滿,這時候,沒有空間寫了,因此CPU也只能是等待invalidate ack了,這個狀態和memory barrier指令的效果是一樣的。
怎麽解決這個問題?CPU設計的硬件工程師對性能的追求是不會停歇的。我們首先看看invalidate ack為何如此之慢呢?這主要是因為cpu在收到invalidate命令後,要對cacheline執行invalidate命令,確保該cacheline的確是invalid狀態後,才會發送ack。如果cache正忙於其他工作,當然不能立刻執行invalidate命令,也就無法會ack。
怎麽破?CPU設計的硬件工程師提供了下面的方法:
Invalidate Queue這個HW block從名字就可以看出來是保存invalidate請求的隊列。其他CPU發送到本CPU的invalidate命令會保存於此,這時候,並不需要等到實際對cacheline的invalidate操作完成,CPU就可以回invalidate ack了。
同store buffer一樣,雖然性能問題解決了,但是對memory的訪問順序導致的邏輯錯誤也隨之引入,我們可以看下面的例子(和store buffer中的例子類似):
我們假設a和b是共享變量,初始值都是0,可以被cpu0和cpu1訪問。cpu 0的cache中保存了b的值(exclusive狀態),而CPU 1和CPU 0的cache中都保存了a的值,狀態是shared。cpu 0執行的匯編代碼是:
ldr r2, [pc, #28] -------------------------- 取變量a的地址
ldr r4, [pc, #20] -------------------------- 取變量b的地址
mov r3, #1
str r3, [r2] --------------------------a=1確保清空store buffer的memory barrier instruction
str r3, [r4] --------------------------b=1
CPU 1執行的代碼是:
ldr r2, [pc, #28] -------------------------- 取變量a的地址
ldr r3, [pc, #20] -------------------------- 取變量b的地址
start: ldr r3, [r3] -------------------------- 取變量b的值
cmp r3, #0 ------------------------ b的值是否等於0?
beq start ------------------------ 等於0的話跳轉到startldr r2, [r2] -------------------------- 取變量a的值
這種情況下,cpu 0和cpu 1執行的指令和動作描述如下:
cpu 0執行的指令 | cpu 0動作描述 | cpu 1執行的指令 | cpu 1動作描述 |
str r3, [r2] (a=1) |
1、a值在CPU 0的cache中狀態是shared,是read only的,因此,需要通知其他的CPU 2、將1保存在store buffer中 3、發送invalidate命令,試圖invalidate CPU 1中a對應的cache line 註:這裏無需等待response,立刻執行下一條指令 |
ldr r3, [r3] (獲取b的值) |
1、發生cache miss 2、發送read命令,試圖加載b對應的cacheline 註:這裏cpu必須等待read response,下面的指令依賴於這個讀取的結果 |
收到來自CPU 0的invalidate命令,放入invalidate queue,立刻回ack。 | |||
memory barrier instruction | CPU收到memory barrier指令,知道軟件要控制訪問順序,因此不會執行下一條str指令,要等到收到invalidate ack後,將store buffer中所有數據寫到cacheline之後才會執行後續的store指令 | ||
收到invalidate ack後,將store buffer中的1寫入cache line。OK,可以繼續執行下一條指令了 | |||
str r3, [r4] (b=1) |
1、cache hit 2、cacheline中的值被修改為1,狀態變成modified |
||
收到CPU 1發送來的read命令,將b值(等於1)放入read response中,回送給CPU 1,write back並將狀態修改為shared。 | |||
收到response(b=1),並加載cacheline,狀態是shared | |||
|
cmp r3, #0 | b等於1,不會執行beq指令,而是執行下一條指令 | |
ldr r2, [r2] (獲取a的值) |
1、cache hit (還沒有執行invalidate動作,命令還在invalidate queue中呢) 2、獲取了a的舊值,也就是0 |
||
對a對應的cacheline執行invalidate 命令,但是,已經晚了 |
可怕的memory misorder問題又來了,都是由於引入了invalidate queue引起,看來我們還需要一個memory barrier的指令,我們將程序修改如下:
ldr r2, [pc, #28] -------------------------- 取變量a的地址
ldr r3, [pc, #20] -------------------------- 取變量b的地址
start: ldr r3, [r3] -------------------------- 取變量b的值
cmp r3, #0 ------------------------ b的值是否等於0?
beq start ------------------------ 等於0的話跳轉到start確保清空invalidate queue的memory barrier instruction
ldr r2, [r2] -------------------------- 取變量a的值
這種情況下,cpu 0和cpu 1執行的指令和動作描述如下:
cpu 0執行的指令 | cpu 0動作描述 | cpu 1執行的指令 | cpu 1動作描述 |
str r3, [r2] (a=1) |
1、a值在CPU 0的cache中狀態是shared,是read only的,因此,需要通知其他的CPU 2、將1保存在store buffer中 3、發送invalidate命令,試圖invalidate CPU 1中a對應的cache line 註:這裏無需等待response,立刻執行下一條指令 |
ldr r3, [r3] (獲取b的值) |
1、發生cache miss 2、發送read命令,試圖加載b對應的cacheline 註:這裏cpu必須等待read response,下面的指令依賴於這個讀取的結果 |
收到來自CPU 0的invalidate命令,放入invalidate queue,立刻回ack。 | |||
memory barrier instruction | CPU收到memory barrier指令,知道軟件要控制訪問順序,因此不會執行下一條str指令,要等到收到invalidate ack後,將store buffer中所有數據寫到cacheline之後才會執行後續的store指令 | ||
收到invalidate ack後,將store buffer中的1寫入cache line。OK,可以繼續執行下一條指令了 | |||
str r3, [r4] (b=1) |
1、cache hit 2、cacheline中的值被修改為1,狀態變成modified |
||
收到CPU 1發送來的read命令,將b值(等於1)放入read response中,回送給CPU 1,write back並將狀態修改為shared。 | |||
收到response(b=1),並加載cacheline,狀態是shared | |||
|
cmp r3, #0 | b等於1,不會執行beq指令,而是執行下一條指令 | |
memory barrier instruction | CPU收到memory barrier指令,知道軟件要控制訪問順序,因此不會執行下一條ldr指令,要等到執行完invalidate queue中的所有的invalidate命令之後才會執行下一個ldr指令 | ||
ldr r2, [r2] (獲取a的值) |
1、cache miss 2、發送read命令,從CPU 0那裏加載新的a值 |
由於增加了memory barrier,保證了a、b這兩個變量的訪問順序,從而保證了程序邏輯。
三、linux kernel的API
linux kernel的memory barrier相關的API列表如下:
接口名稱 | 作用 |
barrier() | 優化屏障,阻止編譯器為了進行性能優化而進行的memory access reorder |
mb() | 內存屏障(包括讀和寫),用於SMP和UP |
rmb() | 讀內存屏障,用於SMP和UP |
wmb() | 寫內存屏障,用於SMP和UP |
smp_mb() | 用於SMP場合的內存屏障,對於UP不存在memory order的問題(對匯編指令),因此,在UP上就是一個優化屏障,確保匯編和c代碼的memory order是一致的 |
smp_rmb() | 用於SMP場合的讀內存屏障 |
smp_wmb() | 用於SMP場合的寫內存屏障 |
barrier()這個接口和編譯器有關,對於gcc而言,其代碼如下:
#define barrier() __asm__ __volatile__("": : :"memory")
這裏的__volatile__主要是用來防止編譯器優化的。而這裏的優化是針對代碼塊而言的,使用嵌入式匯編的代碼分成三塊:
1、嵌入式匯編之前的c代碼塊
2、嵌入式匯編代碼塊
3、嵌入式匯編之後的c代碼塊
這裏__volatile__就是告訴編譯器:不要因為性能優化而將這些代碼重排,我需要清清爽爽的保持這三塊代碼塊的順序(代碼塊內部是否重排不是這裏的__volatile__管轄範圍了)。
barrier中的嵌入式匯編中的clobber list沒有描述匯編代碼對寄存器的修改情況,只是有一個memory的標記。我們知道,clober list是gcc和gas的接口,用於gas通知gcc它對寄存器和memory的修改情況。因此,這裏的memory就是告知gcc,在匯編代碼中,我修改了memory中的內容,嵌入式匯編之前的c代碼塊和嵌入式匯編之後的c代碼塊看到的memory是不一樣的,對memory的訪問不能依賴於嵌入式匯編之前的c代碼塊中寄存器的內容,需要重新加載。
優化屏障是和編譯器相關的,而內存屏障是和CPU architecture相關的,當然,我們選擇ARM為例來描述內存屏障。
Linux內核同步 - memory barrier