c++11 記憶體模型解讀

twoon發表於2014-06-29

關於亂序

說到記憶體模型,首先需要明確一個普遍存在,但卻未必人人都注意到的事實:程式通常並不是總按著照原始碼中的順序一一執行,此謂之亂序,亂序產生的原因可能有好幾種:

  1. 編譯器出於優化的目的,在編譯階段將原始碼的順序進行交換。
  2. 程式執行期間,指令流水被 cpu 亂序執行。
  3. inherent cache 的分層及重新整理策略使得有時候某些寫讀操作的從效果上看,順序被重排。

以上亂序現象雖然來源不同,但從原始碼的角度,對上層應用程式來說,他們的效果其實相同:寫出來的程式碼與最後被執行的程式碼是不一致的。這個事實可能會讓人很驚訝:有這樣嚴重的問題,還怎麼寫得出正確的程式碼?這擔憂是多餘的了,亂序的現象雖然普遍存在,但它們都有很重要的一個共同點:在單執行緒執行的情況下,亂序執行與不亂序執行,最後都會得出相同的結果 (both end up with the same observable result), 這是亂序被允許出現所需要遵循的首要原則,也是為什麼亂序雖然一直存在但卻多數程式設計師大部分時間都感覺不到的根本原因。

亂序的出現說到底是編譯器,CPU 等為了讓你程式跑得更快而作出無限努力的結果,程式設計師們應該為它們的良苦用心抹一把淚。

從亂序的種類來看,亂序主要可以分為如下4種:

  1. 寫寫亂序(store store), 前面的寫操作被放到了後面的操作之後,比如:
a = 3;
b = 4;
被亂序為:
b = 4;
a = 3;
  1. 寫讀亂序(store load),前面的寫操作被放到了後面的讀操作之後,比如:
a = 3;
load(b);
被亂序為
load(b);
a = 3;
  1. 讀讀亂序(load load), 前面的讀操作被放到了後一個讀操作之後,比如:
load(a);
load(b);
被亂序為:
load(b);
load(a);
  1. 讀寫亂序(load store), 前面的讀操作被放到了後一個寫操作之後,比如:
load(a);
b = 4;
被亂序為:
b = 4;
load(a);

程式的亂序在單執行緒的世界裡多數時候並沒有引起太多引人注意的問題,但在多執行緒的世界裡,這些亂序就製造了特別的麻煩,究其原因,最主要的有2個:

  1. 併發不能保證修改和訪問共享變數的操作原子性,使得一些中間狀態暴露了出去,因此像 mutex,各種 lock 之類的東西在寫多執行緒時被頻繁地使用。
  2. 變數被修改後,該修改未必能被另一個執行緒及時觀察到,因此需要“同步”。

解決同步問題就需要確定記憶體模型,也就是需要確定執行緒間應該怎麼通過共享記憶體來進行互動(檢視維基百科).

記憶體模型

記憶體模型所要表達的內容主要是怎麼描述一個記憶體操作的效果,在各個執行緒間的可見性的問題。修改操作的效果不能及時被別的執行緒看見的原因有很多,比較明顯的一個是,對計算機來說,通常記憶體的寫操作相對於讀操作是昂貴很多很多的,因此對寫操作的優化是提升效能的關鍵,而這些對寫操作的種種優化,導致了一個很普遍的現象出現:寫操作通常會在 CPU 內部的 cache 中快取起來。這就導致了在一個 CPU 裡執行一個寫操作之後,該操作導致的記憶體變化卻不一定會馬上就被另一個 CPU 所看到,這從另一個角度講,效果上其實就是讀寫亂序了。

cpu1 執行如下:
a = 3;
cpu2 執行如下:
load(a);

對如上程式碼,假設 a 的初始值是 0, 然後 cpu1 先執行,之後 cpu2 再執行,假設其中讀寫都是原子的,那麼最後 cpu2 如果讀到 a = 0 也其實不是什麼奇怪事情。很顯然,這種在某個執行緒裡成功修改了全域性變數,居然在另一個執行緒裡看不到效果的後果是很嚴重的。

因此必須要有必要的手段對這種修改公共變數的行為進行同步。

c++11 中的 atomic library 中定義了以下6種語義來對記憶體操作的行為進行約定,這些語義分別規定了不同的記憶體操作在其它執行緒中的可見性問題:

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

我們主要討論其中的幾個:relaxed, acquire, release, seq_cst(sequential consistency).

relaxed 語義

首先是 relaxed 語義,這表示一種最寬鬆的記憶體操作約定,該約定其實就是不進行約定,以這種方式修改記憶體時,不需要保證該修改會不會及時被其它執行緒看到,也不對亂序做任何要求,因此當對公共變數以 relaxed 方式進行讀寫時,編譯器,cpu 等是被允許按照任意它們認為合適的方式來加以優化處理的。

release-acquire 語義

