"Memory barrier" Memory barrier 簡介 程式在運行時記憶體實際的訪問順序和程式代碼編寫的訪問順序不一定一致,這就是記憶體亂序訪問。記憶體亂序訪問行為出現的理由是為了提升程式運行時的性能。記憶體亂序訪問主要發生在兩個階段: 1. 編譯時,編譯器優化導致記憶體亂序訪問(指令重排) 2 ...
Memory barrier 簡介
程式在運行時記憶體實際的訪問順序和程式代碼編寫的訪問順序不一定一致,這就是記憶體亂序訪問。記憶體亂序訪問行為出現的理由是為了提升程式運行時的性能。記憶體亂序訪問主要發生在兩個階段:
- 編譯時,編譯器優化導致記憶體亂序訪問(指令重排)
- 運行時,多 CPU 間交互引起記憶體亂序訪問
Memory barrier 能夠讓 CPU 或編譯器在記憶體訪問上有序。一個 Memory barrier 之前的記憶體訪問操作必定先於其之後的完成。Memory barrier 包括兩類:
- 編譯器 barrier
- CPU Memory barrier
很多時候,編譯器和 CPU 引起記憶體亂序訪問不會帶來什麼問題,但一些特殊情況下,程式邏輯的正確性依賴於記憶體訪問順序,這時候記憶體亂序訪問會帶來邏輯上的錯誤,例如:
// thread 1
while (!ok);
do(x);
// thread 2
x = 42;
ok = 1;
此段代碼中,ok 初始化為 0,線程 1 等待 ok 被設置為 1 後執行 do 函數。假如說,線程 2 對記憶體的寫操作亂序執行,也就是 x 賦值後於 ok 賦值完成,那麼 do 函數接受的實參就很可能出乎程式員的意料,不為 42。
編譯時記憶體亂序訪問
在編譯時,編譯器對代碼做出優化時可能改變實際執行指令的順序(例如 gcc 下 O2 或 O3 都會改變實際執行指令的順序):
// test.cpp
int x, y, r;
void f()
{
x = r;
y = 1;
}
編譯器優化的結果可能導致y = 1
在 x = r
之前執行完成。首先直接編譯此源文件:
g++ -S test.cpp
得到相關的彙編代碼如下:
movl r(%rip), %eax
movl %eax, x(%rip)
movl $1, y(%rip)
這裡我們看到,x = r 和 y = 1 並沒有亂序。現使用優化選項 O2(或 O3)編譯上面的代碼(g++ -O2 -S test.cpp),生成彙編代碼如下:
movl r(%rip), %eax
movl $1, y(%rip)
movl %eax, x(%rip)
我們可以清楚的看到經過編譯器優化之後 movl $1, y(%rip) 先於 movl %eax, x(%rip) 執行。避免編譯時記憶體亂序訪問的辦法就是使用編譯器 barrier(又叫優化 barrier)。Linux 內核提供函數 barrier() 用於讓編譯器保證其之前的記憶體訪問先於其之後的完成。內核實現 barrier() 如下(X86-64 架構):
#define barrier() __asm__ __volatile__("" ::: "memory")
現在把此編譯器 barrier 加入代碼中:
int x, y, r;
void f()
{
x = r;
__asm__ __volatile__("" ::: "memory");
y = 1;
}
這樣就避免了編譯器優化帶來的記憶體亂序訪問的問題了(如果有興趣可以再看看編譯之後的彙編代碼)。本例中,我們還可以使用 volatile 這個關鍵字來避免編譯時記憶體亂序訪問(而無法避免後面要說的運行時記憶體亂序訪問)。volatile 關鍵字能夠讓相關的變數之間在記憶體訪問上避免亂序,這裡可以修改 x 和 y 的定義來解決問題:
volatile int x, y;
int r;
void f()
{
x = r;
y = 1;
}
現加上了 volatile 關鍵字,這使得 x 相對於 y、y 相對於 x 在記憶體訪問上有序。在 Linux 內核中,提供了一個巨集 ACCESS_ONCE 來避免編譯器對於連續的 ACCESS_ONCE 實例進行指令重排。其實 ACCESS_ONCE 實現源碼如下:
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
此代碼只是將變數 x 轉換為 volatile 的而已。現在我們就有了第三個修改方案:
int x, y, r;
void f()
{
ACCESS_ONCE(x) = r;
ACCESS_ONCE(y) = 1;
}
到此基本上就闡述完了我們的編譯時記憶體亂序訪問的問題。下麵開始介紹運行時記憶體亂序訪問。
運行時記憶體亂序訪問
在運行時,CPU 雖然會亂序執行指令,但是在單個 CPU 的上,硬體能夠保證程式執行時所有的記憶體訪問操作看起來像是按程式代碼編寫的順序執行的,這時候 Memory barrier 沒有必要使用(不考慮編譯器優化的情況下)。這裡我們瞭解一下 CPU 亂序執行的行為。在亂序執行時,一個處理器真正執行指令的順序由可用的輸入數據決定,而非程式員編寫的順序。
早期的處理器為有序處理器(In-order processors),有序處理器處理指令通常有以下幾步:
- 指令獲取
- 如果指令的輸入操作對象(input operands)可用(例如已經在寄存器中了),則將此指令分發到適當的功能單元中。如果一個或者多個操作對象不可用(通常是由於需要從記憶體中獲取),則處理器會等待直到它們可用
- 指令被適當的功能單元執行
- 功能單元將結果寫回寄存器堆(Register file,一個 CPU 中的一組寄存器)
相比之下,亂序處理器(Out-of-order processors)處理指令通常有以下幾步:
- 指令獲取
- 指令被分發到指令隊列
- 指令在指令隊列中等待,直到輸入操作對象可用(一旦輸入操作對象可用,指令就可以離開隊列,即便更早的指令未被執行)
- 指令被分配到適當的功能單元並執行
- 執行結果被放入隊列(而不立即寫入寄存器堆)
- 只有所有更早請求執行的指令的執行結果被寫入寄存器堆後,指令執行的結果才被寫入寄存器堆(執行結果重排序,讓執行看起來是有序的)
從上面的執行過程可以看出,亂序執行相比有序執行能夠避免等待不可用的操作對象(有序執行的第二步)從而提高了效率。現代的機器上,處理器運行的速度比記憶體快很多,有序處理器花在等待可用數據的時間里已經可以處理大量指令了。
現在思考一下亂序處理器處理指令的過程,我們能得到幾個結論:
- 對於單個 CPU 指令獲取是有序的(通過隊列實現)
- 對於單個 CPU 指令執行結果也是有序返回寄存器堆的(通過隊列實現)
由此可知,在單 CPU 上,不考慮編譯器優化導致亂序的前提下,多線程執行不存在記憶體亂序訪問的問題。我們從內核源碼也可以得到類似的結論(代碼不完全的摘錄):