1. 程式人生 > 其它 >call x86 優化 重定位_併發程式設計基礎 - 重排序和屏障

call x86 優化 重定位_併發程式設計基礎 - 重排序和屏障

技術標籤:call x86 優化 重定位

155db1c0d8a94c2526b229acdf495e93.png

本篇介紹指令重排序,optimization barrier 和 memory barrier。

Correct me if I'm wrong.

現代處理器通常並行地執行若干條指令,且可能重新安排記憶體訪問順序,這種重新排序可以極大地加速程式的執行。編譯器也是出於效能優化的目的,可能會修改記憶體指令的順序。所以不要認為指令會嚴格按照它們在原始碼裡出現的順序執行。在併發程式設計的時候,為了處理同步,就必須避免指令重排序。顯然如果同步原語之後的一條指令在同步原語之前執行,就是很嚴重的錯誤(比如 mutex.lock 之後要進入臨界區,如果臨界區裡的指令被重排到了 mutex.lock

的前面是不可接受的)。正確實現的同步原語肯定得通過編譯器屏障和記憶體屏障確保指令重排不影響正確性。

編譯期指令重排

在單執行緒執行的程式碼裡,程式碼執行的 效果 當然是按照程式碼順序依次執行。現代編譯器為了優化效能,可能會把一些指令重排,這種效應在程式碼單執行緒執行時是無法察覺的(如果察覺到就說明這樣的優化過於激進,產生錯誤的結果)。指令未必會嚴格按照它們在原始碼中出現的順序。

int Value;
int IsPublished = 0;

void sendValue(int x)
{
    Value = x;
    IsPublished = 1;
}

int readValue() {
    while (!IsPublished) {}
    return Value;
}

比如這段程式碼,Value = x;IsPublished = 1; 在單執行緒執行時,誰先執行並不影響正確性,編譯器可能會把 IsPublished = 1; 的彙編程式碼生成在 Value = x; 的前面。單執行緒執行時,即使發生了這樣的重排,在兩個語句的中間也沒有檢查每個變數的值(比如 print Value),因此這兩句話重排並不影響正確性。

然而在 lock-free 程式設計的時候,這樣的程式碼可能會由於編譯器重排產生錯誤。假設執行緒 A 執行 sendValue,執行緒 B 執行 readValue。執行緒 A 先對 Value 釋出一個值 x,然後標記 IsPublished

。執行緒 B 不斷檢查 IsPublished,看是否已經有執行緒釋出了值,如果有執行緒釋出了值,那麼返回 Value 的值。如果編譯器在編譯 sendValue 的時候把兩個指令重排,執行緒 B 就可能看到了 IsPublished == 1,然而 Value 並沒有被正確賦值,返回一個錯誤的值。

編譯器屏障

編譯器屏障,優化屏障,optimization barrier,compiler barrier,看意思就知道說的是一個東西了。optimization barrier 保證編譯器不會混淆放在 barrier 之前的彙編指令和它之後的彙編指令。在 Linux kernel 裡,優化屏障的程式碼就是 barrier() 巨集,展開成 asm volatile("" ::: "memory");。指令 asm 告訴編譯器要插入彙編程式碼(這裡要插入的是空的彙編程式碼)。 volatile 關鍵字禁止編譯器把 asm 指令和程式中的其他指令重新組合。 memory 關鍵字強制編譯器假設記憶體中的所有記憶體單元已經被組合語言指令修改。所以,編譯器不能對 barrier 之前的語句儲存在記憶體單元的值進行假設,不能使用存放在暫存器裡的記憶體單元的值來優化程式碼。但是注意,優化屏障不保證 CPU 執行彙編指令的時候重排序,這種重排需要記憶體屏障禁止。

處理器指令重排和記憶體屏障

