溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

C++11中的原子量和內(nèi)存序有什么用

發(fā)布時(shí)間:2022-04-15 11:02:31 來(lái)源:億速云 閱讀:226 作者:iii 欄目:編程語(yǔ)言

這篇文章主要講解了“C++11中的原子量和內(nèi)存序有什么用”,文中的講解內(nèi)容簡(jiǎn)單清晰,易于學(xué)習(xí)與理解,下面請(qǐng)大家跟著小編的思路慢慢深入,一起來(lái)研究和學(xué)習(xí)“C++11中的原子量和內(nèi)存序有什么用”吧!

一、多線程下共享變量的問(wèn)題

(a) i++問(wèn)題

在多線程編程中,最常拿來(lái)舉例的問(wèn)題便是著名的i++ 問(wèn)題,即:多個(gè)線程對(duì)同一個(gè)共享變量i執(zhí)行i++ 操作。這樣做之所以會(huì)出現(xiàn)問(wèn)題的原因在于i++這個(gè)操作可以分為三個(gè)步驟:

stepoperation
1i->reg(讀取i的值到寄存器)
2inc-reg(在寄存器中自增i的值)
3reg->i (寫(xiě)回內(nèi)存中的i)

上面三個(gè)步驟中間是可以間隔的,并非原子操作,也就是說(shuō)多個(gè)線程同時(shí)執(zhí)行的時(shí)候可能出步驟的交叉執(zhí)行,例如下面的情況:

stepthread Athread B
1i->reg
2inc-reg
3
i->reg
4
inc-reg
5reg->i
6
reg->i

假設(shè)i一開(kāi)始為0,則執(zhí)行完第4步后,在兩個(gè)線程都認(rèn)為寄存器中的值為1,然后在第5、6兩步分別寫(xiě)回去。最終兩個(gè)線程執(zhí)行完成后i的值為1。但是實(shí)際上我們?cè)趦蓚€(gè)線程中執(zhí)行了i++,原本希望i的值為2。i++ 實(shí)際上可以代表多線程編程中由于操作不是原子的而引發(fā)的交叉執(zhí)行這一類的問(wèn)題,但是在這里我們先只關(guān)注對(duì)單個(gè)變量的操作。

(b)指令重排問(wèn)題

有時(shí)候,我們會(huì)用一個(gè)變量作為標(biāo)志位,當(dāng)這個(gè)變量等于某個(gè)特定值的時(shí)候就進(jìn)行某些操作。但是這樣依然可能會(huì)有一些意想不到的坑,例如兩個(gè)線程以如下順序執(zhí)行:

stepthread Athread B
1a = 1
2flag= true
3
if flag== true
4
assert(a == 1)

當(dāng)B判斷flag為true后,斷言a為1,看起來(lái)的確是這樣。那么一定是這樣嗎?可能不是,因?yàn)榫幾g器和CPU都可能將指令進(jìn)行重排(編譯器不同等級(jí)的優(yōu)化和CPU的亂序執(zhí)行)。實(shí)際上的執(zhí)行順序可能變成這樣:

stepthread Athread B
1flag = true
2
if flag== true
3
assert(a == 1)
4a = 1

這種重排有可能會(huì)導(dǎo)致一個(gè)線程內(nèi)相互之間不存在依賴關(guān)系的指令交換執(zhí)行順序,以獲得更高的執(zhí)行效率。比如上面:flag 與 a 在A線程看起來(lái)是沒(méi)有任何依賴關(guān)系,似乎執(zhí)行順序無(wú)關(guān)緊要。但問(wèn)題在于B使用了flag作為是否讀取a的依據(jù),A的指令重排可能會(huì)導(dǎo)致step3的時(shí)候斷言失敗。

解決方案

