執行緒同步 14:條件變量

前篇使用信號量來作為執行緒休眠喚醒的手段,相比於直接的輪詢或手動操作中斷信號來說已經簡單高效許多, 然而在使用上還是存在一些些複雜棘手之難處。 那麼本篇就來看看另一個可能更加方便的工具:條件變量。

本篇要介紹的「條件變量」(Condition Variable)由於部份機制是由前篇相關的弱點增強而來的, 因此在繼續介紹條件變量之前,還是先回顧一下有關信號量使用上的一些棘手問題。 在前篇提到,信號量實際使用上容易不小心出狀況的陷阱有二: 從信號量操作到上鎖執行緒之間存在空檔, 以及信號量的數值和實際的資源數量可能會因為不夠嚴謹的邏輯設計而導致落差。

既然我自己操作這兩者容易不小心出點差錯,那如果它能直接一次都幫我做了呢?啟不快哉? 這就是為什麼條件變量的 wait() 函式會需要傳入一個互斥子的原因, 就是能讓條件變量操作完成的同時還幫你上鎖互斥子。 至於變量不匹配的問題?這裡給出的解答是:既然我記了一份數字,你有一份數字,然後兩者可能不一致, 那乾脆我不記了唄,都依你實際的情況為準不就不會出錯了?! 是的,條件變量裡雖然有「條件」兩個字,但其實弔詭的是它實際上並沒有條件, 而「條件」其實是使用它的人要自己處理判斷的; 甚至於,如果實際的軟體應用中所需要的「條件」過於複雜,遠不是一個整數數值足以表達的時候該怎麼辦呢? 於是條件變量給出的解答就是丟還給你自己判斷處理最好了! 也就是說,條件變量相比於信號量它已完全退去了任何的數值管理功能,也不管什麼條件不條件的, 單純就是提供一個「休眠喚醒」的服務。

條件變量的基本操作就兩個(暫先不管其它的擴展):睡眠、以及喚醒。 想要等待事件的執行緒使用條件變量呼叫 wait() 函式後就會進入等待, 並且當然可以多條執行緒都呼叫 wait() 進入等待; 而另一條執行緒則可以呼叫條件變量的 signal() 來叫醒等待中的其中一條執行緒 (至於叫醒哪一個則由演算法自己決定), 或者也可以呼叫 broadcast() 來一次喚醒所有正在等待同一個條件變量的全部執行緒。 其中比較特別的是在呼叫 wait() 時需要傳入一個互斥子給它,並且這個互斥子需要是已經上鎖完成的狀態。 wait() 在被呼叫的時候接收的是一個已經上鎖的互斥子,而它在返回的時候還回來的也同樣會是已上鎖的狀態; 它在運作的過程會因為需要而將互斥子解鎖,但是還給你之前必定會重新上鎖。 條件變量的基本操作邏輯就是這樣,也許初次接觸的人會有些疑惑,這部份等下面範例講解完之後再回來探討。

範例

解釋完條件變量的使用邏輯之後,讓我們以實際的範例來看看條件變量的使用方式。 這裡仍然繼續拿前篇的測試範例來用,並把其中的信號量改為使用條件變量。 首先當然就是要修改 Deliverer 類別,將原本的信號量代換為條件變量:

class Deliverer
{
public:
    // 用來保護下面那個訊息貯列的互斥子
    pthread_mutex_t msg_mutex;

    // 將產生的、或處理過的訊息放在這裡,讓其他人稍候可以取走。
    list<Message> msg_list;

    // 用來等待與通知訊息的條件變量
    pthread_cond_t msg_cond;
};

然後就是修改將訊息存入貯列的地方,在存入一筆訊息的時候順便通知喚醒可能正在等候的其它執行緒。 可以看到這部份的動作非常單純,從中斷信號以來,再到信號量,一直到本次使用的條件變量, 在這部份的操作幾乎都是一模一樣的,差別基本就只是改換呼叫不同的函式而已。 在實際的其它用途中可能正在等待的執行緒不只一個(當然也可能剛好沒有), 但是這裡一次只存入一筆訊息,也只夠一個執行緒索取使用, 因此這裡沒有呼叫 broadcast() 喚醒等候的全部執行緒, 而是呼叫 signal() 讓條件變量的演算法挑選一個執行緒喚醒:

static
void PushMessage(Deliverer *data, const Message &msg)
{
    /*
     * 將訊息存入訊息貯列
     */

    pthread_mutex_lock(&data->msg_mutex);

    data->msg_list.push_back(msg);

    // 通知任何可能正在等待新訊息的執行緒可以醒過來了
    pthread_cond_signal(&data->msg_cond);

    pthread_mutex_unlock(&data->msg_mutex);
}

放在最後修改的,也是條件變量的使用上最為複雜的那部份,就是在索取訊息的整個過程加上等待訊息的操作了。 這裡我並沒有使用前篇原始的 PopMessage() 來修改, 而是拿了前篇將訊息等待與操作整合在一起的 WaitPopMessage() 來修改; 此外還能注意到,我並沒有呼叫使用最單純的 wait() 函式, 而是與前篇一樣改用了它功能擴展的 timedwait() 函式:

static
bool WaitPopMessage(
    Deliverer *data, Message &msg, unsigned timeout_ms)
{
    /*
     * 嘗試從訊息貯列中取出一筆訊息
     */

    pthread_mutex_lock(&data->msg_mutex);

    struct timespec ts;
    ts.tv_sec = time(nullptr) + timeout_ms / 1000;
    ts.tv_nsec = timeout_ms % 1000 * 1000 * 1000;

    // 若貯列非空的話就嘗試等待看能不能等到訊息?
    // 由於存在「虛假喚醒」的可能性,
    // 因此醒過來的時候得檢查是否需要再次入睡?
    while( data->msg_list.empty() &&
        0 == pthread_cond_timedwait(&data->msg_cond, &data->msg_mutex, &ts) )
    {
    }

    // 檢查與等待結束,依照正常流程嘗試從貯列取出訊息。
    bool have_msg = !data->msg_list.empty();
    if(have_msg)
    {
        msg = data->msg_list.front();
        data->msg_list.pop_front();
    }

    pthread_mutex_unlock(&data->msg_mutex);

    return have_msg;
}

對於上面的程式嗎,我想若是初次接觸條件變量的讀者,或許看到 while 迴圈的那個部份可能會疑惑不解。 那剛好也是條件變量在使用上較為複雜的一部份,下面很快會深入探討, 若讀者一時理解不了那一段寫法的話,可以暫時將眼睛矇起來假裝沒看見 while 其實也無妨。

上面修改的完整程式碼可以 從這裡下載, 其在我電腦上所執行的結果節錄如下。 從結果來看,表現基本和前篇的測試結果差不多!

...... 前面省略

Factory: Generate "message-9"
Reporter: Got message, txt="message-9", counter=1001, delay=12
Factory: Generate "message-10"
Reporter: Got message, txt="message-10", counter=1001, delay=8
Factory: Generate "message-11"
Reporter: Got message, txt="message-11", counter=1001, delay=12
Factory: Generate "message-12"
Reporter: Got message, txt="message-12", counter=1001, delay=8
Factory: Generate "message-13"
Reporter: Got message, txt="message-13", counter=1001, delay=4
Factory: Generate "message-14"
Reporter: Got message, txt="message-14", counter=1001, delay=8

...... 後面省略

使用方式

為了更加容易理解明白, 這裡將上面的 WaitPopMessage() 函式中與條件變量的操作無關的部份屏蔽, 並將相關的操作改寫為對閱讀更加友好的虛擬碼,如下:

bool WaitPopMessage(Message &msg, unsigned timeout)
{
    mutex.Lock();

    /* ...... 這邊省略一些操作 ...... */

    // 這是與條件變量有關的相關操作
    if(msg_list.empty())
    {
        cond.TimedWait(&mutex, timeout);
    }

    /* ...... 這邊省略一些操作 ...... */

    mutex.Unlock();

    return have_msg;
}

說實在的,我在剛開始學習條件變量的時候就被繞的很暈! 我不明白條件變量為什麼不能單單純純的讓我使用?為什麼必需要綁一個互斥子? 更不明白為什麼互斥子必須要是已經上鎖的狀態,然後呼叫條件變量函式之後它再立刻把互斥子給解鎖? 這樣上鎖再解鎖豈不是無端增加負擔嗎? 於是那個時候我其實更加喜歡使用更單純的信號量, 然後在漫漫的使用經驗裡,逐漸從專案實做中明白條件變量為什麼要這樣設計的用意。

在上面的虛擬碼範例中,條件變量的操作是放在互斥子的臨界區段範圍內執行的, 而不是像信號量那樣放在臨界區段外,在上鎖互斥子之前執行。 而也是因為條件變量的設計是在臨界區段內使用,因此才需要傳入給它那個互斥子, 並且在它工作的時候偷偷把互斥子放掉,返回前才把它上鎖回來, 否則若不是這樣的話,可能會讓呼叫喚醒條件變量的另一條執行緒因為無法進入臨界區段, 而永遠沒機會處理相關工作並呼叫喚醒我們。

