C++11內存模型詳解

    C++內存模型可以被看作是C++程序和計算機系統(包括編譯器,多核CPU等可能對程序進行亂序優化的軟硬件)之間的契約,它規定了多個線程訪問同一個內存地址時的語義,以及某個線程對內存地址的更新何時能被其它線程看見.

關於亂序

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

1. 編譯器出於優化的目的,在編譯階段將源碼的順序進行交換。
2. 程序執行期間,指令流水被CPU亂序執行。
3. Cache的分層及刷新策略使得有時候某些寫,讀操作的順序被重排。

   以上亂序現象雖然來源不同,但從源碼的角度,對上層應用程序來說,他們的效果其實相同:寫出來的代碼與最後被執行的代碼是不一致的。

   這個事實可能會讓人很驚訝:有這樣嚴重的問題,還怎麼寫得出正確的代碼?這擔憂是多慮了,亂序的現象雖然普遍存在,但它們都有很重要的一個共同點:在單線程執行的情況下,亂序執行與不亂序執行,最後都會得出相同的結果 (both end up with the same observable result), 這是亂序被允許出現所需要遵循的首要原則,也是爲什麼亂序雖然一直存在但卻大部分程序員都感覺不到的原因。亂序的出現說到底是編譯器,CPU 等爲了讓你程序跑得更快而作出無限努力的結果,程序員們應該爲它們的良苦用心抹一把淚。

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

// 寫寫亂序(store store), 前面的寫操作被放到了後面的操作之後,比如:
a = 3;
b = 4;
// 被亂序爲
b = 4;
a = 3;

// 寫讀亂序(store load),前面的寫操作被放到了後面的讀操作之後,比如:
a = 3;
load(b);
// 被亂序爲
load(b);
a = 3;

// 讀讀亂序(load load), 前面的讀操作被放到了後一個讀操作之後,比如:
load(a);
load(b);
// 被亂序爲
load(b);
load(a);

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

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

1. 共享變量被修改時,併發不能保證修改共享變量的原子性,會導致常說的 race condition。因此像 mutex,各種 lock 等在寫多線程時被頻繁地使用。

2. 共享變量被修改後,該修改未必能被另一個線程及時觀察到。因此需要“同步”。

內存模型

   解決共享變量被修改後的“同步”問題,就需要確定內存模型,即確定線程間怎麼通過共享內存進行交互(查看維基百科).

   內存模型所要表達的內容主要是這麼描述: 一個內存操作的效果,在其他線程中的可見性問題。我們知道,對計算機來說,通常內存的寫操作相對於讀操作是昂貴很多很多的,因此對寫操作的優化是提升性能的關鍵,而這些對寫操作的種種優化,導致了一個很普遍的現象出現:寫操作通常會在 CPU 內部的 cache 中緩存起來。這就導致了在一個 CPU 裏執行一個寫操作之後,該操作導致的內存變化卻不一定會馬上就被另一個 CPU 所看到。

// cpu1 執行如下:
a = 0;

// cpu2 執行如下:
load(a);

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

   因此必須要有必要的手段對這種修改共享變量的行爲進行同步。

    C++11 中的 atomic library 中定義了以下6種語義來對內存操作的行爲進行約定,這些語義分別規定了不同的內存操作在其它線程中的可見性問題

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語義則用於讀操作,它們結合起來表示這樣一個約定:如果一個線程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.

   以上死板的描述事實上還傳達了額外的不那麼明顯的信息:

1. release 和 acquire 是相對兩個線程來說的,它約定的是這兩個線程間的相對行爲,它保證了這兩個線程之間對共享變量操作後的可見性問題。此時如果有第三個線程已非acquire的方式讀取共享變量,它並不保證這種可見性。

2. release 和 acquire 一定程度阻止了亂序的發生,因爲其要求 release 操作之前的所有操作都在另一個線程 acquire 之後可見,那麼:
    - release 操作之前的所有內存操作不允許被亂序到 release 之後。
    - acquire 操作之後的所有內存操作不允許被亂序到 acquire 之前。

   在對release-acquire 語義的使用上,有幾點是特別需要注意和強調的:

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

memory_order_seq_cst語義

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

   簡單來說就是,對所有以 memory_order_seq_cst 方式進行的內存操作,不管它們是不是分散在不同的 cpu 中同時進行,這些操作所產生的效果最終都要求有一個全局的順序,而且這個順序在各個相關的線程看起來是一致的。舉個粟子,假設 a, b 的初始值都是0:

// 線程 A 執行:
a.store(3, seq_cst);

// 線程 B 執行:
b.store(4, seq_cst);

   如上對 a 與 b 的修改雖然分別放在兩個線程裏同時進行,但是這多個動作畢竟是非原子的,因此這些操作地進行在全局上必須要有一個先後順序:先修改a後修改 b,或先修改b,後修改a。且這個順序是固定的,必須在其它任意線程看起來都是一樣,因此 a == 0 && b == 4 與 a == 3 && b == 0 不允許同時成立。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章