C 并發(fā)編程(C 11 到 C 17 )
↓推薦關(guān)注↓
為什么要并發(fā)編程
大型的軟件項(xiàng)目常常包含非常多的任務(wù)需要處理。例如:對(duì)于大量數(shù)據(jù)的數(shù)據(jù)流處理,或者是包含復(fù)雜GUI界面的應(yīng)用程序。如果將所有的任務(wù)都以串行的方式執(zhí)行,則整個(gè)系統(tǒng)的效率將會(huì)非常低下,應(yīng)用程序的用戶體驗(yàn)會(huì)非常的差。
另一方面,自上個(gè)世紀(jì)六七十年代英特爾創(chuàng)始人之一 Gordon Moore 提出?摩爾定義?以來(lái),CPU頻率以每18個(gè)月翻一番的指數(shù)速度增長(zhǎng)。但這一增長(zhǎng)在最近的十年已經(jīng)基本停滯,大家會(huì)發(fā)現(xiàn)曾經(jīng)有過(guò)一段時(shí)間CPU的頻率從3G到達(dá)4G,但在這之后就停滯不前了。因此最近的新款CPU也基本上都是3G左右的頻率。相應(yīng)的,CPU以更多核的形式在增長(zhǎng)。目前的Intel i7有8核的版本,Xeon處理器達(dá)到了28核。并且,最近幾年手機(jī)上使用的CPU也基本上是4核或者8核的了。
由此,掌握并發(fā)編程技術(shù),利用多處理器來(lái)提升軟件項(xiàng)目的性能將是軟件工程師的一項(xiàng)基本技能。
本文以C 語(yǔ)言為例,講解如何進(jìn)行并發(fā)編程。并盡可能涉及C 11,C 14以及C 17中的主要內(nèi)容。
并發(fā)與并行
并發(fā)(Concurrent)與并行(Parallel)都是很常見的術(shù)語(yǔ)。
Erlang之父Joe Armstrong曾經(jīng)以人們使用咖啡機(jī)的場(chǎng)景為例描述了這兩個(gè)術(shù)語(yǔ)。如下圖所示:
- 并發(fā):如果多個(gè)隊(duì)列可以交替使用某臺(tái)咖啡機(jī),則這一行為就是并發(fā)的。
- 并行:如果存在多臺(tái)咖啡機(jī)可以被多個(gè)隊(duì)列交替使用,則就是并行。
這里隊(duì)列中的每個(gè)人類比于計(jì)算機(jī)的任務(wù),咖啡機(jī)類比于計(jì)算機(jī)處理器。因此:并發(fā)和并行都是在多任務(wù)的環(huán)境下的討論。
更嚴(yán)格的來(lái)說(shuō):如果一個(gè)系統(tǒng)支持多個(gè)動(dòng)作同時(shí)存在,那么這個(gè)系統(tǒng)就是一個(gè)并發(fā)系統(tǒng)。如果這個(gè)系統(tǒng)還支持多個(gè)動(dòng)作(物理時(shí)間上)同時(shí)執(zhí)行,那么這個(gè)系統(tǒng)就是一個(gè)并行系統(tǒng)。
你可能已經(jīng)看出,“并行”其實(shí)是“并發(fā)”的子集。它們的區(qū)別在于是否具有多個(gè)處理器。如果存在多個(gè)處理器同時(shí)執(zhí)行多個(gè)線程,就是并行。
在不考慮處理器數(shù)量的情況下,我們統(tǒng)稱之為“并發(fā)”。
進(jìn)程與線程
進(jìn)程與線程是操作系統(tǒng)的基本概念。無(wú)論是桌面系統(tǒng):MacOS,Linux,Windows,還是移動(dòng)操作系統(tǒng):Android,iOS,都存在進(jìn)程和線程的概念。
進(jìn)程(英語(yǔ):process),是指計(jì)算機(jī)中已運(yùn)行的程序。進(jìn)程為曾經(jīng)是分時(shí)系統(tǒng)的基本運(yùn)作單位。在面向進(jìn)程設(shè)計(jì)的系統(tǒng)(如早期的UNIX,Linux 2.4及更早的版本)中,進(jìn)程是程序的基本執(zhí)行實(shí)體;線程(英語(yǔ):thread)是操作系統(tǒng)能夠進(jìn)行運(yùn)算調(diào)度的最小單位。它被包含在進(jìn)程之中,是進(jìn)程中的實(shí)際運(yùn)作單位。-- 維基百科
關(guān)于這兩個(gè)概念在任何一本操作系統(tǒng)書上都可以找到定義。網(wǎng)上也有很多文章對(duì)它們進(jìn)行了解釋。因此這里不再贅述,這里僅僅提及一下它們與編程的關(guān)系。
對(duì)于絕大部分編程語(yǔ)言或者編程環(huán)境來(lái)說(shuō),我們所寫的程序都會(huì)在一個(gè)進(jìn)程中運(yùn)行。一個(gè)進(jìn)程至少會(huì)包含一個(gè)線程。這個(gè)線程我們通常稱之為主線程。
在默認(rèn)的情況下,我們寫的代碼都是在進(jìn)程的主線程中運(yùn)行,除非開發(fā)者在程序中創(chuàng)建了新的線程。
不同編程語(yǔ)言的線程環(huán)境會(huì)不一樣,Java語(yǔ)言在很早就支持了多線程接口。(Java程序在Java虛擬機(jī)中運(yùn)行,虛擬機(jī)通常還會(huì)包含自己特有的線程,例如垃圾回收線程。)。而對(duì)于JavaScript這樣的語(yǔ)言來(lái)說(shuō),它就沒有多線程的概念。
當(dāng)我們只有一個(gè)處理器時(shí),所有的進(jìn)程或線程會(huì)分時(shí)占用這個(gè)處理器。但如果系統(tǒng)中存在多個(gè)處理器時(shí),則就可能有多個(gè)任務(wù)并行的運(yùn)行在不同的處理器上。
下面兩幅圖以不同顏色的矩形代表不同的任務(wù)(可能是進(jìn)程,也可能是線程)來(lái)描述它們可能在處理器上執(zhí)行的順序。
下圖是單核處理器的情況:
下面是四核處理器的情況:
任務(wù)會(huì)在何時(shí)占有處理器,通常是由操作系統(tǒng)的調(diào)度策略決定的。在《Android系統(tǒng)上的進(jìn)程管理:進(jìn)程的調(diào)度》一文中,我們介紹過(guò)Linux的調(diào)度策略。
當(dāng)我們?cè)陂_發(fā)跨平臺(tái)的軟件時(shí),我們不應(yīng)當(dāng)對(duì)調(diào)度策略做任何假設(shè),而應(yīng)該抱有“系統(tǒng)可能以任意順序來(lái)調(diào)度我的任務(wù)”這樣的想法。
并發(fā)系統(tǒng)的性能
開發(fā)并發(fā)系統(tǒng)最主要的動(dòng)機(jī)就是提升系統(tǒng)性能(事實(shí)上,這是以增加復(fù)雜度為代價(jià)的)。
但我們需要知道,單純的使用多線程并不一定能提升系統(tǒng)性能(當(dāng)然,也并非線程越多系統(tǒng)的性能就越好)。從上面的兩幅圖我們就可以直觀的感受到:線程(任務(wù))的數(shù)量要根據(jù)具體的處理器數(shù)量來(lái)決定。假設(shè)只有一個(gè)處理器,那么劃分太多線程可能會(huì)適得其反。因?yàn)楹芏鄷r(shí)間都花在任務(wù)切換上了。
因此,在設(shè)計(jì)并發(fā)系統(tǒng)之前,一方面我們需要做好對(duì)于硬件性能的了解,另一方面需要對(duì)我們的任務(wù)有足夠的認(rèn)識(shí)。
關(guān)于這一點(diǎn),你可能需要了解一下阿姆達(dá)爾定律了。對(duì)于這個(gè)定律,簡(jiǎn)單來(lái)說(shuō):我們想要預(yù)先意識(shí)到那些任務(wù)是可以并行的,那些是無(wú)法并行的。只有明確了任務(wù)的性質(zhì),才能有的放矢的進(jìn)行優(yōu)化。這個(gè)定律告訴了我們將系統(tǒng)并行之后性能收益的上限。
關(guān)于阿姆達(dá)爾定律在Linux系統(tǒng)監(jiān)測(cè)工具sysstat介紹一文中已經(jīng)介紹過(guò),因此這里不再贅述。
C 與并發(fā)編程
前面我們已經(jīng)了解到,并非所有的語(yǔ)言都提供了多線程的環(huán)境。
即便是C 語(yǔ)言,直到C 11標(biāo)準(zhǔn)之前,也是沒有多線程支持的。在這種情況下,Linux/Unix平臺(tái)下的開發(fā)者通常會(huì)使用POSIX Threads,Windows上的開發(fā)者也會(huì)有相應(yīng)的接口。但很明顯,這些API都只針對(duì)特定的操作系統(tǒng)平臺(tái),可移植性較差。如果要同時(shí)支持Linux和Windows系統(tǒng),你可能要寫兩套代碼。
相較而言,Java自JDK 1.0就包含了多線程模型。
這個(gè)狀態(tài)在C 11標(biāo)準(zhǔn)發(fā)布之后得到了改變。并且,在C 14和C 17標(biāo)準(zhǔn)中又對(duì)并發(fā)編程機(jī)制進(jìn)行了增強(qiáng)。
下圖是最近幾個(gè)版本的C 標(biāo)準(zhǔn)特性的線路圖。
編譯器與C 標(biāo)準(zhǔn)
編譯器對(duì)于語(yǔ)言特性的支持是逐步完成的。想要使用特定的特性你需要相應(yīng)版本的編譯器。
- GCC對(duì)于C 特性的支持請(qǐng)參見這里:C Standards Support in GCC。
- Clang對(duì)于C 特性的支持請(qǐng)參見這里:C Support in Clang。
下面兩個(gè)表格列出了C 標(biāo)準(zhǔn)和相應(yīng)編譯器的版本對(duì)照:
- C 標(biāo)準(zhǔn)與相應(yīng)的GCC版本要求如下:
- C 標(biāo)準(zhǔn)與相應(yīng)的Clang版本要求如下:
默認(rèn)情況下編譯器是以較低的標(biāo)準(zhǔn)來(lái)進(jìn)行編譯的,如果希望使用新的標(biāo)準(zhǔn),你需要通過(guò)編譯參數(shù)-std=c xx告知編譯器,例如:
g -std=c 17 your_file.cpp -o your_program
測(cè)試環(huán)境
本文的源碼可以到下載我的github上獲取,地址:paulQuei/cpp-concurrency。你可以直接通過(guò)下面這條命令獲取源碼:
git clone https://github.com/paulQuei/cpp-concurrency.git
源碼下載之后,你可以通過(guò)任何文本編輯器瀏覽源碼。如果希望編譯和運(yùn)行程序,你還需要按照下面的內(nèi)容來(lái)準(zhǔn)備環(huán)境。
本文中的源碼使用cmake編譯,只有cmake 3.8以上的版本才支持C 17,所以你需要安裝這個(gè)或者更新版本的cmake。
另外,截止目前(2019年10月)為止,clang編譯器還不支持并行算法。
但是gcc-9是支持的。因此想要編譯和運(yùn)行這部分代碼,你需要安裝gcc 9.0或更新的版本。并且,gcc-9還要依賴Intel Threading Building Blocks才能使用并行算法以及
具體的安裝方法見下文。
具體編譯器對(duì)于C 特性支持的情況請(qǐng)參見這里:C compiler support。
安裝好之后運(yùn)行根目錄下的下面這個(gè)命令即可:
./make_all.sh
它會(huì)完成所有的編譯工作。
本文的源碼在下面兩個(gè)環(huán)境中經(jīng)過(guò)測(cè)試,環(huán)境的準(zhǔn)備方法如下。
MacOS
在Mac上,我使用brew工具安裝gcc以及tbb庫(kù)。
考慮到其他人與我的環(huán)境可能會(huì)有所差異,所以需要手動(dòng)告知tbb庫(kù)的安裝路徑。讀者需要執(zhí)行下面這些命令來(lái)準(zhǔn)備環(huán)境:
rew install gcc
brew insbtall tbb
export tbb_path=/usr/local/Cellar/tbb/2019_U8/
./make_all.sh
注意,請(qǐng)通過(guò)運(yùn)行g(shù) -9命令以確認(rèn)gcc的版本是否正確,如果版本較低,則需要通過(guò)brew命令將其升級(jí)到新版本:
brew upgrade gcc
Ubuntu
Ubuntu上,通過(guò)下面的命令安裝gcc-9。
sudo add-apt-repository ppa:ubuntu-toolchain-r/test
sudo apt-get update
sudo apt install gcc-9 g -9
但安裝tbb庫(kù)就有些麻煩了。這是因?yàn)閁buntu 16.04默認(rèn)關(guān)聯(lián)的版本是較低的,直接安裝是無(wú)法使用的。我們需要安裝更新的版本。聯(lián)網(wǎng)安裝的方式步驟繁瑣,所以可以通過(guò)下載包的方式進(jìn)行安裝,我已經(jīng)將這需要的兩個(gè)文件放到的這里:
- libtbb2_2019~U8-1_amd64.deb
- libtbb-dev_2019~U8-1_amd64.deb
如果需要,你可以下載后通過(guò)apt命令安裝即可:
sudo apt install ~/Downloads/libtbb2_2019~U8-1_amd64.deb
sudo apt install ~/Downloads/libtbb-dev_2019~U8-1_amd64.deb
線程
創(chuàng)建線程
創(chuàng)建線程非常的簡(jiǎn)單的,下面就是一個(gè)使用了多線程的Hello World示例:
// 01_hello_thread.cpp
#include
#include // ①
using namespace std; // ②
void hello() { // ③
cout << "Hello World from new thread." << endl;
}
int main() {
thread t(hello); // ④
t.join(); // ⑤
return 0;
}
對(duì)于這段代碼說(shuō)明如下:
- 為了使用多線程的接口,我們需要#include
頭文件。 - 為了簡(jiǎn)化聲明,本文中的代碼都將using namespace std;。
- 新建線程的入口是一個(gè)普通的函數(shù),它并沒有什么特別的地方。
- 創(chuàng)建線程的方式就是構(gòu)造一個(gè)thread對(duì)象,并指定入口函數(shù)。與普通對(duì)象不一樣的是,此時(shí)編譯器便會(huì)為我們創(chuàng)建一個(gè)新的操作系統(tǒng)線程,并在新的線程中執(zhí)行我們的入口函數(shù)。
- 關(guān)于join函數(shù)在下文中講解。
thread可以和callable類型一起工作,因此如果你熟悉lambda表達(dá)式,你可以直接用它來(lái)寫線程的邏輯,像這樣:
// 02_lambda_thread.cpp
#include
#include
using namespace std;
int main() {
thread t([] {
cout << "Hello World from lambda thread." << endl;
});
t.join();
return 0;
}
為了減少不必要的重復(fù),若無(wú)必要,下文中的代碼將不貼出include指令以及using聲明。
當(dāng)然,你可以傳遞參數(shù)給入口函數(shù),像下面這樣:
// 03_thread_argument.cpp
void hello(string name) {
cout << "Welcome to " << name << endl;
}
int main() {
thread t(hello, "https://paul.pub");
t.join();
return 0;
}
不過(guò)需要注意的是,參數(shù)是以拷貝的形式進(jìn)行傳遞的。因此對(duì)于拷貝耗時(shí)的對(duì)象你可能需要傳遞指針或者引用類型作為參數(shù)。但是,如果是傳遞指針或者引用,你還需要考慮參數(shù)對(duì)象的生命周期。因?yàn)榫€程的運(yùn)行長(zhǎng)度很可能會(huì)超過(guò)參數(shù)的生命周期(見下文detach),這個(gè)時(shí)候如果線程還在訪問(wèn)一個(gè)已經(jīng)被銷毀的對(duì)象就會(huì)出現(xiàn)問(wèn)題。
join與detach
- 主要API
一旦啟動(dòng)線程之后,我們必須決定是要等待直接它結(jié)束(通過(guò)join),還是讓它獨(dú)立運(yùn)行(通過(guò)detach),我們必須二者選其一。如果在thread對(duì)象銷毀的時(shí)候我們還沒有做決定,則thread對(duì)象在析構(gòu)函數(shù)出將調(diào)用std::terminate()從而導(dǎo)致我們的進(jìn)程異常退出。
請(qǐng)思考在上面的代碼示例中,thread對(duì)象在何時(shí)會(huì)銷毀。
需要注意的是:在我們做決定的時(shí)候,很可能線程已經(jīng)執(zhí)行完了(例如上面的示例中線程的邏輯僅僅是一句打印,執(zhí)行時(shí)間會(huì)很短)。新的線程創(chuàng)建之后,究竟是新的線程先執(zhí)行,還是當(dāng)前線程的下一條語(yǔ)句先執(zhí)行這是不確定的,因?yàn)檫@是由操作系統(tǒng)的調(diào)度策略決定的。不過(guò)這不要緊,我們只要在thread對(duì)象銷毀前做決定即可。
- join:調(diào)用此接口時(shí),當(dāng)前線程會(huì)一直阻塞,直到目標(biāo)線程執(zhí)行完成(當(dāng)然,很可能目標(biāo)線程在此處調(diào)用之前就已經(jīng)執(zhí)行完成了,不過(guò)這不要緊)。因此,如果目標(biāo)線程的任務(wù)非常耗時(shí),你就要考慮好是否需要在主線程上等待它了,因此這很可能會(huì)導(dǎo)致主線程卡住。
- detach:detach是讓目標(biāo)線程成為守護(hù)線程(daemon threads)。一旦detach之后,目標(biāo)線程將獨(dú)立執(zhí)行,即便其對(duì)應(yīng)的thread對(duì)象銷毀也不影響線程的執(zhí)行。并且,你無(wú)法再與之通信。
對(duì)于這兩個(gè)接口,都必須是可執(zhí)行的線程才有意義。你可以通過(guò)joinable()接口查詢是否可以對(duì)它們進(jìn)行join或者detach。
管理當(dāng)前線程
- 主要API
上面是一些在線程內(nèi)部使用的API,它們用來(lái)對(duì)當(dāng)前線程做一些控制。
- yield 通常用在自己的主要任務(wù)已經(jīng)完成的時(shí)候,此時(shí)希望讓出處理器給其他任務(wù)使用。
- get_id 返回當(dāng)前線程的id,可以以此來(lái)標(biāo)識(shí)不同的線程。
- sleep_for 是讓當(dāng)前線程停止一段時(shí)間。
- sleep_until 和sleep_for類似,但是是以具體的時(shí)間點(diǎn)為參數(shù)。這兩個(gè)API都以chrono API(由于篇幅所限,這里不展開這方面內(nèi)容)為基礎(chǔ)。
下面是一個(gè)代碼示例:
// 04_thread_self_manage.cpp
void print_time() {
auto now = chrono::system_clock::now();
auto in_time_t = chrono::system_clock::to_time_t(now);
std::stringstream ss;
ss << put_time(localtime(