[譯]Atomic VS. Non-Atomic 操做

原文連接:atomic-vs-non-atomic-operationsgit

 

     在網上已經寫了不少關於原子操做的文章,可是一般都集中在原子的讀-修改-寫(RMW. read-modify-write)操做。可是這些並是全部的原子操做。一樣重要的屬於原子操做的仍是有load(譯註:讀)和store(譯註:寫)。在這篇文章中,我將會在處理器層面和C/C++語言層面,比較原子性和非原子性的load和store。順便,咱們將會闡明如下在C++11中的「數據競爭」概念。程序員

nonatomic

   

     若是一個共享變量的操做,它能相對於其餘線程,可以一步完成,那麼這個操做就是原子性的操做。當對一個共享變量執行原子性的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指令,也多是非原子的。也多是由於程序員寫的可移植代碼。可是不能簡單地作出這個假設。讓咱們看幾個例子。併發

 

因爲多條CPU指令的非原子操做

假設有一個64位的全局變量,初始化爲0.app

  1 uint64_t sharedValue = 0;
View Code

此時,將一個64位的值更新到此變量:
ide

  1 void storeValue()
  2 {
  3     sharedValue = 0x100000002;
  4 }
View Code

在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         ...
View Code

      如你所見,編譯器實現一個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 
View Code

      一樣,編譯器用兩條機器指令實現讀取操做:第一條指令讀取低32位的值到eax寄存器,而後第二條指令讀取高32位的值到edx寄存器。在這種狀況下,併發地發生一個寫的操做,此時會產生一個「撕裂讀(torn read)」。即便這個併發的寫是原子操做。

      這些問題並不僅是存在於理論上。Mintomic的測試套件中包含了一個叫test_load_store_64_fail的測試用例。一個線程使用普通的賦值操做符更新一個64位變量的值,另外一個線程週期性地執行一個從相同變量的讀取操做,對每次讀取回來的結果進行校驗。在x86多核機器上,和預期同樣,此測試會常常失敗。

load_store_x86

 

非原子性的CPU指令

     即便執行單條CPU指令,一個內存操做也多是非原子性的。例如:在ARMv7指令集中,包含了一個strd指令,實現將兩個32位的寄存器的值存儲到一個64位的變量中。

  1 strd r0, r1, [r2]
View Code

      在一些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;
View Code

force_fail


對於特定處理的狀況已經說的夠多了,接下來看看在C/C++語言層面的原子性。

 

全部的C/C++操做都假設是非原子性的

       在C和C++中,每個操做都被假定爲非原子性的,即便是普通的32位整形賦值。除非編譯器或硬件廠商有特殊說明。

  1 uint32_t foo = 0;
  2 
  3 void storeFoo()
  4 {
  5     foo = 0x80286;
  6 }
  7 
View Code

      語言標準中沒有說起關於以上狀況的原子性。也許整形賦值是原子性的,也許不是。由於非原子性的操做不作任何保證,因此在C中定義普通的整形賦值時非原子性的。

      在實際中,咱們一般更瞭解咱們的目標平臺。例如:在全部的現代x86,x64,Itanium,SPARC,ARM和PowerPC處理器中,普通的32位整形,只要內存地址是對齊的,那麼賦值操做就是原子操做。你能夠經過查看處理器手冊或者編譯器文檔來證明。在遊戲產業,不少32位的賦值時依賴於這個特別的保證。

      儘管如此,當寫真正的可移植的C和C++代碼時,有一個長期的假裝的傳統就是,咱們只知道語言標準中所記錄的,除此以外,一律不知。可移植的C/C++代碼是要運行在每臺可能的設備上,過去的設備,如今的設備以及想象中的設備。從我我的來講,我喜歡想象有臺機器,只能被一開始的混亂所改變。

slot-machines


      在這樣的機器上,你絕對不會想在同一時間執行併發的讀操做,即便是普通的賦值。你可能最終只會讀到一個徹底隨機的值。
      在C++11中,有一種方式能夠真正執行可移植的load原子操做和store原子操做:C++11 atomic庫。使用C++11 atomic庫,即便是運行在想象的機器上,也能夠執行原子性的load和store。即便在C++11 atomic庫的內部祕密地使用互斥鎖使每一個操做變得原子性。一樣還有一個我上個月發佈的叫Mintomic的庫(譯註:2013年6月,此庫目前已廢。)。雖然支持的平臺可能很少,可是在幾個老的編譯器上仍是能夠正常工做的,它是手工優化的而且保證是無鎖的。

 

不嚴格的(Relaxed)原子操做

      讓咱們回到原來的sharedValue例子。咱們將會使用Mintomic對其進行重寫。這樣在Mintomic支持的平臺上,全部的操做都是原子性的了。首先,必須將sharedValue聲明爲Mintomic的原子數據類型的一種。

  1 #include <mintomic/mintomic.h>
  2 
  3 mint_atomic64_t sharedValue = { 0 };
  4 
View Code

      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 }
View Code

一樣的,在讀取sharedValue變量的值時,咱們使用mint_load_64_relaxed

  1 uint64_t loadValue()
  2 {
  3     return mint_load_64_relaxed(&sharedValue);
  4 }
View Code

      使用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 
View Code

      你可能注意到,無論Mintomic仍是C++11版本的代碼都使用了relaxed語義的原子操做,也就是帶有_relaxed後綴的內存序列參數。

      特別地,關於relaxed語義的原子操做,在此原子操做的以前或者以後的指令均可能被影響,也就是被亂序執行。多是由於編譯器指令亂序或者處理器的指令亂序。編譯器可能仍是在重複的relaxed原子操做上作一些優化,就像在非原子性的操做上同樣。在全部的狀況下,這個操做都是原子操做。

      當併發地操做共享變量,一向地使用C++11 atomic庫或者Mintomic是個好習慣,即便是你知道在你所針對的平臺上,普通的load或store操做已是原子操做。一個atomic庫的方法能夠起到一個提示做用,提示這個變量是併發訪問的。

相關文章
相關標籤/搜索