POSIX執行緒詳解(2) (轉)

gugu99發表於2007-08-15
POSIX執行緒詳解(2) (轉)[@more@]

Daniel Robbins
總裁/CEO, Gentoo Technologies, Inc.
2000 年 8 月

POSIX 執行緒是提高程式碼響應和的有力手段。在此三部分系列文章的第二篇中,Daniel Robbins 將說明,如何使用被稱為互斥的靈巧小玩意,來保護執行緒程式碼中共享資料結構的完整性。

互斥我吧!
在/Develop/read_article.?id=19676">前一篇文章中,談到了會導致異常結果的執行緒程式碼。兩個執行緒分別對同一個全域性變數進行了二十次加一。變數的值最後應該是 40,但最終值卻是 21。這是怎麼回事呢?因為一個執行緒不停地“取消”了另一個執行緒的加一操作,所以產生這個問題。現在讓我們來檢視改正後的程式碼,它使用 互斥物件(mutex)來解決該問題:

thread3.c

#include #include #include #include int myglobal; pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER; void *thread_function(void *arg) { int i,j; for ( i=0; i<20; i++) { pthread_mutex_lock(&mymutex); j=myglobal; j=j+1; printf("."); fflush(stdout); sleep(1); myglobal=j; pthread_mutex_unlock(&mymutex); } return NULL; } int main(void) { pthread_t mythread; int i; if ( pthread_create( &mythread, NULL, thread_function, NULL) ) { printf("error creating thread."); abort(); } for ( i=0; i<20; i++) { pthread_mutex_lock(&mymutex); myglobal=myglobal+1; pthread_mutex_unlock(&mymutex); printf("o"); fflush(stdout); sleep(1); } if ( pthread_join ( mythread, NULL ) ) { printf("error joining thread."); abort(); } printf(" myglobal equals %d ",myglobal); exit(0); }



解讀一下
如果將這段程式碼與中給出的版本作一個比較,就會注意到增加了 pthread_mutex_lock() 和 pthread_mutex_unlock() 。線上程中這些呼叫執行了不可或缺的功能。他們提供了一種 相互排斥的方法(互斥物件即由此得名)。兩個執行緒不能同時對同一個互斥物件加鎖。

互斥物件是這樣工作的。如果執行緒 a 試圖鎖定一個互斥物件,而此時執行緒 b 已鎖定了同一個互斥物件時,執行緒 a 就將進入睡眠狀態。一旦執行緒 b 釋放了互斥物件(透過 pthread_mutex_unlock() 呼叫),執行緒 a 就能夠鎖定這個互斥物件(換句話說,執行緒 a 就將從 pthread_mutex_lock() 函式呼叫中返回,同時互斥物件被鎖定)。同樣地,當執行緒 a 正鎖定互斥物件時,如果執行緒 c 試圖鎖定互斥物件的話,執行緒 c 也將臨時進入睡眠狀態。對已鎖定的互斥物件上呼叫 pthread_mutex_lock() 的所有執行緒都將進入睡眠狀態,這些睡眠的執行緒將“排隊”訪問這個互斥物件。

通常使用 pthread_mutex_lock() 和 pthread_mutex_unlock() 來保護資料結構。這就是說,透過執行緒的鎖定和解鎖,對於某一資料結構,確保某一時刻只能有一個執行緒能夠訪問它。可以推測到,當執行緒試圖鎖定一個未加鎖的互斥物件時,POSIX 執行緒庫將同意鎖定,而不會使執行緒進入睡眠狀態。

-7-161250571.gif" width=600 border=0 valign="TOP">

圖中,鎖定了互斥物件的執行緒能夠存取複雜的資料結構,而不必擔心同時會有其它執行緒干擾。那個資料結構實際上是“凍結”了,直到互斥物件被解鎖為止。pthread_mutex_lock() 和 pthread_mutex_unlock() 函式呼叫,如同“在施工中”標誌一樣,將正在修改和讀取的某一特定共享資料包圍起來。這兩個函式呼叫的作用就是警告其它執行緒,要它們繼續睡眠並等待輪到它們對互斥物件加鎖。當然,除非在 每個對特定資料結構進行讀寫操作的語句前後,都分別放上 pthread_mutex_lock() 和 pthread_mutext_unlock() 呼叫,才會出現這種情況。

