Linux系統程式設計(28)——執行緒間同步

尹成發表於2014-09-04


多個執行緒同時訪問共享資料時可能會衝突,這跟前面講訊號時所說的可重入性是同樣的問題。比如兩個執行緒都要把某個全域性變數增加1,這個操作在某平臺需要三條指令完成:

 

從記憶體讀變數值到暫存器

暫存器的值加1

將暫存器的值寫回記憶體

 

假設兩個執行緒在多處理器平臺上同時執行這三條指令,則可能導致下圖所示的結果,最後變數只加了一次而非兩次

 

我們通過一個簡單的程式觀察這一現象。上圖所描述的現象從理論上是存在這種可能的,但實際執行程式時很難觀察到,為了使現象更容易觀察到,我們把上述三條指令做的事情用更多條指令來做:

                   val= counter;
                   printf("%x:%d\n", (unsigned int)pthread_self(), val + 1);
                   counter= val + 1;


我們在“讀取變數的值”和“把變數的新值儲存回去”這兩步操作之間插入一個printf呼叫,它會執行write系統呼叫進核心,為核心排程別的執行緒執行提供了一個很好的時機。我們在一個迴圈中重複上述操作幾千次,就會觀察到訪問衝突的現象。

 

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
 
#define NLOOP 5000
 
int counter;                /* incremented by threads */
 
void *doit(void *);
 
int main(int argc, char **argv)
{
         pthread_ttidA, tidB;
 
         pthread_create(&tidA,NULL, &doit, NULL);
         pthread_create(&tidB,NULL, &doit, NULL);
 
       /* wait for both threads to terminate */
         pthread_join(tidA,NULL);
         pthread_join(tidB,NULL);
 
         return0;
}
 
void *doit(void *vptr)
{
         int    i, val;
 
         /*
          * Each thread fetches, prints, and incrementsthe counter NLOOP times.
          * The value of the counter should increasemonotonically.
          */
 
         for(i = 0; i < NLOOP; i++) {
                   val= counter;
                   printf("%x:%d\n", (unsigned int)pthread_self(), val + 1);
                   counter= val + 1;
         }
 
         returnNULL;
}


我們建立兩個執行緒,各自把counter增加5000次,正常情況下最後counter應該等於10000,但事實上每次執行該程式的結果都不一樣,有時候數到5000多,有時候數到6000多。

 

對於多執行緒的程式,訪問衝突的問題是很普遍的,解決的辦法是引入互斥鎖(Mutex,Mutual Exclusive Lock),獲得鎖的執行緒可以完成“讀-修改-寫”的操作,然後釋放鎖給其它執行緒,沒有獲得鎖的執行緒只能等待而不能訪問共享資料,這樣“讀-修改-寫”三步操作組成一個原子操作,要麼都執行,要麼都不執行,不會執行到中間被打斷,也不會在其它處理器上並行做這個操作。

 

Mutex用pthread_mutex_t型別的變數表示,可以這樣初始化和銷燬:

 

#include <pthread.h>
 
