論fork()函數與Linux中的多線程編程

轉載請說明出處:http://blog.csdn.net/cywosp/article/details/27316803


一、fork()函數
    在操作系統的基本概念中進程是程序的一次執行,且是擁有資源的最小單位和調度單位(在引入線程的操作系統中,線程是最小的調度單位)。在Linux系統中創建進程有兩種方式:一是由操作系統創建,二是由父進程創建進程(通常爲子進程)。系統調用函數fork()是創建一個新進程的唯一方式,當然vfork()也可以創建進程,但是實際上其還是調用了fork()函數。fork()函數是Linux系統中一個比較特殊的函數,其一次調用會有兩個返回值,下面是fork()函數的聲明:
#include <unistd.h>

// On success, The PID of the process is returned in the parent, and 0 is returned in the child. On failure,
// -1 is returned in the parent, no child process is created, and errno is set appropriately.
pid_t fork (void);

    當程序調用fork()函數並返回成功之後,程序就將變成兩個進程,調用fork()者爲父進程,後來生成者爲子進程。這兩個進程將執行相同的程序文本,但卻各自擁有不同的棧段、數據段以及堆棧拷貝。子進程的棧、數據以及棧段開始時是父進程內存相應各部分的完全拷貝,因此它們互不影響。從性能方面考慮,父進程到子進程的數據拷貝並不是創建時就拷貝了的,而是採用了寫時拷貝(copy-on -write)技術來處理。調用fork()之後,父進程與子進程的執行順序是我們無法確定的(即調度進程使用CPU),意識到這一點極爲重要,因爲在一些設計不好的程序中會導致資源競爭,從而出現不可預知的問題。下圖爲寫時拷貝技術處理前後的示意圖:
          

    在Linux系統中,常常存在許多對文件的操作,fork()的執行將會對文件操作帶來一些小麻煩。由於子進程會將父進程的大多數數據拷貝一份,這樣在文件操作中就意味着子進程會獲得父進程所有文件描述符的副本,這些副本的創建方式類似於dup()函數調用,因此父、子進程中對應的文件描述符均指向相同的打開的文件句柄,而且打開的文件句柄包含着當前文件的偏移量以及文件狀態標誌,所以在父子進程中處理文件時要考慮這種情況,以避免文件內容出現混亂或者別的問題。下圖爲執行fork()調用後文件描述符的相關處理及其變化:
                



二、線程
    與進程類似,線程(thread)是允許應用程序併發執行多個任務的一種機制。一個進程中可以包含多個線程,同一個程序中的所有線程均會獨立執行,且共享同一份全局內存區域,其中包括初始化數據段(initialized data),未初始化數據段(uninitialized data),以及堆內存段(heap segment)。在多處理器環境下,多個線程可以同時執行,如果線程數超過了CPU的個數,那麼每個線程的執行順序將是無法確定的,因此對於一些全局共享數據據需要使用同步機制來確保其的正確性。
    在系統中,線程也是稀缺資源,一個進程能同時創建多少個線程這取決於地址空間的大小和內核參數,一臺機器可以同時併發運行多少個線程也受限於CPU的數目。在進行程序設計時,我們應該精心規劃線程的個數,特別是根據機器CPU的數目來設置工作線程的數目,併爲關鍵任務保留足夠的計算資源。如果你設計的程序在背地裏啓動了額外的線程來執行任務,那這也屬於資源規劃漏算的情況,從而影響關鍵任務的執行,最終導致無法達到預期的性能。很多程序中都存在全局對象,這些全局對象的初始化工作都是在進入main()函數之前進行的,爲了能保證全局對象的安全初始化(按順序的),因此在程序進入main()函數之前應該避免線程的創建,從而杜絕未知錯誤的發生。

三、fork()與多線程
    在程序中fork()與多線程的協作性很差,這是POSIX系列操作系統的歷史包袱。因爲長期以來程序都是單線程的,fork()運轉正常。當20世紀90年代初期引入線程之後,fork()的適用範圍就大爲縮小了。
    在多線程執行的情況下調用fork()函數,僅會將發起調用的線程複製到子進程中。(子進程中該線程的ID與父進程中發起fork()調用的線程ID是一樣的,因此,線程ID相同的情況有時我們需要做特殊的處理。)也就是說不能同時創建出於父進程一樣多線程的子進程。其他線程均在子進程中立即停止並消失並且不會爲這些線程調用清理函數以及針對線程局部存儲變量的析構函數。這將導致下列一些問題:
