C++ 中的 volatile
關鍵字,std::atomic
變量及手動插入內存屏障指令(Memory Barrier)均是為了避免內存訪問過程中出現一些不符合預期的行為。這三者的作用有些相似之處,不過顯然它們並不相同,本文就將對這三者的應用場景做一總結。
這三者應用場景的區別可以用一張表來概括:
volatile |
Memory Barrier | atomic |
|
---|---|---|---|
抑制編譯器重排 | Yes | Yes | Yes |
抑制編譯器優化 | Yes | No | Yes |
抑制 CPU 亂序 | No | Yes | Yes |
保證訪問原子性 | No | No | Yes |
下面來具體看一下每一條。
抑制編譯器重排
所謂編譯器重排,這里是指編譯器在生成目標代碼的過程中交換沒有依賴關系的內存訪問順序的行為。
比如以下代碼:
1 |
*p_a = a; |
編譯器不保證在最終生成的匯編代碼中對 p_a
內存的寫入在對 p_b
內存的讀取之前。
如果這個順序是有意義的,就需要用一些手段來保證編譯器不會進行錯誤的優化。具體來說可以通過以下三種方式來實現:
- 把對應的變量聲明為
volatile
的,C++ 標准保證對volatile
變量間的訪問編譯器不會進行重排,不過僅僅是volatile
變量之間,volatile
變量和其他變量間還是有可能會重排的; - 在需要的地方手動添加合適的 Memory Barrier 指令,Memory Barrier 指令的語義保證了編譯器不會進行錯誤的重排操作;
- 把對應變量聲明為
atomic
的, 與volatile
類似,C++ 標准也保證atomic
變量間的訪問編譯器不會進行重排。不過 C++ 中不存在所謂的 “atomic pointer” 這種東西,如果需要對某個確定的地址進行 atomic 操作,需要靠一些技巧性的手段來實現,比如在那個地址上進行 placement new 操作強制生成一個atomic
等;
抑制編譯器優化
此處的編譯器優化特指編譯器不生成其認為無意義的內存訪問代碼的優化行為,比如如下代碼:
1 |
void f() { |
在較高優化級別下對變量 a
的內存訪問基本都會被優化掉,f()
生成的匯編代碼和一個空函數基本差不多。然而如果對 a
循環若干次的內存訪問是有意義的,則需要做一些修改來抑制編譯器的此優化行為。可以把對應變量聲明為 volatile
或 atomic
的來實現此目的,C++ 標准保證對 volatile
或 atomic
內存的訪問肯定會發生,不會被優化掉。
不過需要注意的是,這時候手動添加內存屏障指令是沒有意義的,在上述代碼的 for
循環中加入 mfence
指令后,僅僅是讓循環沒有被優化掉,然而每次循環中對變量 a
的賦值依然會被優化掉,結果就是連續執行了 1000 次 mfence
。
抑制 CPU 亂序
上面說到了編譯器重排,那沒有了編譯器重排內存訪問就會嚴格按照我們代碼中的順序執行了么?非也!現代 CPU 中的諸多特性均會影響這一行為。對於不同架構的 CPU 來說,其保證的內存存儲模型是不一樣的,比如 x86_64 就是所謂的 TSO(完全存儲定序)模型,而很多 ARM 則是 RMO(寬松存儲模型)。再加上多核間 Cache 一致性問題,多線程編程時會面臨更多的挑戰。
為了解決這些問題,從根本上來說只有通過插入所謂的 Memory Barrier 內存屏障指令來解決,這些指令會使得 CPU 保證特定的內存訪問序及內存寫入操作在多核間的可見性。然而由於不同處理器架構間的內存模型和具體 Memory Barrier 指令均不相同,需要在什么位置添加哪條指令並不具有通用性,因此 C++ 11 在此基礎上做了一層抽象,引入了 atomic
類型及 Memory Order 的概念,有助於寫出更通用的代碼。從本質上看就是靠編譯器來根據代碼中指定的高層次 Memory Order 來自動選擇是否需要插入特定處理器架構上低層次的內存屏障指令。
關於 Memory Order,內存模型,內存屏障等東西的原理和具體使用方法網上已經有很多寫得不錯的文章了,可以參考文末的幾篇參考資料。
保證訪問原子性
所謂訪問原子性就是 Read,Write 操作是否存在中間狀態,具體如何實現原子性的訪問與處理器指令集有很大關系,如果處理器本身就支持某些原子操作指令,如 Atomic Store, Atomic Load,Atomic Fetch Add,Atomic Compare And Swap(CAS)等,那只需要在代碼生成時選擇合適的指令即可,否則需要依賴鎖來實現。C++ 中提供的可移植通用方法就是 std::atomic
,volatile
及 Memory Barrier 均與此完全無關。
總結
從上面的比較中可以看出,volatile
,atomic
及 Memory Barrier 的適用范圍還是比較好區分的。
- 如果需要原子性的訪問支持,只能選擇
atomic
; - 如果僅僅只是需要保證內存訪問不會被編譯器優化掉,優先考慮
volatile
; - 如果需要保證 Memory Order,也優先考慮
atomic
,只有當不需要保證原子性,而且很明確要在哪插入內存屏障時才考慮手動插入 Memory Barrier。
參考資料: