Unix下fork與pthread混用的問題

(先是來自雲風的Blog

在 POSIX 標準中,fork 的行爲是這樣的:複製整個用戶空間的數據(通常使用 copy-on-write 的策略,所以可以實現的速度很快)以及所有系統對象,然後僅複製當前線程到子進程。這裏:所有父進程中別的線程,到了子進程中都是突然蒸發掉的。

其它線程的突然消失,是一切問題的根源。

我之前從未寫過多進程多線程程序,不過公司裏有 David Xu 同學(他實現維護着 FreeBSD 的線程庫)是這方面的專家,今天跟徐同學討論了一下午,終於覺得自己搞明白了其中的糾結。嗯,寫點東西整理一下思路。

可能產生的最嚴重的問題是鎖的問題。

因爲爲了性能,大部分系統的鎖是實現在用戶空間的。所以鎖對象會因爲 fork 複製到子進程中。

對於鎖來說,從 OS 看,每個鎖有一個所有者,即最後一次 lock 它的線程。

假設這麼一個環境,在 fork 之前,有一個子線程 lock 了某個鎖,獲得了對鎖的所有權。fork 以後,在子進程中,所有的額外線程都人間蒸發了。而鎖卻被正常複製了,在子進程看來,這個鎖沒有主人,所以沒有任何人可以對它解鎖。

當子進程想 lock 這個鎖時,不再有任何手段可以解開了。程序發生死鎖。

爲何,POSIX 指定標準時,會定下這麼一個顯然不靠譜的規則?允許複製一個完全死掉的鎖?答案是歷史和性能。因爲歷史上,把鎖實現在用戶態是最方便的(今天依舊如此)。背後可能只需要一條原子操作指令即可。大多數 CPU 都支持的。fork 只管用戶空間的複製,不會涉及其中的對象細節。

一般的慣例,多線程程序 fork 前,應該由發起 fork 的線程 lock 所有子進程可能用到的鎖,fork 後,把它們一一 unlock 。當然,這樣的做法就隱含了鎖的次序。如果次序和平時不同,那麼就會死鎖。

不光是顯式的使用鎖,許多 CRT 函數也會間接的使用。比如 fprintf 這些文件操作。因爲對 FILE * 的操作是依靠鎖來達到線程安全的。最常見的問題是在子線程裏調用 fprintf 寫 log 。

除此之外,就是要小心一些不依賴鎖的數據一致性問題了。比如若在父進程裏另一個線程中操作一個鏈表,fork 發生時,因爲其它線程的突然消失,這個鏈表就可能會因爲只操作了一半而是不完整的數據。不過這一般不會是問題,或者可以歸咎於對鎖的處理。(多個線程,訪問同一塊數據。比如一條鏈表。就是需要加鎖的)

最後引用討論中, David Xu 的話 “POSIX這個問題一直是討論的熱門話題。而且雙方立場很清楚,一方是使用者,另外一方是實現者,雙方互相指責”

(然後來自另一篇Blog

UNIX上C++程序設計守則3

準則3:多線程程序裏不準使用fork

在多線程程序裏,在”自身以外的線程存在的狀態”下一使用fork的話,就可能引起各種各樣的問題.比較典型的例子就是,fork出來的子進程可能會死鎖.請不要,在不能把握問題的原委的情況下就在多線程程序裏fork子進程.

執行下面的代碼,在子進程的執行開始處調用doit()時,發生死鎖的機率會很高.

void* doit(void*) {
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    pthread_mutex_lock(&mutex);
    struct timespec ts = {10, 0}; nanosleep(&ts, 0); // 睡10秒
    pthread_mutex_unlock(&mutex);
    return 0;
}

int main(void) {
        pthread_t t;

        pthread_create(&t, 0, doit, 0); // 做成並啓動子線程
        if (fork() == 0) {
              //子進程
             //在子進程被創建的瞬間,父的子進程在執行nanosleep的場合比較多
              doit(0);

              return 0;
        }
        pthread_join(t, 0); //
         // 等待子線程結束
}

以下是說明死鎖的理由。

一般的,fork做如下事情
   1. 父進程的內存數據會原封不動的拷貝到子進程中
   2. 子進程在單線程狀態下被生成

在內存區域裏,靜態變量mutex的內存會被拷貝到子進程裏。而且,父進程裏即使存在多個線程,但它們也不會被繼承到子進程裏。 fork的這兩個特徵就是造成死鎖的原因。
死鎖原因的詳細解釋 ---
    1. 線程裏的doit()先執行。
    2. doit執行的時候會給互斥體變量mutex加鎖。
    3. mutex變量的內容會原樣拷貝到fork出來的子進程中(在此之前,mutex變量的內容已經被線程改寫成鎖定狀態)。
    4. 子進程再次調用doit的時候,在鎖定互斥體mutex的時候會發現它已經被加鎖,所以就一直等待,直到擁有該互斥體的進程釋放它(實際上沒有人擁有這個mutex鎖)。
    5. 線程的doit執行完成之前會把自己的mutex釋放,但這是的mutex和子進程裏的mutex已經是兩份內存。所以即使釋放了mutex鎖也不會對子進程裏的mutex造成什麼影響。

像這裏的doit函數那樣的,在多線程裏因爲fork而引起問題的函數,我們把它叫做”fork-unsafe函數”。反之,不能引起問題的函數叫做”fork-safe函數”。malloc函數就是一個維持自身固有mutex的典型例子,通常情況下它是fork-unsafe的。

如何規避災難呢?

規避方法1:做fork的時候,在它之前讓其他的線程完全終止。

在fork之前,讓其他的線程完全終止的話,則不會引起問題。但這僅僅是可能的情況。還有,因爲一些原因而其他線程不能結束就執行了fork的時候,就會是產生出一些解析困難的不具合的問題。

 

規避方法2:fork後在子進程中馬上調用exec函數

不用使用規避方法1的時候,在fork後不調用任何函數(printf等)就馬上調用execl等,exec系列的函數。如果在程序裏不使用”沒有exec就fork”的話,這應該就是實際的規避方法吧。

 

規避方法3:”其他線程”中,不做fork-unsafe的處理

除了調用fork的線程,其他的所有線程不要做fork-unsafe的處理。爲了提高數值計算的速度而使用線程的場合,這可能是fork- safe的處理,但是在一般的應用程序裏則不是這樣的。即使僅僅是把握了那些函數是fork-safe的,做起來還不是很容易的。fork-safe函數,必須是異步信號安全函數,而他們都是能數的過來的。因此,malloc/new,printf這些函數是不能使用的。

規避方法4:使用pthread_atfork函數,在即將fork之前調用事先準備的回調函數。apue中詳細介紹了它

使用pthread_atfork函數,在即將fork之前調用事先準備的回調函數,在這個回調函數內,協商清除進程的內存數據。但是關於OS提供的函數 (例:malloc),在回調函數裏沒有清除它的方法。因爲malloc裏使用的數據結構在外部是看不見的。因此,pthread_atfork函數幾乎是沒有什麼實用價值的。

規避方法5:在多線程程序裏,不使用fork

就是不使用fork的方法。即用pthread_create來代替fork。這跟規避策2一樣都是比較實際的方法,值得推薦。

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