使用POSIX Threads進行多線程編程(三) ——條件變量

說明:

  1. 本文是翻譯自《MultiThreaded-Programming-With-POSIX》,作者Guy Kerens。
  2. 本文預計翻譯三章,主要涉及pthread基本知識互斥量(鎖)條件變量,一是因爲這已經能夠引導讀者入門,二是因爲本人在工作之餘翻譯,實在時間捉急。
  3. 翻譯:張小川,轉載請保留原作者

精緻同步——條件變量

如前所說的互斥量,他們允許簡單的同步——對資源的互斥訪問。然而,我們經常會需要在線程間作真正的同步:

  1. 在一個服務器,一個線程讀取用戶的請求,並調度幾個線程來處理。在有數據需要處理的時候,這些線程需要被告知,不然的話他們就應該處於等待狀態(不消耗CPU資源);
  2. 在一個GUI(Graphical User Interface,圖形用戶接口)應用中,一個線程讀取用戶輸入,一個線程處理圖形輸出,第三個線程向服務器發送請求並處理回覆。服務器處理線程需要在服務器回覆後通知畫圖線程,所以畫圖線程就可以即使顯示給用戶。用戶輸入線程需要一直等待用戶輸入,例如,取消當前服務器線程的長時間耗時操作。

所有這些例子都需要在條件變量間發送通知的能力,這就引入了條件變量。

什麼是條件變量

條件變量(condition variable)是一種允許線程等待(不消耗CPU)某些事件發生的機制。幾個線程可能會等待一個條件變量,直到其他線程告知(signals)這個條件變量(發送一個通知)。這時,等待這個條件變量線程中的一個被喚醒,並繼續活動。可以使用一個廣播(broadcast)方法喚醒所有等待該條件變量的線程。

需要注意的是,一個條件變量並不提供鎖。因此,在使用條件變量時總是需要互斥量來爲訪問這個條件變量提供必要的鎖。

創建和初始化條件變量

通過定義一個pthread_cond_t類型的變量來創建一個條件變量,並正確地初始化。初始化可以通過一個名爲PTHREAD_COND_INITIALIZER的宏或者使用pthread_cond_init()函數。第一種初始化方法示例如下:

pthread_cond_t got_request = PTHREAD_COND_INITIALIZER;

這句定義了一個名爲got_request的條件變量,並初始化。

注意:因爲PTHREAD_COND_INITIALIZER是一個結構體初始化值,它只能在一個條件變量聲明的時候用來初始化它,如果想要在運行時初始化它,就必須使用pthread_cond_init()函數。

通知(signaling)一個條件變量

我們可以使用pthread_cond_signal()函數來通知一個條件變量(只喚醒等待該條件變量所有線程中的一個),或者使用函數pthread_cond_broadcast()來通知一個條件變量(喚醒等待該條件變量的所有線程)。如下給出了使用通知的一個例子(假定got_request是一個已經正確初始化的條件變量):

int rc = pthread_cond_signal(&got_request);

使用廣播函數

int rc = pthread_cond_broadcast(&got_request);

上述兩個函數成功時返回值‘rc’爲0,不成功返回非零值。在失敗時,返回值表示了發生的錯誤(EINVAL表示輸入參數不是一個條件變量,ENOMEM表示系統內存耗盡)。

注意:通知操作的返回成功並不表示一定有線程被喚醒——可能是沒有線程等待這個條件變量,因此通知操作不做任何事(i.e. thesignal is lost)。並且該喚醒操作並不被記憶以備後用——如果在通知函數返回後,另一個線程開始等待這個條件變量,那麼就需要再來一個通知來喚醒這個線程。

等待一個條件變量

如果一個線程通知條件變量,其他的線程可能會等待這個通知。他們可能通過pthread_cond_wait()pthread_cond_timewait()兩個函數中的一個來進行等待。這兩個函數的參數都是一個條件變量和一個互斥量(在調用等待函數之前應該是鎖定狀態),解鎖互斥量,並等待直到接到條件變量的通知,並且掛起線程的執行。如果這個通知使得線程得到喚醒(看之前關於pthread_cond_signal()的討論),等待函數會使互斥量自動鎖定,然後等待函數返回。

兩個等待函數之間唯一的區別在於pthread_cond_timewait()允許程序員爲等待指定一個超時限制(timeout),在這個時間之後,函數會返回一個適當的錯誤值(ETIMEDOUT)來說明在超時之前條件變量沒有得到通知。而pthread_cond_wait()如果沒有通知的話則會無限期等待。

如下給出了使用這兩個函數的示例。假定got_request是一個已經正確初始化的條件變量,request_mutex是一個正確初始化的互斥量。首先,嘗試下pthread_cond_wait()函數:


/*first lock the mutex*/
int rc = pthread_mutex_lock(&request_mutex);

if( rc )
{
    //error
    perror("pthread_mutex_lock");
    pthread_exit(NULL);
}

/*mutex is now locked - wait on the condition variable*/
/*during the execution of pthread_cond_wait, the mutex is unlocked*/
rc = pthread_cond_wait(&got_request, &request_mutex);
if (rc == 0)
{
    /* we were awakened due to the cond. variable being signaled */
    /* The mutex is now locked again by pthread_cond_wait() */
    /* do your stuff... */
}

/* finally, unlock the mutex */
pthread_mutex_unlock(&request_mutex);

下面給出了一個使用函數pthread_cond_timewait()的函數:

#include<sys/time.h> /* struct timeval definition */
#include<unistd.h>   /* declaration of gettimeofday() */

struct timeval now; //time when we started waiting
struct timespec timeout; //time out value for the wait function
int done;               //are we done waiting

//first lock the mutex
int rc = pthread_mutex_lock(&a_mutex);
if(rc)
{
    //error
    perror("pthread_mutex_lock");
    pthread_exit(NULL);
}

//mutex is now locked
gettimeofday(&now);
//prepare timeout value
timeout.tv_sec = now.tv_sec + 5;
timeout.tv_nsec = now.tv_usec * 1000; /*timeval uses microseconds*/
                                      /*timespec uses nanoseconds*/
                                      /*1000 nanosecond = microsecond*/

// wait on the condition variable
// we use a loop, since the unix signal might stop the wait before the timeout
done = 0;

while(!done)
{
    //remember that pthread_cond_timewait() unlocks the mutex on entrance
    rc = pthread_cond_timewait(&got_request, &request_mutex, &timeout);

    switch(rc)
    {
        case 0: 
        {
            /* we were awakened due to the cond. variable being signaled */
            /* the mutex was now locked again by pthread_cond_timedwait. */
            /* do your stuff here... */

            done = 0;
            break;
        }
        case ETIMEOUT:
        {
            //our time is up
            done = 0;
            break;
        }
        default:            /* some error occurred (e.g. we got a Unix signal) */
            break;          /* break this switch, but re-do the while loop. */
    }
}

/* finally, unlock the mutex */
pthread_mutex_unlock(&request_mutex);

如上所示,時間等待版本要複雜得多,因此打包在某些函數內比較好,而非在每個必要爲之都重新編碼一遍。

注意:有兩個或者更多線程在等待的條件變量即使被通知了多次,線程中的一個仍然可能會一直等待不被喚醒。這是因爲在條件變量被通知時,我們並不保證哪一個等待的線程會被喚醒。可能被喚醒的線程很快又處於等待狀態,而在響應條件變量再次通知的時候再次被喚醒,這樣一直下去。被喚醒線程所處的狀態叫做“飢餓狀態”。如果這種現象會引起程序不良行爲的話那麼程序員應該保證這種現象不會發生。然而,在我們的服務器例子中,這個現象表明請求來的比較緩慢,因此我們有太多線程等待服務器請求了。這個例子中,這種現象是對的,它表明每個請求在其到來時都被及時處理。

注意2:當互斥量被廣播時(使用pthread_cond_broadcast),這並不意味着所有線程都同時開始跑。每個線程在從等待函數返回時都會嘗試鎖住這個條件變量,然後他們會一個一個地跑,每一個會鎖住互斥量,做該線程自己的工作,然後在其他線程有機會跑之前釋放鎖。

銷燬一個條件變量

在我們使用完條件變量後,我們應該銷燬它以釋放其所佔用的系統資源。這可以使用pthread_destroy()函數。這個函數工作的前提是沒有線程在等待這個條件變量了。如下展示了這個函數的用法,我們再次假定got_request是一個事先初始化的條件變量:

int rc = pthread_cond_destroy(&got_request);
if(rc == EBUSY)
{
    /* some thread is still waiting on this condition variable */
    /* handle this case here... */
}

如果仍有線程在等待這個條件變量怎麼辦?視情況而定,這有可能是這個變量使用不當,或者僅僅缺少適當的線程清理代碼。這種現象最好通知程序員,至少是在debug階段。它可能什麼也不會影響,但是也有可能影響巨大。

