C++中多線程的執(zhí)行順序如你預(yù)期嗎
一個(gè)簡(jiǎn)單的例子
先來(lái)看一個(gè)多線程的例子:
如圖所示,我們將變量x和y初始化為0,然后在線程1中執(zhí)行:
x = 1, m = y;
同時(shí)在線程2中執(zhí)行:
y = 1, n = x;
當(dāng)兩個(gè)線程都執(zhí)行結(jié)束以后,m和n的值分別是多少呢?
對(duì)于已經(jīng)工作了n年、寫過(guò)無(wú)數(shù)次并發(fā)程序的的我們來(lái)說(shuō),這還不是小case嗎?讓我們來(lái)分析一下,大概有三種情況:
- 如果程序先執(zhí)行了x = 1, m = y代碼段,后執(zhí)行了y = 1, n = x代碼段,那么結(jié)果是m = 0, n = 1;
- 如果程序先執(zhí)行了y = 1, n = x代碼段,后執(zhí)行了x = 1, m = y代碼段,那么結(jié)果是m = 1, n = 0;
- 如果程序的執(zhí)行順序先是 x = 1, y = 1, 后執(zhí)行m = y, n = x, 那么結(jié)果是m = 1, n = 1;
所以(m, n)的組合一共有3種情況,分別是(0, 1), (1, 0)和(1, 1)。
那有沒(méi)有可能程序執(zhí)行結(jié)束后,(m, n)的值是(0, 0)呢?嗯...我們又仔細(xì)的回顧了一下自己的分析過(guò)程:在m和n被賦值的時(shí)候,x = 1和y = 1至少有一條語(yǔ)句被執(zhí)行了...沒(méi)有問(wèn)題,那應(yīng)該就不會(huì)出現(xiàn)m和n都是0的情況。
詭異的輸出結(jié)果
不過(guò)人在江湖上混,還是要嚴(yán)謹(jǐn)一點(diǎn)。好在這代碼邏輯也不復(fù)雜,那就寫一段簡(jiǎn)單的程序來(lái)驗(yàn)證下吧:
#include <iostream> #include <thread> using namespace std; int x = 0, y = 0, m = 0, n = 0; int main() { while (1) { x = y = 0; thread t1([&]() { x = 1; m = y; }); thread t2([&]() { y = 1; n = x; }); t1.join(); t2.join(); if (m == 0 && n == 0) { cout << " m == 0 && n == 0 ? impossible!\n"; } } return 0; }
考慮到多線程的隨機(jī)性,就寫一個(gè)無(wú)限循環(huán)多跑一會(huì)吧,反正屏幕也不會(huì)有什么輸出。我們信心滿滿的把程序跑了起來(lái),但很快就發(fā)現(xiàn)有點(diǎn)不太對(duì)勁:
m和n居然真的同時(shí)為0了?不可能不可能...這難道是windows或者msvc的bug?那我們到linux上用g++編譯試一下,結(jié)果程序跑起來(lái)之后,又看到了熟悉的輸出:
這...打臉未免來(lái)得也太快了吧!
你看到的執(zhí)行順序不是真的執(zhí)行順序
看來(lái)這不是bug,真的是有可能出現(xiàn)m和n都是0的情況。可是,到底是為什么呢?恍惚之間,我們突然想起曾經(jīng)似乎在哪看過(guò)這樣一個(gè)as-if規(guī)則:
The rule that allows any and all code transformations that do not change the observable behavior of the program.
也就是說(shuō),在不影響可觀測(cè)結(jié)果的前提下,編譯器是有可能對(duì)程序的代碼進(jìn)行重排,以取得更好的執(zhí)行效率的。比如像這樣的代碼:
int a, b; void test() { a = b + 1; b = 1; }
編譯器是完全有可能重新排列成下面的樣子的:
int a, b; void test() { int c = b; b = 1; c += 1; a = c; }
這樣,程序在實(shí)際執(zhí)行過(guò)程中對(duì)a的賦值就晚于對(duì)b的賦值之后了。不過(guò),有了前車之鑒,我們還是先驗(yàn)證一下在下結(jié)論吧。我們使用gcc的-S選項(xiàng),生成匯編代碼(開(kāi)啟-O2優(yōu)化)來(lái)看一下,編譯器生成的指令到底是什么樣子的:
哈哈,果然如我們所料,對(duì)a的賦值被調(diào)整到對(duì)b的賦值后面了!那上面m和n同時(shí)為0也一定是因?yàn)榫幾g器重新排序我們的指令順序?qū)е碌?!想到這里,我們的底氣又漸漸回來(lái)了。那就生成匯編代碼看看吧:
果然不出所料,因?yàn)槲覀冊(cè)诰幾g的時(shí)候開(kāi)了-O3優(yōu)化,賦值的順序被重排了!代碼實(shí)際的執(zhí)行順序大概是下面這個(gè)樣子:
int t1 = y; x = 1; m = t1; //線程1 int t2 = x; y = 1; n = t2; //線程2
這就難怪會(huì)出現(xiàn)m = 0, n = 0這樣的結(jié)果了。分析到這里,我們終于有點(diǎn)松了一口氣,這多年的編程經(jīng)驗(yàn)可不是白來(lái)的,總算是給出了一個(gè)合理的解釋。
那我們?cè)诰幾g的時(shí)候把-O3優(yōu)化選項(xiàng)去掉,盡量讓編譯器不要進(jìn)行優(yōu)化,保持原來(lái)的指令執(zhí)行順序,應(yīng)該就可以避免m和n同時(shí)為0的結(jié)果了吧?試試,保險(xiǎn)起見(jiàn),我們還是先看一看匯編代碼吧:
跟我們的預(yù)期一致,匯編代碼保持了原來(lái)的執(zhí)行順序,這回肯定沒(méi)有問(wèn)題了。那就把程序跑起來(lái)吧。然而...不一會(huì)兒,熟悉的打印又出現(xiàn)了...
這...到底是怎么回事??。?!
你看到的執(zhí)行順序還不是真正的執(zhí)行順序
如果不是編譯器重排了我們的指令順序,那還會(huì)是什么呢?難道是CPU?!
還真是。實(shí)際上,現(xiàn)代CPU為了提高執(zhí)行效率,大多都采用了流水線技術(shù)。例如:一個(gè)執(zhí)行過(guò)程可以被分為:取指(IF),譯碼(ID),執(zhí)行(EX),訪存(MEM),回寫(WB)等階段。這樣,當(dāng)?shù)谝粭l指令在執(zhí)行的時(shí)候,第二條指令可以進(jìn)行譯碼,第三條指令可以進(jìn)行取指...于是CPU被充分利用了,指令的執(zhí)行效率也大大提高。一個(gè)標(biāo)準(zhǔn)的5級(jí)流水線的工作過(guò)程如下表所示(實(shí)際的CPU流水線遠(yuǎn)比這復(fù)雜得多):
序號(hào)/時(shí)鐘周期 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ... |
---|---|---|---|---|---|---|---|---|
1 | IF | ID | EX | MEM | WB | |||
2 | IF | ID | EX | MEM | WB | |||
3 | IF | ID | EX | MEM | WB | |||
4 | IF | ID | EX | MEM | WB | |||
5 | IF | ID | EX | MEM | ||||
6 | IF | ID | EX |
上面展示的指令流水線是完美的,然而實(shí)際情況往往沒(méi)有這么理想。考慮這樣一種情況,假設(shè)第二條指令依賴于第一條指令的執(zhí)行結(jié)果,而第一條指令恰巧又是一個(gè)比較耗時(shí)的操作,那么整個(gè)流水線就停止了。即使第三條指令與前兩條指令完全無(wú)關(guān),它也必須等到第一條指令執(zhí)行完成,流水線繼續(xù)運(yùn)轉(zhuǎn)時(shí)才能得已執(zhí)行。這就浪費(fèi)了CPU的執(zhí)行帶寬。亂序執(zhí)行(Out-Of-Order Execution)就是被用來(lái)解決這一問(wèn)題的,它也是現(xiàn)代CPU提升執(zhí)行效率的基礎(chǔ)技術(shù)之一。
簡(jiǎn)單來(lái)說(shuō),亂序執(zhí)行是指CPU提前分析待執(zhí)行的指令,調(diào)整指令的執(zhí)行順序,以期發(fā)揮更高流水線執(zhí)行效率的一種技術(shù)。引入亂序執(zhí)行技術(shù)以后,CPU執(zhí)行指令過(guò)程大概是下面這個(gè)樣子:
所以,上面的程序出現(xiàn)(m, n)結(jié)果為(0, 0)的情況,應(yīng)該就是因?yàn)橹噶畹膱?zhí)行順序被CPU重排了!
C++多線程內(nèi)存模型
我們通常將讀取操作稱為load,存儲(chǔ)操作稱為store。對(duì)應(yīng)的內(nèi)存操作順序有以下幾種:
- load->load(讀讀)
- load->store(讀寫)
- store->load(寫讀)
- store->store(寫寫)
CPU在執(zhí)行指令的時(shí)候,會(huì)根據(jù)情況對(duì)內(nèi)存操作順序進(jìn)行重新排列。也就是說(shuō),我們只要能夠讓CPU不要進(jìn)行指令重排優(yōu)化,那么應(yīng)該就不會(huì)出現(xiàn)(m, n)為(0, 0)的情況了。但具體要怎么做呢?
實(shí)際上,在C++11之前,我們很難在語(yǔ)言層面做到這件事情。那時(shí)的C++甚至連線程都不支持,更別提什么內(nèi)存模型了。在C++98的年代,我們只能通過(guò)嵌入?yún)R編的方式添加內(nèi)存屏障來(lái)達(dá)到這樣的目的:
asm volatile("mfence" ::: "memory");
不過(guò)在現(xiàn)代C++中,要做這樣的事情就簡(jiǎn)單多了。C++11引入了原子類型(atomic),同時(shí)規(guī)定了6種內(nèi)存執(zhí)行順序:
- memory_order_relaxed: 松散的,在保證原子性的前提下,允許進(jìn)行任務(wù)的重新排序;
- memory_order_release: 代碼中這條語(yǔ)句前的所有讀寫操作, 不允許被重排到這個(gè)操作之后;
- memory_order_acquire: 代碼中這條語(yǔ)句后的所有讀寫操作,不允許被重排到這個(gè)操作之前;
- memory_order_consume: 代碼中這條語(yǔ)句后所有與這塊內(nèi)存相關(guān)的讀寫操作,不允許被重排到這個(gè)操作之前;注意,這個(gè)類型已不建議被使用;
- memory_order_acq_rel: 對(duì)讀取和寫入施加acquire-release語(yǔ)義,無(wú)法被重排;
- memory_order_seq_cst: 順序一致性,如果是寫入就是release語(yǔ)義,如果是讀取是acquire語(yǔ)義,如果是讀取-寫入就是acquire-release語(yǔ)義;也是原子變量的默認(rèn)語(yǔ)義。
所以,我們只需要將x和y的類型改為atmioc_int,就可以避免m和n同時(shí)為0的結(jié)果出現(xiàn)了。修改后的代碼如下:
#include <iostream> #include <thread> #include <atomic> using namespace std; atomic_int x(0); atomic_int y(0); int m = 0, n = 0; int main() { while (1) { x = y = 0; thread t1([&]() { x = 1; m = y; }); thread t2([&]() { y = 1; n = x; }); t1.join(); t2.join(); if (m == 0 && n == 0) { cout << " m == 0 && n == 0 ? impossible!\n"; } } return 0; }
現(xiàn)在編譯運(yùn)行一下,看看結(jié)果:
已經(jīng)不會(huì)再出現(xiàn)"impossible"的打印了。我們?cè)賮?lái)看看生成的匯編代碼:
原來(lái)編譯器已經(jīng)自動(dòng)幫我們插入了內(nèi)存屏障,這樣就再也不會(huì)出現(xiàn)(m, n)為(0, 0)的情況了。
到此這篇關(guān)于C++中多線程的執(zhí)行順序如你預(yù)期嗎的文章就介紹到這了,更多相關(guān)C++多線程執(zhí)行順序內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++ xxx_cast實(shí)現(xiàn)轉(zhuǎn)換代碼實(shí)例解析
這篇文章主要介紹了C++xxx_cast轉(zhuǎn)換代碼實(shí)例解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-07-07opencv3/C++圖像濾波實(shí)現(xiàn)方式
今天小編就為大家分享一篇opencv3/C++圖像濾波實(shí)現(xiàn)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2019-12-12C語(yǔ)言模擬內(nèi)存函數(shù)分析之mencpy與memmove
這篇文章主要介紹了C語(yǔ)言詳解如何模擬內(nèi)存函數(shù),用到了mencpy與memmove兩個(gè)函數(shù),有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步2022-03-03C++指針運(yùn)算符(&和*)的實(shí)現(xiàn)
C++ 提供了兩種指針運(yùn)算符,一種是取地址運(yùn)算符 &,一種是間接尋址運(yùn)算符 *,本文就詳細(xì)的介紹一下這兩種運(yùn)算符的使用,具有一定的參考價(jià)值,感興趣的可以了解一下2023-08-08C語(yǔ)言學(xué)生學(xué)籍管理系統(tǒng)課程設(shè)計(jì)
這篇文章主要介紹了C語(yǔ)言學(xué)生學(xué)籍管理系統(tǒng)課程設(shè)計(jì),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-01-01C/C++函數(shù)參數(shù)聲明解析int?fun()?與?int?fun(void)?的區(qū)別講解
C++中int fun()和int fun(void)的區(qū)別在于函數(shù)參數(shù)的聲明方式,前者默認(rèn)允許任意參數(shù),而后者表示沒(méi)有參數(shù),通過(guò)清晰的實(shí)例源代碼,詳細(xì)解釋了它們?cè)诤瘮?shù)聲明和調(diào)用中的不同之處,這篇文章介紹了C/C++函數(shù)參數(shù)聲明int?fun()與int?fun(void)的差異,需要的朋友可以參考下2024-01-01C++ 壓縮文件及文件夾方法 使用zlib開(kāi)源庫(kù)
下面小編就為大家分享一篇C++ 壓縮文件及文件夾方法 使用zlib開(kāi)源庫(kù),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-03-03