C++11中的原子量和內存序詳解


轉載來自:https://www.jb51.net/article/141896.htm

一、多線程下共享變量的問題

在多線程編程中經常需要在不同線程之間共享一些變量,然而對於共享變量操作卻經常造成一些莫名奇妙的錯誤,除非老老實實加鎖對訪問保護,否則經常出現一些(看起來)匪夷所思的情況。比如下面便是兩種比較“喜聞樂見”的情況。

(a) i++問題

在多線程編程中,最常拿來舉例的問題便是著名的i++ 問題,即:多個線程對同一個共享變量i執行i++ 操作。這樣做之所以會出現問題的原因在於i++這個操作可以分為三個步驟:

 

step operation
1 i->reg(讀取i的值到寄存器)
2 inc-reg(在寄存器中自增i的值)
3 reg->i (寫回內存中的i)

 

上面三個步驟中間是可以間隔的,並非原子操作,也就是說多個線程同時執行的時候可能出步驟的交叉執行,例如下面的情況:

 

step thread A thread B
1 i->reg  
2 inc-reg  
3   i->reg
4   inc-reg
5 reg->i  
6   reg->i

 

假設i一開始為0,則執行完第4步后,在兩個線程都認為寄存器中的值為1,然后在第5、6兩步分別寫回去。最終兩個線程執行完成后i的值為1。但是實際上我們在兩個線程中執行了i++,原本希望i的值為2。i++ 實際上可以代表多線程編程中由於操作不是原子的而引發的交叉執行這一類的問題,但是在這里我們先只關注對單個變量的操作。

(b)指令重排問題

有時候,我們會用一個變量作為標志位,當這個變量等於某個特定值的時候就進行某些操作。但是這樣依然可能會有一些意想不到的坑,例如兩個線程以如下順序執行:

 

step thread A thread B
1 a = 1  
2 flag= true  
3   if flag== true
4   assert(a == 1)

 

當B判斷flag為true后,斷言a為1,看起來的確是這樣。那么一定是這樣嗎?可能不是,因為編譯器和CPU都可能將指令進行重排(編譯器不同等級的優化和CPU的亂序執行)。實際上的執行順序可能變成這樣:

 

step thread A thread B
1 flag = true  
2   if flag== true
3   assert(a == 1)
4 a = 1  

 

這種重排有可能會導致一個線程內相互之間不存在依賴關系的指令交換執行順序,以獲得更高的執行效率。比如上面:flag 與 a 在A線程看起來是沒有任何依賴關系,似乎執行順序無關緊要。但問題在於B使用了flag作為是否讀取a的依據,A的指令重排可能會導致step3的時候斷言失敗。

解決方案

一個比較穩妥的辦法就是對於共享變量的訪問進行加鎖,加鎖可以保證對臨界區的互斥訪問,例如第一種場景如果加鎖后再執行i++ 然后解鎖,則同一時刻只會有一個線程在執行i++ 操作。另外,加鎖的內存語義能保證一個線程在釋放鎖前的寫入操作一定能被之后加鎖的線程所見(即有happens before 語義),可以避免第二種場景中讀取到錯誤的值。

那么如果覺得加鎖操作過重太麻煩而不想加鎖呢?C++11提供了一些原子變量與原子操作來支持。

二、 C++11的原子量

C++11標准在標准庫atomic頭文件提供了模版atomic<>來定義原子量:

template< class T >
struct atomic;

 

它提供了一系列的成員函數用於實現對變量的原子操作,例如讀操作load,寫操作store,以及CAS操作compare_exchange_weak/compare_exchange_strong等。而對於大部分內建類型,C++11提供了一些特化:

std::atomic_bool std::atomic<bool>
std::atomic_char std::atomic<char>
std::atomic_schar std::atomic<signed char>
std::atomic_uchar std::atomic<unsigned char>
std::atomic_short std::atomic<short>
std::atomic_ushort std::atomic<unsigned short>
std::atomic_int std::atomic<int>
std::atomic_uint std::atomic<unsigned int>
std::atomic_long std::atomic<long>
······
//更多類型見:http://en.cppreference.com/w/cpp/atomic/atomic

 

實際上這些特化就是相當於取了一個別名,本質上是同樣的定義。而對於整形的特化而言,會有一些特殊的成員函數,例如原子加fetch_add、原子減fetch_sub、原子與fetch_and、原子或fetch_or等。常見操作符++、--、+=、&= 等也有對應的重載版本。

接下來以int類型為例,解決我們的前面提到的i++ 場景中的問題。先定義一個int類型的原子量:

std::atomic<int> i;

 

由於int型的原子量重載了++ 操作符,所以i++ 是一個不可分割的原子操作,我們用多個線程執行i++ 操作來進行驗證,測試代碼如下:

#include <iostream>
#include <atomic>
#include <vector>
#include <functional>
#include <thread>

std::atomic<int> i;
const int count = 100000;
const int n = 10;

void add()
{
 for (int j = 0; j < count; ++j)
 i++;
}

int main()
{
 i.store(0);
 std::vector<std::thread> workers;
 std::cout << "start " << n << " workers, "
  << "every woker inc " << count << " times" << std::endl;

 for (int j = 0; j < n; ++j)
 workers.push_back(std::move(std::thread(add)));

 for (auto & w : workers)
 w.join();

 std::cout << "workers end "
  << "finally i is " << i << std::endl;

 if (i == n * count)
 std::cout << "i++ test passed!" << std::endl;
 else
 std::cout << "i++ test failed!" << std::endl;

 return 0;
}

 

在測試中,我們定義了一個原子量i,在main函數開始的時候初始化為0,然后啟動10個線程,每個線程執行i++操作十萬次,最終檢查i的值是否正確。執行的最后結果如下

start 10 workers, every woker inc 100000 times
workers end finally i is 1000000
i++ test passed!

 

上面我們可以看到,10個線程同時進行大量的自增操作,i的值依然正常。假如我們把i修改為一個普通的int變量,再次執行程序可以得到結果如下:

start 10 workers, every woker inc 100000 times
workers end finally i is 445227
i++ test failed!

 

顯然,由於自增操作各個步驟的交叉執行,導致最后我們得到一個錯誤的結果。

原子量可以解決i++問題,那么可以解決指令重排的問題嗎?也是可以的,和原子量選擇的內存序有關,我們把這個問題放到下一節專門研究。

上面已經看到atomic是一個模版,那么也就意味着我們可以把自定義類型變成原子變量。但是是否任意類型都可以定義為原子類型呢?當然不是,cppreference中的描述是必須為TriviallyCopyable類型。這個連接為TriviallyCopyable的詳細定義:

一個比較簡單的判斷標准就是這個類型可以用std::memcpy按位復制,例如下面的類:

class {
 int x;
 int y;
}

 

這個類是一個TriviallyCopyable類型,然而如果給它加上一個虛函數:

class {
 int x;
 int y;
 virtual int add ()
 {
  return x + y;
 }
}

 

 

這個類便不能按位拷貝了,不滿足條件,不能進行原子化。

如果一個類型能夠滿足atomic模版的要求,可以原子化,它就不用進行加鎖操作了,因而速度更快嗎?依然不是,atomic有一個成員函數is_lock_free,這個成員函數可以告訴我們到底這個類型的原子量是使用了原子CPU指令實現了無鎖化還是依然使用的加鎖的方式來實現原子操作。不過不管是否用鎖來實現,atomic的使用方式和表現出的語義都是沒有區別的。具體用哪種方式實現C++標准並沒有做約束(除了std::atomic_flag特化要求必須為lock free),跟平台有關

例如在我的Cygwin64、GCC7.3環境下執行如下代碼:

#include <iostream>
#include <atomic>
 
#define N 8
 
struct A {
 char a[N];
};
 
int main()
{
 std::atomic<A> a;
 std::cout << sizeof(A) << std::endl;
 std::cout << a.is_lock_free() << std::endl;
 return 0;
}

 

結果為:

8
1

 

證明上面定義的類型A的原子量是無鎖的。我在這個平台上進行了實驗,修改N的大小,結果如下:

 