1. 雖然只將發起fork()調用的線程複製到子進程中,但全局變量的狀態以及所有的pthreads對象(如互斥量、條件變量等)都會在子進程中得以保留,這就造成一個危險的局面。例如:一個線程在fork()被調用前鎖定了某個互斥量,且對某個全局變量的更新也做到了一半,此時fork()被調用,所有數據及狀態被拷貝到子進程中,那麼子進程中對該互斥量就無法解鎖(因爲其並非該互斥量的屬主),如果再試圖鎖定該互斥量就會導致死鎖,這是多線程編程中最不願意看到的情況。同時,全局變量的狀態也可能處於不一致的狀態,因爲對其更新的操作只做到了一半對應的線程就消失了。fork()函數被調用之後,子進程就相當於處於signal handler之中,此時就不能調用線程安全的函數(用鎖機制實現安全的函數),除非函數是可重入的,而只能調用異步信號安全(async-signal-safe)的函數。fork()之後,子進程不能調用:
  • malloc(3)。因爲malloc()在訪問全局狀態時會加鎖。
  • 任何可能分配或釋放內存的函數,包括new、map::insert()、snprintf() ……
  • 任何pthreads函數。你不能用pthread_cond_signal()去通知父進程,只能通過讀寫pipe(2)來同步。
  • printf()系列函數,因爲其他線程可能恰好持有stdout/stderr的鎖。
  • 除了man 7 signal中明確列出的“signal安全”函數之外的任何函數。
2. 因爲並未執行清理函數和針對線程局部存儲數據的析構函數,所以多線程情況下可能會導致子進程的內存泄露。另外,子進程中的線程可能無法訪問(父進程中)由其他線程所創建的線程局部存儲變量,因爲(子進程)沒有任何相應的引用指針。

    由於這些問題,推薦在多線程程序中調用fork()的唯一情況是:其後立即調用exec()函數執行另一個程序,徹底隔斷子進程與父進程的關係。由新的進程覆蓋掉原有的內存,使得子進程中的所有pthreads對象消失。
    對於那些必須執行fork(),而其後又無exec()緊隨其後的程序來說,pthreads API提供了一種機制:fork()處理函數。利用函數pthread_atfork()來創建fork()處理函數。pthread_atfork()聲明如下:
#include <pthread.h>

// Upon successful completion, pthread_atfork() shall return a value of zero; otherwise, an error number shall be returned to indicate the error.
// @prepare 新進程產生之前被調用
// @parent  新進程產生之後在父進程被調用
// @child    新進程產生之後,在子進程被調用
int pthread_atfork (void (*prepare) (void), void (*parent) (void), void (*child) (void));

該函數的作用就是往進程中註冊三個函數,以便在不同的階段調用,有了這三個參數,我們就可以在對應的函數中加入對應的處理功能。同時需要注意的是,每次調用pthread_atfork()函數會將prepare添加到一個函數列表中,創建子進程之前會(按與註冊次序相反的順序)自動執行該函數列表中函數。parentchild也會被添加到一個函數列表中,在fork()返回前,分別在父子進程中自動執行(按註冊的順序)。具體事例可參考:http://blog.chinaunix.net/uid-26885237-id-3210394.html

四、總結
    fork()函數的調用會導致在子進程中除調用線程外的其它線程全都終止執行並消失,因此在多線程的情況下會導致死鎖和內存泄露的情況。在進行多線程編程的時候儘量避免fork()的調用,同時在程序在進入main函數之前應避免創建線程,因爲這會影響到全局對象的安全初始化。線程不應該被強行終止,因爲這樣它就沒有機會調用清理函數來做相應的操作,同時也就沒有機會來釋放已被鎖住的鎖,如果另一線程對未被解鎖的鎖進行加鎖,那麼將會立即發生死鎖,從而導致程序無法正常運行。


參考
[1] Linux/UNIX系統編程手冊(上)
[2] Linux多線程服務端編程使用muduo C++網絡庫
[3] http://blog.chinaunix.net/uid-26885237-id-3210394.html
發佈了8 篇原創文章 · 獲贊 32 · 訪問量 30萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章