pthread_mutex_t & pthread_cond_t 總結

2022-10-16 06:02:56

pthread_mutex_t & pthread_cond_t 總結

一、多執行緒並行

1.1 多執行緒並行引起的問題

我們先來看如下程式碼:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define MAX 1E7

int giNum = 0;

void *func1(void *arg)
{
    int i;
    for (i = 1; i <= MAX; i++)
    {
        giNum++;
    }
    return NULL;
}

void *func2(void *arg)
{
    int i;
    for (i = 1; i <= MAX; i++)
    {
        giNum++;
    }
    return NULL;
}

int main()
{
    pthread_t th1;
    pthread_create(&th1, NULL, func1, NULL);

    pthread_t th2;
    pthread_create(&th2, NULL, func2, NULL);

    pthread_join(th1, NULL);
    pthread_join(th2, NULL);

    printf("giNum = %d\n", giNum);

    return 0;
}

程式碼的內容很簡單:

  1. 建立了兩個子執行緒 th1、th2
  2. 兩個子執行緒分別執行giNum++操作
  3. 最後輸出giNum的值

直觀地看過去:

  • th1 執行時giNum++要執行 \(10^7\)

  • th2 執行時giNum++也要執行 \(10^7\)

似乎計算得到的最後 giNum 應該是 \(2\times10^7\)。但實際上是這樣的嗎?讓我們來看一下執行結果:

多次執行,你會發現,僅有一次(甚至沒有)結果是正確的。

1.2 知根知底

上述程式碼得到的結果為什麼不如順序執行所預期的那樣呢?可以用程式修改變數值時所經歷的三個步驟解釋這個現象:

  • 從記憶體單元讀入暫存器
  • 在暫存器中對變數操作(加/減1)
  • 把新值寫回到記憶體單元

即當我們當我們執行giNum++時,底層發生的事件其實是:

  1. 記憶體中讀取 giNum;
  2. 將 giNum++;
  3. 將 giNum 寫入到記憶體。

這不是一個原子化操作,當兩個執行緒交錯執行的時候,很容易發生結果的丟失。因此最後的結果肯定是要 \(\leq 2\times10^7\) 的。這種情況有種專有名詞,叫 race condition。為了解決這個問題,我們可以「加鎖」。

二、執行緒鎖

2.1 互斥量

多執行緒程式中可能會存在資料不一致的情況,那麼如何保證資料一致呢?可以考慮同一時間只有一個執行緒存取資料。

而互斥量(mutex)就是一把鎖。多個執行緒只有一把鎖一個鑰匙,誰上的鎖就只有誰能開鎖。當一個執行緒要存取一個共用變數時,先用鎖把變數鎖住,然後再操作,操作完了之後再釋放掉鎖;當另一個執行緒也要存取這個變數時,發現這個變數被鎖住了,無法存取,它就會一直等待,直到鎖沒了,它再給這個變數上個鎖,然後使用,使用完了釋放鎖,以此進行。這樣即使有多個執行緒同時存取這個變數,也好像是對這個變數的操作是順序進行的。

互斥變數使用特定的資料型別:pthread_mutex_t。使用互斥量前要先初始化,初始化又分為靜態初始化和動態初始化:

  • 靜態初始化:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
  • 動態初始化:pthread_mutex_init(&mutex,NULL);

第一種方法僅侷限於靜態初始化的時候使用:將「宣告、定義、初始化」一氣呵成,除此之外的情況都只能使用 pthread_mutex_init函數。

2.2 pthread_mutex_init

函數原型:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

頭 文 件:#include <pthread.h>

返 回 值:成功返回 0,失敗返回錯誤碼

引數介紹:

  1. mutex:指向 pthread_mutex_t 宣告的變數的地址
  2. attr:指定了新建互斥鎖的屬性。一般置為 NULL(如果引數attr為 NULL,則使用預設的互斥鎖屬性,預設屬性為快速互斥鎖 )。

restrict 關鍵字只用於限制指標。告訴編譯器所有修改該指標指向記憶體中的操作,只能通過本指標完成,不能通過除了本指標之外的變數或指標修改。

當我們通過 pthread_mutex_init() 初始化互斥量後,接下來就是上鎖(pthread_mutex_lock)和解鎖(pthread_mutex_unlock)操作了。

2.3 上鎖 & 解鎖

上鎖 解鎖
函數原型 pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex);
頭 文 件 #include <pthread.h> #include <pthread.h>
返 回 值 成功返回 0,失敗返回錯誤碼 成功返回 0,失敗返回錯誤碼