一個(gè)比較穩(wěn)妥的辦法就是對(duì)于共享變量的訪問(wèn)進(jìn)行加鎖,加鎖可以保證對(duì)臨界區(qū)的互斥訪問(wèn),例如第一種場(chǎng)景如果加鎖后再執(zhí)行i++ 然后解鎖,則同一時(shí)刻只會(huì)有一個(gè)線程在執(zhí)行i++ 操作。另外,加鎖的內(nèi)存語(yǔ)義能保證一個(gè)線程在釋放鎖前的寫(xiě)入操作一定能被之后加鎖的線程所見(jiàn)(即有happens before 語(yǔ)義),可以避免第二種場(chǎng)景中讀取到錯(cuò)誤的值。

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

二、 C++11的原子量

C++11標(biāo)準(zhǔn)在標(biāo)準(zhǔn)庫(kù)atomic頭文件提供了模版atomic<>來(lái)定義原子量:

template< class T >
struct atomic;

它提供了一系列的成員函數(shù)用于實(shí)現(xiàn)對(duì)變量的原子操作,例如讀操作load,寫(xiě)操作store,以及CAS操作compare_exchange_weak/compare_exchange_strong等。而對(duì)于大部分內(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>
······
//更多類型見(jiàn):http://en.cppreference.com/w/cpp/atomic/atomic

實(shí)際上這些特化就是相當(dāng)于取了一個(gè)別名,本質(zhì)上是同樣的定義。而對(duì)于整形的特化而言,會(huì)有一些特殊的成員函數(shù),例如原子加fetch_add、原子減fetch_sub、原子與fetch_and、原子或fetch_or等。常見(jiàn)操作符++、--、+=、&= 等也有對(duì)應(yīng)的重載版本。

接下來(lái)以int類型為例,解決我們的前面提到的i++ 場(chǎng)景中的問(wèn)題。先定義一個(gè)int類型的原子量:

std::atomic<int> i;

由于int型的原子量重載了++ 操作符,所以i++ 是一個(gè)不可分割的原子操作,我們用多個(gè)線程執(zhí)行i++ 操作來(lái)進(jìn)行驗(yàn)證,測(cè)試代碼如下:

#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;
}

在測(cè)試中,我們定義了一個(gè)原子量i,在main函數(shù)開(kāi)始的時(shí)候初始化為0,然后啟動(dòng)10個(gè)線程,每個(gè)線程執(zhí)行i++操作十萬(wàn)次,最終檢查i的值是否正確。執(zhí)行的最后結(jié)果如下:

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

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

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

顯然,由于自增操作各個(gè)步驟的交叉執(zhí)行,導(dǎo)致最后我們得到一個(gè)錯(cuò)誤的結(jié)果。

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

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

http://en.cppreference.com/w/cpp/concept/TriviallyCopyable

一個(gè)比較簡(jiǎn)單的判斷標(biāo)準(zhǔn)就是這個(gè)類型可以用std::memcpy按位復(fù)制,例如下面的類:

class {
 int x;
 int y;
}

這個(gè)類是一個(gè)TriviallyCopyable類型,然而如果給它加上一個(gè)虛函數(shù):

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

這個(gè)類便不能按位拷貝了,不滿足條件,不能進(jìn)行原子化。

