天天看點

fork() 函數與 Linux 中的多線程程式設計

一、fork()函數

在作業系統的基本概念中程序是程式的一次執行,且是擁有資源的最小機關和排程機關(在引入線程的作業系統中,線程是最小的排程機關)。在linux系統中 建立程序有兩種方式:一是由作業系統建立,二是由父程序建立程序(通常為子程序)。系統調用函數fork()是建立一個新程序的唯一方式,當然 vfork()也可以建立程序,但是實際上其還是調用了fork()函數。fork()函數是linux系統中一個比較特殊的函數,其一次調用會有兩個返 回值,下面是fork()函數的聲明:

當程式調用fork()函數并傳回成功之後,程式就将變成兩個程序,調用fork()者為父程序,後來生成者為子程序。這兩個程序将執行相同的程式 文本, 但卻各自擁有不同的棧段、資料段以及堆棧拷貝。子程序的棧、資料以及棧段開始時是父程序記憶體相應各部分的完全拷貝,是以它們互不影響。從性能方面考慮,父 程序到子程序的資料拷貝并不是建立時就拷貝了的,而是采用了寫時拷貝(copy-on -write)技術來處理。調用fork()之後,父程序與子程序的執行順序是我們無法确定的(即排程程序使用cpu),意識到這一點極為重要,因為在一 些設計不好的程式中會導緻資源競争,進而出現不可預知的問題。下圖為寫時拷貝技術處理前後的示意圖:

fork() 函數與 Linux 中的多線程程式設計

在linux系統中,常常存在許多對檔案的操作,fork()的執行将會對檔案操作帶來一些小麻煩。由于子程序會将父程序的大多數資料拷貝一份,這樣在文 件操作中就意味着子程序會獲得父程序所有檔案描述符的副本,這些副本的建立方式類似于dup()函數調用,是以父、子程序中對應的檔案描述符均指向相同的 打開的檔案句柄,而且打開的檔案句柄包含着目前檔案的偏移量以及檔案狀态标志,是以在父子程序中處理檔案時要考慮這種情況,以避免檔案内容出現混亂或者别 的問題。下圖為執行fork()調用後檔案描述符的相關處理及其變化:

fork() 函數與 Linux 中的多線程程式設計

二、線程

與程序類似,線程(thread)是允許應用程式并發執行多個任務的一種機制。一個程序中可以包含多個線程,同一個程式中的所有線程均會獨立執行,且共享 同一份全局記憶體區域,其中包括初始化資料段(initialized data),未初始化資料段(uninitialized data),以及堆記憶體段(heap segment)。在多處理器環境下,多個線程可以同時執行,如果線程數超過了cpu的個數,那麼每個線程的執行順序将是無法确定的,是以對于一些全局共 享資料據需要使用同步機制來確定其的正确性。

在系統中,線程也是稀缺資源,一個程序能同時建立多少個線程這取決于位址空間的大小和核心參數,一台機器可以同時并發運作多少個線程也受限于cpu的數 目。在進行程式設計時,我們應該精心規劃線程的個數,特别是根據機器cpu的數目來設定工作線程的數目,并為關鍵任務保留足夠的計算資源。如果你設計的程 序在背地裡啟動了額外的線程來執行任務,那這也屬于資源規劃漏算的情況,進而影響關鍵任務的執行,最終導緻無法達到預期的性能。很多程式中都存在全局對 象,這些全局對象的初始化工作都是在進入main()函數之前進行的,為了能保證全局對象的安全初始化(按順序的),是以在程式進入main()函數之前 應該避免線程的建立,進而杜絕未知錯誤的發生。

三、fork()與多線程

在程式中fork()與多線程的協作性很差,這是posix系列作業系統的曆史包袱。因為長期以來程式都是單線程的,fork()運轉正常。當20世紀90年代初期引入線程之後,fork()的适用範圍就大為縮小了。

在多線程執行的情況下調用fork()函數,僅會将發起調用的線程複制到子程序中。(子程序中該線程的id與父程序中發起fork()調用的線程id是一 樣的,是以,線程id相同的情況有時我們需要做特殊的處理。)也就是說不能同時建立出于父程序一樣多線程的子程序。其他線程均在子程序中立即停止并消失, 并且不會為這些線程調用清理函數以及針對線程局部存儲變量的析構函數。這将導緻下列一些問題:

雖然隻将發起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安全”函數之外的任何函數。

因為并未執行清理函數和針對線程局部存儲資料的析構函數,是以多線程情況下可能會導緻子程序的記憶體洩露。另外,子程序中的線程可能無法通路(父程序中)由其他線程所建立的線程局部存儲變量,因為(子程序)沒有任何相應的引用指針。

由于這些問題,推薦在多線程程式中調用fork()的唯一情況是:其後立即調用exec()函數執行另一個程式,徹底隔斷子程序與父程序的關系。由新的程序覆寫掉原有的記憶體,使得子程序中的所有pthreads對象消失。

對于那些必須執行fork(),而其後又無exec()緊随其後的程式來說,pthreads api提供了一種機制:fork()處理函數。利用函數pthread_atfork()來建立fork()處理函數。pthread_atfork()聲明如下:

四、總結

fork()函數的調用會導緻在子程序中除調用線程外的其它線程全都終止執行并消失,是以在多線程的情況下會導緻死鎖和記憶體洩露的情況。在進行多線程程式設計 的時候盡量避免fork()的調用,同時在程式在進入main函數之前應避免建立線程,因為這會影響到全局對象的安全初始化。線程不應該被強行終止,因為 這樣它就沒有機會調用清理函數來做相應的操作,同時也就沒有機會來釋放已被鎖住的鎖,如果另一線程對未被解鎖的鎖進行加鎖,那麼将會立即發生死鎖,進而導 緻程式無法正常運作。

繼續閱讀