讓我們來梳理一下互斥量的使用流程:

  1. 通過 pthread_mutex_init() 購買一把鎖
  2. 通過 pthread_mutex_lock() 加鎖
  3. 通過 pthread_mutex_unlock() 解鎖

下面讓我們通過「鎖」操作修改一下上述程式碼:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 靜態初始化鎖
void *func1(void *arg)
{
    pthread_mutex_lock(&mutex);
    puts("執行緒 th1 搶到鎖");
    puts("執行緒 th1 開始執行 giNum++");
    int i;
    for (i = 1; i <= MAX; i++)
    {
        giNum++;
    }
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void *func2(void *arg)
{
    pthread_mutex_lock(&mutex);
    puts("執行緒 th2 搶到鎖");
    puts("開始執行 giNum++");
    int i;
    for (i = 1; i <= MAX; i++)
    {
        giNum *= 2;
    }
    pthread_mutex_unlock(&mutex);
    return NULL;
}

此時,再次執行程式,你會發現不管執行多少次,結果都是 \(giNum = 2\times10^7\)

下面我們對上面的程式碼做個簡單的修改,將 func2 中的giNum++操作修改為giNum *= 2

三、條件變數

3.1 為什麼要使用條件變數

如果沒有條件變數,那麼我們等待一個條件滿足則會是下面這樣的模型:

  • 首先加鎖進入臨界區去檢視條件是否滿足,不滿足則解鎖離開臨界區,睡眠一段時間再繼續迴圈判斷。

在這種情況下如果剛離開臨界區,條件變為滿足,那麼執行緒必須還要等一段時間重新進入臨界區才能知道條件滿足(如果在這段時間內,條件依舊一直保持滿足的話);如果這一小段時間條件又變為了不滿足,那麼這個執行緒還要繼續迴圈判斷,不斷地加鎖解鎖(會影響使用同一把鎖的其他執行緒),還不能第一時間收到條件滿足。

這種模型既費時又開銷大,所以條件變數的產生,正是為了不迴圈加鎖解鎖,並且第一時間收到條件滿足的通知。

3.2 條件變數函數介紹

3.2.1 pthread_cond_t

條件變數使用特定的資料型別:pthread_cond_t。使用條件變數前要先初始化,初始化又分為靜態初始化和動態初始化:

  • 靜態初始化:pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
  • 動態初始化:pthread_cond_init(&cond, NULL);

靜態初始化的條件變數只能擁有預設的條件變數屬性,不能設定其他條件變數屬性。

3.2.2 pthread_cond_init

函數原型:int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

頭 文 件:#include <pthread.h>

功 能:對條件變數初始化

返 回 值:成功返回 0,失敗返回錯誤碼

引數介紹:

  1. cond:需要初始化的條件變數
  2. attr:初始化時條件變數的屬性,一般置為 NULL,表示使用預設屬性

3.2.3 pthread_cond_destory

函數原型:int pthread_cond_destroy(pthread_cond_t *cond);

頭 文 件:#include <pthread.h>

功 能:對條件變數反初始化(在條件變數釋放記憶體之前)

返 回 值:成功返回 0,失敗返回錯誤碼

引數介紹:需要反初始化的條件變數

備註:此函數只是反初始化互斥量,並沒有釋放記憶體空間。如果互斥量是通過 malloc 等函數申請的,那麼需要在 free 掉互斥量之前呼叫 pthread_mutex_destroy 函數

3.2.4 pthread_cond_wait

函數原型:int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

頭 文 件:#include <pthread.h>

功 能:用於阻塞當前執行緒,等待別的執行緒使用pthread_cond_signal()pthread_cond_broadcast()來喚醒它

返 回 值:成功返回 0,失敗返回錯誤碼

函數 pthread_cond_wait 必須與 pthread_mutex_t 配套使用。pthread_cond_wait() 一旦進入 wait 狀態就會主動呼叫 pthread_mutex_unlock() 釋放掉 mutex。當其他執行緒通過 pthread_cond_signal() 或 pthread_cond_broadcast() 把該執行緒喚醒,使 pthread_cond_wait() 返回時,該執行緒又主動呼叫 pthread_mutex_lock() 來獲取該 mutex。

3.2.5 pthread_cond_signal

函數原型:int pthread_cond_signal(pthread_cond_t *cond);

頭 文 件:#include <pthread.h>

功 能:傳送一個訊號給另外一個正在處於阻塞等待狀態的執行緒,使其脫離阻塞狀態

返 回 值:成功返回 0,失敗返回錯誤碼

使用 pthread_cond_signal 一般不會有「驚群現象」產生,它最多隻給一個執行緒發訊號。假如有多個執行緒正在阻塞等待著這個條件變數的話,那麼是根據各等待執行緒優先順序的高低確定哪個執行緒先接收到訊號並開始繼續執行。如果各執行緒優先順序相同,則根據等待時間的長短來確定哪個執行緒獲得訊號。但無論如何一個 pthread_cond_signal() 呼叫最多發信一次。

3.2.6 pthread_cond_broadcast

函數原型:int pthread_cond_broadcast(pthread_cond_t *cond);

頭 文 件:#include <pthread.h>

功 能:喚醒等待該條件的所有執行緒

返 回 值:成功返回 0,失敗返回錯誤碼

這兩個函數 pthread_cond_broadcast() 和 pthread_cond_signal 用於通知執行緒條件變數已經滿足條件(變為真)。在呼叫這兩個函數時,是在給執行緒或者條件發訊號。

3.3 如何使用條件變數

我們對「2.3」中的函數 func2 做個簡單的修改:

#define MAX 3

void *func2(void *arg)
{
    pthread_mutex_lock(&mutex);
    puts("執行緒 th2 搶到鎖,開始執行 giNum *= 2");
    int i;
    for (i = 1; i <= MAX; i++)
    {
        giNum *= 2;
    }
    pthread_mutex_unlock(&mutex);
    return NULL;
}

對 func2() 做了個微小的改動:將giNum++修改為了giNum *= 2

這樣的話,執行緒搶到鎖的順序不同會影響giNum的最終結果:

  1. th1 先搶到鎖:giNum 先執行加操作,然後在執行乘操作,最終結果為 24
  2. th2 先搶到鎖:giNum 先執行乘操作,然後在執行加操作,最終結果為 3

如果如何才能做到執行緒 th1 總是能夠先搶到鎖呢?下面我們通過條件變數的方式來實現這一想法。

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

#define MAX 3
#define TRUE 1
#define FALSE 0

int giNum = 0;
int giFlag = FALSE; // TRUE:執行執行緒 2 的乘操作
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // 靜態初始化鎖
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;     // 靜態初始化條件變數

void *func1(void *arg)
{
    pthread_mutex_lock(&mutex);
    puts("執行緒 th1 搶到鎖");
    puts("執行緒 th1 開始執行 giNum++");
    int i;
    for (i = 1; i <= MAX; i++)
    {
        giNum++;
    }

    giFlag = TRUE; // 修改 giFlag 的值,使得執行緒 th2 滿足條件
    pthread_cond_signal(&cond); // 向執行緒 th2 發出訊號
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void *func2(void *arg)
{
    pthread_mutex_lock(&mutex);
    puts("執行緒 th2 搶到鎖");
    while (FALSE == giFlag) // 不滿足執行緒 th2 的執行條件
    {
        puts("執行緒 th2 不滿足條件,等待~");
        pthread_cond_wait(&cond, &mutex); // 等待被觸發
    }
    puts("執行緒 th2 滿足條件,開始執行 giNum *= 2");
    int i;
    for (i = 1; i <= MAX; i++)
    {
        giNum *= 2;
    }
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main()
{
    pthread_t th1;
    pthread_create(&th1, NULL, func1, NULL);

    pthread_t th2;
    pthread_create(&th2, NULL, func2, NULL);

    pthread_join(th1, NULL);
    pthread_join(th2, NULL);

    printf("giNum = %d\n", giNum);

    return 0;
}

經過修改後的程式碼就可以確保執行緒 th1 的「加」操作會先於執行緒 th2 的「乘」操作:

  1. 情況一:執行緒 th1 先搶到鎖,順利執行「加」操作,並將執行緒 th2 的觸發條件giFlag修改為 TRUE;繼而當執行緒 th2 搶到鎖後,不會進入到 while 迴圈。
  2. 情況二:執行緒 th2 先搶到鎖,但由於此時giFlag為 FALSE,所以會進入到 while 迴圈執行 pthread_cond_wait 語句,並阻塞在這兒釋放掉 mutex;那麼此時執行緒 th1 就可以順利加鎖,執行完「加」操作後將giFlag置為 TRUE,並行出訊號,使得執行緒 th2 可以繼續向下執行。

關於為什麼要使用 while 迴圈來判斷條件是否滿足,解釋如下:某些應用,如執行緒池,pthread_cond_broadcast() 喚醒全部執行緒,但我們通常只需要一部分執行緒去做執行任務,而其它的執行緒則需要繼續 wait,所以強烈推薦對 pthread_cond_wait()
使用 while 迴圈來做條件判斷。

四、深入理解條件變數

以下內容摘抄自 linux 下 pthread_cond_t 詳解,博主寫的很詳細,通俗易懂