在網上已經有很多有關介紹原子操作的內容,通常都是注重於原子讀-修改-寫(RMW)操作。然而,這些並不是原子操作的全部,還有同樣重要的原子載入和原子儲存。在這篇文章中,我將要在處理器級別和C/C++語言級別兩個方面來對比原子載入和原子儲存與它們相應的非原子部分。沿著這條路,我們將弄清楚C++11中“資料競爭”這個概念。
共享記憶體中的原子操作是指它是否完成了一個執行緒相關的單步操作。當一個原子儲存作用於一個共享變數時,其他的執行緒不能監測到這個未完成的修改值。當一個原子載入作用於一個共享變數時,它讀取到這個完整的值,就像此時出現了一個單獨的時刻,而非原子載入和儲存則不能做到這些保證。
如果沒有這些保證,無鎖程式設計將不可能實現,因為你不能使不同的執行緒同時操作一個共享變數。我們可以制定如下規則:
任何時刻兩個執行緒同時操作一個共享變數,當其中一個為寫操作時,這兩個執行緒必須使用原子操作。
如果你違反這條規則,並且每個執行緒都使用非原子操作,你將會看到C++11標準中提到的資料競爭(不要混淆於Java中資料競爭的概念,這個是不同的,或者說是更廣義上的競爭情況)。C++11標準並沒有告訴你為什麼資料競爭是糟糕的,但只要你出現這種情況,就會發生“未定義行為”(1.10.21部分)。這種糟糕的資料競爭的原因是非常簡單的:它們導致了讀寫撕裂。
一個記憶體操作可以是非原子的,因為它使用非原子的多CPU指令,即使當使用單CPU指令時也是非原子的,因為你不能簡單的設想你寫出的可移植程式碼。讓我們來看幾個例子。
非原子性是由於多CPU指令
假設你有一個64位初始化為0的全域性變數。
1 |
uint64_t sharedValue = 0; |
在某些時刻,你給這個變數賦一個64位的值。
1 2 3 4 |
void storeValue() { sharedValue = 0x100000002; } |
當你在32位的x86環境下使用GCC來編譯這個函式時,將會生成如下機器碼。
1 2 3 4 5 6 7 |
$ gcc -O2 -S -masm=intel test.c $ cat test.s ... mov DWORD PTR sharedValue, 2 mov DWORD PTR sharedValue+4, 1 ret ... |
這個時候你就會看到,編譯器會使用兩個單獨的機器指令來完成這個64位的賦值。第一條指令設定低32位的0x00000002,第二條指令設定高32位的0x00000001.非常明顯,這個賦值操作是非原子的。如果共享變數同時被不同的執行緒存取,就會出現很多錯誤:
- 如果一個執行緒在兩個機器指令的間隙先呼叫儲存變數,將會在記憶體中留下像0x0000000000000002這樣的值——這是一個寫撕裂。在這個時候,如果另一個執行緒讀取共享變數,它將會接收到一個完全偽造的、沒有人想要儲存的值。
- 更糟糕的是,如果一個執行緒在兩個機器指令的間隙先佔用變數,而另一個執行緒在第一個執行緒重新獲得這個變數之前修改了sharedValue,那將導致一個永久性的寫撕裂:一個執行緒得到高32位,另一個執行緒得到低32位。
- 在多核裝置上,並不是只有先行佔有其中一個執行緒來導致一個寫撕裂。當一個執行緒呼叫storeValue時,任何執行緒在另一個核上可能同時讀取一個明顯未修改完的sharedValue。
同時讀取sharedValue會帶給它一系列的問題:
1 2 3 4 5 6 7 8 9 10 11 12 |
uint64_t loadValue() { return sharedValue; } $ gcc -O2 -S -masm=intel test.c $ cat test.s ... mov eax, DWORD PTR sharedValue mov edx, DWORD PTR sharedValue+4 ret ... |
這裡也一樣,編譯器會使用兩條機器指令來執行這個載入操作:第一條讀取低32位到eax,第二條讀取高32位到edx。在這種情況下,如果對於sharedValue進行同時儲存則會發現,它將導致一個讀撕裂——即使這個同時儲存是原子的。
這個問題並不是理論上的,Mintomic的測試集包含了一個名為test_load_store_64_fail的測試案例,在這個案例中,一個執行緒使用一個普通的賦值操作,儲存了很多64位的值到一個單獨的變數,同時另一個執行緒對這個變數反覆地執行一個簡單的載入,來確認每一個結果。在一個多核的x86機器上,這個測試像我們想象的一樣一直失敗。
非原子的CPU指令
一個記憶體操作可以是非原子的,甚至是當由一個單CPU指令來執行的時候。例如,ARMv7指令設定包含了將兩個由32位源暫存器的內容儲存到記憶體中的一個64位值的strd指令。
1 |
strd r0, r1, [r2] |
在一些ARMv7處理器中,這條指令是非原子的。當這個處理器遇到這條指令時,它實際上在底層執行兩個單獨的32位儲存(A3.5.3部分)。再來一次,另一個執行緒在一個單獨的核上執行,有可能觀察到一個寫撕裂。有趣的是,寫撕裂更可能出現在一個單核的裝置上:例如,一個預定執行緒的上下文切換的系統中斷,確實可以執行在兩個內部的32位儲存之間!在這種情況下,當這個執行緒從這個中斷恢復時,它將再一次重新呼叫這個strd指令。
再看另一個例子,眾所周知,在x86環境下,如果記憶體運算元是自然對齊的,那麼一個32位的mov指令就是原子的,但如果不是自然對齊,那麼將是非原子的。換句話說,原子性的保證僅僅是當一個32位整數的地址正好是4的倍數的時候。Mintomic提出另一個證實這個保證的測試案例,test_load_store_32_fail。就像寫的那樣,這個測試在x86總是成功的,但是如果你修改這個測試,強制將sharedInt置於一個未對齊的地址,它將失敗。在我的Core 2 Quad Q6600上,這個測試失敗了,因為sharedInt在一個暫存器中越界了。
1 2 3 4 5 6 7 8 |
// Force sharedInt to cross a cache line boundary: #pragma pack(2) MINT_DECL_ALIGNED(static struct, 64) { char padding[62]; mint_atomic32_t sharedInt; } g_wrapper; |
現在已經有很多特定於處理器的細節,讓我們再來看看C/C++語言級別的原子性。
所有的C/C++操作被認定為非原子的
在C和C++中,所有操作被認定是非原子的,甚至是普通的32位整數賦值,除非被別的編譯器或者硬體供應商指定。
1 2 3 4 5 6 |
uint32_t foo = 0; void storeFoo() { foo = 0x80286; } |
這個語言標準並沒有提到任何有關於這種情況下的原子性。也許整型賦值是原子的,也許不是。因為非原子操作沒有做任何保證,在C定義中,普通整型賦值是非原子的。
實際上,我們對我們的目標平臺瞭解的更多。例如,大家都知道在現在的x86、x64、Itanium、SPARC、ARM和PowerPC處理機上,只要目標變數是自然對齊的,那麼普通32位整型賦值就是原子的,你可以通過查詢你的處理機手冊或者編譯器文件來證實。在遊戲行業,我可以告訴你很多關於32位整型賦值依賴這個特殊保證的例子。
儘管如此,但在寫真正的可移植的C和C++程式碼時,有一個歷史悠久的傳統,就是我們所知道的僅僅是語言標準告訴我們的。可移植的C和C++程式碼的設計是為了可以執行在任何可能的計算裝置上,過去的、現在的以及虛擬的。就我自己而言,我想設計一種機器,它的記憶體僅僅可以通過先到先得來改變:
在這樣的機器上,你絕對不會想要在執行一個併發的讀操作的同時執行一個普通的賦值,你可能會最終讀取到一個完全隨機的值。
在C++11中,有一個最終的方案來執行實際的可移植原子載入和儲存——C++11原子庫。通過使用C++11原子庫來執行原子載入和儲存,甚至可以執行在虛擬的計算機上,即使這意味著C++11原子庫必須默默地加一個互斥量來確保每一個操作都是原子的。這裡還有我上個月釋出的Mintomic庫,它並不支援這麼多平臺,但是可以執行在很多以前的編譯器上,它是優化過的,並且保證是無鎖的。
寬鬆的原子操作
讓我們回到前面那個sharedValue例子最開始的地方,我們將用Mintomic重寫它,這樣所有的操作就可以原子地執行在任何Mintomic支援的平臺上了。首先,我們必須宣告sharedValue為Mintomic原子資料型別中的一個。
1 2 3 |
#include <mintomic/mintomic.h> mint_atomic64_t sharedValue = { 0 }; |
mint_atomic64_t型別保證了在所有平臺上原子存取的正確記憶體對齊。這是非常重要的,因為,例如ARM的GCC4.2編譯器附帶的Xcode3.2.5並不保證普通的uint64_t以8位元組對齊。
對於storeValue,通過執行一個普通的、非原子的賦值來替代,我們必須呼叫mint_store_64_relaxed。
1 2 3 4 |
void storeValue() { mint_store_64_relaxed(&sharedValue, 0x100000002); } |
相似的,在loadValue中,我們呼叫mint_load_64_relaxed。
1 2 3 4 |
uint64_t loadValue() { return mint_load_64_relaxed(&sharedValue); } |
使用C++11的術語,這些函式現在不存在資料競爭。當併發操作執行時,無論程式碼執行在ARMv6/ARMv7 (Thumb或者ARM模式)、x86、x64 或者PowerPC上,絕對不可能出現讀寫撕裂。你是否好奇mint_load_64_relaxed和mint_store_64_relaxed是如何工作的,這兩個函式在x86上都是擴充套件到一個內聯的cmpxchg8b指令上,對於其他平臺,請查詢Mintomic的實現。
在C++11中明確的寫出了類似的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#include <atomic> std::atomic<uint64_t> sharedValue(0); void storeValue() { sharedValue.store(0x100000002, std::memory_order_relaxed); } uint64_t loadValue() { return sharedValue.load(std::memory_order_relaxed); } |
你會注意到,在Mintomic和C++11的例子中都使用了寬鬆的原子性,由_relaxed字尾的多個識別符號來證明。_relaxed字尾暗示了,就像普通的載入和儲存一樣,沒有記憶體訪問排序的保證。
一個寬鬆的原子載入(或儲存)和一個非原子載入(或儲存)之間的唯一區別就是,寬鬆的原子操作保證了原子性,沒有其他區別來保證。
特別的,在程式指令中,一個寬鬆的原子操作,被它前面或者後面的指令由於處理機本身任何一個因為編譯器重新排序或者記憶體重新排序所產生的影響,對記憶體來說依然是合法的。編譯器甚至可以在冗餘的寬鬆原子操作上執行優化,就像非原子操作一樣。就一切情況而言,這些操作仍然是原子的。
當併發操作同時共享記憶體時,我認為,一直使用Mintomic或者C++11原子庫函式是非常好的練習,甚至當你知道在你的目標平臺上,一個普通的載入或者儲存已經是原子的情況下。一個原子庫函式就像提示這個變數是併發資料儲存的目標。
我希望,現在大家可以更清楚的知道,為什麼《世界上最簡單的無鎖雜湊表》使用Mintomic庫函式來併發地操作不同執行緒的共享記憶體。
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!