Linux平臺上的多線(xiàn)程程序開(kāi)發(fā)相對應其他平臺(比如 Windows)的多線(xiàn)程 API 有一些細微和隱晦的差別。不注意這些 Linux 上的一些開(kāi)發(fā)陷阱,常常會(huì )導致程序問(wèn)題不窮,死鎖不斷。本文中我們從 5 個(gè)方面總結出 Linux 多線(xiàn)程編程
Linux平臺上的多線(xiàn)程程序開(kāi)發(fā)相對應其他平臺(比如 Windows)的多線(xiàn)程 API 有一些細微和隱晦的差別。不注意這些 Linux 上的一些開(kāi)發(fā)陷阱,常常會(huì )導致程序問(wèn)題不窮,死鎖不斷。本文中我們從 5 個(gè)方面總結出 Linux 多線(xiàn)程編程上的問(wèn)題,并分別引出相關(guān)改善的開(kāi)發(fā)經(jīng)驗,用以避免這些的陷阱。我們希望這些經(jīng)驗可以幫助讀者們能更好更快的熟悉 Linux 平臺的多線(xiàn)程編程。
我們假設讀者都已經(jīng)很熟悉 Linux 平臺上基本的線(xiàn)程編程的 Pthread 庫 API 。其他的第三方用以線(xiàn)程編程的庫,如 boost,將不會(huì )在本文中提及。本文中主要涉及的題材包括線(xiàn)程開(kāi)發(fā)中的線(xiàn)程管理,互斥變量,條件變量等。進(jìn)程概念將不會(huì )在本文中涉及。
Linux上線(xiàn)程開(kāi)發(fā)API的概要介紹
多線(xiàn)程開(kāi)發(fā)在 Linux 平臺上已經(jīng)有成熟的 Pthread 庫支持。其涉及的多線(xiàn)程開(kāi)發(fā)的最基本概念主要包含三點(diǎn):線(xiàn)程,互斥鎖,條件。其中,線(xiàn)程操作又分線(xiàn)程的創(chuàng )建,退出,等待 3 種?;コ怄i則包括 4 種操作,分別是創(chuàng )建,銷(xiāo)毀,加鎖和解鎖。條件操作有 5 種操作:創(chuàng )建,銷(xiāo)毀,觸發(fā),廣播和等待。其他的一些線(xiàn)程擴展概念,如信號燈等,都可以通過(guò)上面的三個(gè)基本元素的基本操作封裝出來(lái)。
線(xiàn)程,互斥鎖,條件在 Linux 平臺上對應的 API 可以用表 1 歸納。為了方便熟悉 Windows 線(xiàn)程編程的讀者熟悉 Linux 多線(xiàn)程開(kāi)發(fā)的 API,我們在表中同時(shí)也列出 Windows SDK 庫中所對應的 API 名稱(chēng)。
表 1. 線(xiàn)程函數列表
多線(xiàn)程開(kāi)發(fā)在 Linux 平臺上已經(jīng)有成熟的 Pthread 庫支持。其涉及的多線(xiàn)程開(kāi)發(fā)的最基本概念主要包含三點(diǎn):線(xiàn)程,互斥鎖,條件。其中,線(xiàn)程操作又分線(xiàn)程的創(chuàng )建,退出,等待 3 種?;コ怄i則包括 4 種操作,分別是創(chuàng )建,銷(xiāo)毀,加鎖和解鎖。條件操作有 5 種操作:創(chuàng )建,銷(xiāo)毀,觸發(fā),廣播和等待。其他的一些線(xiàn)程擴展概念,如信號燈等,都可以通過(guò)上面的三個(gè)基本元素的基本操作封裝出來(lái)。
Linux線(xiàn)程編程中的5條經(jīng)驗
盡量設置 recursive 屬性以初始化 Linux 的互斥變量
互斥鎖是多線(xiàn)程編程中基本的概念,在開(kāi)發(fā)中被廣泛使用。其調用次序層次清晰簡(jiǎn)單:建鎖,加鎖,解鎖,銷(xiāo)毀鎖。但是需要注意的是,與諸如 Windows 平臺的互斥變量不同,在默認情況下,Linux 下的同一線(xiàn)程無(wú)法對同一互斥鎖進(jìn)行遞歸加速,否則將發(fā)生死鎖。
所謂遞歸加鎖,就是在同一線(xiàn)程中試圖對互斥鎖進(jìn)行兩次或兩次以上的行為。其場(chǎng)景在 Linux 平臺上的代碼可由清單 1 所示。
清單 1. Linux 重復對互斥鎖加鎖實(shí)例
// 通過(guò)默認條件建鎖
pthread_mutex_t *theMutex = new pthread_mutex_t;
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutex_init(theMutex,&attr);
pthread_mutexattr_destroy(&attr);
// 遞歸加鎖
pthread_mutex_lock (theMutex);
pthread_mutex_lock (theMutex);
pthread_mutex_unlock (theMutex);
pthread_mutex_unlock (theMutex);
在以上代碼場(chǎng)景中,問(wèn)題將出現在第二次加鎖操作。由于在默認情況下,Linux 不允許同一線(xiàn)程遞歸加鎖,因此在第二次加鎖操作時(shí)線(xiàn)程將出現死鎖。
Linux 互斥變量這種奇怪的行為或許對于特定的某些場(chǎng)景會(huì )所有用處,但是對于大多數情況下看起來(lái)更像是程序的一個(gè) bug 。畢竟,在同一線(xiàn)程中對同一互斥鎖進(jìn)行遞歸加鎖在尤其是二次開(kāi)發(fā)中經(jīng)常會(huì )需要。
這個(gè)問(wèn)題與互斥鎖的中的默認 recursive 屬性有關(guān)。解決問(wèn)題的方法就是顯式地在互斥變量初始化時(shí)將設置起 recursive 屬性?;诖?,以上代碼其實(shí)稍作修改就可以很好的運行,只需要在初始化鎖的時(shí)候加設置一個(gè)屬性。請看清單 2 。
清單 2. 設置互斥鎖 recursive 屬性實(shí)例
pthread_mutexattr_init(&attr);
// 設置 recursive 屬性
pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE_NP);
pthread_mutex_init(theMutex,&attr);
因此,建議盡量設置 recursive 屬性以初始化 Linux 的互斥鎖,這樣既可以解決同一線(xiàn)程遞歸加鎖的問(wèn)題,又可以避免很多情況下死鎖的發(fā)生。這樣做還有一個(gè)額外的好處,就是可以讓 Windows 和 Linux 下讓鎖的表現統一。
注意 Linux 平臺上觸發(fā)條件變量的自動(dòng)復位問(wèn)題
條件變量的置位和復位有兩種常用模型:第一種模型是當條件變量置位(signaled)以后,如果當前沒(méi)有線(xiàn)程在等待,其狀態(tài)會(huì )保持為置位(signaled),直到有等待的線(xiàn)程進(jìn)入被觸發(fā),其狀態(tài)才會(huì )變?yōu)閺臀唬╱nsignaled),這種模型的采用以 Windows 平臺上的 Auto-set Event 為代表。其狀態(tài)變化如圖 1 所示:
圖 1. Windows 的條件變量狀態(tài)變化流程
第二種模型則是 Linux 平臺的 Pthread 所采用的模型,當條件變量置位(signaled)以后,即使當前沒(méi)有任何線(xiàn)程在等待,其狀態(tài)也會(huì )恢復為復位(unsignaled)狀態(tài)。其狀態(tài)變化如圖 2 所示:
圖 2. Linux 的條件變量狀態(tài)變化流程
具體來(lái)說(shuō),Linux 平臺上 Pthread 下的條件變量狀態(tài)變化模型是這樣工作的:調用 pthread_cond_signal() 釋放被條件阻塞的線(xiàn)程時(shí),無(wú)論存不存在被阻塞的線(xiàn)程,條件都將被重新復位,下一個(gè)被條件阻塞的線(xiàn)程將不受影響。而對于 Windows,當調用 SetEvent 觸發(fā) Auto-reset 的 Event 條件時(shí),如果沒(méi)有被條件阻塞的線(xiàn)程,那么條件將維持在觸發(fā)狀態(tài),直到有新的線(xiàn)程被條件阻塞并被釋放為止。
這種差異性對于那些熟悉 Windows 平臺上的條件變量狀態(tài)模型而要開(kāi)發(fā) Linux 平臺上多線(xiàn)程的程序員來(lái)說(shuō)可能會(huì )造成意想不到的尷尬結果。試想要實(shí)現一個(gè)旅客坐出租車(chē)的程序:旅客在路邊等出租車(chē),調用條件等待。出租車(chē)來(lái)了,將觸發(fā)條件,旅客停止等待并上車(chē)。一個(gè)出租車(chē)只能搭載一波乘客,于是我們使用單一觸發(fā)的條件變量。這個(gè)實(shí)現邏輯在第一個(gè)模型下即使出租車(chē)先到,也不會(huì )有什么問(wèn)題,其過(guò)程如圖 3 所示:
圖 3. 采用 Windows 條件變量模型的出租車(chē)實(shí)例流程
然而如果按照這個(gè)思路來(lái)在 Linux 上來(lái)實(shí)現,代碼看起來(lái)可能是清單 3 這樣。
清單 3. Linux 出租車(chē)案例代碼實(shí)例
……
// 提示出租車(chē)到達的條件變量
pthread_cond_t taxiCond;
// 同步鎖
pthread_mutex_t taxiMutex;
// 旅客到達等待出租車(chē)
void * traveler_arrive(void * name) {
cout<< ” Traveler: ” <<(char *)name<< ” needs a taxi now! ” <<endl;
pthread_mutex_lock(&taxiMutex);
pthread_cond_wait (&taxiCond, &taxtMutex);
pthread_mutex_unlock (&taxtMutex);
cout<< ” Traveler: ” << (char *)name << ” now got a taxi! ” <<endl;
pthread_exit( (void *)0 );
}
// 出租車(chē)到達
void * taxi_arrive(void *name) {
cout<< ” Taxi ” <<(char *)name<< ” arrives. ” <<endl;
pthread_cond_signal(&taxtCond);
pthread_exit( (void *)0 );
}
void main() {
// 初始化
taxtCond= PTHREAD_COND_INITIALIZER;
taxtMutex= PTHREAD_MUTEX_INITIALIZER;
pthread_t thread;
pthread_attr_t threadAttr;
pthread_attr_init(&threadAttr);
pthread_create(&thread, & threadAttr, taxt_arrive, (void *)( ” Jack ” ));
sleep(1);
pthread_create(&thread, &threadAttr, traveler_arrive, (void *)( ” Susan ” ));
sleep(1);
pthread_create(&thread, &threadAttr, taxi_arrive, (void *)( ” Mike ” ));
sleep(1);
return 0;
}
好的,運行一下,看看結果如清單 4 。
清單 4. 程序結果輸出
Taxi Jack arrives.
Traveler Susan needs a taxi now!
Taxi Mike arrives.
Traveler Susan now got a taxi.
其過(guò)程如圖 4 所示:
圖 4. 采用 Linux 條件變量模型的出租車(chē)實(shí)例流程
通過(guò)對比結果,你會(huì )發(fā)現同樣的邏輯,在 Linux 平臺上運行的結果卻完全是兩樣。對于在 Windows 平臺上的模型一, Jack 開(kāi)著(zhù)出租車(chē)到了站臺,觸發(fā)條件變量。如果沒(méi)顧客,條件變量將維持觸發(fā)狀態(tài),也就是說(shuō) Jack 停下車(chē)在那里等著(zhù)。直到 Susan 小姐來(lái)了站臺,執行等待條件來(lái)找出租車(chē)。 Susan 搭上 Jack 的出租車(chē)離開(kāi),同時(shí)條件變量被自動(dòng)復位。
但是到了 Linux 平臺,問(wèn)題就來(lái)了,Jack 到了站臺一看沒(méi)人,觸發(fā)的條件變量被直接復位,于是 Jack 排在等待隊列里面。來(lái)遲一秒的 Susan 小姐到了站臺卻看不到在那里等待的 Jack,只能等待,直到 Mike 開(kāi)車(chē)趕到,重新觸發(fā)條件變量,Susan 才上了 Mike 的車(chē)。這對于在排隊系統前面的 Jack 是不公平的,而問(wèn)題癥結是在于 Linux 平臺上條件變量觸發(fā)的自動(dòng)復位引起的一個(gè) Bug 。
條件變量在 Linux 平臺上的這種模型很難說(shuō)好壞。但是在實(shí)際開(kāi)發(fā)中,我們可以對代碼稍加改進(jìn)就可以避免這種差異的發(fā)生。由于這種差異只發(fā)生在觸發(fā)沒(méi)有被線(xiàn)程等待在條件變量的時(shí)刻,因此我們只需要掌握好觸發(fā)的時(shí)機即可。最簡(jiǎn)單的做法是增加一個(gè)計數器記錄等待線(xiàn)程的個(gè)數,在決定觸發(fā)條件變量前檢查下該變量即可。改進(jìn)后 Linux 函數如清單 5 所示。
清單 5. Linux 出租車(chē)案例代碼實(shí)例
……
// 提示出租車(chē)到達的條件變量
pthread_cond_t taxiCond;
// 同步鎖
pthread_mutex_t taxiMutex;
// 旅客人數,初始為 0
int travelerCount=0;
// 旅客到達等待出租車(chē)
void * traveler_arrive(void * name) {
cout<< ” Traveler: ” <<(char *)name<< ” needs a taxi now! ” <<endl;
pthread_mutex_lock(&taxiMutex);
// 提示旅客人數增加
travelerCount++;
pthread_cond_wait (&taxiCond, &taxiMutex);
pthread_mutex_unlock (&taxiMutex);
cout<< ” Traveler: ” << (char *)name << ” now got a taxi! ” <<endl;
pthread_exit( (void *)0 );
}
// 出租車(chē)到達
void * taxi_arrive(void *name)
{
cout<< ” Taxi ” <<(char *)name<< ” arrives. ” <<endl;
while(true)
{
pthread_mutex_lock(&taxiMutex);
// 當發(fā)現已經(jīng)有旅客在等待時(shí),才觸發(fā)條件變量
if(travelerCount>0)
{
pthread_cond_signal(&taxtCond);
pthread_mutex_unlock (&taxiMutex);
break;
}
pthread_mutex_unlock (&taxiMutex);
}
pthread_exit( (void *)0 );
}
因此我們建議在 Linux 平臺上要出發(fā)條件變量之前要檢查是否有等待的線(xiàn)程,只有當有線(xiàn)程在等待時(shí)才對條件變量進(jìn)行觸發(fā)。
注意條件返回時(shí)互斥鎖的解鎖問(wèn)題
在 Linux 調用 pthread_cond_wait 進(jìn)行條件變量等待操作時(shí),我們增加一個(gè)互斥變量參數是必要的,這是為了避免線(xiàn)程間的競爭和饑餓情況。但是當條件等待返回時(shí)候,需要注意的是一定不要遺漏對互斥變量進(jìn)行解鎖。
Linux 平臺上的 pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) 函數返回時(shí),互斥鎖 mutex 將處于鎖定狀態(tài)。因此之后如果需要對臨界區數據進(jìn)行重新訪(fǎng)問(wèn),則沒(méi)有必要對 mutex 就行重新加鎖。但是,隨之而來(lái)的問(wèn)題是,每次條件等待以后需要加入一步手動(dòng)的解鎖操作。正如前文中乘客等待出租車(chē)的 Linux 代碼如清單 6 所示:
清單 6. 條件變量返回后的解鎖實(shí)例
void * traveler_arrive(void * name) {
cout<< ” Traveler: ” <<(char *)name<< ” needs a taxi now! ” <<endl;
pthread_mutex_lock(&taxiMutex);
pthread_cond_wait (&taxiCond, &taxtMutex);
pthread_mutex_unlock (&taxtMutex);
cout<< ” Traveler: ” << (char *)name << ” now got a taxi! ” <<endl;
pthread_exit( (void *)0 );
}
這一點(diǎn)對于熟悉 Windows 平臺多線(xiàn)程開(kāi)發(fā)的開(kāi)發(fā)者來(lái)說(shuō)尤為重要。 Windows 上的 SignalObjectAndWait() 函數是常與 Linux 平臺上的 pthread_cond_wait() 函數被看作是跨平臺編程時(shí)的一對等價(jià)函數。但是需要注意的是,兩個(gè)函數退出時(shí)的狀態(tài)是不一樣的。在 Windows 平臺上,SignalObjectAndWait(HANDLE a, HANDLE b, …… ) 方法在調用結束返回時(shí)的狀態(tài)是 a 和 b 都是置位(signaled)狀態(tài),在普遍的使用方法中,a 經(jīng)常是一個(gè) Mutex 變量,在這種情況下,當返回時(shí),Mutex a 處于解鎖狀態(tài)(signaled),Event b 處于置位狀態(tài)(signaled), 因此,對于 Mutex a 而言,我們不需要考慮解鎖的問(wèn)題。而且,在 SignalObjectAndWait() 之后,如果需要對臨界區數據進(jìn)行重新訪(fǎng)問(wèn),都需要調用 WaitForSingleObject() 重新加鎖。這一點(diǎn)剛好與 Linux 下的 pthread_cond_wait() 完全相反。
Linux 對于 Windows 的這一點(diǎn)額外解鎖的操作區別很重要,一定得牢記。否則從 Windows 移植到 Linux 上的條件等待操作一旦忘了結束后的解鎖操作,程序將肯定會(huì )發(fā)生死鎖。
等待的絕對時(shí)間問(wèn)題
超時(shí)是多線(xiàn)程編程中一個(gè)常見(jiàn)的概念。例如,當你在 Linux 平臺下使用 pthread_cond_timedwait() 時(shí)就需要指定超時(shí)這個(gè)參數,以便這個(gè) API 的調用者最多只被阻塞指定的時(shí)間間隔。但是如果你是第一次使用這個(gè) API 時(shí),首先你需要了解的就是這個(gè) API 當中超時(shí)參數的特殊性(就如本節標題所提示的那樣)。我們首先來(lái)看一下這個(gè) API 的定義。 pthread_cond_timedwait() 定義請看清單 7 。
清單 7. pthread_cond_timedwait() 函數定義
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
參數 abstime 在這里用來(lái)表示和超時(shí)時(shí)間相關(guān)的一個(gè)參數,但是需要注意的是它所表示的是一個(gè)絕對時(shí)間,而不是一個(gè)時(shí)間間隔數值,只有當系統的當前時(shí)間達到或者超過(guò) abstime 所表示的時(shí)間時(shí),才會(huì )觸發(fā)超時(shí)事件。這對于擁有 Windows 平臺線(xiàn)程開(kāi)發(fā)經(jīng)驗的人來(lái)說(shuō)可能尤為困惑。因為 Windows 平臺下所有的 API 等待參數(如 SignalObjectAndWait,等)都是相對時(shí)間,
假設我們指定相對的超時(shí)時(shí)間參數如 dwMilliseconds (單位毫秒)來(lái)調用和超時(shí)相關(guān)的函數,這樣就需要將 dwMilliseconds 轉化為 Linux 下的絕對時(shí)間參數 abstime 使用。常用的轉換方法如清單 8 所示:
清單 8. 相對時(shí)間到絕對時(shí)間轉換實(shí)例
/* get the current time */
struct timeval now;
gettimeofday(&now, NULL);
/* add the offset to get timeout value */
abstime ->tv_nsec = now.tv_usec * 1000 + (dwMilliseconds % 1000) * 1000000;
abstime ->tv_sec = now.tv_sec + dwMilliseconds / 1000;
Linux 的絕對時(shí)間看似簡(jiǎn)單明了,卻是開(kāi)發(fā)中一個(gè)非常隱晦的陷阱。而且一旦你忘了時(shí)間轉換,可以想象,等待你的錯誤將是多么的令人頭疼:如果忘了把相對時(shí)間轉換成絕對時(shí)間,相當于你告訴系統你所等待的超時(shí)時(shí)間是過(guò)去式的 1970 年 1 月 1 號某個(gè)時(shí)間段,于是操作系統毫不猶豫馬上送給你一個(gè) timeout 的返回值,然后你會(huì )舉著(zhù)拳頭抱怨為什么另外一個(gè)同步線(xiàn)程耗時(shí)居然如此之久,并一頭扎進(jìn)尋找耗時(shí)原因的深淵里。
正確處理 Linux 平臺下的線(xiàn)程結束問(wèn)題
在 Linux 平臺下,當處理線(xiàn)程結束時(shí)需要注意的一個(gè)問(wèn)題就是如何讓一個(gè)線(xiàn)程善始善終,讓其所占資源得到正確釋放。在 Linux 平臺默認情況下,雖然各個(gè)線(xiàn)程之間是相互獨立的,一個(gè)線(xiàn)程的終止不會(huì )去通知或影響其他的線(xiàn)程。但是已經(jīng)終止的線(xiàn)程的資源并不會(huì )隨著(zhù)線(xiàn)程的終止而得到釋放,我們需要調用 pthread_join() 來(lái)獲得另一個(gè)線(xiàn)程的終止狀態(tài)并且釋放該線(xiàn)程所占的資源。 Pthread_join() 函數的定義如清單 9 。
清單 9. pthread_join 函數定義
int pthread_join(pthread_t th, void **thread_return);
調用該函數的線(xiàn)程將掛起,等待 th 所表示的線(xiàn)程的結束。 thread_return 是指向線(xiàn)程 th 返回值的指針。需要注意的是 th 所表示的線(xiàn)程必須是 joinable 的,即處于非 detached(游離)狀態(tài);并且只可以有唯一的一個(gè)線(xiàn)程對 th 調用 pthread_join() 。如果 th 處于 detached 狀態(tài),那么對 th 的 pthread_join() 調用將返回錯誤。
如果你壓根兒不關(guān)心一個(gè)線(xiàn)程的結束狀態(tài),那么也可以將一個(gè)線(xiàn)程設置為 detached 狀態(tài),從而來(lái)讓操作系統在該線(xiàn)程結束時(shí)來(lái)回收它所占的資源。將一個(gè)線(xiàn)程設置為 detached 狀態(tài)可以通過(guò)兩種方式來(lái)實(shí)現。一種是調用 pthread_detach() 函數,可以將線(xiàn)程 th 設置為 detached 狀態(tài)。其申明如清單 10 。
清單 10. pthread_detach 函數定義
int pthread_detach(pthread_t th);
另一種方法是在創(chuàng )建線(xiàn)程時(shí)就將它設置為 detached 狀態(tài),首先初始化一個(gè)線(xiàn)程屬性變量,然后將其設置為 detached 狀態(tài),最后將它作為參數傳入線(xiàn)程創(chuàng )建函數 pthread_create(),這樣所創(chuàng )建出來(lái)的線(xiàn)程就直接處于 detached 狀態(tài)。方法如清單 11 。
清單 11. 創(chuàng )建 detach 線(xiàn)程代碼實(shí)例
………………………………… ..
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&tid, &attr, THREAD_FUNCTION, arg);
總之為了在使用 Pthread 時(shí)避免線(xiàn)程的資源在線(xiàn)程結束時(shí)不能得到正確釋放,從而避免產(chǎn)生潛在的內存泄漏問(wèn)題,在對待線(xiàn)程結束時(shí),要確保該線(xiàn)程處于 detached 狀態(tài),否著(zhù)就需要調用 pthread_join() 函數來(lái)對其進(jìn)行資源回收。