天天看點

謹慎使用多線程中的 fork !!!!

【原文連結】

前言

在單核時代,大家所編寫的程式都是單程序 / 單線程程式。随着計算機硬體技術的發展,進入了多核時代後,為了降低響應時間,重複充分利用多核 cpu 的資源,使用多程序程式設計的手段逐漸被人們接受和掌握。然而因為建立一個程序代價比較大,多線程程式設計的手段也就逐漸被人們認可和喜愛了。

記得在我剛剛學習線程程序的時候就想,為什麼很少見人把多程序和多線程結合起來使用呢,把二者結合起來不是更好嗎?現在想想當初真是 too young too simple,後文就主要讨論一下這個問題。

程序與線程

程序 的經典定義就是一個執行中的程式的執行個體。系統中的每個程式都是運作在某個程序的 context 中的。context 是由程式正确運作所需的狀态組成的,這個狀态包括存放在存儲器中的程式的代碼和資料,它的棧、通用目的寄存器的内容、程式計數器(PC)、環境變量以及打開的檔案描述符的集合。

程序主要提供給上層的應用程式兩個抽象:

  • 一個獨立的邏輯控制流,它提供一個假象,好像我們程式獨占的使用處理器。
  • 一個私有的虛拟位址空間,它提供一個假象,好像我們的程式獨占的使用存儲器系統。

線程 ,就是運作在程序 context 中的邏輯流。線程由核心自動排程。每個線程都有它自己的線程 context,包括一個唯一的整數線程 ID、棧、棧指針、程式計數器(PC)、通用目的寄存器和條件碼。每個線程和運作在同一程序内的其他線程一起共享程序 context 的剩餘部分。這包括 整個使用者虛拟位址空間,它是由隻讀文本(代碼)、讀 / 寫資料、堆以及所有的共享庫代碼和資料區域組成。線程也同樣共享打開檔案的集合。

——即程序是資源管理的最小機關,而線程是程式執行的最小機關。

在 linux 系統中,posix 線程可以 “看做” 為一種輕量級的程序,pthread_create 建立線程和 fork 建立程序都是在核心中調用__clone 函數建立的,隻不過建立線程或程序的時候選項不同,比如是否共享虛拟位址空間、檔案描述符等。

多線程程序中的fork

我們知道通過 fork 建立的一個子程序幾乎但不完全與父程序相同。子程序得到與父程序使用者級虛拟位址空間相同的(但是獨立的)一份拷貝,包括文本、資料和 bss 段、堆以及使用者棧等。子程序還獲得與父程序任何打開檔案描述符相同的拷貝,這就意味着子程序可以讀寫父程序中任何打開的檔案,父程序和子程序之間最大的差別在于它們有着不同的 PID。

但是有一點需要注意的是,在 Linux 中,fork 的時候隻複制目前線程到子程序,在 fork(2)-Linux Man Page 中有着這樣一段相關的描述:

The child process is created with a single thread–the one that called fork(). The entire virtual address space of the parent is replicated in the child, including the states of mutexes, condition variables, and other pthreads objects; the use of pthread_atfork(3) may be helpful for dealing with problems that this can cause.

也就是說除了調用 fork 的線程外,其他線程在子程序中 “蒸發” 了。

這就是多線程中 fork 所帶來的一切問題的根源所在了。

多線程程序中互斥鎖引發的fork問題

互斥鎖,就是多線程 fork 大部分問題的關鍵部分。

在大多數作業系統上,為了性能的因素,鎖基本上都是實作在使用者态的而非核心态(因為在使用者态實作最友善,基本上就是通過原子操作或者之前文章中提到的 memory barrier 實作的),是以調用 fork 的時候,會複制父程序的所有鎖到子程序中。

問題就出在這了。從作業系統的角度上看,對于每一個鎖都有它的持有者,即 對它進行 lock 操作的線程。假設在 fork 之前,一個線程對某個鎖進行的 lock 操作,即持有了該鎖,然後 另外一個線程調用了 fork 建立子程序。可是 在子程序中持有那個鎖的線程卻 “消失” 了,從子程序的角度來看,這個鎖被 “永久” 的上鎖了,因為它的持有者 “蒸發” 了。

那麼如果子程序中的任何一個線程對這個已經被持有的鎖進行 lock 操作話,就會發生死鎖。

當然了有人會說可以在 fork 之前,讓準備調用 fork 的線程擷取所有的鎖,然後再在 fork 出的子程序的中釋放每一個鎖。先不說現實中的業務邏輯以及其他因素允不允許這樣做,這種做法會帶來一個問題,那就是隐含了一種上鎖的先後順序,如果次序和平時不同,就會發生死鎖。