條件變量的實際條件

關於條件變量需要注意的是——他們在沒有與之相伴的實際的條件檢查時通常是無意義的。爲了講清楚這個,考慮之前介紹的服務器的例子。假設我們使用條件變量got_request來通知一個新的請求來到需要處理,這個請求被保存到一個請求隊列裏。如果我們有線程在等待這個條件變量,那麼肯定會有一個線程被喚醒來處理這個請求。

然而,如果在一個新的請求到來時所有線程都正忙於處理之前的請求怎麼辦呢?條件變量的通知什麼也不會做(因爲所有線程都正忙於其他事,沒有在等待這個條件變量),在所有線程處理完當前的請求後,他們返回等待條件變量的狀態,然而卻不一定會再次得到通知(例如,沒有新的請求到來)。那麼,至少會有一個請求處於等待狀態,同時所有的線程都處於等待狀態,等待被通知。

爲了解決這個問題,我們可以設置一些整數變量來表示等待的請求的數目,讓每個線程再等待之前都檢查一下這個值。如果這個值是正的,有請求處於等待狀態,線程應該處理它,而不是去等待下一個通知。進一步來說,一個線程再處理了一個請求後,應該將該值減1,保證其正確性。

我們來看一下這是如何影響上面所示的等待程序的:

/* number of pending requests, initially none */
int num_requests = 0;

/* first, lock the mutex */
int rc = pthread_mutex_lock(&request_mutex);
if (rc) 
{ 
    /* an error has occurred */
    perror("pthread_mutex_lock");
    pthread_exit(NULL);
}
/* mutex is now locked - wait on the condition variable */
/* if there are no requests to be handled. */
rc = 0;
if (num_requests == 0)
{
    rc = pthread_cond_wait(&got_request, &request_mutex);
}

if (num_requests > 0 && rc == 0) 
{ 
    /* we have a request pending */
    /* unlock mutex - so other threads would be able to handle */
    /* other reqeusts waiting in the queue paralelly. */
    rc = pthread_mutex_unlock(&request_mutex);
    /* do your stuff... */

    /* decrease count of pending requests */
    num_requests--;
    /* and lock the mutex again - to remain symmetrical,. */
    rc = pthread_mutex_lock(&request_mutex);
}
/* finally, unlock the mutex */
pthread_mutex_unlock(&request_mutex);

使用條件變量——一個完整例子

作爲一個使用條件變量的實際例子,我們將給出一個程序模擬我們之前提到的服務器——一個線程,作爲接收端,接收用戶請求。它將用戶請求插入一個鏈表,一系列線程,作爲處理器,來處理這些請求。爲了簡便,在我們的模擬中,接收端自己創建請求而非從實際用戶讀取請求。

程序源代碼在thread_pool_server.c,包含許多註釋。請先閱讀源代碼,燃盡後在閱讀下面的說明:

  1. main函數首先啓動處理線程,然後通過它的主循環來擔任接收端線程;
  2. 使用了一個互斥鎖,一來保護條件變量,二來保護鏈表中等待的請求。這簡化了設計,作爲一個練習,你可以想一下怎麼將這部分工作分給兩個鎖;
  3. 互斥量本身必須是一個遞歸鎖。爲了說明原因,看函數handle_requests_loop 。你會發現,它首先會鎖住互斥量,然後調用get_request函數,這個函數會再次鎖住互斥量。如果使用一個非遞歸鎖,那麼在函數get_request中互斥量就會永久被鎖定;
  4. 作爲一個規則,當使用遞歸鎖時,我們應該保證在同一個函數中每個鎖定操作都伴隨着一個解鎖操作。不然的話,在鎖定互斥量幾次之後,很難保證會解鎖同樣的次數,這時就會發生死鎖現象;
  5. 調用函數pthread_cond_wait()函數時隱含的解鎖和重新鎖定互斥量一開始是容易困惑的。最好是在代碼中添加一個註釋,否則其他人讀到這段代碼時可能會不小心再添加一個鎖定操作;
  6. 當一個處理線程處理一個請求時——它應該釋放互斥鎖,以避免阻塞所有其他的處理線程。在它處理完這個請求之後,它應該再次鎖定這個互斥量,並檢查是否有更多的請求需要處理。

參考:《MultiThreaded-Programming-With-POSIX》
備註:所有的代碼都可以在上述參考中直接找到,就不貼在這兒了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章