現代處理器架構越來越複雜啦,一般是多個核心,每個核心有自己的快取,又有所有核心共用的快取,之後才是記憶體(這是 SMP,還有 NUMA, 總之挺複雜的我也不是很懂,但是理解記憶體模型這方面知道個大概就行)。CPU暫存器, L1, L2, L3, ..., memory,速度依次變慢,容量依次變大,也是計算機系統經典知識了。現代處理器通常並行地執行若干條指令,且可能重新安排記憶體訪問順序,這種重新排序可以極大地加速程式的執行。總之為了效能,CPU 是支援亂序執行的 (Out-of-Order, OOO),學體系結構的時候應該都學過。現代處理器非常複雜,而且隨著不斷髮展也越來越複雜,這裡不打算細究到底怎麼亂序執行,快取一致性協議啥的。

記憶體屏障 (memory barrier) 確保,在屏障之後的操作開始執行之前,屏障之前的操作已經完成。所以這個東西叫做屏障(barrier),也有的地方叫柵欄(fence),意思就是它前後的指令不能穿越這個 barrier。前面介紹的 optimization barrier 可以阻止編譯器進行指令重排。對於處理器指令重排,相對會更加複雜,對於不同的指令重排,有不同的記憶體屏障。

注意,這裡說的記憶體屏障,和 JVM 裡的 write barriers 沒關係。Memory barrier instructions directly control only the interaction of a CPU with its cache, with its write-buffer that holds stores waiting to be flushed to memory, and/or its buffer of waiting loads or speculatively executed instructions. These effects may lead to further interaction among caches, main memory and other processors.

幾乎所有的處理器都至少提供一種粗粒度的 barrier 指令,一般就只叫 Fence,可以確保 Fence 前的 Load 和 Store 嚴格地在 Fence 後的 Load 和 Store 前執行。無論是哪種處理器,這種一般 barrier 都是很耗時的(一般都接近甚至比原子操作指令更慢)。大多數處理器會另外提供一些細粒度的 barrier。下面是一種普遍的 barrier 分類,基本能比較好地對映到特定真實 CPU 裡的指令:

  1. LoadLoad barrier: 序列 Load1; LoadLoad; Load2 確保 Load1 比 Load2 以及之後的所有 Load 指令先執行。
  2. StoreStore barrier: 序列 Store1; StoreStore; Store2 確保 Store1 的寫入可見於所有處理器(比如從快取重新整理到記憶體),先發生於 Store2 的寫入和所有之後的 Store 指令。
  3. LoadStore barrier: 序列 Load1; LoadStore; Store2 確保 Load1 讀取資料先發生於 Store2 和之後的所有 Store 指令 寫入的資料可見於所有處理器。
  4. StoreLoad barrier: 序列 Store1; StoreLoad; Load2 確保 Store1 寫入的資料可見於所有處理器先發生於 Load2 和之後的所有 Load 指令的執行。

這幾種是常見的指令重排情況和對應的 barrier。在特定的處理器上,並不是這四種重排序都有可能發生,有些處理器體系結構確保了有些重排序是不會發生的。參考下圖。x86 上只會出現 StoreLoad 型重排序(個別 x86 不一樣),Alpha 架構(神威·太湖之光就是這個架構)就一切皆有可能了。跑個題,x86 雖然只有 StoreLoad 重排序,lfence 指令是有用的,近似於 NOP,但仍有其必要場景。具體參考 :

intel x86系列CPU既然是strong order的,不會出現loadload亂序,為什麼還需要lfence指令?​www.zhihu.com

04b0ca1fc557c74283389fcf71698435.png
一些體系結構的記憶體重排序情況

最後

本來想繼續講一些 acquire-release 語義,強弱記憶體模型啥的,還是放在後續的文章裡講解吧。

參考資料:

  1. Memory Ordering at Compile Time
  2. Memory Barriers Are Like Source Control Operations
  3. Memory ordering
  4. Understanding the Linux Kernel
  5. The JSR-133 Cookbook for Compiler Writers