原文地址:
http://www.ibm.com/developerworks/cn/linux/thread/posix_thread2/
POSIX 線程是提高代碼響應(yīng)和性能的有力手段。在此三部分系列文章的第二篇中,Daniel Robbins 將說明,如何使用被稱為互斥對象的靈巧小玩意,來保護(hù)線程代碼中共享數(shù)據(jù)結(jié)構(gòu)的完整性。
互斥我吧!
在 前一篇文章中 ,談到了會導(dǎo)致異常結(jié)果的線程代碼。兩個線程分別對同一個全局變量進(jìn)行了二十次加一。變量的值最后應(yīng)該是 40,但最終值卻是 21。這是怎么回事呢?因?yàn)橐粋€線程不停地“取消”了另一個線程執(zhí)行的加一操作,所以產(chǎn)生這個問題。現(xiàn)在讓我們來查看改正后的代碼,它使用 互斥對象(mutex)來解決該問題:
thread3.c
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
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("\nmyglobal equals %d\n",myglobal);
exit(0);
}
|
解讀一下
如果將這段代碼與 前一篇文章 中給出的版本作一個比較,就會注意到增加了 pthread_mutex_lock() 和 pthread_mutex_unlock() 函數(shù)調(diào)用。在線程程序中這些調(diào)用執(zhí)行了不可或缺的功能。他們提供了一種 相互排斥的方法(互斥對象即由此得名)。兩個線程不能同時對同一個互斥對象加鎖。
互斥對象是這樣工作的。如果線程 a 試圖鎖定一個互斥對象,而此時線程 b 已鎖定了同一個互斥對象時,線程 a 就將進(jìn)入睡眠狀態(tài)。一旦線程 b 釋放了互斥對象(通過 pthread_mutex_unlock() 調(diào)用),線程 a 就能夠鎖定這個互斥對象(換句話說,線程 a 就將從 pthread_mutex_lock() 函數(shù)調(diào)用中返回,同時互斥對象被鎖定)。同樣地,當(dāng)線程 a 正鎖定互斥對象時,如果線程 c 試圖鎖定互斥對象的話,線程 c 也將臨時進(jìn)入睡眠狀態(tài)。對已鎖定的互斥對象上調(diào)用 pthread_mutex_lock() 的所有線程都將進(jìn)入睡眠狀態(tài),這些睡眠的線程將“排隊(duì)”訪問這個互斥對象。
通常使用 pthread_mutex_lock() 和 pthread_mutex_unlock() 來保護(hù)數(shù)據(jù)結(jié)構(gòu)。這就是說,通過線程的鎖定和解鎖,對于某一數(shù)據(jù)結(jié)構(gòu),確保某一時刻只能有一個線程能夠訪問它。可以推測到,當(dāng)線程試圖鎖定一個未加鎖的互斥對象時,POSIX 線程庫將同意鎖定,而不會使線程進(jìn)入睡眠狀態(tài)。
請看這幅輕松的漫畫,四個小精靈重現(xiàn)了最近一次 pthread_mutex_lock() 調(diào)用的一個場面。
圖中,鎖定了互斥對象的線程能夠存取復(fù)雜的數(shù)據(jù)結(jié)構(gòu),而不必?fù)?dān)心同時會有其它線程干擾。那個數(shù)據(jù)結(jié)構(gòu)實(shí)際上是“凍結(jié)”了,直到互斥對象被解鎖為止。pthread_mutex_lock() 和 pthread_mutex_unlock() 函數(shù)調(diào)用,如同“在施工中”標(biāo)志一樣,將正在修改和讀取的某一特定共享數(shù)據(jù)包圍起來。這兩個函數(shù)調(diào)用的作用就是警告其它線程,要它們繼續(xù)睡眠并等待輪到它們對互斥對象加鎖。當(dāng)然,除非在 每個 對特定數(shù)據(jù)結(jié)構(gòu)進(jìn)行讀寫操作的語句前后,都分別放上 pthread_mutex_lock() 和 pthread_mutext_unlock() 調(diào)用,才會出現(xiàn)這種情況。
為什么要用互斥對象?
聽上去很有趣,但究竟為什么要讓線程睡眠呢?要知道,線程的主要優(yōu)點(diǎn)不就是其具有獨(dú)立工作、更多的時候是同時工作的能力嗎?是的,確實(shí)是這樣。然而,每個重要的線程程序都需要使用某些互斥對象。讓我們再看一下示例程序以便理解原因所在。
請看 thread_function(),循環(huán)中一開始就鎖定了互斥對象,最后才將它解鎖。在這個示例程序中,mymutex 用來保護(hù) myglobal 的值。仔細(xì)查看 thread_function(),加一代碼把 myglobal 復(fù)制到一個局部變量,對局部變量加一,睡眠一秒鐘,在這之后才把局部變量的值傳回給 myglobal。不使用互斥對象時,即使主線程在 thread_function() 線程睡眠一秒鐘期間內(nèi)對 myglobal 加一,thread_function() 蘇醒后也會覆蓋主線程所加的值。使用互斥對象能夠保證這種情形不會發(fā)生。(您也許會想到,我增加了一秒鐘延遲以觸發(fā)不正確的結(jié)果。把局部變量的值賦給 myglobal 之前,實(shí)際上沒有什么真正理由要求 thread_function() 睡眠一秒鐘。)使用互斥對象的新程序產(chǎn)生了期望的結(jié)果:
$ ./thread3
o..o..o.o..o..o.o.o.o.o..o..o..o.ooooooo
myglobal equals 40
|
為了進(jìn)一步探索這個極為重要的概念,讓我們看一看程序中進(jìn)行加一操作的代碼:
thread_function() 加一代碼:
j=myglobal;
j=j+1;
printf(".");
fflush(stdout);
sleep(1);
myglobal=j;
主線程加一代碼:
myglobal=myglobal+1;
|
如果代碼是位于單線程程序中,可以預(yù)期 thread_function() 代碼將完整執(zhí)行。接下來才會執(zhí)行主線程代碼(或者是以相反的順序執(zhí)行)。在不使用互斥對象的線程程序中,代碼可能(幾乎是,由于調(diào)用了 sleep() 的緣故)以如下的順序執(zhí)行:
thread_function() 線程 主線程
j=myglobal;
j=j+1;
printf(".");
fflush(stdout);
sleep(1); myglobal=myglobal+1;
myglobal=j;
|
當(dāng)代碼以此特定順序執(zhí)行時,將覆蓋主線程對 myglobal 的修改。程序結(jié)束后,就將得到不正確的值。如果是在操縱指針的話,就可能產(chǎn)生段錯誤。注意到 thread_function() 線程按順序執(zhí)行了它的所有指令。看來不象是 thread_function() 有什么次序顛倒。問題是,同一時間內(nèi),另一個線程對同一數(shù)據(jù)結(jié)構(gòu)進(jìn)行了另一個修改。
線程內(nèi)幕 1
在解釋如何確定在何處使用互斥對象之前,先來深入了解一下線程的內(nèi)部工作機(jī)制。請看第一個例子:
假設(shè)主線程將創(chuàng)建三個新線程:線程 a、線程 b 和線程 c。假定首先創(chuàng)建線程 a,然后是線程 b,最后創(chuàng)建線程 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() 調(diào)用完成后,可以假定線程 a 不是已存在就是已結(jié)束并停止。第二個 pthread_create() 調(diào)用后,主線程和線程 b 都可以假定線程 a 存在(或已停止)。
然而,就在第二個 create() 調(diào)用返回后,主線程無法假定是哪一個線程(a 或 b)會首先開始運(yùn)行。雖然兩個線程都已存在,線程 CPU 時間片的分配取決于內(nèi)核和線程庫。至于誰將首先運(yùn)行,并沒有嚴(yán)格的規(guī)則。盡管線程 a 更有可能在線程 b 之前開始執(zhí)行,但這并無保證。對于多處理器系統(tǒng),情況更是如此。如果編寫的代碼假定在線程 b 開始執(zhí)行之前實(shí)際上執(zhí)行線程 a 的代碼,那么,程序最終正確運(yùn)行的概率是 99%。或者更糟糕,程序在您的機(jī)器上 100% 地正確運(yùn)行,而在您客戶的四處理器服務(wù)器上正確運(yùn)行的概率卻是零。
從這個例子還可以得知,線程庫保留了每個單獨(dú)線程的代碼執(zhí)行順序。換句話說,實(shí)際上那三個 pthread_create() 調(diào)用將按它們出現(xiàn)的順序執(zhí)行。從主線程上來看,所有代碼都是依次執(zhí)行的。有時,可以利用這一點(diǎn)來優(yōu)化部分線程程序。例如,在上例中,線程 c 就可以假定線程 a 和線程 b 不是正在運(yùn)行就是已經(jīng)終止。它不必?fù)?dān)心存在還沒有創(chuàng)建線程 a 和線程 b 的可能性。可以使用這一邏輯來優(yōu)化線程程序。
線程內(nèi)幕 2
現(xiàn)在來看另一個假想的例子。假設(shè)有許多線程,他們都正在執(zhí)行下列代碼:
那么,是否需要在加一操作語句前后分別鎖定和解鎖互斥對象呢?也許有人會說“不”。編譯器極有可能把上述賦值語句編譯成一條機(jī)器指令。大家都知道,不可能"半途"中斷一條機(jī)器指令。即使是硬件中斷也不會破壞機(jī)器指令的完整性。基于以上考慮,很可能傾向于完全省略 pthread_mutex_lock() 和 pthread_mutex_unlock() 調(diào)用。不要這樣做。
我在說廢話嗎?不完全是這樣。首先,不應(yīng)該假定上述賦值語句一定會被編譯成一條機(jī)器指令,除非親自驗(yàn)證了機(jī)器代碼。即使插入某些內(nèi)嵌匯編語句以確保加一操作的完整執(zhí)行――甚至,即使是自己動手寫編譯器!-- 仍然可能有問題。
答案在這里。使用單條內(nèi)嵌匯編操作碼在單處理器系統(tǒng)上可能不會有什么問題。每個加一操作都將完整地進(jìn)行,并且多半會得到期望的結(jié)果。但是多處理器系統(tǒng)則截然不同。在多 CPU 機(jī)器上,兩個單獨(dú)的處理器可能會在幾乎同一時刻(或者,就在同一時刻)執(zhí)行上述賦值語句。不要忘了,這時對內(nèi)存的修改需要先從 L1 寫入 L2 高速緩存、然后才寫入主存。(SMP 機(jī)器并不只是增加了處理器而已;它還有用來仲裁對 RAM 存取的特殊硬件。)最終,根本無法搞清在寫入主存的競爭中,哪個 CPU 將會"勝出"。要產(chǎn)生可預(yù)測的代碼,應(yīng)使用互斥對象。互斥對象將插入一道"內(nèi)存關(guān)卡",由它來確保對主存的寫入按照線程鎖定互斥對象的順序進(jìn)行。
考慮一種以 32 位塊為單位更新主存的 SMP 體系結(jié)構(gòu)。如果未使用互斥對象就對一個 64 位整數(shù)進(jìn)行加一操作,整數(shù)的最高 4 位字節(jié)可能來自一個 CPU,而其它 4 個字節(jié)卻來自另一 CPU。糟糕吧!最糟糕的是,使用差勁的技術(shù),您的程序在重要客戶的系統(tǒng)上有可能不是很長時間才崩潰一次,就是早上三點(diǎn)鐘就崩潰。David R. Butenhof 在他的《POSIX 線程編程》(請參閱本文末尾的 參考資料部分)一書中,討論了由于未使用互斥對象而將產(chǎn)生的種種情況。
許多互斥對象
如果放置了過多的互斥對象,代碼就沒有什么并發(fā)性可言,運(yùn)行起來也比單線程解決方案慢。如果放置了過少的互斥對象,代碼將出現(xiàn)奇怪和令人尷尬的錯誤。幸運(yùn)的是,有一個中間立場。首先,互斥對象是用于串行化存取*共享數(shù)據(jù)*。不要對非共享數(shù)據(jù)使用互斥對象,并且,如果程序邏輯確保任何時候都只有一個線程能存取特定數(shù)據(jù)結(jié)構(gòu),那么也不要使用互斥對象。
其次,如果要使用共享數(shù)據(jù),那么在讀、寫共享數(shù)據(jù)時都應(yīng)使用互斥對象。用 pthread_mutex_lock() 和 pthread_mutex_unlock() 把讀寫部分保護(hù)起來,或者在程序中不固定的地方隨機(jī)使用它們。學(xué)會從一個線程的角度來審視代碼,并確保程序中每一個線程對內(nèi)存的觀點(diǎn)都是一致和合適的。為了熟悉互斥對象的用法,最初可能要花好幾個小時來編寫代碼,但是很快就會習(xí)慣并且*也*不必多想就能夠正確使用它們。
使用調(diào)用:初始化
現(xiàn)在該來看看使用互斥對象的各種不同方法了。讓我們從初始化開始。在 thread3.c 示例 中,我們使用了靜態(tài)初始化方法。這需要聲明一個 pthread_mutex_t 變量,并賦給它常數(shù) PTHREAD_MUTEX_INITIALIZER:
pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;
|
很簡單吧。但是還可以動態(tài)地創(chuàng)建互斥對象。當(dāng)代碼使用 malloc() 分配一個新的互斥對象時,使用這種動態(tài)方法。此時,靜態(tài)初始化方法是行不通的,并且應(yīng)當(dāng)使用例程 pthread_mutex_init():
int pthread_mutex_init( pthread_mutex_t *mymutex, const pthread_mutexattr_t *attr)
|
正如所示,pthread_mutex_init 接受一個指針作為參數(shù)以初始化為互斥對象,該指針指向一塊已分配好的內(nèi)存區(qū)。第二個參數(shù),可以接受一個可選的 pthread_mutexattr_t 指針。這個結(jié)構(gòu)可用來設(shè)置各種互斥對象屬性。但是通常并不需要這些屬性,所以正常做法是指定 NULL。
一旦使用 pthread_mutex_init() 初始化了互斥對象,就應(yīng)使用 pthread_mutex_destroy() 消除它。pthread_mutex_destroy() 接受一個指向 pthread_mutext_t 的指針作為參數(shù),并釋放創(chuàng)建互斥對象時分配給它的任何資源。請注意, pthread_mutex_destroy() 不會 釋放用來存儲 pthread_mutex_t 的內(nèi)存。釋放自己的內(nèi)存完全取決于您。還必須注意一點(diǎn),pthread_mutex_init() 和 pthread_mutex_destroy() 成功時都返回零。
使用調(diào)用:鎖定
pthread_mutex_lock(pthread_mutex_t *mutex)
|
pthread_mutex_lock() 接受一個指向互斥對象的指針作為參數(shù)以將其鎖定。如果碰巧已經(jīng)鎖定了互斥對象,調(diào)用者將進(jìn)入睡眠狀態(tài)。函數(shù)返回時,將喚醒調(diào)用者(顯然)并且調(diào)用者還將保留該鎖。函數(shù)調(diào)用成功時返回零,失敗時返回非零的錯誤代碼。
pthread_mutex_unlock(pthread_mutex_t *mutex)
|
pthread_mutex_unlock() 與 pthread_mutex_lock() 相配合,它把線程已經(jīng)加鎖的互斥對象解鎖。始終應(yīng)該盡快對已加鎖的互斥對象進(jìn)行解鎖(以提高性能)。并且絕對不要對您未保持鎖的互斥對象進(jìn)行解鎖操作(否則,pthread_mutex_unlock() 調(diào)用將失敗并帶一個非零的 EPERM 返回值)。
pthread_mutex_trylock(pthread_mutex_t *mutex)
|
當(dāng)線程正在做其它事情的時候(由于互斥對象當(dāng)前是鎖定的),如果希望鎖定互斥對象,這個調(diào)用就相當(dāng)方便。調(diào)用 pthread_mutex_trylock() 時將嘗試鎖定互斥對象。如果互斥對象當(dāng)前處于解鎖狀態(tài),那么您將獲得該鎖并且函數(shù)將返回零。然而,如果互斥對象已鎖定,這個調(diào)用也不會阻塞。當(dāng)然,它會返回非零的 EBUSY 錯誤值。然后可以繼續(xù)做其它事情,稍后再嘗試鎖定。
等待條件發(fā)生
互斥對象是線程程序必需的工具,但它們并非萬能的。例如,如果線程正在等待共享數(shù)據(jù)內(nèi)某個條件出現(xiàn),那會發(fā)生什么呢?代碼可以反復(fù)對互斥對象鎖定和解鎖,以檢查值的任何變化。同時,還要快速將互斥對象解鎖,以便其它線程能夠進(jìn)行任何必需的更改。這是一種非常可怕的方法,因?yàn)榫€程需要在合理的時間范圍內(nèi)頻繁地循環(huán)檢測變化。
在每次檢查之間,可以讓調(diào)用線程短暫地進(jìn)入睡眠,比如睡眠三秒鐘,但是因此線程代碼就無法最快作出響應(yīng)。真正需要的是這樣一種方法,當(dāng)線程在等待滿足某些條件時使線程進(jìn)入睡眠狀態(tài)。一旦條件滿足,還需要一種方法以喚醒因等待滿足特定條件而睡眠的線程。如果能夠做到這一點(diǎn),線程代碼將是非常高效的,并且不會占用寶貴的互斥對象鎖。這正是 POSIX 條件變量能做的事!
而 POSIX 條件變量將是我下一篇文章的主題,其中將說明如何正確使用條件變量。到那時,您將擁有了創(chuàng)建復(fù)雜線程程序所需的全部資源,那些線程程序可以模擬工作人員、裝配線等等。既然您已經(jīng)越來越熟悉線程,我將在下一篇文章中加快進(jìn)度。這樣,在下一篇文章的結(jié)尾就能放上一個相對復(fù)雜的線程程序。說到等到條件產(chǎn)生,下次再見!
參考資料
關(guān)于作者
 |
|
 |
Daniel Robbins 居住在新墨西哥州的 Albuquerque。他是 Gentoo Technologies, Inc. 的總裁兼 CEO, Gentoo 項(xiàng)目的總設(shè)計(jì)師,多本 MacMillan 出版書籍的作者,包括: Caldera OpenLinux Unleashed、 SuSE Linux Unleashed和 Samba Unleashed 。Daniel 自小學(xué)二年級起就與計(jì)算機(jī)結(jié)下不解之緣,那時他首先接觸的是 Logo 程序語言,并沉溺于 Pac-Man 游戲中。這也許就是他至今仍擔(dān)任 SONY Electronic Publishing/Psygnosis 的首席圖形設(shè)計(jì)師的原因所在。Daniel 喜歡與妻子 Mary 和剛出生的女兒 Hadassah 一起共渡時光。可通過 drobbins@gentoo.org 與 Daniel 取得聯(lián)系。
|
Daniel Robbins 居住在新墨西哥州的 Albuquerque。他是 Gentoo Technologies, Inc. 的總裁兼 CEO, Gentoo 項(xiàng)目的總設(shè)計(jì)師,多本 MacMillan 出版書籍的作者,包括: Caldera OpenLinux Unleashed、 SuSE Linux Unleashed和 Samba Unleashed 。Daniel 自小學(xué)二年級起就與計(jì)算機(jī)結(jié)下不解之緣,那時他首先接觸的是 Logo 程序語言,并沉溺于 Pac-Man 游戲中。這也許就是他至今仍擔(dān)任 SONY Electronic Publishing/Psygnosis 的首席圖形設(shè)計(jì)師的原因所在。Daniel 喜歡與妻子 Mary 和剛出生的女兒 Hadassah 一起共渡時光。可通過 drobbins@gentoo.org 與 Daniel 取得聯(lián)系。