首先需要明確一個普遍存在,但卻未必人人都注意到的事實:程序並不總是按照源碼中的順序被執行的,此謂之亂序,亂序產生的原因可能有好幾種:
以上亂序現象雖然來源不同,但從源碼的角度,對上層應用程序來說,他們的效果其實相同:寫出來的代碼與最後被執行的代碼是不一致的。
這個事實可能會讓人很驚訝:有這樣嚴重的問題,還怎麼寫得出正確的代碼?這擔憂是多慮了,亂序的現象雖然普遍存在,但它們都有很重要的一個共同點:在單線程執行的情況下,亂序執行與不亂序執行,最後都會得出相同的結果 (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個:
解決同步問題就需要確定內存模型,也就是需要確定線程間應該怎麼通過共享內存來進行交互(查看維基百科).
內存模型所要表達的內容主要是怎麼描述:一個內存操作的效果,在各個線程間的可見性的問題。我們知道,對計算機來說,通常內存的寫操作相對於讀操作是昂貴很多很多的,因此對寫操作的優化是提升性能的關鍵,而這些對寫操作的種種優化,導致了一個很普遍的現象出現:寫操作通常會在 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 方式進行讀寫時,編譯器,cpu 等是被允許按照任意它們認為合適的方式來加以優化處理的。
如果你曾經去看過別的介紹內存模型相關的文章,你一定會發現 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 之後可見,那麼:
而在對它們的使用上,有幾點是特別需要注意和強調的:
現代的處理器通常都支持一些 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 == 0 && b == 4 與 a == 3 && b == 0 不允許同時成立。
這篇隨筆躺在我的草稿箱裡已經半年多時間了,半年多來我不斷地整理在這方面的知識,也在不斷理清自己的思路,最後還是覺得關於內存模型有太多可以說卻不是一下子能說得清楚的東西了,因此這兒只能把想說的東西一減再減,把范圍縮小到 C++11 語言層面上作簡單介紹,純粹算是做個總結,有興趣深入了解更多細節的讀者,我強烈推薦去看一下 Herb Sutter 在這方面做的一個 talk, 內存模型方面的知識是很難理解,更難以正確使用的,在大多數情況下使用它而得到的些少性能優勢,已經完全不值得為此而帶來的代碼復雜性及可讀性方面的損失,如果你還在猶豫是否要用這些相對底層的東西的時候,就不要用它,猶豫就說明還有其它選擇,不到沒得選擇,都不要親自實現 lock free 相關的東西。
C++11新特性:Lambda函數(匿名函數) http://www.linuxidc.com/Linux/2013-12/93367p2.htm
C++ Primer Plus 第6版 中文版 清晰有書簽PDF+源代碼 http://www.linuxidc.com/Linux/2014-05/101227.htm
讀C++ Primer 之構造函數陷阱 http://www.linuxidc.com/Linux/2011-08/40176.htm
讀C++ Primer 之智能指針 http://www.linuxidc.com/Linux/2011-08/40177.htm
讀C++ Primer 之句柄類 http://www.linuxidc.com/Linux/2011-08/40175.htm
C++11 獲取系統時間庫函數 time since epoch http://www.linuxidc.com/Linux/2014-03/97446.htm
C++11中正則表達式測試 http://www.linuxidc.com/Linux/2012-08/69086.htm