謹慎使用多線程中的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大部分問題的關鍵部分。
在大多數操作系統上,為了性能的因素,鎖基本上都是實現在用戶態的而非內核態(因為在用戶態實現最方便,基本上就是通過原子操作或者之前文章中提到的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設置文件屬性是一種辦法:
1 2 3 4 5 6 |
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:
1 |
int pthread_atfork( void (*prepare)( void ), void (*parent) void (), void (*child)( void ));
|
- prepare處理函數由父進程在fork創建子進程前調用,這個函數的任務是獲取父進程定義的所有鎖。
- parent處理函數是在fork創建了子進程以後,但在fork返回之前在父進程環境中調用的。它的任務是對prepare獲取的所有鎖解鎖。
- child處理函數在fork返回之前在子進程環境中調用,與parent處理函數一樣,它也必須解鎖所有prepare中所獲取的鎖。
因為子進程繼承的是父進程的鎖的拷貝,所有上述並不是解鎖了兩次,而是各自獨自解鎖。可以多次調用pthread_atfork函數從而設置多套fork處理程序,但是使用多個處理程序的時候。處理程序的調用順序並不相同。parent和child是以它們註冊時的順序調用的,而prepare的調用順序與註冊順序相反。這樣可以允許多個模塊註冊它們自己的處理程序並且保持鎖的層次(類似於多個RAII對象的構造析構層次)。
需要註意的是pthread_atfork只能清理鎖,但不能清理條件變量。在有些系統的實現中條件變量不需要清理。但是在有的系統中,條件變量的實現中包含了鎖,這種情況就需要清理。但是目前並沒有清理條件變量的接口和方法。
結語
- 在多線程程序中最好只用fork來執行exec函數,不要對fork出的子進程進行其他任何操作。
- 如果確定要在多線程中通過fork出的子進程執行exec函數,那麽在fork之前打開文件描述符時需要加上CLOEXEC標誌。
謹慎使用多線程中的fork 學習!!!!