為什麼要用互斥物件?
聽上去很有趣,但究竟為什麼要讓執行緒睡眠呢?要知道,執行緒的主要優點不就是其具有獨立工作、更多的時候是同時工作的能力嗎?是的,確實是這樣。然而,每個重要的執行緒程式都需要使用某些互斥物件。讓我們再看一下示例程式以便理解原因所在。

請看 thread_function(),迴圈中一開始就鎖定了互斥物件,最後才將它解鎖。在這個示例程式中,mymutex 用來保護 myglobal 的值。仔細檢視 thread_function(),加一程式碼把 myglobal 複製到一個區域性變數,對區域性變數加一,睡眠一秒鐘,在這之後才把區域性變數的值傳回給 myglobal。不使用互斥物件時,即使主執行緒在 thread_function() 執行緒睡眠一秒鐘期間內對 myglobal 加一,thread_function() 甦醒後也會覆蓋主執行緒所加的值。使用互斥物件能夠保證這種情形不會發生。(您也許會想到,我增加了一秒鐘延遲以觸發不正確的結果。把區域性變數的值賦給 myglobal 之前,實際上沒有什麼真正理由要求 thread_function() 睡眠一秒鐘。)使用互斥物件的新程式產生了期望的結果:

$ ./thread3 o..o..o.o..o..o.o.o.o.o..o..o..o.ooooooo myglobal equals 40



為了進一步探索這個極為重要的概念,讓我們看一看程式中進行加一操作的程式碼:

thread_function() 加一程式碼: j=myglobal; j=j+1; printf("."); fflush(stdout); sleep(1); myglobal=j; 主執行緒加一程式碼: myglobal=myglobal+1;



如果程式碼是位於單執行緒程式中,可以預期 thread_function() 程式碼將完整執行。接下來才會執行主執行緒程式碼(或者是以相反的順序執行)。在不使用互斥物件的執行緒程式中,程式碼可能(幾乎是,由於呼叫了 sleep() 的緣故)以如下的順序執行:

thread_function() 執行緒 主執行緒 j=myglobal; j=j+1; printf("."); fflush(stdout); sleep(1); myglobal=myglobal+1; myglobal=j;



當程式碼以此特定順序執行時,將覆蓋主執行緒對 myglobal 的修改。程式結束後,就將得到不正確的值。如果是在操縱指標的話,就可能產生段錯誤。注意到 thread_function() 執行緒按順序執行了它的所有指令。看來不象是 thread_function() 有什麼次序顛倒。問題是,同一時間內,另一個執行緒對同一資料結構進行了另一個修改。

執行緒內幕 1
在解釋如何確定在何處使用互斥物件之前,先來深入瞭解一下執行緒的內部工作機制。請看第一個例子:

假設主執行緒將建立三個新執行緒:執行緒 a、執行緒 b 和執行緒 c。假定首先建立執行緒 a,然後是執行緒 b,最後建立執行緒 c。

pthread_create( &thread_a, NULL, thread_function, NULL); pthread_create( &thread_b, NULL, thread_function, NULL); pthread_create( &thread_c, NULL, thread_function, NULL);



在第一個 pthread_create() 呼叫完成後,可以假定執行緒 a 不是已存在就是已結束並停止。第二個 pthread_create() 呼叫後,主執行緒和執行緒 b 都可以假定執行緒 a 存在(或已停止)。

然而,就在第二個 create() 呼叫返回後,主執行緒無法假定是哪一個執行緒(a 或 b)會首先開始執行。雖然兩個執行緒都已存在,執行緒 時間片的分配取決於核心和執行緒庫。至於誰將首先執行,並沒有嚴格的規則。儘管執行緒 a 更有可能線上程 b 之前開始執行,但這並無保證。對於多,情況更是如此。如果編寫的程式碼假定線上程 b 開始執行之前實際上執行執行緒 a 的程式碼,那麼,程式最終正確執行的機率是 99%。或者更糟糕,程式在您的機器上 100% 地正確執行,而在您客戶的四處理器上正確執行的機率卻是零。

