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