N sizeof(A) is_lock_free()
1 1 1
2 2 1
3 3 0
4 4 1
5 5 0
6 6 0
7 7 0
8 8 1
> 8 / 0

 

將A修改為內建類型,對於內建類型的實驗結果如下:

 

type sizeof() is_lock_free()
char 1 1
short 2 1
int 4 1
long long 8 1
float 4 1
double 8 1

 

可以看出在我的平台下常用內建類型都是lock free的,自定義類型則和大小有關。

從上面的統計還可以看出似乎當自定義類型的長度和某種自定義類型相等的時候is_lock_free()就為true。我推測可能我這里的atomic實現的無鎖是通過編譯器內建的原子操作實現的,只有當數據長度剛好能調用編譯器內建原子操作時才能進行無鎖化。查看GCC參考手冊(https://gcc.gnu.org/onlinedocs/gcc-7.3.0/gcc/_005f_005fatomic-Builtins.html#g_t_005f_005fatomic-Builtins) 中內建原子操作的原型,以CAS操作為例:

1
2
3
bool __atomic_compare_exchange_n (type *ptr, type *expected, type desired, bool weak, int success_memorder, int failure_memorder)
 
bool __atomic_compare_exchange (type *ptr, type *expected, type *desired, bool weak, int success_memorder, int failure_memorder)

其參數類型為type *的指針,在同一頁可以找到GCC關於type的描述:

The ‘__atomic' builtins can be used with any integral scalar or pointer type that is 1, 2, 4, or 8 bytes in length. 16-byte integral types are also allowed if ‘__int128' (see __int128) is supported by the architecture.

type類型的長度應該為1、2、4、8字節中的一個,少數支持__int128的平台可以到16字節,所以只有長度為1,2,4,8字節的數據才能實現無鎖。這個只是我的推測,具體是否如此尚不明白。

三、C++11的六種內存序

前面我們解決i++問題的時候已經使用過原子量的寫操作load將原子量賦值,實際上成員函數還有另一個參數:

void store( T desired, std::memory_order order = std::memory_order_seq_cst )
這個參數代表了該操作使用的內存序用於控制變量在不同線程見的順序可見性問題,不只load,其他成員函數也帶有該參數。c++11提供了六種內存序供選擇,分別為:

typedef enum memory_order {
 memory_order_relaxed,
 memory_order_consume,
 memory_order_acquire,
 memory_order_release,
 memory_order_acq_rel,
 memory_order_seq_cst
} memory_order;

 

之前在場景2中,因為指令的重排導致了意料之外的錯誤,通過使用原子變量並選擇合適內存序,可以解決這個問題。下面先來看看這幾種內存序

memory_order_release/memory_order_acquire

內存序選項用來作為原子量成員函數的參數,memory_order_release用於store操作,memory_order_acquire用於load操作,這里我們把使用了memory_order_release的調用稱之為release操作。從邏輯上可以這樣理解:release操作可以阻止這個調用之前的讀寫操作被重排到后面去,而acquire操作則可以保證調用之后的讀寫操作不會重排到前面來。聽起來有種很繞的感覺,還是以一個例子來解釋:假設flag為一個 atomic特化的bool 原子量,a為一個int變量,並且有如下時序的操作:

 

step thread A thread B
1 a = 1  
2 flag.store(true, memory_order_release)  
3   if( true == flag.load(memory_order_acquire))
4   assert(a == 1)

 

實際上這就是把我們上文場景2中的flag變量換成了原子量,並用其成員函數進行讀寫。在這種情況下的邏輯順序上,step1不會跑到step2后面去,step4不會跑到step3前面去。這樣一來,實際上我們就已經保證了當讀取到flag為true的時候a一定已經被寫入為1了,場景2得到了解決。換一種比較嚴謹的描述方式可以總結為:

對於同一個原子量,release操作前的寫入,一定對隨后acquire操作后的讀取可見。
這兩種內存序是需要配對使用的,這也是將他們放在一起介紹的原因。還有一點需要注意的是:只有對同一個原子量進行操作才會有上面的保證,比如step3如果是讀取了另一個原子量flag2,是不能保證讀取到a的值為1的。

memory_order_release/memory_order_consume

memory_order_release還可以和memory_order_consume搭配使用。memory_order_release操作的作用沒有變化,而memory_order_consume用於load操作,我們簡稱為consume操作,comsume操作防止在其后對原子變量有依賴的操作被重排到前面去。這種情況下:

  • 對於同一個原子變量,release操作所依賴的寫入,一定對隨后consume操作后依賴於該原子變量的操作可見。

這個組合比上一種更寬松,comsume只阻止對這個原子量有依賴的操作重拍到前面去,而非像aquire一樣全部阻止。將上面的例子稍加改造來展示這種內存序,假設flag為一個 atomic特化的bool 原子量,a為一個int變量,b、c各為一個bool變量,並且有如下時序的操作:

 

step thread A thread B
1 b = true  
2 a = 1  
3 flag.store(b, memory_order_release)  
4   while (!(c = flag.load(memory_order_consume)))
5   assert(a == 1)
6   assert(c == true)
7   assert(b == true)

 

step4使得c依賴於flag,當step4線程B讀取到flag的值為true的時候,由於flag依賴於b,b在之前的寫入是可見的,此時b一定為true,所以step6、step7的斷言一定會成功。而且這種依賴關系具有傳遞性,假如b又依賴與另一個變量d,則d在之前的寫入同樣對step4之后的操作可見。那么a呢?很遺憾在這種內存序下a並不能得到保證,step5的斷言可能會失敗。

memory_order_acq_rel

這個選項看名字就很像release和acquire的結合體,實際上它的確兼具兩者的特性。這個操作用於“讀取-修改-寫回”這一類既有讀取又有修改的操作,例如CAS操作。可以將這個操作在內存序中的作用想象為將release操作和acquire操作捆在一起,因此任何讀寫操作的重拍都不能跨越這個調用。依然以一個例子來說明,flag為一個 atomic特化的bool 原子量,a、c各為一個int變量,b為一個bool變量,並且剛好按如下順序執行:

 

step thread A thread B
1 a = 1  
2 flag.store(true, memory_order_release)  
3   b = true
4   c = 2
5   while (!flag.compare_exchange_weak(b, false, memory_order_acq_rel)) {b = true}
6   assert(a == 1)
7 if (true == flag.load(memory_order_acquire)  
8 assert(c == 2)  

 

由於memory_order_acq_rel同時具有memory_order_release與memory_order_acquire的作用,因此step2可以和step5組合成上面提到的release/acquire組合,因此step6的斷言一定會成功,而step5又可以和step7組成release/acquire組合,step8的斷言同樣一定會成功。

memory_order_seq_cst

這個內存序是各個成員函數的內存序默認選項,如果不選擇內存序則默認使用memory_order_seq_cst。這是一個“美好”的選項,如果對原子變量的操作都是使用的memory_order_seq_cst內存序,則多線程行為相當於是這些操作都以一種特定順序被一個線程執行,在哪個線程觀察到的對這些原子量的操作都一樣。同時,任何使用該選項的寫操作都相當於release操作任何讀操作都相當於acquire操作,任何“讀取-修改-寫回”這一類的操作都相當於使用memory_order_acq_rel的操作。

memory_order_relaxed

這個選項如同其名字,比較松散,它僅僅只保證其成員函數操作本身是原子不可分割的,但是對於順序性不做任何保證

代價

總的來講,越嚴格的內存序其性能開銷會越大。對於我們常用的x86處理器而言,在處理器層級本身就支持release/acquire語義,因此release與acquire/consume都只影響編譯器的優化,而memory_order_seq_cst還會影響處理器的指令重排。

感想

查資料學習原子量與內存序的過程中,深感多線程和並發的深奧,盡管出於好奇心會嘗試了解各種內存序,但是在實踐中寫代碼還是盡量選擇比較穩妥的方式來實現吧,能加鎖加鎖,實在不行用默認的memory_order_seq_cst選項的原子量。畢竟就普通程序員而言,其實很難遇到要在這上面擠性能的場景,如果真覺得需要,多半是我們的設計不科學 = = !假如確確實實遇到這樣的場景,做之前一定要謹慎的多做些研究,選擇簡單的使用方式,做到心中有數。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM