【C++工程實踐】條件變量

一、linux條件變量簡介

先看看linux下條件變量的api:linux

1 #include <pthread.h> 
2 int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
3 int pthread_cond_signal(pthread_cond_t *cond);

pthread_cond_wait:These functions atomically release mutex and cause the calling thread to block on the condition variable cond.git

pthread_cond_signal : call unblocks at least one of the threads that are blocked on the specified condition variable cond (if any threads are blocked on cond).github

這裏包括了一個解鎖的操做,會引入一個疑問,爲何wait裏須要互斥器mutex?c#

 

二、linux鎖的使用

對於自旋鎖,至關於一直嘗試獲取鎖:api

while (lock(mutex) == false) {
}
// do something

若是其餘thread一直持有該鎖,會致使本線程一直while搶鎖,浪費CPU作無用功.app

理論上不用一直判斷lock,只須要在lock失敗後判斷mutex是否有變化便可。搶鎖失敗後只要鎖的持有狀態一直沒有改變,那就讓出 CPU 給別的線程先執行好了。ide

這就是互斥鎖:函數

while (lock(mutex) == false) {
  thread sleep untile lock state change  
}

操做系統負責線程調度,爲了實現鎖的狀態發生改變時再喚醒,mutex_lock sleep須要操做系統處理,所以pthread_mutex_lock涉及上下文切換,開銷比較大。ui

自旋鎖和互斥鎖都是保證可以排它地訪問被鎖保護的資源。this

 

三、條件變量分析

不少狀況下,咱們並不須要徹底排他性的佔有某些資源,以生產者消費者爲例:

生產者向Queue中添加元素,消費者從Queue中消費元素,使用互斥鎖mutex用於生產者/消費者Queue同步:

lock(mutex); // mutex 保護對 queue 的操做
while (queue.isEmpty()) { // 隊列爲空時等待
    unlock(mutex);
    // wait, 這裏讓出鎖,讓生產者有機會往 queue 裏安放數據
    lock(mutex);
}
data = queue.pop(); // 至此確定非空,因此能對資源進行操做
unlock(mutex);
consume(data); // 在臨界區外作其它處理

這裏的while,至關於又搞出了一個自旋鎖,一直等待queue非空。

有了前面自旋鎖、互斥器的經驗就不難想到:「只要條件沒有發生改變,while 裏就沒有必要再去解鎖、 判斷、條件不成立、加鎖,徹底可讓出 CPU 給別的線程」。不過因爲「條件是否達成」屬於業務邏輯, 操做系統無法管理,須要讓可以做出這一改變的代碼來手動「通知」,好比上面的例子裏就須要在生產者 往 queue 裏 push 後「通知」!queue.isEmpty() 成立。

所以咱們但願把while改爲這種形式:

while (queue.isEmpty()) {
    解鎖後等待通知喚醒再加鎖(用來收發通知的東西, lock);
}

而通知機制則爲:

觸發通知(用來收發通知的東西);
// 通常有兩種方式:
// 通知全部在等待的(notifyAll / broadcast)
// 通知一個在等待的(notifyOne / signal)

這就是條件變量,它解決的不是互斥,而是等待。

上述的解鎖後等待通知再加鎖,就是

pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

換句話說,pthread_cond_wait本質上包含了三個操做:

    pthread_mutex_unlock(mtx);
    pthread_cond_just_wait(cv);
    pthread_mutex_lock(mtx);

上面三行代碼的並非pthread_cond_wait(cv, mtx)的內聯展開。其中第一行和第二行必須「原子化」,而第三行是能夠分離出去的。那麼爲何第一行和第二行不能分離呢?這是由於必須得保證:

若是線程A先進入wait函數(即便沒有進入實際的等待狀態,好比正在釋放mtx),那麼必須得保證其餘線程在其以後調用的broadcast必須可以將線程A喚醒。

四、條件變量使用總結

條件變量只有一種正確使用的方式,幾乎不可能用錯。對於 wait 端:
1. 必須與 mutex 一塊兒使用,該布爾表達式的讀寫需受此 mutex 保護。
2. 在 mutex 已上鎖的時候才能調用 wait()。
3. 把判斷布爾條件和 wait() 放到 while 循環中。

對於 signal/broadcast 端:
1. 不必定要在 mutex 已上鎖的狀況下調用 signal (理論上)。
2. 在 signal 以前通常要修改布爾表達式。
3. 修改布爾表達式一般要用 mutex 保護(至少用做 full memory barrier)。
4. 注意區分 signal 與 broadcast:「broadcast 一般用於代表狀態變化,signal 一般用於表示資源可用。(broadcast should generally be used to indicate state change rather than resource availability。)」

若是用條件變量來實現一個「事件等待器/Waiter」,正確的作法是怎樣的?個人最終答案見 WaiterInMuduo class。「事件等待器」的一種用途是程序啓動時等待初始化完成,也能夠直接用 muduo::CountDownLatch 到達相同的目的,將初值設爲 1 便可。

只要記住 Pthread 的條件變量是邊沿觸發(edge trigger),即 signal()/broadcast() 只會喚醒已經等在 wait() 上的線程(s),咱們在編碼時必需要考慮 signal() 早於 wait() 的可能,那麼就很容易判斷如下各個版本的正誤了

總結: 使用條件變量,調用 signal() 的時候沒法知道是否已經有線程等待在 wait() 上。所以通常老是要先修改「條件」,使其爲 true,再調用 signal();這樣 wait 線程先檢查「條件」,只有當條件不成立時纔去 wait(),避免了丟事件的可能。換言之,經過使用「條件」,將邊沿觸發(edge trigger)改成電平觸發(level trigger)。這裏「修改條件」和「檢查條件」都必須在 mutex 保護下進行,並且這個 mutex 必須用於配合 wait()。

tips: 

spurious wakeup?Wikipedia中是這樣說的:

Spurious wakeup describes a complication in the use of condition variables as provided by certain multithreading APIs such as POSIX Threads and the Windows API. Even after a condition variable appears to have been signaled from a waiting thread's point of view, the condition that was awaited may still be false. One of the reasons for this is a spurious wakeup; that is, a thread might be awoken from its waiting state even though no thread signaled the condition variable.

spurious wakeup 指的是一次 signal() 調用喚醒兩個或以上 wait()ing 的線程,或者沒有調用 signal() 卻有線程從 wait() 返回,虛假喚醒。

APUE上這樣說:

POSIX規範爲了簡化實現,容許pthread_cond_signal在實現的時候能夠喚醒不止一個線程。

在發生的spurious wakeup時候,waiting線程被意外的喚醒,而後到真正signal的時候,waiting線程在以前已經spurious wakeup喚醒了。

 

五、條件變量原理

https://www.zhihu.com/question/24116967
 
做者:馬牛
連接:https://www.zhihu.com/question/24116967/answer/26848581
來源:知乎
著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。

有幾篇30多年前的論文極大地影響了現代操做系統中進程/線程的同步機制的實現,尤爲是樓主問題中的實現。一篇是 Monitors: An Operating System Structuring Concept ( ),還有一篇是 Experience with Processes and Monitors in Mesa ()。另外,還有討論semaphore,生產者/消費者問題,哲學家就餐問題等等的論文。

這裏,我先介紹這兩篇論文的內容,而後引出問題的答案。

第一篇是Tony Hoare寫的,一般被叫作Hoare's Monitor。你可能不知道Tony Hoare是誰,可是你應該知道他發明的quicksort。你也應該知道他得過的那個圖靈獎。Monitor是一種用來同步對資源的訪問的機制。Monitor裏有一個或多個過程(procedure),在一個時刻只能有一個過程被激活(active)。讓我來給個例子:
MONITOR account { int balance; //initial value is 0; procedure add_one() { balance++ } procedure remove_one() { balance-- } } 

若是這是一個monitor,add_one()和remove_one()中只能有一箇中被激活的。也就是說,balance++和balance--不可能同時執行,沒有race。

正如論文的標題所說的,monitor只是一個概念,他能夠被幾乎任何現代的語言實現。若是咱們要用C++來實現Monitor,僞代碼差很少就是這樣(Java的Synchronization是Monitor的一個實現):
class Account { private: int balance; //initial value is 0; lock mutex; public: void add_one() { pthread_mutex_lock(&mutex); balance++; pthread_mutex_unlock(&mutex); } void remove_one() { pthread_mutex_lock(&mutex); balance-- pthread_mutex_unlock(&mutex); } }; 

論文中也有條件變量(conditional variable),使用形式是cond_var.wait()和cond_var.signal()。讓咱們來看一下論文裏最簡單的一個例子。這是同步對單個資源訪問的monitor。原文中的代碼(好像)是用Pascal寫的,我這裏有C-style重寫了一下。
//注意這是一個monitor,這裏的acquire()和release()不能也不會同時active。 Monitor SingleResource { private: bool busy; condition_variable nonbusy; public: void acquire() { if ( busy == true ) { nonbusy.wait() } busy = true } void release() { busy = false; nonbusy.signal() } busy = false; //initial value of busy }; 
須要特別注意,這是一個monitor(不是class)。其中的acquire()和release()不能也不會同時active。咱們注意到,這裏的nonbusy.wait()並無使用lock做爲參數。可是,Hoare實際上是假設有的。只是在論文中,他把這個lock叫作monitor invariant。論文中,Hoare解釋了爲何要在conditional wait時用到這個值(也就解釋了樓主的問題)。我原文引用一下:
Since other programs may invoke a monitor procedure during a wait, a waiting program must ensure that the invariant t for the monitor is true beforehand.

換句話說,當線程A等待時,爲了確保其餘線程能夠調用monitor的procedure,線程A在等待前,必須釋放鎖。例如,在使用monitor SingleResource時,線程A調用acquire()並等待。線程A必須在實際睡眠前釋放鎖,要否則,即便線程A已經不active了,線程B也無法調用acquire()。(固然,你也能夠不釋放鎖,另外一個線程根本不檢查鎖的狀態,而後進入對條件變量的等待!! 可是,首先,這已經不是一個monitor了,其次,看下文。)

pthread只是提供了一種同步的機制,你可使用這種機制來作任何事情。有的對,有的錯。Hoare在論文裏的一段話也說更能解答樓主的問題:
The introduction of condition variables makes it possible to write monitors subject to the risk of deadly embrace [7]. It is the responsibility of the programmer to avoid this risk, together with other scheduling disasters (thrashing, indefinitely repeated overtaking, etc. [11]).

有興趣的同窗能夠讀讀這篇文章,文中有一節專門解釋了樓主的問題。樓主的問題顯然是很深入的。




另外,爲何上面的代碼裏acquire()的實現使用的是:
if ( busy == true ) { 
而不是:
while ( busy == true ) { 

?
這是由於,在Hoare的假設裏,當線程A調用nonbusy.signal()以後,線程A必須當即中止執行,正在等待的線程B必須緊接着當即開始執行。這樣,就能夠確保線程B開始執行時 busy==false。這正是咱們想要的。

可是,在現代的系統中,這個假設並不成立。現代操做系統中的機制跟Mesa中描述的一致:在condvar.signal()被調用以後,正在等待的線程並不須要當即開始執行。等待線程能夠在任何方便的時候恢復執行(優勢之一:這樣就把同步機制和調度機制分開了)。

在Mesa的假設下,上面的Monitor SingleResource的代碼是錯的。試想下面的執行過程:1. 線程A調用acquire()並等待,2. 線程B調用release(),2.線程C調用acquire(),如今 busy=true,3. 線程A恢復執行,可是此時busy已是true了! 這就會致使線程A和線程C同時使用被這個monitor保護的資源!!!
void acquire() { if ( busy == true ) { nonbusy.wait() } //assert(busy != true) busy = true } 

在Mesa中,Butler Lampson和David Redell提出了一個簡單的解決方案-把 if 改爲 while。這樣的話,在線程A恢復執行時,還要再檢查一下busy的值。若是還不是想要的,就會再次等待。

相關文章
相關標籤/搜索