pthread_mutex_t & pthread_cond_t 總結

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\)。但實際上是這樣的嗎?讓我們來看一下運行結果:

image-20221012221636410

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

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

image-20221015163353196

如果如何才能做到執行緒 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 循環。image-20221015210446897
  2. 情況二:執行緒 th2 先搶到鎖,但由於此時giFlag為 FALSE,所以會進入到 while 循環執行 pthread_cond_wait 語句,並阻塞在這兒釋放掉 mutex;那麼此時執行緒 th1 就可以順利加鎖,執行完「加」操作後將giFlag置為 TRUE,並發出訊號,使得執行緒 th2 可以繼續向下執行。image-20221015210511195

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

四、深入理解條件變數

以下內容摘抄自 linux 下 pthread_cond_t 詳解,部落客寫的很詳細,通俗易懂👍

4.1 本文目的

首先說明,本文重點不在怎麼用條件變數。這裡我先列出 apue 中對於 pthread_cond_wait 函數的這麼一段話:「調用者把鎖住的互斥量傳給函數,函數然後自動把調用執行緒放到等待條件的執行緒列表上,對互斥量解鎖。這就關閉了條件檢查和執行緒進入休眠狀態等待條件改變這兩個操作之間的時間通道,這樣執行緒就不會錯過條件的任何變化。pthread_cond_wait 返回時,互斥量再次被鎖住。」

這段話的資訊量很大,其中關於互斥量的操作可以理解為以下三個點:

  1. 調用 pthread_cond_wait 前需要先對互斥量 mutex 上鎖,之後才能把 mutex 傳入 pthread_cond_wait 函數
  2. 在 pthread_cond_wait 函數內部,會首先對傳入的 mutex 解鎖
  3. 當訊號到來後,pthread_cond_wait 函數內部在返回前會去鎖住傳入的 mutex

我當時看到這裡,各種疑問:1、傳入前為何要鎖;2、傳入後為何要釋放;3、返回時又為何再次鎖?本文就這三個問題進行詳細解釋。

4.2 三個問題

要回答那三個問題,那麼首先需要明白「等待與喚醒」的配合。其實這個圖就能解釋上述三個問題,不過我還是詳細解釋一下。

在這裡插入圖片描述

圖中有一個關鍵點,就是「判斷條件是否滿足」的操作,是在「調用 pthread_cond_wait 之前、鎖 mutex 之後」發生的。也就是說 pthread_cond_wait 不具備判斷條件的能力,需要我們在外部寫判斷語句:

  1. 條件不滿足時,才會進入 pthread_cond_wait
  2. 進入 pthread_cond_wait 先解鎖然後就馬上阻塞
  3. pthread_cond_signal 喚醒的是阻塞在 pthread_cond_wait 的執行緒

把這個基本流程弄清楚後,就可以解釋那三個問題了。

4.2.1 傳入前為何要鎖

傳入前鎖 mutex 是為了保證執行緒從條件判斷到進入 pthread_cond_wait 前,條件不被改變。

如果沒有傳入前的鎖,就會有這樣的情況:執行緒 A 在「判斷條件不滿足之後、調用 pthread_cond_wait 之前」,A 因為休眠、又或者因為多執行緒下多個執行緒執行順序和快慢的因素,令執行緒 B 更改了條件,使得條件滿足。但此時執行緒 A 還沒有調用pthread_cond_wait。等到執行緒 A 啟動調用 pthread_cond_wait 後雖然條件滿足,但卻收不到 pthread_cond_signal 的喚醒,就會一直阻塞下去。

結合下面的偽程式碼來加深理解:

/* 執行緒A執行函數 */
int giFlag = FALSE; // FALSE:執行緒A不滿足執行條件
void *funcA(void *arg)
{
    // pthread_mutex_lock(&mutex); // 傳入前不加鎖
    while (FALSE == giFlag)
    {
        // 在調用pthread_cond_wait前,執行緒B啟動並執行了函數funcB,修改條件並發出訊號
        // 但此時由於執行緒A還未執行pthread_cond_wait函數,所以會忽略掉執行緒B發出的訊號

        // 等到執行緒A開始執行pthread_cond_wait時,已經收不到來自執行緒B的訊號了,會一直阻塞
        pthread_cond_wait(&cond, &mutex);
    }

    // ToDo

    pthread_mutex_unlock(&mutex);
    return NULL;
}
/* 執行緒B執行條件 */
void *funcB(void *arg)
{
    pthread_mutex_lock(&mutex);

    // 執行緒B將giFlag置為TRUE,並通過cond_signal將訊號發送給了執行緒A
    giFlag = TRUE;
    pthread_cond_signal(&cond);

    pthread_mutex_unlock(&mutex);
    return NULL;
}

4.2.2 傳入後為何要釋放

傳入後解鎖是為了條件能夠被改變。

傳入後的解鎖,是因為調用 pthread_cond_signal 的那部分,需要先加鎖更改條件後才調用 pthread_cond_signal(更改條件與等待條件滿足,都是針對條件這一個資源的競爭,所以調用 pthread_cond_wait 和調用 pthread_cond_signal 的兩個執行緒需要同一把鎖)。

如果 pthread_cond_wait 內不對 mutex 解鎖,那麼在調用 pthread_cond_wait 後,其他執行緒就不能更改條件,條件就會一直不滿足。

4.2.3 返回時又為何再次鎖

  1. 返回前再次鎖 mutex 是為了保證條件從「執行緒從 pthread_cond_wait 返回後」到「再次條件判斷前」不被改變。
  2. 使得在「pthread_cond_signal之後」與「pthread_mutex_unlock 之前」可以執行其他的語句。

對於 1,這裡的理由與傳入 pthread_cond_wait 前鎖 mutex 的理由差不多。如果不鎖,那麼執行緒 A 調用 pthread_cond_wait後,條件滿足,執行緒 A 被喚醒,從 pthread_cond_wait 返回。執行緒 B 在此時更改了條件,使得條件不滿足,執行緒 A 並不知道條件又被更改,還是以為條件滿足,就可能出錯。

對於 2,由於 mutex 在這時已經被這個執行緒鎖住,還沒有解鎖,所以調用 pthread_cond_wait 的那個執行緒在 pthread_cond_wait 返回前的鎖 mutex 的行為就會阻塞,直到 pthread_cond_signal 後的語句執行完並解鎖,pthread_cond_wait 才會返回。

4.3 pthread_cond_signal 的兩種寫法

由於 pthread_cond_wait 返回前再次鎖的行為,所以 pthread_cond_signal 不一定必須放在解鎖 mutex之前。

4.3.1 寫法一

{
    pthread_mutex_lock(&mutex);
    // ToDo
    pthread_cond_signal(&cond);
    // ToDo
    pthread_mutex_unlock(&mutex);
}

缺點:在某些執行緒的實現中,會造成等待執行緒從內核中被喚醒(接收到了 cond_signal 發出的訊號)回到用戶空間,然後 pthread_cond_wait 返回前需要加鎖,但是發現鎖沒有被釋放,又回到內核空間所以一來一回會有性能的問題。

但是在 LinuxThreads 或者 NPTL 裡面,就不會有這個問題。因為在 Linux 執行緒中,有兩個隊列,分別是 cond_wait 隊列和mutex_lock 隊列, cond_signal 只是讓執行緒從 cond_wait 隊列移到 mutex_lock 隊列,而不用返回到用戶空間,不會有性能的損耗,所以Linux中這樣用沒問題。

4.3.2 寫法二

{
    pthread_mutex_lock(&mutex);
    // ToDo
    pthread_mutex_unlock(&mutex);
    pthread_cond_signal(&cond);
}

優點:不會出現之前說的那個潛在的性能損耗,因為在 signal 之前就已經釋放鎖了

缺點:如果 unlock 之後 signal 之前,發生進程交換,另一個進程(不是等待條件的進程)拿到這把夢寐以求的鎖後加鎖操作,那麼等最終切換到等待條件的執行緒時鎖被別人拿去還沒歸還,只能繼續等待。

參考資料

Tags: