從 0 開始學習 Linux 系列之「25.Posix 執行緒」
多執行緒概念
多執行緒技術是應用開發中非常重要的技術之一,幾乎大型的應用軟體都使用這個技術,這次一起來學習下 Linux 中的多執行緒開發基礎(其他的系統中概念也是類似的)。
在 Linux 中,一個簡單的程序可以看成只有一個單執行緒(主執行緒),因為只有一個執行緒,所以程序在某一個時刻只能做一件事。為了能夠使得程序可以在同一時刻做多件事情,可以讓這個程序內部產生多個執行緒來分工同時完成。
例如典型的字處理程式,有一個執行緒在前臺與使用者進行圖形介面的互動,有一個執行緒在進行語法和拼寫檢查,還有一個執行緒在週期性的儲存文件,這 3 個執行緒共同完成了文件的編寫和儲存功能。想想假如只有一個主執行緒,那麼你先鍵入文件,然後進行語法和拼寫檢查,最後才儲存文件,這 3 個步驟是序列執行
使用多執行緒技術有下面幾個優點:
- 簡化任務的程式碼:在單獨的執行緒中執行一個任務可以使用簡單的同步程式設計模式
- 共享程序資源:多個執行緒可以訪問相同的程序地址空間
- 提高程序的吞吐量:使用多執行緒可以並行執行多個任務
- 優化程式體驗:可以使用多執行緒分開處理程式的輸入輸出
總體來說使用多執行緒技術可以優化程式,提升使用者的體驗。瞭解了基本概念後,接下來看看作業系統實現執行緒的模型。
多執行緒模型
現在的作業系統有 2 種不同的方法來提供執行緒支援:
1. 使用者執行緒:受核心支援,無須核心管理
2. 核心執行緒
這兩種方法之間有一定的聯絡,畢竟使用者執行緒要受核心的支援,有 3 種常見的建立兩者關係的模型:
1. 多對一模型:多個使用者執行緒對映到一個核心執行緒,多個執行緒不能並行執行
2. 一對一模型:一個使用者執行緒對映到一個核心執行緒,建立執行緒的開銷較大
3. 多對多模型:多個使用者執行緒可以複用同樣數量或更小數量的核心執行緒,沒有前面 2 者的缺點
3 種模型圖如下:
實現的模型在不同的作業系統上都差不多,但是不同作業系統上的執行緒庫的實現卻是不大相同的。
執行緒庫
作業系統為我們提供建立和管理執行緒的 API,執行緒操作也有 2 種實現方法:
1. 核心支援的使用者執行緒庫
2. 原始的核心級別的庫:此庫的程式碼和資料都存在核心空間,API 會進行系統呼叫
有 3 種主要的執行緒庫:
1. Posix Pthread
:POSIX 標準的拓展,可以提供使用者級和核心級的庫,但僅僅是執行緒行為規範,而不是實現,Linux,Solaris 等 OS 都實現了這個規範
2. Win 32
:適用於 Windows OS 的核心級執行緒庫
3. Java
執行緒:由於 JVM 執行在宿主 OS 上,所以 Java 執行緒 API 通常採用宿主 OS 上的執行緒庫的實現
因為本次介紹的是 Linux 下的多執行緒技術,所以這裡學習的就是 Posix Pthread 的規範定義的 API 了,下面來看看都有哪些常用的函式。
比較,獲取執行緒 ID
就像每個程序有一個程序 ID 一樣,每個執行緒也有一個執行緒 ID,執行緒 ID 只有在它所屬的程序上下文中才有意義。在 Posix Pthread 中用 pthread_t
型別來表示一個執行緒 ID,該型別在 Linux 下是一個無符號長整型,並提供了 2 個相關的操作:
#include <pthread.h>
// 比較 2 個執行緒 ID
int pthread_equal(pthread_t t1, pthread_t t2);
// 獲取自身執行緒 ID
pthread_t pthread_self(void);
這兩個函式比較簡單,就不介紹例子了,返回值等資訊可以參考 man pthread_equal
和 man pthread_self
手冊。
建立執行緒
Posix 執行緒定義下面的函式來建立新的執行緒:
#include <pthread.h>
/*
* thread: 指向執行緒 ID
* attr: 定製執行緒屬性,傳遞 NULL 設定預設屬性
* start_routine: 要執行的執行緒函式
* arg: 要傳遞的函式引數
* return: 成功返回 0,失敗返回錯誤碼,但不會設定 erron
*/
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
來看一個簡單的建立執行緒的例子,這個例子建立一個子執行緒並列印子執行緒的程序 ID 和執行緒 ID:
// thread_create.c
#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
/* print pid and tid. */
void print_id(const char *s) {
printf("%s pid %lu, tid 0x%lx\n", s,
(unsigned long)getpid(),
(unsigned long)pthread_self());
}
/* thread fun */
void *thread_fun(void *arg) {
print_id("new thread: ");
return NULL;
}
int main(void) {
pthread_t tid;
// create pthread
if (pthread_create(&tid, NULL, thread_fun, NULL) != 0) {
printf("thread create failed.\n");
return -1;
}
print_id("main thread: ");
// 等待子執行緒執行完,後面會用 pthread_join 代替
sleep(1);
return 0;
}
編譯需要連結 -lpthread
執行緒庫,執行結果如下:
orange@ubuntu:~/$ gcc -Wall thread_create.c -lpthread -o thread_create
orange@ubuntu:~/$ ./thread_create
new thread: pid 21301, tid 0x7f05c2ee3700
main thread: pid 21301, tid 0x7f05c36bf700
從結果可看出兩個執行緒的程序 pid 是相同的,因為 2 者都所屬同一個程序,但是執行緒 tid 就不同了。另外,大家以後在用 gcc 編譯的時候儘量都加上 -Wall
來開啟所有的警告,可以幫助我們編寫更加嚴謹的程式碼。
執行緒終止,等待
單個執行緒可以通過 3 種方式退出,並且不會終止整個程序:
1. 執行緒直接返回
2. 執行緒被其他執行緒取消
3. 執行緒呼叫 pthread_exit
這裡主要介紹第 3 種方法,先來看看這個函式的定義:
#include <pthread.h>
void pthread_exit(void *retval);
其中 retval
返回的值可以通過呼叫 pthread_join
函式訪問:
#include <pthread.h>
/*
* thread: 要等待的執行緒 ID
* retval: 執行緒的退出狀態
* return: 成功返回 0,失敗返回錯誤編號
*/
int pthread_join(pthread_t thread, void **retval);
呼叫執行緒將一直阻塞,直到執行緒 ID 為 thread 的執行緒呼叫 pthread_exit
,被取消或從啟動例程中返回。並且,程序中的其他執行緒可以通過呼叫 pthread_join
函式獲得該執行緒的退出狀態。其中 retval
有 2 種情況:
1. 如果不為 NULL,則拷貝執行緒的退出狀態碼到 retval
2. 如果目標執行緒被取消,PTHREAD_CANCELED
被放到 retval
中
下面結合上面這兩個函式來看一個例子:主執行緒開啟 2 個子執行緒,然後分別使用 return 返回和 pthread_exit 退出,最後在主執行緒中獲取執行緒的返回碼。
#include <stdio.h>
#include <pthread.h>
void *thread_fun1(void *arg) {
printf("thread 1 return.\n");
return ((void *)1);
}
void *thread_fun2(void *arg) {
printf("thread 2 exit.\n");
pthread_exit((void *)2);
}
int main(void) {
pthread_t tid1, tid2;
void *ret_val = NULL;
// create thread 1 and 2
pthread_create(&tid1, NULL, thread_fun1, NULL);
pthread_create(&tid2, NULL, thread_fun2, NULL);
// wait thread 1
pthread_join(tid1, &ret_val);
printf("thread 1 exit code %ld\n", (long)ret_val);
// wait thread 2
pthread_join(tid2, &ret_val);
printf("thread 2 exit code %ld\n", (long)ret_val);
return 0;
}
編譯,執行可以看到成功獲得了 2 個執行緒的退出碼:
gcc -Wall thread_join.c -o thread_join -lpthread
./thread_join
thread 1 return.
thread 1 exit code 1
thread 2 exit.
thread 2 exit code 2
執行緒分離
Posix 也給我們提供了分離執行緒的函式,當分離一個執行緒後,該執行緒在終止時,執行緒資源由系統自動釋放,不需要其他執行緒再次 join 等待它。:
#include <pthread.h>
// thread: 要分離的執行緒 ID,成功返回 0,失敗返回錯誤碼
int pthread_detach(pthread_t thread);
注意,如果嘗試分離一個已經分離的執行緒會產生未定義的行為。該函式的使用方法很簡單,只需要一行程式碼:
#include <pthread.h>
// 在主執行緒建立 tid 執行緒
pthread_create(&tid, NULL, thread_fun, NULL);
// 從主執行緒分離 tid 執行緒
pthread_detach(tid);
執行緒同步
當多個執行緒同時訪問共享資源時會產生執行緒安全的問題,我們需要使用一些技術來使得這些執行緒同步訪問共享資源(一個一個訪問,不同時訪問),並且使它們訪問變數的儲存記憶體時不會訪問到無效的值。
執行緒同步的方法主要有互斥鎖,訊號量等,前面在介紹 IPC 的時候使用訊號量進行了程序間的通訊,其中的訊號量操作也是適用與執行緒的,所以這次主要介紹的同步方法是:互斥鎖 Mutex
。
使用互斥鎖進行執行緒同步的基本思想是:執行緒在獲取共享資源的訪問前首先需要獲得鎖,沒有獲取則阻塞或返回,訪問結束後必須釋放鎖,虛擬碼如下:
mutex_lock();
operate share resource
mutex_unlock();
Posix 執行緒給我們提供下面這些函式來操作互斥鎖。
初始化和銷燬 Mutex
#include <pthread.h>
// 動態初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
// 靜態初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 銷燬
int pthread_mutex_destroy(pthread_mutex_t *mutex);
靜態初始化比較簡單,動態初始化需要和銷燬 Mutex 的函式成對使用。
加鎖,解鎖 Mutex
#include <pthread.h>
// 加鎖,如果不能獲取鎖會阻塞呼叫程序
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 嘗試加鎖,不能獲取鎖就返回,不會阻塞
int pthread_mutex_trylock(pthread_mutex_t *mutex);
// 解鎖
int pthread_mutex_unlock(pthread_mutex_t *mutex);
例子:使用 Mutex 來保護共享資源
雖然 Mutex 的函式比較多,但是使用起來是很簡單的,只需要 4 步:初始化,加鎖,解鎖,銷燬(靜態初始化不需要),來看一個簡單的程式:
#include <stdio.h>
#include <pthread.h>
// 靜態初始化不需要 main 中的 1,2 兩步
//pthread_mutex_t mymutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mymutex;
void* thread_fun(void* arg) {
// lock
pthread_mutex_lock(&mymutex);
for(int i = 0; i < 5; i++)
printf("thread num = %d\n", (int)arg);
// unlock
pthread_mutex_unlock(&mymutex);
return NULL;
}
int main() {
// 1. 動態初始化
pthread_mutex_init(&mymutex, NULL);
pthread_t mythread[3];
void* retval = NULL;
for (int i = 0; i < 3; i++) {
pthread_create(&mythread[i], NULL, thread_fun, (void *)i);
pthread_join(mythread[i], &retval);
}
// 2. 銷燬,與動態初始化成對使用
pthread_mutex_destroy(&mymutex);
return 0;
}
編譯,執行:
gcc -Wall lock_thread.c -o lock_thread -lpthread
./lock_thread
thread num = 0
thread num = 0
thread num = 0
thread num = 0
thread num = 0
thread num = 1
thread num = 1
thread num = 1
thread num = 1
thread num = 1
thread num = 2
thread num = 2
thread num = 2
thread num = 2
thread num = 2
這是我的機器的執行結果,可以將 pthread_mutex_lock
和 pthread_mutex_unlock
註釋掉,即不加鎖檢視最後的結果會不會亂序。
執行緒池
執行緒池概念也是經常遇到,這裡來了解一下它的基本原理,這裡沒有給出實現,有興趣可以 Google 相關的執行緒池技術。來看一個 Web 服務的例子,以來了解為何使用執行緒池會有優勢。
假設現在有一個多執行緒的 Web 伺服器,沒有使用執行緒池前,每接受一個客戶端的連線請求就建立一個獨立執行緒來處理,但是如果請求較多就會嚴重影響系統性能,主要有 2 個原因:
1. 建立很多執行緒需要耗費資源
2. 一個執行緒做完任務後就被銷燬,不能重複利用
基於這兩個缺點,提出了執行緒池的概念:它的主要思想是在程序開始的時候就建立一定數量的執行緒,放到一起(稱為池)等待分配工作:
- 當伺服器收到請求即喚醒一個執行緒處理任務,在一個執行緒處理完後會重新回到池中等待下一次的任務,使得執行緒可以重複使用
- 如果池中沒有可用執行緒,則伺服器會等待直到有可用執行緒,使得系統不會再建立新的執行緒
執行緒池中的執行緒數量可以手動規定,也可以在系統執行時根據當前負荷動態調整,具有動態調整功能的執行緒池比較高階。
結語
多執行緒技術的應用非常廣泛,這篇文章主要介紹了在 Linux 下的 Posix 的標準的執行緒庫的使用方法,相關 API 的使用其實不難,關鍵是要理解多執行緒的概念及為和要使用它。另外多執行緒中比較重要的是如何處理執行緒安全(同步)的問題,常見的處理方法有互斥鎖,訊號量等,同步的話題比較複雜有興趣可以深入學習。
最後,感謝你的閱讀,我們下次再見 :)
本文原創釋出於微信公眾號「cdeveloper」,程式設計、職場,人生,關注並回復關鍵字「linux」、「機器學習」等獲取免費學習資料。