回到範例碼,程式在檢查訊息貯列為空的時候就呼叫條件變量進入等待狀態; 而這個貯列是否為空的條件,就是我們這次使用條件變量時所指的這個「條件」。 如同前面所述,條件變量其實並不包含這個條件的檢查,而這個條件則是我們需要自己判斷處理的。 這個實際的條件到底是什麼東西會因為實際的應用不同而有所不同, 在有些這個條件可能會相當複雜,在有些地方可能會相當簡單,各不相同; 而在本篇的範例,訊息貯列是否為空的狀態就是我們的「條件」。

當進入 WaitPopMessage() 函式內的時候,如果條件符合需求(也就是貯列非空), 那麼根本也不會呼叫條件變量的功能,而是會直接略過那部份並繼續往下執行, 也就是說若無必要的話則根本也不需經歷條件變量的操作。 這點就與信號量的使用不同,在使用信號量的情況下,就算貯列非空也不能忽略操作信號量, 因為至少也需要把信號量的數值減一。 除此之外因為條件變量並不像信號量那樣去管理一個計數, 而只是單純的進入等待,並等待被其他人喚醒。 那麼有沒有可能在檢查到條件不滿足(也就是貯列為空), 到實際操作信號量並真正開始進入休眠之間的空檔, 正好趕上另一條執行緒推送一個訊息並發出喚醒呼叫, 導致我們沒有來得及接到喚醒的訊息,而進入長久的等待呢? 正常來說是不會需要擔心這件事情的! 因為存入訊息的執行緒也需要先上鎖了互斥子之後才能夠進行這樣的操作, 然而在我們這邊的信號量實際進入休眠等待之前,互斥子還被我們給拿在手上呢!

以上這就是為什麼信號量的操作必須要綁定一個相關的互斥子的原因。 透過與關聯互斥子之間的協同操作,條件變量在維持機制單純不複雜的同時還巧妙的保障了狀態邏輯; 雖然這讓條件變量的相關操作函式介面看上去比其它執行緒同步工具更加複雜些, 但是在實際的應用中,它反而簡化了使用者的操作流程與邏輯。

虛假喚醒

說到條件變量,就不得不說它的一個問題。 這個問題就完美的理論設計來說也許是不應該出現的, 然而在現實世界的妥協之下導致這些問題它有可能會發生; 並且更加好玩的是這個問題甚至已經被習以為常了,以至於大部份人並不會把它當作問題來對待, 而是看作一個會合理發生的正常「現象」。 而這個問題,就是「虛假喚醒」(Spurious Wakeup)。

正常來說當我們呼叫了條件變量的 Wait() (或這函式的其它衍生變形), 那麼執行緒就會進入休眠等待,直到任何人呼叫了這個條件變量的 Signal() 把它給叫醒; 然而有得時候,Wait() 呼叫的睡眠會在非正常的情況下醒過來,而這就是虛假喚醒的情況! 更正確點的說法,Wait() 在結束休眠返回的時候一定要是「條件」符合的, 而如果 Wait() 醒過來的時候那個「條件」並沒有在預期符合的狀態,那麼這個甦醒就被稱為虛假喚醒。 至於為什麼會發生虛假喚醒呢?主要原因可能有下面幾點:

  1. 中斷信號

    在中斷信號的那篇解釋過,許多的系統呼叫能被中斷信號給打斷且系統不能復原, 而條件變量操作中的休眠就是其中一種。 在單純的範例程式裡面可能並沒有多少機會發生這樣的事情, 然而現實的實際軟體多半並不會只做這麼單純的工作, 現實的軟體程式往往工作內容可能相當多樣複雜,可能還呼叫許多第三方程式褲,或與第三方行程協作等等, 只要什麼東西向我們的行程發送了一個中斷信號,就可能會剛好打斷我們條件變量的休眠,導致醒過來。

  2. 互斥子爭奪

    條件變量的操作中雖然會借我們的互斥子拿去用,並且會在返回前幫我們重新上鎖回來, 但是在被喚醒到鎖上互斥子之間的空檔仍然存在,仍然有機會被其它執行緒給搶先截胡。

    以本篇的範例程式的結構為例,加設我們可能有很多執行緒會向同一個貯列索取訊息 (本篇的範例中每一個貯列只會有一個索取者,但在這裡我們假設把這段程式碼放到現實的程式裡被別人使用), 那麼一條執行緒呼叫 WaitPopMessage() 發現貯列為空,於是呼叫了條件變量函式進入等待。 然後終於等到了某人存入一筆訊息到貯列,並呼叫了 Signal() 通知我們甦醒; 然而就在這期間,也就是從那條執行緒呼叫喚醒並解鎖互斥子,到我們醒過來並上鎖互斥子之間的空檔, 剛好遇上某另一條執行緒正好也呼叫了 WaitPopMessage() 並搶先上鎖互斥子。 那麼結果可以想見,那條率先搶到互斥子的執行緒會先把訊息給取走然後拍拍屁股走人, 當我們的執行緒終於等到互斥子上鎖時,貯列狀態已經是空的了! 這也就導致讓我們遇上了一次虛假喚醒。

  3. 廣播喚醒

    還有一種虛假喚醒的情況其實並不是出自於什麼異常情況, 而是我們呼叫了條件變量操作函式中的廣播 Broadcast() 來一次喚醒所有正在等待中的執行緒。 那麼這種情況下,就可能會發生部份執行緒搶到並取走了有限的資源, 而其它醒過來但搶不到東西的執行緒自然就變成虛假喚醒了!

  4. 作業系統最佳化設計

    有的時候當行程收到信號時,會有不只一條執行緒被喚醒, 這個情況主要是因為作業系統相關設計最佳化的緣故。 (至於實際是什麼樣的最佳化設計如何導致此現象? 因為我也不是作業系統專家,這個問題目前還解答不了。) 雖然說作業系統相關設計其實也不是沒有辦法做的更嚴謹保證, 但是會需要付出一定程度的效能下降為代價; 然後再回頭看看,反正就算作業系統這邊顧完美了,前面還有其它好多狀況都同樣會導致虛假喚醒呢! 那反正怎麼做也不能完全避免虛假喚醒,不如就讓效能更好一點吧!