從這個例子還可以得知,執行緒庫保留了每個單獨執行緒的程式碼執行順序。換句話說,實際上那三個 pthread_create() 呼叫將按它們出現的順序執行。從主執行緒上來看,所有程式碼都是依次執行的。有時,可以利用這一點來部分執行緒程式。例如,在上例中,執行緒 c 就可以假定執行緒 a 和執行緒 b 不是正在執行就是已經終止。它不必擔心存在還沒有建立執行緒 a 和執行緒 b 的可能性。可以使用這一邏輯來最佳化執行緒程式。

執行緒內幕 2
現在來看另一個假想的例子。假設有許多執行緒,他們都正在執行下列程式碼:

myglobal=myglobal+1;



那麼,是否需要在加一操作語句前後分別鎖定和解鎖互斥物件呢?也許有人會說“不”。極有可能把上述賦值語句編譯成一條機器指令。大家都知道,不可能"半途"中斷一條機器指令。即使是中斷也不會破壞機器指令的完整性。基於以上考慮,很可能傾向於完全省略 pthread_mutex_lock() 和 pthread_mutex_unlock() 呼叫。不要這樣做。

我在說廢話嗎?不完全是這樣。首先,不應該假定上述賦值語句一定會被編譯成一條機器指令,除非親自驗證了機器程式碼。即使插入某些內嵌語句以確保加一操作的完整執行——甚至,即使是自己動手寫編譯器!-- 仍然可能有問題。

答案在這裡。使用單條內嵌彙編操作碼在單處理器系統上可能不會有什麼問題。每個加一操作都將完整地進行,並且多半會得到期望的結果。但是多處理器系統則截然不同。在多 CPU 機器上,兩個單獨的處理器可能會在幾乎同一時刻(或者,就在同一時刻)執行上述賦值語句。不要忘了,這時對的修改需要先從 L1 寫入 L2 快取記憶體、然後才寫入主存。(SMP 機器並不只是增加了處理器而已;它還有用來仲裁對 RAM 存取的特殊硬體。)最終,根本無法搞清在寫入主存的競爭中,哪個 CPU 將會"勝出"。要產生可預測的程式碼,應使用互斥物件。互斥物件將插入一道"記憶體關卡",由它來確保對主存的寫入按照執行緒鎖定互斥物件的順序進行。

考慮一種以 32 位塊為單位主存的 SMP 體系結構。如果未使用互斥物件就對一個 64 位整數進行加一操作,整數的最高 4 位位元組可能來自一個 CPU,而其它 4 個位元組卻來自另一 CPU。糟糕吧!最糟糕的是,使用差勁的技術,您的程式在重要客戶的系統上有可能不是很長時間才崩潰一次,就是早上三點鐘就崩潰。David R. Butenhof 在他的《POSIX 執行緒》(請參閱本文末尾的Linux/thread/posix_thread2/index.shtml#res">參考資料部分)一書中,討論了由於未使用互斥物件而將產生的種種情況。

許多互斥物件
如果放置了過多的互斥物件,程式碼就沒有什麼併發性可言,執行起來也比單執行緒解決方案慢。如果放置了過少的互斥物件,程式碼將出現奇怪和令人尷尬的錯誤。幸運的是,有一箇中間立場。首先,互斥物件是用於化存取*共享資料*。不要對非共享資料使用互斥物件,並且,如果程式邏輯確保任何時候都只有一個執行緒能存取特定資料結構,那麼也不要使用互斥物件。

其次,如果要使用共享資料,那麼在讀、寫共享資料時都應使用互斥物件。用 pthread_mutex_lock() 和 pthread_mutex_unlock() 把讀寫部分保護起來,或者在程式中不固定的地方隨機使用它們。學會從一個執行緒的角度來審視程式碼,並確保程式中每一個執行緒對記憶體的觀點都是一致和合適的。為了熟悉互斥物件的用法,最初可能要花好幾個小時來編寫程式碼,但是很快就會習慣並且*也*不必多想就能夠正確使用它們。

使用呼叫:初始化
現在該來看看使用互斥物件的各種不同方法了。讓我們從初始化開始。在 thread3.c 示例中,我們使用了靜態初始化方法。這需要宣告一個 pthread_mutex_t 變數,並賦給它常數 PTHREAD_MUTEX_INITIALIZER:

pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;



很簡單吧。但是還可以動態地建立互斥物件。當程式碼使用 malloc() 分配一個新的互斥物件時,使用這種動態方法。此時,靜態初始化方法是行不通的,並且應當使用例程 pthread_mutex_init():

int pthread_mutex_init( pthread_mutex_t *mymutex, const pthread_mutexattr_t *attr)



正如所示,pthread_mutex_init 接受一個指標作為引數以初始化為互斥物件,該指標指向一塊已分配好的記憶體區。第二個引數,可以接受一個可選的 pthread_mutexattr_t 指標。這個結構可用來設定各種互斥物件屬性。但是通常並不需要這些屬性,所以正常做法是指定 NULL。

一旦使用 pthread_mutex_init() 初始化了互斥物件,就應使用 pthread_mutex_destroy() 消除它。pthread_mutex_destroy() 接受一個指向 pthread_mutext_t 的指標作為引數,並釋放建立互斥物件時分配給它的任何資源。請注意, pthread_mutex_destroy() 不會釋放用來 pthread_mutex_t 的記憶體。釋放自己的記憶體完全取決於您。還必須注意一點,pthread_mutex_init() 和 pthread_mutex_destroy() 成功時都返回零。

使用呼叫:鎖定

pthread_mutex_lock(pthread_mutex_t *mutex)



pthread_mutex_lock() 接受一個指向互斥物件的指標作為引數以將其鎖定。如果碰巧已經鎖定了互斥物件,呼叫者將進入睡眠狀態。函式返回時,將喚醒呼叫者(顯然)並且呼叫者還將保留該鎖。函式呼叫成功時返回零,失敗時返回非零的錯誤程式碼。

pthread_mutex_unlock(pthread_mutex_t *mutex)



pthread_mutex_unlock() 與 pthread_mutex_lock() 相配合,它把執行緒已經加鎖的互斥物件解鎖。始終應該儘快對已加鎖的互斥物件進行解鎖(以提高效能)。並且絕對不要對您未保持鎖的互斥物件進行解鎖操作(否則,pthread_mutex_unlock() 呼叫將失敗並帶一個非零的 EPE返回值)。

pthread_mutex_trylock(pthread_mutex_t *mutex)



當執行緒正在做其它事情的時候(由於互斥物件當前是鎖定的),如果希望鎖定互斥物件,這個呼叫就相當方便。呼叫 pthread_mutex_trylock() 時將嘗試鎖定互斥物件。如果互斥物件當前處於解鎖狀態,那麼您將獲得該鎖並且函式將返回零。然而,如果互斥物件已鎖定,這個呼叫也不會阻塞。當然,它會返回非零的 EBUSY 錯誤值。然後可以繼續做其它事情,稍後再嘗試鎖定。

等待條件發生
互斥物件是執行緒程式必需的工具,但它們並非萬能的。例如,如果執行緒正在等待共享資料內某個條件出現,那會發生什麼呢?程式碼可以反覆對互斥物件鎖定和解鎖,以檢查值的任何變化。同時,還要將互斥物件解鎖,以便其它執行緒能夠進行任何必需的更改。這是一種非常可怕的方法,因為執行緒需要在合理的時間範圍內頻繁地迴圈檢測變化。

在每次檢查之間,可以讓呼叫執行緒短暫地進入睡眠,比如睡眠三秒鐘,但是因此執行緒程式碼就無法最快作出響應。真正需要的是這樣一種方法,當執行緒在等待滿足某些條件時使執行緒進入睡眠狀態。一旦條件滿足,還需要一種方法以喚醒因等待滿足特定條件而睡眠的執行緒。如果能夠做到這一點,執行緒程式碼將是非常高效的,並且不會佔用寶貴的互斥物件鎖。這正是 POSIX 條件變數能做的事!

而 POSIX 條件變數將是下一篇文章的主題,其中將說明如何正確使用條件變數。到那時,您將擁有了建立複雜執行緒程式所需的全部資源,那些執行緒程式可以模擬工作人員、裝配線等等。

 


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10748419/viewspace-959312/,如需轉載,請註明出處,否則將追究法律責任。

相關文章