如果你說自己一定可以按正确的順序上鎖而不出錯的話,還有一個隐含的問題是 你所不能控制的,那就是庫函數。

因為你不能确定你所用到的所有庫函數都不會使用共享資料,即他們都是完全線程安全的。有相當一部分線程安全的庫函數都是在内部通過持有互斥鎖的方式來實作的,比如幾乎所有程式都會用到的 C/C++ 标準庫函數 malloc、printf 等等。

比如一個多線程程式在 fork 之前難免會配置設定動态記憶體,這就必然會用到 malloc 函數;而在 fork 之後的子程序中也難免要配置設定動态記憶體,這也同樣要用到 malloc,可這卻是不安全的,因為有可能 malloc 内部的鎖已經在 fork 之前被某一個線程所持有了,而那個線程卻在子程序中消失了。

場景解決方案

exec 與檔案描述符

按照上文的分析,似乎多線程中在 fork 出的子程序中立刻調用 exec 函數是唯一明智的選擇了,其實即使這樣做還是有一點不足。因為子程序會繼承父程序中所有已打開的檔案描述符,是以在執行 exec 之前子程序仍然可以讀寫父程序中的檔案,但如果你不希望子程序能讀寫父程序裡的某個已打開的檔案該怎麼辦?

或許 fcntl 設定檔案屬性是一種辦法:

int  fd = open( "file" , O_RDWR | O_CREAT);
if  (fd < 0)
{
     perror ( "open" );
}
fcntl(fd, F_SETFD, FD_CLOEXEC);
           

但是如果在 open 打開 file 檔案之後,調用 fcntl 設定 CLOEXEC 屬性之前有其他線程 fork 出了子程序了的話,這個子程序仍然是可以讀寫 file 檔案。如果用鎖的話,就又回到了上文所讨論的情況了。

從 Linux 2.6.23 版本的核心開始,我們可以在 open 中設定 O_CLOEXEC 标志了,相當于 “打開檔案再設定 CLOEXEC” 成為了一個原子操作。這樣在 fork 出的子程序執行 exec 之前就不能讀寫父程序中已打開的檔案了。

pthread_atfork

如果你不幸真的碰到了一個要解決多線程中 fork 的問題的時候,可以嘗試使用 pthread_atfork:

prepare : 處理函數由父程序在 fork 建立子程序前調用,這個函數的任務是擷取父程序定義的所有鎖。

parent : 處理函數是在 fork 建立了子程序以後,但在 fork 傳回之前在父程序環境中調用的。它的任務是對 prepare 擷取的所有鎖解鎖。

child : 處理函數在 fork 傳回之前在子程序環境中調用,與 parent 處理函數一樣,它也必須解鎖所有 prepare 中所擷取的鎖。

因為__子程序繼承的是父程序的鎖的拷貝,所有上述并不是解鎖了兩次,而是各自獨自解鎖__。可以多次調用 pthread_atfork 函數進而設定多套 fork 處理程式,但是使用多個處理程式的時候。處理程式的調用順序并不相同。parent 和 child 是以它們注冊時的順序調用的,而 prepare 的調用順序與注冊順序相反。這樣可以允許多個子產品注冊它們自己的處理程式并且保持鎖的層次(類似于多個 RAII 對象的構造析構層次)。

#include <mutex>
// 以 ProcessState::init(const char *driver, bool requireDefault) 為例
int ret = pthread_atfork(ProcessState::onFork, ProcessState::parentPostFork, ProcessState::childPostFork);

void ProcessState::onFork() {
	// make sure another thread isn't currently retrieving ProcessState
    gProcessMutex.lock();
}
  
void ProcessState::parentPostFork() {
	gProcessMutex.unlock();
}
 
void ProcessState::childPostFork() {
     // another thread might call fork before gProcess is instantiated, but after
     // the thread handler is installed
     if (gProcess) {
         gProcess->mForked = true;
     }
     gProcessMutex.unlock();
}
           

需要注意的是 pthread_atfork 隻能清理鎖,但不能清理條件變量。在有些系統的實作中條件變量不需要清理。但是在有的系統中,條件變量的實作中包含了鎖,這種情況就需要清理。但是目前并沒有清理條件變量的接口和方法。

結語

  • 在多線程程式中最好隻用 fork 來執行 exec 函數,不要對 fork 出的子程序進行其他任何操作。
  • 如果确定要在多線程中通過 fork 出的子程序執行 exec 函數,那麼在 fork 之前打開檔案描述符時需要加上 CLOEXEC 标志。

繼續閱讀