- C++服務器開發精髓
- 張遠龍
- 4388字
- 2021-07-23 18:22:30
3.2 線程的基本操作
本節介紹對線程的一些基本操作。
3.2.1 創建線程
在使用線程之前,我們首先要學會創建一個新的線程。不管是哪個庫還是哪種高級語言(如Java),線程的創建最終還是通過調用操作系統的API進行的。這里先介紹操作系統的接口,分 Linux和Windows兩種常用的操作系統來介紹。當然,這里并不是照本宣科地把Linux man手冊或者msdn上的函數簽名搬過來,而是介紹實際開發中常用的參數和需要注意的重難點。
1.Linux線程的創建
在Linux平臺上使用pthread_create這個API來創建線程,其函數簽名如下:

其中,參數 thread是一個輸出參數,如果線程創建成功,則通過這個參數可以得到創建成功的線程 ID(本章很快會介紹線程 ID 的知識);參數 attr 指定了該線程的屬性,一般被設置為NULL,表示使用默認的屬性;參數start_routine指定了線程函數,這個函數的調用方式必須是__cdecl,這是C Declaration的縮寫,__cdecl是在C/C++中定義全局函數時默認的調用方式。在 Windows 操作系統上使用 CreateThread 創建線程函數時要求線程函數必須使用__stdcall調用方式,但是定義的函數默認是__cdecl 調用方式,所以必須顯式聲明 Windows 的線程函數為__stdcall 調用方式(下文很快會介紹到)。也就是說,定義出來的全局函數可以作為Linux pthread_create的線程函數,但不能作為Windows的CreateThread的線程函數,這是因為如下函數調用方式是等價的:

參數 arg 用于在創建線程時將某個參數傳入線程函數中,由于它是 void*類型,所以可以方便我們最大化地傳入任意信息給線程函數;對于代碼的返回值,如果成功創建線程,則返回 0;如果創建失敗,則返回響應的錯誤碼,常見的錯誤碼有 EAGAIN、EINVAL、EPERM。
下面是一個使用pthread_create創建線程的簡單示例:


2.Windows線程的創建
在Windows上創建線程要用到CreateThread,其函數簽名如下:

其中,參數 lpThreadAttributes 表示線程的安全屬性,一般被設置為 NULL;參數dwStackSize 指線程的棧空間大小,單位為字節數,一般被指定為 0,表示使用默認的大小;參數 lpStartAddress 為線程函數,其類型是 LPTHREAD_START_ROUTINE(函數指針類型),其定義如下:

上文提到過,在 Windows 上使用 CreateThread 創建的線程函數,其調用方式必須是__stdcall,因此將如下函數設置成CreateThread的線程函數是不行的:

上文對其原因進行了解釋:如果不指定函數的調用方式,則默認使用__cdecl 調用方式,而這里的線程函數要求是__stdcall調用方式,因此必須在函數名前面顯式指定函數調用方式為__stdcall:

在Windows上,WINAPI和CALLBACK這兩個宏的值都是__stdcall,因此在很多項目中看到的線程函數簽名大多有如下兩種寫法:

參數lpParameter是傳給線程函數的參數,和Linux下pthread_create函數的arg一樣,實際上都是void*類型(LPVOID類型實際上是用typedef包裝后的void*類型):

參數dwCreationFlags是一個32位的無符號整型(DWORD),一般被設置為0,表示創建好線程后立即啟動線程的運行;對于一些特殊情況,比如我們不希望創建線程后立即開始執行,則可以將這個值設置為 4(對應 Windows 定義的宏 CREATE_SUSPENDED),在需要時再使用ResumeThread這個API運行線程。
參數 lpThreadId表示線程創建成功時返回的線程 ID,也表示一個 32位無符號整數(DWORD)的指針(LPDWORD)。
在Windows上使用句柄(HANDLE類型)來管理線程對象,句柄在本質上是內核句柄表中的索引值。如果成功創建線程,則返回該線程的句柄,否則返回NULL。
下面的代碼片段演示了如何在Windows上創建一個線程:

3.Windows CRT提供的線程創建函數
CRT(C Runtime,C運行時)通俗地說就是C函數庫。在Windows上,微軟實現的C庫也提供了一套用于創建線程的函數(當然,這個函數底層還是調用相應的操作系統的線程創建 API)。在實際項目開發中推薦使用這個函數來創建線程,而不是使用CreateThread函數。
Windows C庫創建線程時常用的函數是_beginthreadex,其聲明位于process.h頭文件中,簽名如下:

_beginthreadex函數簽名和Windows的CreateThread API函數簽名基本一致,這里不再贅述。
以下是一個使用_beginthreadex創建線程的例子:

4.C++11提供的std::thread類
無論是在Linux上還是在Windows上創建線程的API,都有一個非常不方便的地方,就是線程函數簽名必須使用規定的格式(對參數的個數和類型、返回值類型都有要求)。C++11新標準引入了一個新的類std::thread(需要包含頭文件<thread>),使用這個類可以將任意簽名形式的函數作為線程函數。以下代碼分別創建了兩個線程,線程函數簽名不一樣:


當然,std::thread 在使用上容易出錯,即std::thread 對象在線程函數運行期間必須是有效的。什么意思呢?我們來看一個例子:



以上代碼在 main 函數中調用了func函數,在func函數中創建了一個線程,乍一看好像沒什么問題,但在實際運行時會崩潰。崩潰的原因是,在func函數調用結束后,func中的局部變量 t(線程對象)被銷毀,而此時線程函數仍在運行。所以在使用 std::thread類時,必須保證在線程函數運行期間其線程對象有效。std::thread 對象提供了一個 detach方法,通過這個方法可以讓線程對象與線程函數脫離關系,這樣即使線程對象被銷毀,也不影響線程函數的運行。我們只需在func函數中調用detach方法即可,代碼如下:

然而在實際開發中并不推薦這么做,原因是我們可能需要使用線程對象去控制和管理線程的運行和生命周期。所以,我們的代碼應該盡量保證線程對象在線程運行期間有效,而不是單純地調用detach方法使線程對象與線程函數的運行分離。
3.2.2 獲取線程ID
在1個線程創建成功以后,我們可以拿到一個線程ID。線程ID在整個操作系統范圍內是唯一的。我們可以使用線程ID來標識和區分線程,例如在日志文件中輸出日志的同時將輸出日志的線程ID一起輸出,這樣可以方便我們判斷和排查問題。上面也介紹了創建線程時可以通過pthread_create函數的第1個參數thread (Linux平臺)和CreateThread函數的最后一個參數 lpThreadId(Windows 平臺)得到線程的ID。大多數時候,我們需要在當前調用線程中獲取當前線程的ID,在Linux平臺上可以調用pthread_self函數獲取,在Windows平臺上可以調用GetCurrentThreadID函數獲取,其函數簽名分別如下:


這兩個函數都比較簡單,這里不再介紹。pthread_t和DWORD類型在本質上都是32位無符號整型。
在Windows 7中可以在任務管理器中查看某個進程的線程數量。在下圖中框住的是每個進程的線程數量,例如對于 vmware-tray.exe 進程一共有三個線程。如果在打開任務管理器時沒有看到線程數這一列,則可以單擊任務管理器的“查看”→“選擇列”菜單,在彈出的對話框中勾選線程數即可顯示。

1.pstack命令
在Linux系統中可以通過pstack命令查看一個進程的線程數量和每個線程的調用堆棧情況:

這時將 pid設置為要查看的進程 ID即可。以筆者機器上 Nginx的 worker進程為例,首先使用ps命令查看Nginx進程ID,然后使用pstack即可查看該進程每個線程的調用堆棧(這里演示的Nginx只有1個線程,如果有多個線程,則會顯示每個線程的調用堆棧):

使用pstack命令查看的程序必須有調試符號,用戶必須有相應的查看權限。
2.利用pstack命令排查Linux進程CPU使用率過高的問題
在實際開發中,我們經常需要排查和定位一個進程 CPU占用率過高的問題,這時可以結合使用Linux top和pstack命令來排查。來看一個具體的例子。如下圖所示,我們使用top命令后發現機器上進程ID為4427的qmarket進程的CPU使用率達到22.8%。

我們使用top-H命令再次輸出系統的進程列表,top命令的-H選項的作用是顯示每個進程各個線程的運行狀態(線程模式)。執行結果如下圖所示。

如上圖所示,top命令第 1欄的輸出雖然還是PID,但顯示的實際上是每個線程的線程 ID。我們可以發現 qmarket 線程 ID 為 4429、4430、4431、4432、4433、4434、4445的線程其CPU使用率較高。那么這幾個線程到底做了什么導致CPU使用率高呢?我們使用pstack 4427命令來看一下這幾個線程(4427是qmarket的進程ID):

在 pstack 輸出的各個線程中,只要逐一對照我們的程序源碼,梳理該線程中是否有大多數時間處于空轉狀態的邏輯,然后修改和優化這些邏輯,就可以解決 CPU使用率過高的問題。在一般情況下,對不工作的線程應盡量使用鎖對象讓其掛起,而不是空轉,這樣可以提高系統資源利用率。
3.Linux系統線程ID的本質
在Linux系統中有三種方法可以獲取1個線程的ID。
方法一,調用pthread_create函數時,在函數調用成功后,通過第1個參數可以得到線程ID:

方法二,在需要獲取ID的線程中調用pthread_self函數獲取:

方法三,通過系統調用獲取線程ID:

方法一和方法二獲取線程ID的結果是一樣的,都是pthread_t類型,輸出的是一塊內存空間地址,示意圖如下。

由于不同的進程可能有同樣地址的內存塊,因此通過方法一和方法二獲取的線程 ID可能不是全系統唯一的,一般是一個很大的數字(內存地址)。而通過方法三獲取的線程ID是系統范圍內全局唯一的,一般是一個不太大的整數,也就是LWP(Light Weight Process,輕量級進程,早期Linux系統的線程是通過進程實現的,這種線程被稱為輕量級進程)的ID。
來看一段具體的代碼:


以上代碼在新開的線程中使用上面介紹的三種方式獲取線程ID并打印,輸出結果如下:

tid2即LWP的ID,而tid1和tid3是一個內存地址,轉換成16進制是0x7F7F5D935700。這與我們使用pstack命令看到的線程ID是一樣的:


4.C++11獲取當前線程ID的方法
C++11的線程庫可以使用std::this_thread類的get_id獲取當前線程ID,這是一個類靜態方法。
當然,也可以使用std::thread的get_id獲取指定線程的ID,這是一個類實例方法。
但 get_id 方法返回的是一個 std::thread::id 的包裝類型,該類型不可以被直接強轉成整型,C++11線程庫也沒有為該對象提供任何轉換成整型的接口。所以,我們一般使用std::cout這樣的輸出流來輸出,或者先轉換為std::ostringstream對象,再轉換為字符串類型,然后把字符串類型轉換為我們需要的整型,這算是 C++11 線程庫獲取線程 ID 一個不太方便的地方:


在Linux x64系統上編譯并運行程序,輸出結果如下:

編譯成Windows x86程序,運行結果如下圖所示。

3.2.3 等待線程結束
在實際項目開發中,我們常常會有這樣一種需求,即一個線程需要等待另一個線程執行完任務并退出后再繼續執行。這在 Linux 和 Windows 中都提供了相應的 API,下面分別介紹一下。
1.在Linux下等待線程結束
Linux 線程庫提供了pthread_join 函數,用來等待某線程的退出并接收它的返回值。這種操作被稱為匯接(join)。pthread_join函數簽名如下:

參數 thread 是需要等待的線程 ID;參數 retval 是輸出參數,用于接收等待退出的線程的退出碼(Exit Code),可以在調用 pthread_exit 退出線程時指定線程退出碼,也可以在線程函數中通過return語句返回線程退出碼。pthread_exit函數簽名如下:

參數value_ptr的值可以通過pthread_join函數的第2個參數得到,如果不需要使用這個參數,則可以將其設置為NULL。
pthread_join 函數在等待目標線程退出期間會掛起當前線程(調用 pthread_join 的線程),被掛起的線程處于等待狀態,不會消耗任何CPU時間片。直到目標線程退出后,調用pthread_join的線程才會被喚醒,繼續執行接下來的邏輯。這里通過一個實例演示這個函數的使用方法,實例功能為:在程序啟動時開啟一個工作線程,工作線程將當前系統時間寫入文件后退出,主線程等待工作線程退出后,從文件中讀取時間并將其顯示在屏幕上。
相應的代碼如下:


程序執行結果如下:


2.Windows等待線程結束
在Windows下有兩個非常重要的函數API:WaitForSingleObject和WaitForMultipleObjects,前者用于等待1個線程結束,后者可以同時等待多個線程結束。這兩個函數不僅可以用于等待線程退出,還可以用于等待其他線程同步對象。與Linux的pthread_join函數不同,Windows的WaitForSingleObject函數提供了對可選擇的等待時間的精細控制。
這里僅演示等待線程退出。將上面的Linux示例代碼改寫成Windows版本:



程序執行結果如下圖所示。

3.C++11提供的等待線程結果的函數
可以想到,既然C++11的std::thread統一了Linux和Windows的線程創建函數,那么它應該也提供了等待線程退出的接口。確實如此,std::thread的join方法就是用來等待線程退出的方法。當然,使用這個函數時,必須保證該線程處于運行狀態,也就是說等待的線程必須是可以 join 的,如果需要等待的線程已經退出,則此時調用 join 方法,程序就會崩潰。因此,C++11 的線程庫同時提供了一個 joinable 方法來判斷某個線程是否可以join。
將上面的代碼改寫成C++11版本:


- GAE編程指南
- CockroachDB權威指南
- 垃圾回收的算法與實現
- 數據結構簡明教程(第2版)微課版
- Mastering Scientific Computing with R
- Object-Oriented JavaScript(Second Edition)
- Learning Zurb Foundation
- Spring Boot進階:原理、實戰與面試題分析
- Learning Apache Cassandra
- 響應式Web設計:HTML5和CSS3實戰(第2版)
- 寫給程序員的Python教程
- JavaScript+jQuery網頁特效設計任務驅動教程
- Mastering Adobe Captivate 7
- Scratch從入門到精通
- MySQL數據庫教程(視頻指導版)