條件變量的陷阱與思考


前言

在多線程編程中,互斥鎖與條件變量恐怕是最常用也是最實用的線程同步原語。

關於條件變量一共也就pthread_cond_init、pthread_cond_destroy、pthread_cond_wait、pthread_cond_timedwait、pthread_cond_signal、pthread_cond_broadcast這么幾個函數,但是在實際使用中卻是很容易用錯,后文將來分析幾種常見使用情況的正確性。

分析

下面是一個輔助基類、便於減少篇幅(為了簡單起見,后文中的所有函數調用並未檢查返回的錯誤情況):

class ConditionBase
{
public:
    ConditionBase()
    {
        pthread_mutex_init(&mutex_, NULL);
        pthread_cond_init(&cond_, NULL);
    }

    ~ConditionBase()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&cond_);
    }

private:
    pthread_mutex_t  mutex_;
    pthread_cond_t   cond_;
};

版本一

class Condition1 : public ConditionBase
{
public:
    void wait()
    {
        pthread_mutex_lock(&mutex_);
        pthread_cond_wait(&cond_, &mutex_);
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        pthread_cond_signal(&cond_);
    }
};

錯誤,這種情況有可能丟失事件。當signal發生在wait之前,就會丟失這次signal事件。如下圖

版本二

class Condition2 : public ConditionBase
{
public:
    void wait()
    {
        pthread_mutex_lock(&mutex_);
        pthread_cond_wait(&cond_, &mutex_);
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        pthread_mutex_lock(&mutex_);
        pthread_cond_signal(&cond_);
        pthread_mutex_unlock(&mutex_);
    }
};

錯誤,同情況一一樣有可能丟失事件。當signal事件發生在wait之前就會丟失signal事件。如下圖

版本

class Condition3 : public ConditionBase
{
public:
    Condition3()
        : signal_(false)
    {
    }

    void wait()
    {
        pthread_mutex_lock(&mutex_);
        if (!signal_)
        {
            pthread_cond_wait(&cond_, &mutex_);
        }
        signal_ = false;
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        pthread_mutex_lock(&mutex_);
        signal_ = true;
        pthread_cond_signal(&cond_);
        pthread_mutex_unlock(&mutex_);
    }

private:
    bool signal_;
};

錯誤。引入了bool變量來檢查狀態,但是遇到spurious wakeup仍然會發生錯誤。

什么是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.

也就是說一次signal調用喚醒了2個或者2個以上的waiting中的線程,這種現象就是spurious wakeup,虛假喚醒。

APUE上這樣說:

POSIX規范為了簡化實現,允許pthread_cond_signal在實現的時候可以喚醒不止一個線程。

在發生的spurious wakeup時候,waiting線程被意外的喚醒,然后到真正signal的時候,waiting線程在之前已經spurious wakeup喚醒了,這樣就會造成不易debug的錯誤。如下圖

版本四

class Condition4 : public ConditionBase
{
public:
    Condition4()
        : signal_(false)
    {
    }

    void wait()
    {
        pthread_mutex_lock(&mutex_);
        while (!signal_)
        {
            pthread_cond_wait(&cond_, &mutex_);
        }
        signal_ = false;
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        pthread_mutex_lock(&mutex_);
        signal_ = true;
        pthread_cond_signal(&cond_);
        pthread_mutex_unlock(&mutex_);
    }

private:
    bool signal_;
};

正確。這個是推薦用法,APUE, UNP,man手冊中都是這種用法,在wait上用while循環而不是if就可以正確處理spurious wakeup情況了。當發生spurious wakeup時,wait被意料之外的喚醒,但是循環條件並沒有改變,於是循環繼續執行pthread_cond_wait,然后繼續進入wait,等待被正確的喚醒。

版本五

class Condition5 : public ConditionBase
{
public:
    Condition5()
        : signal_(false)
    {
    }

    void wait()
    {
        pthread_mutex_lock(&mutex_);
        while (!signal_)
        {
            pthread_cond_wait(&cond_, &mutex_);
        }
        signal_ = false;
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        pthread_mutex_lock(&mutex_);
        signal_ = true;
        pthread_mutex_unlock(&mutex_);
        pthread_cond_signal(&cond_);
    }

private:
    bool signal_;
};