如果你曾經去看過別的介紹記憶體模型相關的文章,你一定會發現 release 總是和 acquire 放到一起來講,這並不是偶然。事實上,release 和 acquire 是相輔相承的,它們必須配合起來使用,這倆是一個 “package deal”, 分開使用則完全沒有意義。具體到其中, release 用於進行寫操作,acquire 則用於進行讀操作,它們結合起來表示這樣一個約定:

如果一個執行緒A對一塊記憶體 m 以 release 的方式進行修改,那麼線上程 A 中,所有在該 release 操作之前進行的記憶體操作,都在另一個執行緒 B 對記憶體 m 以 acquire 的方式進行讀取之後,變得可見。

舉個粟子,假設執行緒 A 執行如下指令:

a.store(3);
b.store(4);
m.store(5, release);

執行緒 B 執行如下:

e.load();
f.load();
m.load(acquire);
g.load();
h.load();

如上,假設執行緒 A 先執行,執行緒 B 後執行, 因為執行緒 A 中對 m 以 release 的方式進行修改, 而執行緒 B 中以 acquire 的方式對 m 進行讀取,所以當執行緒 B 執行完 m.load(acquire) 之後, 執行緒 B 必須已經能看到 a == 3, b == 4. 以上死板的描述事實上還傳達了額外的不那麼明顯的資訊:

  • release 和 acquire 是相對兩個執行緒來說的,它約定的是兩個執行緒間的相對行為:如果其中一個執行緒 A 以 release 的方式修改公共變數 m, 另一個執行緒 B 以 acquire 的方式時讀取該 m 時,要有什麼樣的後果,但它並不保證,此時如果還有另一個執行緒 C 以非 acquire 的方式來讀取 m 時,會有什麼後果。

  • 一定程度阻止了亂序的發生,因為要求 release 操作之前的所有操作都在另一個執行緒 acquire 之後可見,那麼:
    • release 操作之前的所有記憶體操作不允許被亂序到 release 之後。
    • acquire 操作之後的所有記憶體操作不允許被亂序到 acquire 之前。

而在對它們的使用上,有幾點是特別需要注意和強調的:

  1. release 和 acquire 必須配合使用,分開單獨使用是沒有意義。
  2. release 只對寫操作(store) 有效,對讀 (load) 是沒有意義的。
  3. acquire 則只對讀操作有效,對寫操作是沒有意義的。

現代的處理器通常都支援一些 read-modify-write 之類的指令,對這種指令,有時我們可能既想對該操作 執行 release 又要對該操作執行 acquire,因此 c++11 中還定義了 memory_order_acq_rel,該型別的操作就是 release 與 acquire 的結合,除前面提到的作用外,還起到了 memory barrier 的功能。

sequential consistency

sequential consistency 相當於 release + acquire 之外,還加上了一個對該操作加上全域性順序的要求,這是什麼意思呢?

簡單來說就是,對所有以 memory_order_seq_cst 方式進行的記憶體操作,不管它們是不是分散在不同的 cpu 中同時進行,這些操作所產生的效果最終都要求有一個全域性的順序,而且這個順序在各個相關的執行緒看起來是一致的。

舉個粟子,假設 a, b 的初始值都是0:

執行緒 A 執行:

a.store(3, seq_cst);

執行緒 B 執行:

b.store(4, seq_cst);

如上對 a 與 b 的修改雖然分別放在兩個執行緒裡同時進行,但是這多個動作畢竟是非原子的,因此這些操作地進行在全域性上必須要有一個先後順序:

  1. 先修改a, 後修改 b,或
  2. 先修改b, 把整個a。

而且這個順序是固定的,必須在其它任意執行緒看起來都是一樣,因此 a == 0 && b == 4 與 a == 3 && b == 0 不允許同時成立。

後話

這篇隨筆躺在我的草稿箱裡已經半年多時間了,半年多來我不斷地整理在這方面的知識,也在不斷理清自己的思路,最後還是覺得關於記憶體模型有太多可以說卻不是一下子能說得清楚的東西了,因此這兒只能把想說的東西一減再減,把範圍縮小到 c++11 語言層面上作簡單介紹,純粹算是做個總結,有興趣深入瞭解更多細節的讀者,我強烈推薦去看一下 Herb Sutter 在這方面做的一個 talk, 記憶體模型方面的知識是很難理解,更難以正確使用的,在大多數情況下使用它而得到的些少效能優勢,已經完全不值得為此而帶來的程式碼複雜性及可讀性方面的損失,如果你還在猶豫是否要用這些相對底層的東西的時候,就不要用它,猶豫就說明還有其它選擇,不到沒得選擇,都不要親自實現 lock free 相關的東西。

【引用】

http://bartoszmilewski.com/2008/11/11/who-ordered-sequential-consistency/

http://bartoszmilewski.com/2008/11/05/who-ordered-memory-fences-on-an-x86/

http://bartoszmilewski.com/2008/12/01/c-atomics-and-memory-ordering/

http://en.cppreference.com/w/cpp/atomic/memory_order

http://preshing.com

相關文章