1. 程式人生 > >memory_order 強記憶體模型保證記憶體順序就好

memory_order 強記憶體模型保證記憶體順序就好

在C++11標準原子庫中,大多數函式接收一個memory_order引數:

 
  1. enum memory_order {

  2. memory_order_relaxed,

  3. memory_order_consume,

  4. memory_order_acquire,

  5. memory_order_release,

  6. memory_order_acq_rel,

  7. memory_order_seq_cst

  8. };

上面的值被稱為記憶體順序約束。每一個都有自己的目的。在它們之中,memory_order_consume很可能是最少被正確理解的。它是最複雜的排序約束,也最難被正確使用。儘管如此,然而還是吸引著好奇的程式設計師去弄懂它--或者只是想解開它的神祕面紗。這就是這篇文章的目的所在。

首先,讓這個術語直白著:一個使用memory_order_consume的操作具有消費語義

(consume semantics)。我們稱這個操作為消費操作(consume operations)。

也許對於memory_order_consume最的價值的觀察結果就是總是可以安全的將它替換成memory_order_acquire。那是因為獲取操作(acquire operations)提供了消費操作(consume operations)的所有保證,而且還更多。換句話說,獲取語義更強。

消費和獲取都為了同一個目的:幫助非原子資訊線上程間安全的傳遞。就像獲取操作一樣,消費操作必須與另一個執行緒的釋放操作一起使用。它們之間主要的區別在於消費操作可以正確起作用的案例更少。相對於它的使用不便,反過來也就意味著消費操作在某些平臺使用更有效。我將使用一個例子演示所有這些問題點。

---------------------------------------------------------------------------------------------------------------------------------------

對獲取和釋放語義的簡要介紹

這個例子將從使用獲取和釋放語義線上程間傳遞少量資料開始。然後,我們將使用消費語義替代它。

首先,讓我們宣告兩個共享變數。Guard是一個C++11原子整數,而Payload只是一個普通int。兩個變數初始值都 為0。

 
  1. atomic<int> Guard(0);

  2. int Payload = 0;

主執行緒有一個迴圈,它反覆嘗試下面一系列讀操作。基本上,Guard的目的是使用獲取語義保護對Payload的訪問。主執行緒不會從Payload中讀取到資料直到Guard等於非0。

 
  1. g = Guard.load(memory_order_acquire);

  2. if (g != 0)

  3. p = Payload;

同時,一個非同步任務(執行在另一個執行緒)給Payload賦值42,然後使用釋放語義對Guard賦值1。

 
  1. Payload = 42;

  2. Guard.store(1, memory_order_acquire);

讀者現在應該熟悉這一樣式;在以前的文章中我們應該見過它很多次。一旦非同步任務寫到Guard中,主執行緒將讀到它,這意味著寫-釋放與讀-獲取同步(synchronized-with)了。在這種情況下,我們保證p會等於42,不管這個例子執行在什麼平臺。

我們使用獲取和釋放語義線上程間傳遞簡單的非原子整數payload,但是此模式在傳遞大資料量時也能工作的很好,就如同在以前文章中演示的那樣。
---------------------------------------------------------------------------------------------------------------------------------------

獲取語義的開銷

為了測量memory_order_acquire的開銷,我在3 個不同的多核處理器中編譯執行以上例子。對於每個架構,我選擇對c++11原子支援最好的編譯器。你們將在GitHub上找到完整的程式碼。

讓我們來看一下讀-獲取附近程式碼產生的機器碼:

 
  1. g = Guard.load(memory_order_acquire);

  2. if (g != 0)

  3. p = Payload;

Intel x86-64

在Intel x86-64上,Clang編譯器給這個例子產生了緊湊的機器碼--每行C++程式碼對應一條機器指令。這一處理器家族採用強記憶體模型,所以編譯器不需要放置特定有記憶體柵欄以實現讀-獲取。只需要保證機器指令的順序正確就行。
PowerPC

PowerPC是弱排序CPU,這就意味著編譯器在多核系統中必須放置記憶體柵欄指令以保證獲取語義。在這個例子中,GCC使用了這裡推薦的由3個指令組成的一串指令:cmp;bne;isync。(單個指令lwsync也可以完成相同的工作)
ARMv7

ARM也是弱排序CPU,所以編譯器在多核系統中也必須放置記憶體柵欄指令以保證獲取語義。在ARMv7中,dmb ish是最合適的指令,儘管也是一個記憶體柵欄。

如下就是我們例子的主迴圈在測試機器上每迴圈一次的計時:

在PowerPC和ARMv7上,記憶體柵欄指令造成的效能懲罰,但它們對正確執行是必須的。事實上,如果你從ARMv7機器碼中刪除dmb ish指令,同時保留其它指令,在iPhone 4S上記憶體重排序能被直接觀察到。
---------------------------------------------------------------------------------------------------------------------------------------

資料依賴順序

我已說過PowerPC和ARM是弱排序CPU,但事實上,在機器指令級別上執行記憶體排序時總會有一些情況是不需要顯式的使用記憶體柵欄指令的。特別是那些使用資料依指令保持記憶體排序的處理器。

當兩個機器指令在同一個執行緒執行時,如果第一個指令的輸出值會被第二個指令作為輸入用到,那它們就是資料依賴(data-dependent)的。輸出值可能會被寫入暫存器,就如同下方PowerPC所示的那樣。這裡,第一個指令載入值到r9,第二個指令會在接下來的載入過程中將r9作為一個指標:

因為在這兩個指令之間存在資料依賴(data-dependency),載入將按順序執行。

你們可能認為這是很明顯的。然而,在第一個指令載入了r9之前第二個指令怎麼知道從哪個地址載入?很顯然不知道。記住,載入指令也可能從不同的快取讀取資料。如果另外一個CPU核心正在併發修改記憶體,第二個指令的快取不會像第一個指令那樣及時更新,那樣也會導致記憶體重排!PowerPC 提供了其它技術路徑避免這種情況,即通過保持每個快取是最新的從而確保資料依賴排序總是被保持。

資料依賴不只是會通過暫存器建立;它們也能通過記憶體位置建立。在這個列表中,第一個指令寫值到記憶體,第二個指令將值讀出,從而在兩個指令之間建立了資料依賴:

當多個指令彼此之間相互資料依賴時,我們稱之為資料依賴鏈(data dependency chain)。在如下的PowerPC列表中,有兩個獨立的資料依賴鏈:

資料依賴順序保證所有的沿著同一條鏈的記憶體訪問將按順序執行。例如,在上面的列表中,在第一個藍色的載入與最後一個藍色的載入之間記憶體順序將會被保證;在第一個綠色的載入與最後一個綠色的載入之間記憶體順序將也會被保證。另一方面,獨立的鏈之間記憶體順序是沒有保證的!所以,每一個藍色的載入也可以有效的在任意一個綠的的載入之後發生。

其它一些處理器族也可以保持資料依賴順序。Itanium, PA-RISC, SPARC (in RMO mode) and zSeries也在機器指令級別遵從資料依賴順序。事實上,唯一知道的不可以保持資料依賴順序的弱排序處理器是 DEC Alpha

更不用說像Intel x86, x86-64 and SPARC (in TSO mode)這樣的強排序CPU,也同樣是遵從資料依賴順序的。
---------------------------------------------------------------------------------------------------------------------------------------

消費語義就是被設計來使用這一特性的

當你使用消費語義,你就是想讓編譯器在所有那些處理器族上利用資料依賴。這就是為什麼簡簡單單的將memory_order_acquire改為memory_order_consume是不夠的。你必須確定在C++原始碼級別存在資料依賴。

在原始碼級別,依賴鏈是一串表示式,它的值將給其它程式碼提供一個依賴(Carries-a-dependency)。提供一個依賴是在C++標準§1.10.9中定義的。在大多資料情況下,也就是說當第一個的值被用來作為第二個的運算元時,一個程式碼的值提供一個依賴給其它程式碼。這種描述就像是機器級資料依賴的程式語言級版本。(其實在c++11中有一套嚴格的條件說明什麼構成了提供一個依賴,但我不會在這裡詳細論述。)

現在主我們回過頭來修改原先的例子以使用消費語義。首先,我們將改變Guard的型別,從atomic<int> 改為atomic<int*>:

 
  1. atomic<int*> Guard(nullptr);

  2. int Payload = 0;

我們這樣做是因為,在非同步任務中,我們想儲存一個指標給Payload,以便說明payload準備好了:

 
  1. Payload = 42;

  2. Guard.store(&Payload, memory_order_release);

最終,在主執行緒,我們將memory_order_acquire替代為memory_order_consume,我們經由從g獲取的指標間接載入p。從p載入而不是直接從payload中讀取,這是關鍵!這使得第一行程式碼給第三行程式碼提供了一個依賴,在這個例子中,為了使用消費語義這點是至關重要的:

 
  1. g = Guard.load(memory_order_consume);

  2. if (g != nullptr)

  3. p = *g;

可以在GitHub上找到完整的程式碼。

現在,這個被修改的例子可以跟原先的例子一樣可靠的執行。一旦非同步任務寫入Guard,同時主執行緒讀取它,C++標準保證p將等於42,不管程式碼執行在什麼平臺。不同之處在於這次沒有在任何地方使用synchronizes-with關係。這次我們使用的關係被稱為dependency-ordered-before關係。


在任何dependency-ordered-before關係中,依賴鏈從消費操作開始,在寫-釋放執行前的所有記憶體操作被保證在鏈上可見。
---------------------------------------------------------------------------------------------------------------------------------------

消費語義的值

現在,我們看一下使用消費語義修改後的例子生成的機器碼。

Intel x86-64


機器碼載入Guard到暫存器rcx,然後,如果rcx是空,使用rcx載入payload,這樣在兩個載入指令之間建立一個數據依賴。資料依賴並沒有產生什麼實質的不同。x86-64的強記憶體模型總能保證卡莉法按順序執行,即使沒有資料依賴。
PowerPC

機器碼載入Guard到暫存器r9,然後,使用r9載入payload,這樣在兩個載入指令之間建立一個數據依賴。它起作用了,這個資料依賴讓我們完全避免了在原先例子中構成記憶體柵欄的cmp;bne;isync指令序列,同時仍然會確保兩個卡載入會按順序執行。
ARMv7

機器碼載入Guard到暫存器r4,然後,使用r4載入payload,這樣在兩個載入指令之間建立一個數據依賴。這個資料依賴讓我們完全避免了在原先例子客戶出現的dmb ish指令,同時仍然會確保兩個卡載入會按順序執行。

最終,根據我前面提供的彙編列表,下面是主迴圈每次迭代的最新計時:

一點也不讓人奇怪,消費語義在Intel x86-64上幾乎沒什麼變化,但通過去除昂貴的記憶體柵欄,它們在PowerPC產生了巨大的變化,在ARMv7上也產生了顯著的變化。當然,請記住這些是微基準測試。在實際應用中,效能獲取將依賴於獲取操作被執行的頻率。

在真實世界中使用這一技術--利用資料依賴順序以避免記憶體柵欄--的例子就是Linux核心。Linux提供了一個對讀-複製-更新(RCU)的實現,它適合構建在多個執行緒中需要頻繁讀取但寫入不頻繁的資料結構。然而,在本文寫作期間,Linux實際上沒有使用C++11消費語義來去除那些記憶體柵欄。相反,它依靠它自己的API和規範。其實起初RCU就被看作是給C++11新增消費語義的動機
---------------------------------------------------------------------------------------------------------------------------------------

現在缺少編譯器的支援

我不得不坦白,我展示給你們的那些針對PowerPC和ARMv7彙編程式碼列表,是捏造的。對不起,GCC 4.8.3和Clang 4.6其實不會為消費操作生成機器碼!我知道,這多少有點讓人失望。但是這篇文章的目的是展示memory_order_consume的目的。不幸的是,事實是現在的編譯器還沒有依此行事。

你會看到,針對弱排序處理器,編譯器會從兩種策略中選擇實現memory_order_consume的方式:一種的高效的策略和一種代價高昂的策略。高效的策略是這篇文章所描述過的。如果處理器遵從資料依賴順序,編譯器會避免放置記憶體柵欄指令,只要它在消費操作開始時為每個程式程式碼級依賴鏈輸出機器級依賴鏈。而在代價高昂的策略中,編譯器會簡單的把memory_order_consume看成作是memory_order_acquire,並忽略整個依賴鏈。

當前版本的GCC和Clang/LLVM總是使用代價高昂的策略(除了在當前版本GCC中已知的bug)。結果就是,如果你在PowerPC和 ARMv7中使用當前的編譯器編譯memory_order_consume,將會產生不必要的記憶體柵欄指令,破壞了初衷。

這表明在遵從C++11規範時實現高效的策略對編譯器作者是很難的。這裡有一些提案幫助提高規範,目的就是為了讓編譯器很容易實現。我不會在這裡評論這些細節;可能會寫相關文章加以論述。

如果編譯器確實實現了高效策略,你可以使用它優化針對雙重檢查鎖定的惰性載入、有意義型別的無鎖雜湊表、無鎖棧和無鎖佇列。記住,只會在特定的處理器族中獲得性能提升,並且很可能在載入-消費執行次數少於,比如10000次每秒時,效能提升會微不足道。