如果一個(gè)類型能夠滿足atomic模版的要求,可以原子化,它就不用進(jìn)行加鎖操作了,因而速度更快嗎?依然不是,atomic有一個(gè)成員函數(shù)is_lock_free,這個(gè)成員函數(shù)可以告訴我們到底這個(gè)類型的原子量是使用了原子CPU指令實(shí)現(xiàn)了無(wú)鎖化,還是依然使用的加鎖的方式來(lái)實(shí)現(xiàn)原子操作。不過(guò)不管是否用鎖來(lái)實(shí)現(xiàn),atomic的使用方式和表現(xiàn)出的語(yǔ)義都是沒(méi)有區(qū)別的。具體用哪種方式實(shí)現(xiàn)C++標(biāo)準(zhǔn)并沒(méi)有做約束(除了std::atomic_flag特化要求必須為lock free),跟平臺(tái)有關(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的原子量是無(wú)鎖的。我在這個(gè)平臺(tái)上進(jìn)行了實(shí)驗(yàn),修改N的大小,結(jié)果如下:

Nsizeof(A)is_lock_free()
111
221
330
441
550
660
770
881
> 8/0

將A修改為內(nèi)建類型,對(duì)于內(nèi)建類型的實(shí)驗(yàn)結(jié)果如下:

typesizeof()is_lock_free()
char11
short21
int41
long long81
float41
double81

可以看出在我的平臺(tái)下常用內(nèi)建類型都是lock free的,自定義類型則和大小有關(guān)。

從上面的統(tǒng)計(jì)還可以看出似乎當(dāng)自定義類型的長(zhǎng)度和某種自定義類型相等的時(shí)候is_lock_free()就為true。我推測(cè)可能我這里的atomic實(shí)現(xiàn)的無(wú)鎖是通過(guò)編譯器內(nèi)建的原子操作實(shí)現(xiàn)的,只有當(dāng)數(shù)據(jù)長(zhǎng)度剛好能調(diào)用編譯器內(nèi)建原子操作時(shí)才能進(jìn)行無(wú)鎖化。查看GCC參考手冊(cè)(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 *的指針,在同一頁(yè)可以找到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類型的長(zhǎng)度應(yīng)該為1、2、4、8字節(jié)中的一個(gè),少數(shù)支持__int128的平臺(tái)可以到16字節(jié),所以只有長(zhǎng)度為1,2,4,8字節(jié)的數(shù)據(jù)才能實(shí)現(xiàn)無(wú)鎖。這個(gè)只是我的推測(cè),具體是否如此尚不明白。

三、C++11的六種內(nèi)存序

前面我們解決i++問(wèn)題的時(shí)候已經(jīng)使用過(guò)原子量的寫(xiě)操作load將原子量賦值,實(shí)際上成員函數(shù)還有另一個(gè)參數(shù):

void store( T desired, std::memory_order order = std::memory_order_seq_cst )
這個(gè)參數(shù)代表了該操作使用的內(nèi)存序,用于控制變量在不同線程見(jiàn)的順序可見(jiàn)性問(wèn)題,不只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;

之前在場(chǎng)景2中,因?yàn)橹噶畹闹嘏艑?dǎo)致了意料之外的錯(cuò)誤,通過(guò)使用原子變量并選擇合適內(nèi)存序,可以解決這個(gè)問(wèn)題。下面先來(lái)看看這幾種內(nèi)存序

memory_order_release/memory_order_acquire

內(nèi)存序選項(xiàng)用來(lái)作為原子量成員函數(shù)的參數(shù),memory_order_release用于store操作,memory_order_acquire用于load操作,這里我們把使用了memory_order_release的調(diào)用稱之為release操作。從邏輯上可以這樣理解:release操作可以阻止這個(gè)調(diào)用之前的讀寫(xiě)操作被重排到后面去,而acquire操作則可以保證調(diào)用之后的讀寫(xiě)操作不會(huì)重排到前面來(lái)。聽(tīng)起來(lái)有種很繞的感覺(jué),還是以一個(gè)例子來(lái)解釋:假設(shè)flag為一個(gè) atomic特化的bool 原子量,a為一個(gè)int變量,并且有如下時(shí)序的操作:

stepthread Athread B
1a = 1
2flag.store(true, memory_order_release)
3
if( true == flag.load(memory_order_acquire))
4
assert(a == 1)

實(shí)際上這就是把我們上文場(chǎng)景2中的flag變量換成了原子量,并用其成員函數(shù)進(jìn)行讀寫(xiě)。在這種情況下的邏輯順序上,step1不會(huì)跑到step2后面去,step4不會(huì)跑到step3前面去。這樣一來(lái),實(shí)際上我們就已經(jīng)保證了當(dāng)讀取到flag為true的時(shí)候a一定已經(jīng)被寫(xiě)入為1了,場(chǎng)景2得到了解決。換一種比較嚴(yán)謹(jǐn)?shù)拿枋龇绞娇梢钥偨Y(jié)為:

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