既然條件變量的等待有機會會在條件其實沒達成的時候醒過來,那我們該怎麼辦呢? 答案就是在每次醒過來的時候都要檢查一下條件是否滿足,若條件不滿足的話就再重新睡一次得了! 是的,就是這麼簡單! 而這也是為什麼前面的範例程式會使用迴圈來循環執行條件變量的操作呼叫的原因。 若將上面的虛擬碼示例對應虛假喚醒的情況做改寫,則會變成下面這樣:

bool WaitPopMessage(Message &msg, unsigned timeout)
{
    mutex.Lock();

    /* ...... 這邊省略一些操作 ...... */

    while( msg_list.empty() &&
        cond.TimedWait(&mutex, timeout).NotExpired() )
    {
    }

    /* ...... 這邊省略一些操作 ...... */

    mutex.Unlock();

    return have_msg;
}

理論上只需要檢查若條件沒有達到就不斷重新呼叫 Wait() 就好了, 但是本篇的範例因為使用了有超時條件的 TimedWait(),於是檢查的條件就多了一個。 除了檢查貯列是否為空之外,還要檢查 TimedWait() 的返回是不是因為超時的緣故? 如果沒有發生超時的話才重複呼叫 TimedWait() 繼續等待。 而這裡就體現出了 TimedWait() 的超時設定之所以使用絕對時間的其中一個好處: 我們不需要重新計算休眠時間,只需要使用原來的時間數字傳進去再睡一次就好了; 而如果函式介面使用的是相對時間的話,那麼這時就必須要先計算剛才已經休眠了多少時間, 然後接下來應該再休眠多少時間,然後才能使用新的超時時間設定再次呼叫 TimedWait()

說到這裡,你會不會覺得什麼地方有點奇怪呢? 上面提到虛假喚醒的這些原因,其實在信號量、以及一些其它的同步工具上都可能會發生的, 但是為什麼好像只有條件變量相關的話題才會有人探討虛假喚醒相關的內容呢? 我想其中最關鍵的地方應該就在於條件變量它本身其實並不包含也不能處理「條件」這件事了! 不像信號量它自身還有個數值,互斥子它自身還有個旗標; 而條件變量的條件其實是由使用者自己去檢查處理的,它自身並沒有這部份的任何參考訊息, 使得當被喚醒的時候它自己會有些時候根本也無法知道是不是一個應該的喚醒。

總結

本篇介紹了條件變量的使用方式,也介紹了在使用條件變量的時候應該小心注意的一些常見問題。 條件變量的設計雖看似較為複雜,但實際使用上反而通常能讓我們的使用邏輯變得更簡單清晰些, 因此條件變量成為執行緒設計當中相當常被使用的同步工具。

上一篇:「執行緒同步 13:信號量」
下一篇:「執行緒同步 15:Select」