1. 程式人生 > >從 0 開始學習 Linux 系列之「25.Posix 執行緒」

從 0 開始學習 Linux 系列之「25.Posix 執行緒」

多執行緒概念

多執行緒技術是應用開發中非常重要的技術之一,幾乎大型的應用軟體都使用這個技術,這次一起來學習下 Linux 中的多執行緒開發基礎(其他的系統中概念也是類似的)。

在 Linux 中,一個簡單的程序可以看成只有一個單執行緒(主執行緒),因為只有一個執行緒,所以程序在某一個時刻只能做一件事。為了能夠使得程序可以在同一時刻做多件事情,可以讓這個程序內部產生多個執行緒來分工同時完成。

例如典型的字處理程式,有一個執行緒在前臺與使用者進行圖形介面的互動,有一個執行緒在進行語法和拼寫檢查,還有一個執行緒在週期性的儲存文件,這 3 個執行緒共同完成了文件的編寫和儲存功能。想想假如只有一個主執行緒,那麼你先鍵入文件,然後進行語法和拼寫檢查,最後才儲存文件,這 3 個步驟是序列執行

的,而使用多執行緒時這 3 個任務是並行執行的,效率提高了很多,也更加安全了,如下圖:

thread

使用多執行緒技術有下面幾個優點:
- 簡化任務的程式碼:在單獨的執行緒中執行一個任務可以使用簡單的同步程式設計模式
- 共享程序資源:多個執行緒可以訪問相同的程序地址空間
- 提高程序的吞吐量:使用多執行緒可以並行執行多個任務
- 優化程式體驗:可以使用多執行緒分開處理程式的輸入輸出

總體來說使用多執行緒技術可以優化程式,提升使用者的體驗。瞭解了基本概念後,接下來看看作業系統實現執行緒的模型。

多執行緒模型

現在的作業系統有 2 種不同的方法來提供執行緒支援:
1. 使用者執行緒:受核心支援,無須核心管理
2. 核心執行緒

:由核心直接支援和管理

這兩種方法之間有一定的聯絡,畢竟使用者執行緒要受核心的支援,有 3 種常見的建立兩者關係的模型:
1. 多對一模型:多個使用者執行緒對映到一個核心執行緒,多個執行緒不能並行執行
2. 一對一模型:一個使用者執行緒對映到一個核心執行緒,建立執行緒的開銷較大
3. 多對多模型:多個使用者執行緒可以複用同樣數量或更小數量的核心執行緒,沒有前面 2 者的缺點

3 種模型圖如下:
thread2

實現的模型在不同的作業系統上都差不多,但是不同作業系統上的執行緒庫的實現卻是不大相同的。

執行緒庫

作業系統為我們提供建立和管理執行緒的 API,執行緒操作也有 2 種實現方法:
1. 核心支援的使用者執行緒庫

:此庫的程式碼和資料都存在使用者空間中,API 不會進行系統呼叫
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_equalman 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_lockpthread_mutex_unlock 註釋掉,即不加鎖檢視最後的結果會不會亂序。

執行緒池

執行緒池概念也是經常遇到,這裡來了解一下它的基本原理,這裡沒有給出實現,有興趣可以 Google 相關的執行緒池技術。來看一個 Web 服務的例子,以來了解為何使用執行緒池會有優勢。

假設現在有一個多執行緒的 Web 伺服器,沒有使用執行緒池前,每接受一個客戶端的連線請求就建立一個獨立執行緒來處理,但是如果請求較多就會嚴重影響系統性能,主要有 2 個原因:
1. 建立很多執行緒需要耗費資源
2. 一個執行緒做完任務後就被銷燬,不能重複利用

基於這兩個缺點,提出了執行緒池的概念:它的主要思想是在程序開始的時候就建立一定數量的執行緒,放到一起(稱為池)等待分配工作
- 當伺服器收到請求即喚醒一個執行緒處理任務,在一個執行緒處理完後會重新回到池中等待下一次的任務,使得執行緒可以重複使用
- 如果池中沒有可用執行緒,則伺服器會等待直到有可用執行緒,使得系統不會再建立新的執行緒

執行緒池中的執行緒數量可以手動規定,也可以在系統執行時根據當前負荷動態調整,具有動態調整功能的執行緒池比較高階。

結語

多執行緒技術的應用非常廣泛,這篇文章主要介紹了在 Linux 下的 Posix 的標準的執行緒庫的使用方法,相關 API 的使用其實不難,關鍵是要理解多執行緒的概念及為和要使用它。另外多執行緒中比較重要的是如何處理執行緒安全(同步)的問題,常見的處理方法有互斥鎖,訊號量等,同步的話題比較複雜有興趣可以深入學習。

最後,感謝你的閱讀,我們下次再見 :)

本文原創釋出於微信公眾號「cdeveloper」,程式設計、職場,人生,關注並回復關鍵字「linux」、「機器學習」等獲取免費學習資料。