原子操作 vs 非原子操作
在網(wǎng)上已經(jīng)有很多有關(guān)介紹原子操作的內(nèi)容,通常都是注重于原子讀-修改-寫(RMW)操作。然而,這些并不是原子操作的全部,還有同樣重要的原子加 載和原子存儲(chǔ)。在這篇文章中,我將要在處理器級(jí)別和C/C++語(yǔ)言級(jí)別兩個(gè)方面來對(duì)比原子加載和原子存儲(chǔ)與它們相應(yīng)的非原子部分。沿著這條路,我們將弄清 楚C++11中“數(shù)據(jù)競(jìng)爭(zhēng)”這個(gè)概念。
共享內(nèi)存中的原子操作是指它是否完成了一個(gè)線程相關(guān)的單步操作。當(dāng)一個(gè)原子存儲(chǔ)作用于一個(gè)共享變量時(shí),其他的線程不能監(jiān)測(cè)到這個(gè)未完成的修改值。當(dāng) 一個(gè)原子加載作用于一個(gè)共享變量時(shí),它讀取到這個(gè)完整的值,就像此時(shí)出現(xiàn)了一個(gè)單獨(dú)的時(shí)刻,而非原子加載和存儲(chǔ)則不能做到這些保證。
如果沒有這些保證,無鎖編程將不可能實(shí)現(xiàn),因?yàn)槟悴荒苁共煌木€程同時(shí)操作一個(gè)共享變量。我們可以制定如下規(guī)則:
任何時(shí)刻兩個(gè)線程同時(shí)操作一個(gè)共享變量,當(dāng)其中一個(gè)為寫操作時(shí),這兩個(gè)線程必須使用原子操作。
如果你違反這條規(guī)則,并且每個(gè)線程都使用非原子操作,你將會(huì)看到C++11標(biāo)準(zhǔn)中提到的數(shù)據(jù)競(jìng)爭(zhēng)(不要混淆于Java中數(shù)據(jù)競(jìng)爭(zhēng)的概念,這個(gè)是不同的,或者說是更廣義上的競(jìng)爭(zhēng)情況)。C++11標(biāo)準(zhǔn)并沒有告訴你為什么數(shù)據(jù)競(jìng)爭(zhēng)是糟糕的,但只要你出現(xiàn)這種情況,就會(huì)發(fā)生“未定義行為”(1.10.21部分)。這種糟糕的數(shù)據(jù)競(jìng)爭(zhēng)的原因是非常簡(jiǎn)單的:它們導(dǎo)致了讀寫撕裂。
一個(gè)內(nèi)存操作可以是非原子的,因?yàn)樗褂梅窃拥亩郈PU指令,即使當(dāng)使用單CPU指令時(shí)也是非原子的,因?yàn)槟悴荒芎?jiǎn)單的設(shè)想你寫出的可移植代碼。讓我們來看幾個(gè)例子。
非原子性是由于多CPU指令
假設(shè)你有一個(gè)64位初始化為0的全局變量。
- uint64_t sharedValue = 0;
在某些時(shí)刻,你給這個(gè)變量賦一個(gè)64位的值。
- void storeValue()
- {
- sharedValue = 0x100000002;
- }
當(dāng)你在32位的x86環(huán)境下使用GCC來編譯這個(gè)函數(shù)時(shí),將會(huì)生成如下機(jī)器碼。
- $ gcc -O2 -S -masm=intel test.c
- $ cat test.s
- ...
- mov DWORD PTR sharedValue, 2
- mov DWORD PTR sharedValue+4, 1
- ret
- ...
這個(gè)時(shí)候你就會(huì)看到,編譯器會(huì)使用兩個(gè)單獨(dú)的機(jī)器指令來完成這個(gè)64位的賦值。第一條指令設(shè)置低32位的0×00000002,第二條指令設(shè)置高32位的0×00000001.非常明顯,這個(gè)賦值操作是非原子的。如果共享變量同時(shí)被不同的線程存取,就會(huì)出現(xiàn)很多錯(cuò)誤:
- 如果一個(gè)線程在兩個(gè)機(jī)器指令的間隙先調(diào)用存儲(chǔ)變量,將會(huì)在內(nèi)存中留下像0×0000000000000002這樣的值——這是一個(gè)寫撕裂。在這個(gè)時(shí)候,如果另一個(gè)線程讀取共享變量,它將會(huì)接收到一個(gè)完全偽造的、沒有人想要存儲(chǔ)的值。
- 更糟糕的是,如果一個(gè)線程在兩個(gè)機(jī)器指令的間隙先占用變量,而另一個(gè)線程在第一個(gè)線程重新獲得這個(gè)變量之前修改了sharedValue,那將導(dǎo)致一個(gè)永久性的寫撕裂:一個(gè)線程得到高32位,另一個(gè)線程得到低32位。
- 在多核設(shè)備上,并不是只有先行占有其中一個(gè)線程來導(dǎo)致一個(gè)寫撕裂。當(dāng)一個(gè)線程調(diào)用storeValue時(shí),任何線程在另一個(gè)核上可能同時(shí)讀取一個(gè)明顯未修改完的sharedValue。
同時(shí)讀取sharedValue會(huì)帶給它一系列的問題:
- 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
- ...
這里也一樣,編譯器會(huì)使用兩條機(jī)器指令來執(zhí)行這個(gè)加載操作:第一條讀取低32位到eax,第二條讀取高32位到edx。在這種情況下,如果對(duì)于sharedValue進(jìn)行同時(shí)存儲(chǔ)則會(huì)發(fā)現(xiàn),它將導(dǎo)致一個(gè)讀撕裂——即使這個(gè)同時(shí)存儲(chǔ)是原子的。
這個(gè)問題并不是理論上的,Mintomic的 測(cè)試集包含了一個(gè)名為test_load_store_64_fail的測(cè)試案例,在這個(gè)案例中,一個(gè)線程使用一個(gè)普通的賦值操作,存儲(chǔ)了很多64位的值 到一個(gè)單獨(dú)的變量,同時(shí)另一個(gè)線程對(duì)這個(gè)變量反復(fù)地執(zhí)行一個(gè)簡(jiǎn)單的加載,來確認(rèn)每一個(gè)結(jié)果。在一個(gè)多核的x86機(jī)器上,這個(gè)測(cè)試像我們想象的一樣一直失 敗。
#p#
非原子的CPU指令
一個(gè)內(nèi)存操作可以是非原子的,甚至是當(dāng)由一個(gè)單CPU指令來執(zhí)行的時(shí)候。例如,ARMv7指令設(shè)置包含了將兩個(gè)由32位源寄存器的內(nèi)容存儲(chǔ)到內(nèi)存中的一個(gè)64位值的strd指令。
- strd r0, r1, [r2]
在一些ARMv7處理器中,這條指令是非原子的。當(dāng)這個(gè)處理器遇到這條指令時(shí),它實(shí)際上在底層執(zhí)行兩個(gè)單獨(dú)的32位存儲(chǔ)(A3.5.3部分)。再來 一次,另一個(gè)線程在一個(gè)單獨(dú)的核上運(yùn)行,有可能觀察到一個(gè)寫撕裂。有趣的是,寫撕裂更可能出現(xiàn)在一個(gè)單核的設(shè)備上:例如,一個(gè)預(yù)定線程的上下文切換的系統(tǒng) 中斷,確實(shí)可以執(zhí)行在兩個(gè)內(nèi)部的32位存儲(chǔ)之間!在這種情況下,當(dāng)這個(gè)線程從這個(gè)中斷恢復(fù)時(shí),它將再一次重新調(diào)用這個(gè)strd指令。
再看另一個(gè)例子,眾所周知,在x86環(huán)境下,如果內(nèi)存操作數(shù)是自然對(duì)齊的,那么一個(gè)32位的mov指令就是原子的,但如果不是自然對(duì)齊,那么將是非 原子的。換句話說,原子性的保證僅僅是當(dāng)一個(gè)32位整數(shù)的地址正好是4的倍數(shù)的時(shí)候。Mintomic提出另一個(gè)證實(shí)這個(gè)保證的測(cè)試案 例,test_load_store_32_fail。就像寫的那樣,這個(gè)測(cè)試在x86總是成功的,但是如果你修改這個(gè)測(cè)試,強(qiáng)制將sharedInt置 于一個(gè)未對(duì)齊的地址,它將失敗。在我的Core 2 Quad Q6600上,這個(gè)測(cè)試失敗了,因?yàn)閟haredInt在一個(gè)寄存器中越界了。
- // 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;
現(xiàn)在已經(jīng)有很多特定于處理器的細(xì)節(jié),讓我們?cè)賮砜纯碈/C++語(yǔ)言級(jí)別的原子性。
所有的C/C++操作被認(rèn)定為非原子的
在C和C++中,所有操作被認(rèn)定是非原子的,甚至是普通的32位整數(shù)賦值,除非被別的編譯器或者硬件供應(yīng)商指定。
- uint32_t foo = 0;
- void storeFoo()
- {
- foo = 0x80286;
- }
這個(gè)語(yǔ)言標(biāo)準(zhǔn)并沒有提到任何有關(guān)于這種情況下的原子性。也許整型賦值是原子的,也許不是。因?yàn)榉窃硬僮鳑]有做任何保證,在C定義中,普通整型賦值是非原子的。
實(shí)際上,我們對(duì)我們的目標(biāo)平臺(tái)了解的更多。例如,大家都知道在現(xiàn)在的x86、x64、Itanium、SPARC、ARM和PowerPC處理機(jī) 上,只要目標(biāo)變量是自然對(duì)齊的,那么普通32位整型賦值就是原子的,你可以通過查詢你的處理機(jī)手冊(cè)或者編譯器文檔來證實(shí)。在游戲行業(yè),我可以告訴你很多關(guān) 于32位整型賦值依賴這個(gè)特殊保證的例子。
盡管如此,但在寫真正的可移植的C和C++代碼時(shí),有一個(gè)歷史悠久的傳統(tǒng),就是我們所知道的僅僅是語(yǔ)言標(biāo)準(zhǔn)告訴我們的??梢浦驳腃和C++代碼的設(shè) 計(jì)是為了可以運(yùn)行在任何可能的計(jì)算設(shè)備上,過去的、現(xiàn)在的以及虛擬的。就我自己而言,我想設(shè)計(jì)一種機(jī)器,它的內(nèi)存僅僅可以通過先到先得來改變:
在這樣的機(jī)器上,你絕對(duì)不會(huì)想要在執(zhí)行一個(gè)并發(fā)的讀操作的同時(shí)執(zhí)行一個(gè)普通的賦值,你可以結(jié)束讀取一個(gè)完整的隨機(jī)值。
在C++11中,有一個(gè)最終的方案來執(zhí)行實(shí)際的可移植原子加載和存儲(chǔ)——C++11原子庫(kù)。通過使用C++11原子庫(kù)來執(zhí)行原子加載和存儲(chǔ),甚至可以運(yùn)行在虛擬的計(jì)算機(jī)上,即使這意味著C++11原子庫(kù)必須默默地加一個(gè)互斥量來確保每一個(gè)操作都是原子的。這里還有我上個(gè)月發(fā)布的Mintomic庫(kù),它并不支持這么多平臺(tái),但是可以運(yùn)行在很多以前的編譯器上,它是優(yōu)化過的,并且保證是無鎖的。
寬松的原子操作
讓我們回到前面那個(gè)sharedValue例子最開始的地方,我們將用Mintomic重寫它,這樣所有的操作就可以原子地執(zhí)行在任何Mintomic支持的平臺(tái)上了。首先,我們必須聲明sharedValue為Mintomic原子數(shù)據(jù)類型中的一個(gè)。
- #include <mintomic/mintomic.h>
- mint_atomic64_t sharedValue = { 0 };
mint_atomic64_t類型保證了在所有平臺(tái)上原子存取的正確內(nèi)存對(duì)齊。這是非常重要的,因?yàn)?,例如ARM的GCC4.2編譯器附帶的Xcode3.2.5并不保證普通的uint64_t以8字節(jié)對(duì)齊。
對(duì)于storeValue,通過執(zhí)行一個(gè)普通的、非原子的賦值來替代,我們必須調(diào)用mint_store_64_relaxed。
- void storeValue()
- {
- mint_store_64_relaxed(&sharedValue, 0x100000002);
- }
相似的,在loadValue中,我們調(diào)用mint_load_64_relaxed。
- uint64_t loadValue()
- {
- return mint_load_64_relaxed(&sharedValue);
- }
使用C++11的術(shù)語(yǔ),這些函數(shù)現(xiàn)在不存在數(shù)據(jù)競(jìng)爭(zhēng)。當(dāng)并發(fā)操作執(zhí)行時(shí),無論代碼運(yùn)行在ARMv6/ARMv7 (Thumb或者ARM模式)、x86、x64 或者PowerPC上,絕對(duì)不可能出現(xiàn)讀寫撕裂。你是否好奇mint_load_64_relaxed和mint_store_64_relaxed是如 何工作的,這兩個(gè)函數(shù)在x86上都是擴(kuò)展到一個(gè)內(nèi)聯(lián)的cmpxchg8b指令上,對(duì)于其他平臺(tái),請(qǐng)查詢Mintomic的實(shí)現(xiàn)。
在C++11中明確的寫出了類似的代碼:
- #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);
- }
你會(huì)注意到,在Mintomic和C++11的例子中都使用了寬松的原子性,由_relaxed后綴的多個(gè)標(biāo)識(shí)符來證明。_relaxed后綴暗示了,就像普通的加載和存儲(chǔ)一樣,沒有內(nèi)存訪問排序的保證。
一個(gè)寬松的原子加載(或存儲(chǔ))和一個(gè)非原子加載(或存儲(chǔ))之間的唯一區(qū)別就是,寬松的原子操作保證了原子性,沒有其他區(qū)別來保證。
特別的,在程序指令中,一個(gè)寬松的原子操作,被它前面或者后面的指令由于處理機(jī)本身任何一個(gè)因?yàn)榫幾g器重新排序或者內(nèi)存重新排序所產(chǎn)生的影響,對(duì)內(nèi)存來說依然是合法的。編譯器甚至可以在冗余的寬松原子操作上執(zhí)行優(yōu)化,就像非原子操作一樣。就一切情況而言,這些操作仍然是原子的。
當(dāng)并發(fā)操作同時(shí)共享內(nèi)存時(shí),我認(rèn)為,一直使用Mintomic或者C++11原子庫(kù)函數(shù)是非常好的練習(xí),甚至當(dāng)你知道在你的目標(biāo)平臺(tái)上,一個(gè)普通的加載或者存儲(chǔ)已經(jīng)是原子的情況下。一個(gè)原子庫(kù)函數(shù)就像提示這個(gè)變量是并發(fā)數(shù)據(jù)存儲(chǔ)的目標(biāo)。
我希望,現(xiàn)在大家可以更清楚的知道,為什么《世界上最簡(jiǎn)單的無鎖哈希表》使用Mintomic庫(kù)函數(shù)來并發(fā)地操作不同線程的共享內(nèi)存。
原文鏈接:http://preshing.com/20130618/atomic-vs-non-atomic-operations/