int pthread_mutex_destroy(pthread_mutex_t*mutex);
int pthread_mutex_init(pthread_mutex_t*restrict mutex,
      const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex =PTHREAD_MUTEX_INITIALIZER;

返回值:成功返回0,失敗返回錯誤號。

 

pthread_mutex_init函式對Mutex做初始化,引數attr設定Mutex的屬性,如果attr為NULL則表示預設屬性,本章不詳細介紹Mutex屬性,感興趣的讀者可以參考[APUE2e]。用pthread_mutex_init函式初始化的Mutex可以用pthread_mutex_destroy銷燬。如果Mutex變數是靜態分配的(全域性變數或static變數),也可以用巨集定義PTHREAD_MUTEX_INITIALIZER來初始化,相當於用pthread_mutex_init初始化並且attr引數為NULL。Mutex的加鎖和解鎖操作可以用下列函式:

#include <pthread.h>
 
int pthread_mutex_lock(pthread_mutex_t*mutex);
int pthread_mutex_trylock(pthread_mutex_t*mutex);
int pthread_mutex_unlock(pthread_mutex_t*mutex);

 

返回值:成功返回0,失敗返回錯誤號。

 

一個執行緒可以呼叫pthread_mutex_lock獲得Mutex,如果這時另一個執行緒已經呼叫pthread_mutex_lock獲得了該Mutex,則當前執行緒需要掛起等待,直到另一個執行緒呼叫pthread_mutex_unlock釋放Mutex,當前執行緒被喚醒,才能獲得該Mutex並繼續執行。

 

如果一個執行緒既想獲得鎖,又不想掛起等待,可以呼叫pthread_mutex_trylock,如果Mutex已經被另一個執行緒獲得,這個函式會失敗返回EBUSY,而不會使執行緒掛起等待。

 

現在我們用Mutex解決先前的問題:

 

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
 
 
#define NLOOP 5000
 
int counter;                /* incremented by threads */
pthread_mutex_t counter_mutex =PTHREAD_MUTEX_INITIALIZER;
 
void *doit(void *);
 
int main(int argc, char **argv)
{
         pthread_ttidA, tidB;
 
         pthread_create(&tidA,NULL, doit, NULL);
         pthread_create(&tidB,NULL, doit, NULL);
 
       /* wait for both threads to terminate */
         pthread_join(tidA,NULL);
         pthread_join(tidB,NULL);
 
         return0;
}
 
void *doit(void *vptr)
{
         int     i, val;
 
         /*
          * Each thread fetches, prints, and incrementsthe counter NLOOP times.
          * The value of the counter should increasemonotonically.
          */
 
         for(i = 0; i < NLOOP; i++) {
                   pthread_mutex_lock(&counter_mutex);
 
                   val= counter;
                   printf("%x:%d\n", (unsigned int)pthread_self(), val + 1);
                   counter= val + 1;
 
                   pthread_mutex_unlock(&counter_mutex);
         }
 
         returnNULL;
}

 

這樣執行結果就正常了,每次執行都能數到10000。


那麼掛起等待”和“喚醒等待執行緒”的操作如何實現?每個Mutex有一個等待佇列,一個執行緒要在Mutex上掛起等待,首先在把自己加入等待佇列中,然後置執行緒狀態為睡眠,然後呼叫排程器函式切換到別的執行緒。一個執行緒要喚醒等待佇列中的其它執行緒,只需從等待佇列中取出一項,把它的狀態從睡眠改為就緒,加入就緒佇列,那麼下次排程器函式執行時就有可能切換到被喚醒的執行緒。

一般情況下,如果同一個執行緒先後兩次呼叫lock,在第二次呼叫時,由於鎖已經被佔用,該執行緒會掛起等待別的執行緒釋放鎖,然而鎖正是被自己佔用著的,該執行緒又被掛起而沒有機會釋放鎖,因此就永遠處於掛起等待狀態了,這叫做死鎖(Deadlock)。另一種典型的死鎖情形是這樣:執行緒A獲得了鎖1,執行緒B獲得了鎖2,這時執行緒A呼叫lock試圖獲得鎖2,結果是需要掛起等待執行緒B釋放鎖2,而這時執行緒B也呼叫lock試圖獲得鎖1,結果是需要掛起等待執行緒A釋放鎖1,於是執行緒A和B都永遠處於掛起狀態了。不難想象,如果涉及到更多的執行緒和更多的鎖,有沒有可能死鎖的問題將會變得複雜和難以判斷。

寫程式時應該儘量避免同時獲得多個鎖,如果一定有必要這麼做,則有一個原則:如果所有執行緒在需要多個鎖時都按相同的先後順序(常見的是按Mutex變數的地址順序)獲得鎖,則不會出現死鎖。比如一個程式中用到鎖1、鎖2、鎖3,它們所對應的Mutex變數的地址是鎖1<鎖2<鎖3,那麼所有執行緒在需要同時獲得2個或3個鎖時都應該按鎖1、鎖2、鎖3的順序獲得。如果要為所有的鎖確定一個先後順序比較困難,則應該儘量使用pthread_mutex_trylock呼叫代替pthread_mutex_lock呼叫,以免死鎖。

 

 

 

 

 

 

 

 

 

 

 

 

相關文章