您好,登錄后才能下訂單哦!
本篇文章為大家展示了C++11中原子量和內(nèi)存序的作用是什么,內(nèi)容簡明扼要并且容易理解,絕對能使你眼前一亮,通過這篇文章的詳細(xì)介紹希望你能有所收獲。
一、多線程下共享變量的問題
(a) i++問題
在多線程編程中,最常拿來舉例的問題便是著名的i++ 問題,即:多個線程對同一個共享變量i執(zhí)行i++ 操作。這樣做之所以會出現(xiàn)問題的原因在于i++這個操作可以分為三個步驟:
step | operation |
---|---|
1 | i->reg(讀取i的值到寄存器) |
2 | inc-reg(在寄存器中自增i的值) |
3 | reg->i (寫回內(nèi)存中的i) |
上面三個步驟中間是可以間隔的,并非原子操作,也就是說多個線程同時執(zhí)行的時候可能出步驟的交叉執(zhí)行,例如下面的情況:
step | thread A | thread B |
---|---|---|
1 | i->reg | |
2 | inc-reg | |
3 | i->reg | |
4 | inc-reg | |
5 | reg->i | |
6 | reg->i |
假設(shè)i一開始為0,則執(zhí)行完第4步后,在兩個線程都認(rèn)為寄存器中的值為1,然后在第5、6兩步分別寫回去。最終兩個線程執(zhí)行完成后i的值為1。但是實(shí)際上我們在兩個線程中執(zhí)行了i++,原本希望i的值為2。i++ 實(shí)際上可以代表多線程編程中由于操作不是原子的而引發(fā)的交叉執(zhí)行這一類的問題,但是在這里我們先只關(guān)注對單個變量的操作。
(b)指令重排問題
有時候,我們會用一個變量作為標(biāo)志位,當(dāng)這個變量等于某個特定值的時候就進(jìn)行某些操作。但是這樣依然可能會有一些意想不到的坑,例如兩個線程以如下順序執(zhí)行:
step | thread A | thread B |
---|---|---|
1 | a = 1 | |
2 | flag= true | |
3 | if flag== true | |
4 | assert(a == 1) |
當(dāng)B判斷flag為true后,斷言a為1,看起來的確是這樣。那么一定是這樣嗎?可能不是,因?yàn)榫幾g器和CPU都可能將指令進(jìn)行重排(編譯器不同等級的優(yōu)化和CPU的亂序執(zhí)行)。實(shí)際上的執(zhí)行順序可能變成這樣:
step | thread A | thread B |
---|---|---|
1 | flag = true | |
2 | if flag== true | |
3 | assert(a == 1) | |
4 | a = 1 |
這種重排有可能會導(dǎo)致一個線程內(nèi)相互之間不存在依賴關(guān)系的指令交換執(zhí)行順序,以獲得更高的執(zhí)行效率。比如上面:flag 與 a 在A線程看起來是沒有任何依賴關(guān)系,似乎執(zhí)行順序無關(guān)緊要。但問題在于B使用了flag作為是否讀取a的依據(jù),A的指令重排可能會導(dǎo)致step3的時候斷言失敗。
解決方案
一個比較穩(wěn)妥的辦法就是對于共享變量的訪問進(jìn)行加鎖,加鎖可以保證對臨界區(qū)的互斥訪問,例如第一種場景如果加鎖后再執(zhí)行i++ 然后解鎖,則同一時刻只會有一個線程在執(zhí)行i++ 操作。另外,加鎖的內(nèi)存語義能保證一個線程在釋放鎖前的寫入操作一定能被之后加鎖的線程所見(即有happens before 語義),可以避免第二種場景中讀取到錯誤的值。
那么如果覺得加鎖操作過重太麻煩而不想加鎖呢?C++11提供了一些原子變量與原子操作來支持。
二、 C++11的原子量
C++11標(biāo)準(zhǔn)在標(biāo)準(zhǔn)庫atomic頭文件提供了模版atomic<>來定義原子量:
template< class T > struct atomic;
它提供了一系列的成員函數(shù)用于實(shí)現(xiàn)對變量的原子操作,例如讀操作load,寫操作store,以及CAS操作compare_exchange_weak/compare_exchange_strong等。而對于大部分內(nèi)建類型,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
實(shí)際上這些特化就是相當(dāng)于取了一個別名,本質(zhì)上是同樣的定義。而對于整形的特化而言,會有一些特殊的成員函數(shù),例如原子加fetch_add、原子減fetch_sub、原子與fetch_and、原子或fetch_or等。常見操作符++、--、+=、&= 等也有對應(yīng)的重載版本。
接下來以int類型為例,解決我們的前面提到的i++ 場景中的問題。先定義一個int類型的原子量:
std::atomic<int> i;
由于int型的原子量重載了++ 操作符,所以i++ 是一個不可分割的原子操作,我們用多個線程執(zhí)行i++ 操作來進(jìn)行驗(yàn)證,測試代碼如下:
#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函數(shù)開始的時候初始化為0,然后啟動10個線程,每個線程執(zhí)行i++操作十萬次,最終檢查i的值是否正確。執(zhí)行的最后結(jié)果如下:
start 10 workers, every woker inc 100000 times workers end finally i is 1000000 i++ test passed!
上面我們可以看到,10個線程同時進(jìn)行大量的自增操作,i的值依然正常。假如我們把i修改為一個普通的int變量,再次執(zhí)行程序可以得到結(jié)果如下:
start 10 workers, every woker inc 100000 times workers end finally i is 445227 i++ test failed!
顯然,由于自增操作各個步驟的交叉執(zhí)行,導(dǎo)致最后我們得到一個錯誤的結(jié)果。
原子量可以解決i++問題,那么可以解決指令重排的問題嗎?也是可以的,和原子量選擇的內(nèi)存序有關(guān),我們把這個問題放到下一節(jié)專門研究。
上面已經(jīng)看到atomic是一個模版,那么也就意味著我們可以把自定義類型變成原子變量。但是是否任意類型都可以定義為原子類型呢?當(dāng)然不是,cppreference中的描述是必須為TriviallyCopyable類型。這個連接為TriviallyCopyable的詳細(xì)定義:
http://en.cppreference.com/w/cpp/concept/TriviallyCopyable
一個比較簡單的判斷標(biāo)準(zhǔn)就是這個類型可以用std::memcpy按位復(fù)制,例如下面的類:
class { int x; int y; }
這個類是一個TriviallyCopyable類型,然而如果給它加上一個虛函數(shù):
class { int x; int y; virtual int add () { return x + y; } }
這個類便不能按位拷貝了,不滿足條件,不能進(jìn)行原子化。
如果一個類型能夠滿足atomic模版的要求,可以原子化,它就不用進(jìn)行加鎖操作了,因而速度更快嗎?依然不是,atomic有一個成員函數(shù)is_lock_free,這個成員函數(shù)可以告訴我們到底這個類型的原子量是使用了原子CPU指令實(shí)現(xiàn)了無鎖化,還是依然使用的加鎖的方式來實(shí)現(xiàn)原子操作。不過不管是否用鎖來實(shí)現(xiàn),atomic的使用方式和表現(xiàn)出的語義都是沒有區(qū)別的。具體用哪種方式實(shí)現(xiàn)C++標(biāo)準(zhǔn)并沒有做約束(除了std::atomic_flag特化要求必須為lock free),跟平臺有關(guān)。
例如在我的Cygwin64、GCC7.3環(huán)境下執(zhí)行如下代碼:
#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; }
結(jié)果為:
8
1
證明上面定義的類型A的原子量是無鎖的。我在這個平臺上進(jìn)行了實(shí)驗(yàn),修改N的大小,結(jié)果如下:
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修改為內(nèi)建類型,對于內(nèi)建類型的實(shí)驗(yàn)結(jié)果如下:
type | sizeof() | is_lock_free() |
---|---|---|
char | 1 | 1 |
short | 2 | 1 |
int | 4 | 1 |
long long | 8 | 1 |
float | 4 | 1 |
double | 8 | 1 |
可以看出在我的平臺下常用內(nèi)建類型都是lock free的,自定義類型則和大小有關(guān)。
從上面的統(tǒng)計還可以看出似乎當(dāng)自定義類型的長度和某種自定義類型相等的時候is_lock_free()就為true。我推測可能我這里的atomic實(shí)現(xiàn)的無鎖是通過編譯器內(nèi)建的原子操作實(shí)現(xiàn)的,只有當(dāng)數(shù)據(jù)長度剛好能調(diào)用編譯器內(nèi)建原子操作時才能進(jìn)行無鎖化。查看GCC參考手冊(https://gcc.gnu.org/onlinedocs/gcc-7.3.0/gcc/_005f_005fatomic-Builtins.html#g_t_005f_005fatomic-Builtins) 中內(nèi)建原子操作的原型,以CAS操作為例:
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)
其參數(shù)類型為type *的指針,在同一頁可以找到GCC關(guān)于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類型的長度應(yīng)該為1、2、4、8字節(jié)中的一個,少數(shù)支持__int128的平臺可以到16字節(jié),所以只有長度為1,2,4,8字節(jié)的數(shù)據(jù)才能實(shí)現(xiàn)無鎖。這個只是我的推測,具體是否如此尚不明白。
三、C++11的六種內(nèi)存序
前面我們解決i++問題的時候已經(jīng)使用過原子量的寫操作load將原子量賦值,實(shí)際上成員函數(shù)還有另一個參數(shù):
void store( T desired, std::memory_order order = std::memory_order_seq_cst )
這個參數(shù)代表了該操作使用的內(nèi)存序,用于控制變量在不同線程見的順序可見性問題,不只load,其他成員函數(shù)也帶有該參數(shù)。c++11提供了六種內(nèi)存序供選擇,分別為:
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中,因?yàn)橹噶畹闹嘏艑?dǎo)致了意料之外的錯誤,通過使用原子變量并選擇合適內(nèi)存序,可以解決這個問題。下面先來看看這幾種內(nèi)存序
memory_order_release/memory_order_acquire
內(nèi)存序選項(xiàng)用來作為原子量成員函數(shù)的參數(shù),memory_order_release用于store操作,memory_order_acquire用于load操作,這里我們把使用了memory_order_release的調(diào)用稱之為release操作。從邏輯上可以這樣理解:release操作可以阻止這個調(diào)用之前的讀寫操作被重排到后面去,而acquire操作則可以保證調(diào)用之后的讀寫操作不會重排到前面來。聽起來有種很繞的感覺,還是以一個例子來解釋:假設(shè)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) |
實(shí)際上這就是把我們上文場景2中的flag變量換成了原子量,并用其成員函數(shù)進(jìn)行讀寫。在這種情況下的邏輯順序上,step1不會跑到step2后面去,step4不會跑到step3前面去。這樣一來,實(shí)際上我們就已經(jīng)保證了當(dāng)讀取到flag為true的時候a一定已經(jīng)被寫入為1了,場景2得到了解決。換一種比較嚴(yán)謹(jǐn)?shù)拿枋龇绞娇梢钥偨Y(jié)為:
對于同一個原子量,release操作前的寫入,一定對隨后acquire操作后的讀取可見。
這兩種內(nèi)存序是需要配對使用的,這也是將他們放在一起介紹的原因。還有一點(diǎn)需要注意的是:只有對同一個原子量進(jìn)行操作才會有上面的保證,比如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一樣全部阻止。將上面的例子稍加改造來展示這種內(nèi)存序,假設(shè)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,當(dāng)step4線程B讀取到flag的值為true的時候,由于flag依賴于b,b在之前的寫入是可見的,此時b一定為true,所以step6、step7的斷言一定會成功。而且這種依賴關(guān)系具有傳遞性,假如b又依賴與另一個變量d,則d在之前的寫入同樣對step4之后的操作可見。那么a呢?很遺憾在這種內(nèi)存序下a并不能得到保證,step5的斷言可能會失敗。
memory_order_acq_rel
這個選項(xiàng)看名字就很像release和acquire的結(jié)合體,實(shí)際上它的確兼具兩者的特性。這個操作用于“讀取-修改-寫回”這一類既有讀取又有修改的操作,例如CAS操作??梢詫⑦@個操作在內(nèi)存序中的作用想象為將release操作和acquire操作捆在一起,因此任何讀寫操作的重拍都不能跨越這個調(diào)用。依然以一個例子來說明,flag為一個 atomic特化的bool 原子量,a、c各為一個int變量,b為一個bool變量,并且剛好按如下順序執(zhí)行:
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
這個內(nèi)存序是各個成員函數(shù)的內(nèi)存序默認(rèn)選項(xiàng),如果不選擇內(nèi)存序則默認(rèn)使用memory_order_seq_cst。這是一個“美好”的選項(xiàng),如果對原子變量的操作都是使用的memory_order_seq_cst內(nèi)存序,則多線程行為相當(dāng)于是這些操作都以一種特定順序被一個線程執(zhí)行,在哪個線程觀察到的對這些原子量的操作都一樣。同時,任何使用該選項(xiàng)的寫操作都相當(dāng)于release操作,任何讀操作都相當(dāng)于acquire操作,任何“讀取-修改-寫回”這一類的操作都相當(dāng)于使用memory_order_acq_rel的操作。
memory_order_relaxed
這個選項(xiàng)如同其名字,比較松散,它僅僅只保證其成員函數(shù)操作本身是原子不可分割的,但是對于順序性不做任何保證。
代價
總的來講,越嚴(yán)格的內(nèi)存序其性能開銷會越大。對于我們常用的x86處理器而言,在處理器層級本身就支持release/acquire語義,因此release與acquire/consume都只影響編譯器的優(yōu)化,而memory_order_seq_cst還會影響處理器的指令重排。
感想
查資料學(xué)習(xí)原子量與內(nèi)存序的過程中,深感多線程和并發(fā)的深奧,盡管出于好奇心會嘗試了解各種內(nèi)存序,但是在實(shí)踐中寫代碼還是盡量選擇比較穩(wěn)妥的方式來實(shí)現(xiàn)吧,能加鎖加鎖,實(shí)在不行用默認(rèn)的memory_order_seq_cst選項(xiàng)的原子量。畢竟就普通程序員而言,其實(shí)很難遇到要在這上面擠性能的場景,如果真覺得需要,多半是我們的設(shè)計不科學(xué) = = !假如確確實(shí)實(shí)遇到這樣的場景,做之前一定要謹(jǐn)慎的多做些研究,選擇簡單的使用方式,做到心中有數(shù)。
上述內(nèi)容就是C++11中原子量和內(nèi)存序的作用是什么,你們學(xué)到知識或技能了嗎?如果還想學(xué)到更多技能或者豐富自己的知識儲備,歡迎關(guān)注億速云行業(yè)資訊頻道。
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。