memory_order_release/memory_order_consume

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

  • 對(duì)于同一個(gè)原子變量,release操作所依賴的寫(xiě)入,一定對(duì)隨后consume操作后依賴于該原子變量的操作可見(jiàn)。

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

stepthread Athread B
1b = true
2a = 1
3flag.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的時(shí)候,由于flag依賴于b,b在之前的寫(xiě)入是可見(jiàn)的,此時(shí)b一定為true,所以step6、step7的斷言一定會(huì)成功。而且這種依賴關(guān)系具有傳遞性,假如b又依賴與另一個(gè)變量d,則d在之前的寫(xiě)入同樣對(duì)step4之后的操作可見(jiàn)。那么a呢?很遺憾在這種內(nèi)存序下a并不能得到保證,step5的斷言可能會(huì)失敗。

memory_order_acq_rel

這個(gè)選項(xiàng)看名字就很像release和acquire的結(jié)合體,實(shí)際上它的確兼具兩者的特性。這個(gè)操作用于“讀取-修改-寫(xiě)回”這一類既有讀取又有修改的操作,例如CAS操作??梢詫⑦@個(gè)操作在內(nèi)存序中的作用想象為將release操作和acquire操作捆在一起,因此任何讀寫(xiě)操作的重拍都不能跨越這個(gè)調(diào)用。依然以一個(gè)例子來(lái)說(shuō)明,flag為一個(gè) atomic特化的bool 原子量,a、c各為一個(gè)int變量,b為一個(gè)bool變量,并且剛好按如下順序執(zhí)行:

stepthread Athread B
1a = 1
2flag.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)
7if (true == flag.load(memory_order_acquire)
8assert(c == 2)

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

memory_order_seq_cst

這個(gè)內(nèi)存序是各個(gè)成員函數(shù)的內(nèi)存序默認(rèn)選項(xiàng),如果不選擇內(nèi)存序則默認(rèn)使用memory_order_seq_cst。這是一個(gè)“美好”的選項(xiàng),如果對(duì)原子變量的操作都是使用的memory_order_seq_cst內(nèi)存序,則多線程行為相當(dāng)于是這些操作都以一種特定順序被一個(gè)線程執(zhí)行,在哪個(gè)線程觀察到的對(duì)這些原子量的操作都一樣。同時(shí),任何使用該選項(xiàng)的寫(xiě)操作都相當(dāng)于release操作,任何讀操作都相當(dāng)于acquire操作,任何“讀取-修改-寫(xiě)回”這一類的操作都相當(dāng)于使用memory_order_acq_rel的操作。

memory_order_relaxed

這個(gè)選項(xiàng)如同其名字,比較松散,它僅僅只保證其成員函數(shù)操作本身是原子不可分割的,但是對(duì)于順序性不做任何保證。

代價(jià)

總的來(lái)講,越嚴(yán)格的內(nèi)存序其性能開(kāi)銷會(huì)越大。對(duì)于我們常用的x86處理器而言,在處理器層級(jí)本身就支持release/acquire語(yǔ)義,因此release與acquire/consume都只影響編譯器的優(yōu)化,而memory_order_seq_cst還會(huì)影響處理器的指令重排。

感謝各位的閱讀,以上就是“C++11中的原子量和內(nèi)存序有什么用”的內(nèi)容了,經(jīng)過(guò)本文的學(xué)習(xí)后,相信大家對(duì)C++11中的原子量和內(nèi)存序有什么用這一問(wèn)題有了更深刻的體會(huì),具體使用情況還需要大家實(shí)踐驗(yàn)證。這里是億速云,小編將為大家推送更多相關(guān)知識(shí)點(diǎn)的文章,歡迎關(guān)注!

向AI問(wèn)一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

c++
AI