系統程式設計之高效同步機制:條件變數

2023-09-14 06:00:13

以下內容為本人的學習筆記,如需要轉載,請宣告原文連結 微信公眾號「ENG八戒」https://mp.weixin.qq.com/s/zy6Dmo_b3xMPPEO3HNxuuw

有一段時間沒碰條件變數【condition variable】,快忘了它到底是啥。大概記得,之前是用來寫底層介面,輔助實現安全的生產消費模式等等。

下面讓我們來探討一下條件變數的是非,簡單起見接下來的所有介面函數和程式碼都基於 linux C。

用途

一般資料的生產消費或者相關業務邏輯分佈在不同的執行緒中,如果他們的執行順序是有條件觸發的,那麼就需要用到條件變數了。

條件變數允許系統把條件變數所在的執行緒 A 掛起,就是說條件變數阻塞了當前執行緒的執行,直到在其它執行緒中通過相同的條件變數喚醒執行緒 A.

這有點像倆小朋友在玩你追我趕的遊戲,你不追過來,我就不動。

再比如,涉及到多執行緒的應用中,執行緒結束後資源是否會被自動回收,有賴於執行緒的屬性設定。如果需要在一個執行緒裡連線(join)另一個執行緒並獲取資訊,那麼這個執行緒會被阻塞直到另一個執行緒結束。這種連線機制需要等待執行緒結束,所以也屬於條件變數的特殊應用。

建立和銷燬

使用 pthread_cond_t 型別定義的條件變數需要使用 pthread_cond_init 初始化。

int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);

使用 pthread_cond_init 初始化條件變數時,可以傳入 pthread_condattr_t 型別變數指定條件變數的屬性,如果屬性是預設值可以傳入 NULL,或者直接使用宏定義 PTHREAD_COND_INITIALIZER 代替 pthread_cond_init(此時,條件變數必須是靜態變數)

static pthread_cond_t cond_variable = PTHREAD_COND_INITIALIZER;

條件變數在使用完畢後應該使用 pthread_cond_destroy 釋放佔用的資源。

int pthread_cond_destroy(pthread_cond_t *cond);

有個小細節,如果條件變數被分配線上程的棧上,該執行緒會維護一個條件變數的列表,那麼在該執行緒被終止前必須先釋放條件變數所佔用的資源(呼叫 pthread_cond_destroy),否則會產生 memory corrupted 類似的錯誤。

等待和喚醒

條件變數說到底就是執行緒之間同步機制的眾多方式中的一種,但是在使用過程中,必須搭配使用互斥鎖。

為什麼必須搭配使用互斥鎖?

基本正規化

先來看看一般的使用方式,比如,實現的資料佇列中,推入資料的介面函數

void queue_push(void *data)
{
    pthread_mutex_lock(mutex);

    // 往佇列中推入資料 data
    // ...

    pthread_cond_signal(cond_variable);

    pthread_mutex_unlock(mutex);
}

接著,提取資料的介面函數

void *queue_pop()
{
    pthread_mutex_lock(mutex);

    while (wait_condition) {
        pthread_cond_wait(cond_variable, mutex);
    }

    // 從佇列中彈出資料 data
    // ...

    pthread_mutex_unlock(mutex);
}

上面程式碼中 cond_variable 是已初始化的條件變數。mutex 同樣是經過初始化的互斥鎖,型別是 pthread_mutex_t。wait_condition 是條件表示式,布林型別,用於判斷是否進入條件變數的等待。

提取資料的函數介面程式碼中,開始判斷條件表示式之前,先佔用互斥鎖。當條件表示式為真,條件變數進入等待狀態並且釋放互斥鎖(這是原子操作),所線上程就會被掛起,直到被其它執行緒通過條件變數 cond_variable 喚醒。喚醒後,再次嘗試佔用互斥鎖,然後執行後續的資料處理(從佇列中提取資料),在資料處理完成後釋放互斥鎖。

可以看到,條件表示式的使用要素有三個:條件變數、起保護作用的鎖、僅起判斷作用的條件表示式。

效能提升

其實,如果為了同步資料,單純用鎖也是能實現的,但是會長期佔用系統資源,效率太低。比如下面把提取資料的介面函數寫成

void *queue_pop_2()
{
    pthread_mutex_lock(mutex);

    while (wait_condition) {
        sleep(1);
    }

    // 從佇列中彈出資料 data
    // ...

    pthread_mutex_unlock(mutex);
}

可見,如果僅用鎖來實現介面,在每次提取資料之前都會空轉固定的時間。如果資料佇列中已經準備好資料,那麼提取資料的操作需要等待最長可達一個週期(範例程式碼是 1 秒)。

條件變數和鎖的配合充分利用了系統的能力,大大降低效能損耗,避免長時間佔用鎖。但要注意,使用鎖不是為了保護條件變數自身,而是為了保護條件表示式的判斷,防止在判斷之後和條件變數進入等待狀態之前,其它執行緒修改條件而導致判斷失效,以及對目標資料邏輯的序列化執行(也就是同步)。

while (wait_condition) {
    pthread_cond_wait(cond_variable, mutex);
}

細看這段程式碼,發現喚醒後(也就是在條件變數退出等待和重新佔用互斥鎖之後),還要再次執行條件表示式的判斷。這是因為喚醒之後等待的條件可能會被其它執行緒變更,為了安全起見需要重新檢查條件,如果等待的條件為真,就再次進入等待狀態直到下次被喚醒。

等待

條件變數等待的方式有兩種,一種是持續等待,直到被喚醒

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

如果一直處於等待狀態,如何退出?

pthread_cond_wait 提供了執行緒取消的功能,可通過 pthread_cancel 退出指定執行緒。

另一種是條件變數進入等待後,開始計時,在計時結束後仍然未被喚醒則主動退出等待並返回錯誤資訊。

int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);

計時結束指的是到達指定時刻。

喚醒

關於喚醒同樣也有兩種方式,其一是,僅喚醒一個正在等待的條件變數

int pthread_cond_signal(pthread_cond_t *cond);

如果有多個正在等待的條件變數,那麼最終被喚醒的執行緒由系統排程策略確定。

其二是,喚醒所有正在等待的條件變數,這種方式非必要不使用,因為會對系統帶來不必要的執行消耗,被稱為驚群效應

int pthread_cond_broadcast(pthread_cond_t *cond);

希望以上的內容對你有所幫助,也歡迎聯絡我一起探討