原文連接:atomic-vs-non-atomic-operationsgit
在網上已經寫了不少關於原子操做的文章,可是一般都集中在原子的讀-修改-寫(RMW. read-modify-write)操做。可是這些並是全部的原子操做。一樣重要的屬於原子操做的仍是有load(譯註:讀)和store(譯註:寫)。在這篇文章中,我將會在處理器層面和C/C++語言層面,比較原子性和非原子性的load和store。順便,咱們將會闡明如下在C++11中的「數據競爭」概念。程序員
若是一個共享變量的操做,它能相對於其餘線程,可以一步完成,那麼這個操做就是原子性的操做。當對一個共享變量執行原子性的store操做,其餘線程只能觀察到它已經修改完後的數據。當對一個共享變量執行原子性的load操做,它會讀取單一時刻所顯示的完整的值。非原子性的store和load不會有上述的保證。github
離開上述的保證,無鎖編程(lock-free programming)將變得不可能,由於不能在相同時間,讓多個線程操做同一個共享變量。咱們能夠將此明確表達爲一個規則:編程
任什麼時候間,兩個線程併發地操做在一個共享變量上,這些操做中的一個執行一個寫動做,全部的線程都必須使用原子操做。
若是你違反這個規則,其中有個線程使用了非原子操做,那麼你將會陷入一個在C++11標準中稱之爲數據競爭(不要和Java中的data race概念,以及更通用的race condition搞混淆)的情形。C++11標準沒有告訴編程人員爲何數據競爭是很差的。可是若是你引起了數據競爭,那麼就會獲得一個"未定義行爲(undefined behavior)"的結果。數據競爭是很差的真正理由只有一個:它們會致使「撕裂讀」(torn reads)和「撕裂寫」(torn writes。譯註:就是一個非完整的讀寫)。緩存
一個內存操做多是非原子的,由於它使用了多條CPU指令,甚至即便使用單條CPU指令,也多是非原子的。也多是由於程序員寫的可移植代碼。可是不能簡單地作出這個假設。讓咱們看幾個例子。併發
假設有一個64位的全局變量,初始化爲0.app
1 uint64_t sharedValue = 0;
此時,將一個64位的值更新到此變量:
ide
1 void storeValue() 2 { 3 sharedValue = 0x100000002; 4 }
在32位的 x86平臺上,使用GCC編譯此函數,會產生如下彙編代碼:
函數
1 $ gcc -O2 -S -masm=intel test.c 2 $ cat test.s 3 ... 4 mov DWORD PTR sharedValue, 2 5 mov DWORD PTR sharedValue+4, 1 6 ret 7 ...
如你所見,編譯器實現一個64位整形的賦值是經過兩個單獨的機器指令。第一條指令將低32位設置爲0x00000002,第二條指令將高32位設置爲0x00000001。很明顯,這個賦值操做不是原子操做。若是sharedValue被不一樣線程併發訪問,將會出錯。測試
1. 若是一個線程在兩條指令之間對sharedValue的訪問時獨佔的,那麼在內存中,sharedValue將會被設置爲0x0x0000000000000002,
一個「撕裂寫(a torn write)」。此時,若是另一個線程讀取sharedValue的值,那麼將會讀到一個徹底虛假的值。
2. 更遭的是,若是一個線程在兩條指令之間進行獨佔訪問,此時另外一個在第一個線程恢復前修改變量sharedValue,會
致使一個永久性的「撕裂寫(torn write)」:高32位來源於一個線程,低32位來源於另外一個線程。
3. 在多核設備中,線程都不必進行一個會致使「撕裂寫」的資源搶佔。由於當一個線程調用sharedValue,在不一樣核心上的
任意線程在某個時刻均可能會去讀sharedValue,此時的sharedValue可能處於修改的一半當中。
併發地從sharedValue讀也會帶來一些問題:
1 uint64_t loadValue() 2 { 3 return sharedValue; 4 } 5 6 $ gcc -O2 -S -masm=intel test.c 7 $ cat test.s 8 ... 9 mov eax, DWORD PTR sharedValue 10 mov edx, DWORD PTR sharedValue+4 11 ret 12 ... 13
一樣,編譯器用兩條機器指令實現讀取操做:第一條指令讀取低32位的值到eax寄存器,而後第二條指令讀取高32位的值到edx寄存器。在這種狀況下,併發地發生一個寫的操做,此時會產生一個「撕裂讀(torn read)」。即便這個併發的寫是原子操做。
這些問題並不僅是存在於理論上。Mintomic的測試套件中包含了一個叫test_load_store_64_fail的測試用例。一個線程使用普通的賦值操做符更新一個64位變量的值,另外一個線程週期性地執行一個從相同變量的讀取操做,對每次讀取回來的結果進行校驗。在x86多核機器上,和預期同樣,此測試會常常失敗。
即便執行單條CPU指令,一個內存操做也多是非原子性的。例如:在ARMv7指令集中,包含了一個strd指令,實現將兩個32位的寄存器的值存儲到一個64位的變量中。
1 strd r0, r1, [r2]
在一些ARMv7處理器中,這條指令時非原子性的。當處理器碰到這條指令時,其實是執行2條32位的單獨存儲動做。再一次,任何運行在其餘核心的線程均可能會觀察到一個「撕裂讀(torn write)」。有意思的是,「撕裂讀(torn write)」甚至可能會發生在單核設備中:由於系統中斷。在2條32位存儲指令中間,可能會發生線程上下文的調度切換。這種狀況下,當線程從中斷中恢復後,將會從新執行一次strd指令。
另一個例子,是發生在你們熟知的x86平臺上。一個32位的mov指令只有在內存操做數是天然對齊的狀況下才是原子性的!其餘狀況下是非原子性的。換句話說,一個32位的整形,只有它的內存地址是4的整數倍狀況下,原子性纔能有保證。Mintomic有另外一個測試用例test_load_store_32_fail,能夠驗證此種狀況。在寫本文的時候(譯註:2013年6月),這個測試用例在x86平臺上老是成功的。可是若是你將測試變量sharedInt的地址強制修改成非對齊的內存地址,那麼測試結果將會失敗。在個人Core 2 Quad Q6600機器上,若是sharedInt是跨越了單條緩存行界限(crosses a cache line boundary),那麼測試就會失敗。
1 // Force sharedInt to cross a cache line boundary: 2 #pragma pack(2) 3 MINT_DECL_ALIGNED(static struct, 64) 4 { 5 char padding[62]; 6 mint_atomic32_t sharedInt; 7 } 8 g_wrapper;
對於特定處理的狀況已經說的夠多了,接下來看看在C/C++語言層面的原子性。
在C和C++中,每個操做都被假定爲非原子性的,即便是普通的32位整形賦值。除非編譯器或硬件廠商有特殊說明。
1 uint32_t foo = 0; 2 3 void storeFoo() 4 { 5 foo = 0x80286; 6 } 7
語言標準中沒有說起關於以上狀況的原子性。也許整形賦值是原子性的,也許不是。由於非原子性的操做不作任何保證,因此在C中定義普通的整形賦值時非原子性的。
在實際中,咱們一般更瞭解咱們的目標平臺。例如:在全部的現代x86,x64,Itanium,SPARC,ARM和PowerPC處理器中,普通的32位整形,只要內存地址是對齊的,那麼賦值操做就是原子操做。你能夠經過查看處理器手冊或者編譯器文檔來證明。在遊戲產業,不少32位的賦值時依賴於這個特別的保證。
儘管如此,當寫真正的可移植的C和C++代碼時,有一個長期的假裝的傳統就是,咱們只知道語言標準中所記錄的,除此以外,一律不知。可移植的C/C++代碼是要運行在每臺可能的設備上,過去的設備,如今的設備以及想象中的設備。從我我的來講,我喜歡想象有臺機器,只能被一開始的混亂所改變。
在這樣的機器上,你絕對不會想在同一時間執行併發的讀操做,即便是普通的賦值。你可能最終只會讀到一個徹底隨機的值。
在C++11中,有一種方式能夠真正執行可移植的load原子操做和store原子操做:C++11 atomic庫。使用C++11 atomic庫,即便是運行在想象的機器上,也能夠執行原子性的load和store。即便在C++11 atomic庫的內部祕密地使用互斥鎖使每一個操做變得原子性。一樣還有一個我上個月發佈的叫Mintomic的庫(譯註:2013年6月,此庫目前已廢。)。雖然支持的平臺可能很少,可是在幾個老的編譯器上仍是能夠正常工做的,它是手工優化的而且保證是無鎖的。
讓咱們回到原來的sharedValue例子。咱們將會使用Mintomic對其進行重寫。這樣在Mintomic支持的平臺上,全部的操做都是原子性的了。首先,必須將sharedValue聲明爲Mintomic的原子數據類型的一種。
1 #include <mintomic/mintomic.h> 2 3 mint_atomic64_t sharedValue = { 0 }; 4
mint_atomic64_t類型在不一樣的平臺上,保證原子訪問都有正確的內存對齊。這很重要。由於在一些平臺的編譯器中並不作出相似的保證。好比ARM上的和Xcode 3.2.5綁定的GCC4.2版,就不保證普通的uint64_t是8字節對齊的。
在修改sharedValue時,再也不調用普通的、非原子的賦值操做,而是調用mint_store_64_relaxed
1 void storeValue() 2 { 3 mint_store_64_relaxed(&sharedValue, 0x100000002); 4 }
一樣的,在讀取sharedValue變量的值時,咱們使用mint_load_64_relaxed
1 uint64_t loadValue() 2 { 3 return mint_load_64_relaxed(&sharedValue); 4 }
使用C++11的術語來講,上述方法是無數據競爭(data race-free)的。在執行併發操做時,絕對不可能存在「撕裂讀」或「撕裂寫」。不論是運行在ARMv6/ARMv7,x86,x64或PowerPC。
下面是C++11的版本
1 #include <atomic> 2 3 std::atomic<uint64_t> sharedValue(0); 4 5 void storeValue() 6 { 7 sharedValue.store(0x100000002, std::memory_order_relaxed); 8 } 9 10 uint64_t loadValue() 11 { 12 return sharedValue.load(std::memory_order_relaxed); 13 } 14
你可能注意到,無論Mintomic仍是C++11版本的代碼都使用了relaxed語義的原子操做,也就是帶有_relaxed後綴的內存序列參數。
特別地,關於relaxed語義的原子操做,在此原子操做的以前或者以後的指令均可能被影響,也就是被亂序執行。多是由於編譯器指令亂序或者處理器的指令亂序。編譯器可能仍是在重複的relaxed原子操做上作一些優化,就像在非原子性的操做上同樣。在全部的狀況下,這個操做都是原子操做。
當併發地操做共享變量,一向地使用C++11 atomic庫或者Mintomic是個好習慣,即便是你知道在你所針對的平臺上,普通的load或store操做已是原子操做。一個atomic庫的方法能夠起到一個提示做用,提示這個變量是併發訪問的。