正確。版本五與版本四的唯一區別就是在喚醒的時候先解鎖再調用signal發起喚醒。這么做不會有錯誤,但是可能有較大幾率會使線程調度不在最理想狀態,例如在wakeup調用中的解鎖以后,調用signal以前,系統調度發生線程切換,使得signal沒有在第一時間被發出。

這是在stackoverflow的一個帖子中的說法:http://stackoverflow.com/questions/4544234/calling-pthread-cond-signal-without-locking-mutex

Note that you can actually move the pthread_cond_signal() itself after the pthread_mutex_unlock(), but this can result in less optimal scheduling of threads, and you've necessarily locked the mutex already in this code path due to changing the condition itself.

版本六

class Condition6 : public ConditionBase
{
public:
    Condition6()
        : signal_(false)
    {
    }

    void wait()
    {
        pthread_mutex_lock(&mutex_);
        while (!signal_)
        {
            pthread_cond_wait(&cond_, &mutex_);
        }
        signal_ = false;
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        pthread_mutex_lock(&mutex_);
        pthread_cond_signal(&cond_);
        signal_ = true;
        pthread_mutex_unlock(&mutex_);
    }

private:
    bool signal_;
};

正確,版本六與版本四的區別狀態的改變和發起signal喚醒信號的順序互換了,由於整個wakeup過程都在mutex的包含之下,所以並沒有影響。但是個人更推薦版本四,因為更符合邏輯,不然APUE和UNP也不會都用版本四的寫法順序了:)

版本七

class Condition7 : public ConditionBase
{
public:
    Condition7()
        : signal_(false)
    {
    }

    void wait()
    {
        pthread_mutex_lock(&mutex_);
        while (!signal_)
        {
            pthread_cond_wait(&cond_, &mutex_);
        }
        signal_ = false;
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        pthread_mutex_lock(&mutex_);
        signal_ = true;
        pthread_cond_broadcast(&cond_);
        pthread_mutex_unlock(&mutex_);
    }

private:
    bool signal_;
};

正確。與版本四的區別是這里用了broadcast,而不是signal。對於這種只有一個wait線程的時候,是沒有問題的。但是當有多個wait線程的時候,使用broadcast就把所有wait線程都喚醒了。

另外,當我們使用條件變量cond實現事件等待器的時候,就要用broadcast而不是signal了,因為當有多個事件掛起在wait調用上等待時,signal只能喚醒其中的一個等待線程,並且我們不能期待它喚醒具體的某一個線程,因為這個是不可控的。

版本八

class Condition8 : public ConditionBase
{
public:
    Condition8()
        : signal_(false)
    {
    }

    void wait()
    {
        pthread_mutex_lock(&mutex_);
        while (!signal_)
        {
            pthread_cond_wait(&cond_, &mutex_);
        }
        signal_ = false;
        pthread_mutex_unlock(&mutex_);
    }

    void wakeup()
    {
        signal_ = true;
        pthread_cond_signal(&cond_);
    }

private:
    bool signal_;
};

錯誤。存在data race,從而導致有可能丟失事件。當wakeup調用發生在wait調用中的進入while循環之后,調用pthread_cond_wait之前,就會丟失signal事件。如下圖

另外,在wait調用中,必須用一個mutex同時保護條件狀態和cond的pthread_cond_wait的調用,而不能用2個mutex,一個保護條件狀態,一個保護pthread_cond_wait,pthread_cond_signal的調用。

這樣仍然會出現race condition。比如先發生wait調用,保護條件的mutex加鎖、檢查條件、解鎖,然后切換線程調用wakeup發送signal信號,再切回wait線程,保護cond的mutex加鎖、進入pthread_cond_wait的waiting狀態中,從而丟失了之前的signal事件。

總結

使用條件變量,調用signal/broadcast的時候,無法知道是否已經有線程等在wait上了。因此,一般要先改變條件狀態,然后再發送signal/broadcast信號。然后在wait調用線程上先檢查條件狀態,只有當條件狀態為假的時候才進入pthread_cond_wait進行等待,從而防止丟失signal/broadcast事件。並且檢查條件、pthread_cond_wait,修改條件、signal/broadcast都要在同一個mutex的保護下進行。

參考文獻

  1. W.Richard Stevens. UNIX環境高級編程(第3版), 人民郵電出版社, 2014
  2. Wikipedia. Spurious_wakeup
  3. stackoverflow. Calling pthread_cond_signal without locking mutex

(完)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM