c 11 記憶體模型解讀

2022-07-06 23:30:25 字數 3824 閱讀 2906

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

編譯器出於優化的目的,在編譯階段將原始碼的順序進行交換。

程式執行期間,指令流水被 cpu 亂序執行。

inherent 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個:

併發不能保證修改和訪問共享變數的操作原子性,使得一些中間狀態暴露了出去,因此像 mutex,各種 lock 之類的東西在寫多執行緒時被頻繁地使用。

變數被修改後,該修改未必能被另乙個執行緒及時觀察到,因此需要「同步」。

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

記憶體模型所要表達的內容主要是怎麼描述乙個記憶體操作的效果,在各個執行緒間的可見性的問題。修改操作的效果不能及時被別的執行緒看見的原因有很多,比較明顯的乙個是,對計算機來說,通常記憶體的寫操作相對於讀操作是昂貴很多很多的,因此對寫操作的優化是提公升效能的關鍵,而這些對寫操作的種種優化,導致了乙個很普遍的現象出現:寫操作通常會在 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 ;
我們主要討論其中的幾個:relaxed, acquire, release, seq_cst(sequential consistency).

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

如果你曾經去看過別的介紹記憶體模型相關的文章,你一定會發現 release 總是和 acquire 放到一起來講,這並不是偶然。事實上,release 和 acquire 是相輔相承的,它們必須配合起來使用,這倆是乙個 「package deal」, 分開使用則完全沒有意義。具體到其中, release 用於進行寫操作,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 必須配合使用,分開單獨使用是沒有意義。

release 只對寫操作(store) 有效,對讀 (load) 是沒有意義的。

acquire 則只對讀操作有效,對寫操作是沒有意義的。

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

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 的修改雖然分別放在兩個執行緒裡同時進行,但是這多個動作畢竟是非原子的,因此這些操作地進行在全域性上必須要有乙個先後順序:

先修改a, 後修改 b,或

先修改b, 把整個a。

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

【引用】

C 11記憶體模型

一 幾種同步關係 1.執行緒內部的資料關係 1.1 sequenced before 這是表示式與表示式之間的一種配對的不對稱的關係,僅用於同乙個執行緒內。實際執行順序不能破壞語句間sequenced before的關係。1 的1.9.13 1.2 carries a dependency to 僅...

C 11 多工記憶體模型

記憶體模型 一般來說,記憶體模型可以分為靜態記憶體模型和動態記憶體模型 c 11的記憶體模型 std memory order就是c 11的記憶體模型。c 11為std atomic提供的memory order enum class memory order 雖然列舉定義了6個,但它們表示的是4種...

簡介記憶體模型與C 11的memory order

先看一段 include include int a 0 int b 0 void func1 void func2 intmain void 再看一段彙編 1 load reg3,1 將立即數1放入暫存器reg3 2 move reg4,reg3 將reg3的資料放入reg4 3 store re...