先來看一個多執行緒的例子:
如圖所示,我們將變數x和y初始化為0,然後線上程1中執行:
x = 1, m = y;
同時線上程2中執行:
y = 1, n = x;
當兩個執行緒都執行結束以後,m和n的值分別是多少呢?
對於已經工作了n年、寫過無數次並行程式的的我們來說,這還不是小case嗎?讓我們來分析一下,大概有三種情況:
所以(m, n)的組合一共有3種情況,分別是(0, 1), (1, 0)和(1, 1)。
那有沒有可能程式執行結束後,(m, n)的值是(0, 0)呢?嗯...我們又仔細的回顧了一下自己的分析過程:在m和n被賦值的時候,x = 1和y = 1至少有一條語句被執行了...沒有問題,那應該就不會出現m和n都是0的情況。
不過人在江湖上混,還是要嚴謹一點。好在這程式碼邏輯也不復雜,那就寫一段簡單的程式來驗證下吧:
#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;
}
考慮到多執行緒的隨機性,就寫一個無限迴圈多跑一會吧,反正螢幕也不會有什麼輸出。我們信心滿滿的把程式跑了起來,但很快就發現有點不太對勁:
m和n居然真的同時為0了?不可能不可能...這難道是windows或者msvc的bug?那我們到linux上用g++編譯試一下,結果程式跑起來之後,又看到了熟悉的輸出:
這...打臉未免來得也太快了吧!
看來這不是bug,真的是有可能出現m和n都是0的情況。可是,到底是為什麼呢?恍惚之間,我們突然想起曾經似乎在哪看過這樣一個as-if規則:
The rule that allows any and all code transformations that do not change the observable behavior of the program.
也就是說,在不影響可觀測結果的前提下,編譯器是有可能對程式的程式碼進行重排,以取得更好的執行效率的。比如像這樣的程式碼:
int a, b;
void test()
{
a = b + 1;
b = 1;
}
編譯器是完全有可能重新排列成下面的樣子的:
int a, b;
void test()
{
int c = b;
b = 1;
c += 1;
a = c;
}
這樣,程式在實際執行過程中對a的賦值就晚於對b的賦值之後了。不過,有了前車之鑑,我們還是先驗證一下在下結論吧。我們使用gcc的-S選項,生成組合程式碼(開啟-O2優化)來看一下,編譯器生成的指令到底是什麼樣子的:
哈哈,果然如我們所料,對a的賦值被調整到對b的賦值後面了!那上面m和n同時為0也一定是因為編譯器重新排序我們的指令順序導致的!想到這裡,我們的底氣又漸漸回來了。那就生成組合程式碼看看吧:
果然不出所料,因為我們在編譯的時候開了-O3優化,賦值的順序被重排了!程式碼實際的執行順序大概是下面這個樣子:
int t1 = y; x = 1; m = t1; //執行緒1
int t2 = x; y = 1; n = t2; //執行緒2
這就難怪會出現m = 0, n = 0這樣的結果了。分析到這裡,我們終於有點鬆了一口氣,這多年的程式設計經驗可不是白來的,總算是給出了一個合理的解釋。
那我們在編譯的時候把-O3優化選項去掉,儘量讓編譯器不要進行優化,保持原來的指令執行順序,應該就可以避免m和n同時為0的結果了吧?試試,保險起見,我們還是先看一看組合程式碼吧:
跟我們的預期一致,組合程式碼保持了原來的執行順序,這回肯定沒有問題了。那就把程式跑起來吧。然而...不一會兒,熟悉的列印又出現了...
這...到底是怎麼回事?!!!
如果不是編譯器重排了我們的指令順序,那還會是什麼呢?難道是CPU?!
還真是。實際上,現代CPU為了提高執行效率,大多都採用了流水線技術。例如:一個執行過程可以被分為:取指(IF),譯碼(ID),執行(EX),訪存(MEM),回寫(WB)等階段。這樣,當第一條指令在執行的時候,第二條指令可以進行譯碼,第三條指令可以進行取指...於是CPU被充分利用了,指令的執行效率也大大提高。一個標準的5級流水線的工作過程如下表所示(實際的CPU流水線遠比這複雜得多):
序號/時鐘週期 | 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 |
上面展示的指令流水線是完美的,然而實際情況往往沒有這麼理想。考慮這樣一種情況,假設第二條指令依賴於第一條指令的執行結果,而第一條指令恰巧又是一個比較耗時的操作,那麼整個流水線就停止了。即使第三條指令與前兩條指令完全無關,它也必須等到第一條指令執行完成,流水線繼續運轉時才能得已執行。這就浪費了CPU的執行頻寬。亂序執行(Out-Of-Order Execution)就是被用來解決這一問題的,它也是現代CPU提升執行效率的基礎技術之一。
簡單來說,亂序執行是指CPU提前分析待執行的指令,調整指令的執行順序,以期發揮更高流水線執行效率的一種技術。引入亂序執行技術以後,CPU執行指令過程大概是下面這個樣子:
所以,上面的程式出現(m, n)結果為(0, 0)的情況,應該就是因為指令的執行順序被CPU重排了!
我們通常將讀取操作稱為load,儲存操作稱為store。對應的記憶體操作順序有以下幾種:
CPU在執行指令的時候,會根據情況對記憶體操作順序進行重新排列。也就是說,我們只要能夠讓CPU不要進行指令重排優化,那麼應該就不會出現(m, n)為(0, 0)的情況了。但具體要怎麼做呢?
實際上,在C++11之前,我們很難在語言層面做到這件事情。那時的C++甚至連執行緒都不支援,更別提什麼記憶體模型了。在C++98的年代,我們只能通過嵌入組合的方式新增記憶體屏障來達到這樣的目的:
asm volatile("mfence" ::: "memory");
不過在現代C++中,要做這樣的事情就簡單多了。C++11引入了原子型別(atomic),同時規定了6種記憶體執行順序:
所以,我們只需要將x和y的型別改為atmioc_int,就可以避免m和n同時為0的結果出現了。修改後的程式碼如下:
#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;
}
現在編譯執行一下,看看結果:
已經不會再出現"impossible"的列印了。我們再來看看生成的組合程式碼:
原來編譯器已經自動幫我們插入了記憶體屏障,這樣就再也不會出現(m, n)為(0, 0)的情況了。
全文完。