簡單來講,進程就是運行中的程序。更進一步,在用戶空間中,進程是加載器根據(jù)程序頭提供的信息將程序加載到內(nèi)存并運行的實體。 在本文中,我們就來深挖進程在用戶空間內(nèi)的更多細(xì)節(jié),主要包括以下幾部分內(nèi)容:
01進程的虛擬空間排布1.1 虛擬空間及其功能在理解虛擬空間排布之前,先要明確虛擬空間的概念。在《攻克 Linux 系統(tǒng)編程》中,我們解釋了的 ELF 文件頭中指定的程序入口地址,各個節(jié)區(qū)在程序運行時的內(nèi)存排布地址等,指的都是在進程虛擬空間中的地址。 虛擬空間可以認(rèn)為是操作系統(tǒng)給每個進程準(zhǔn)備的沙盒,就像電影《黑客帝國》中 Matrix 給每個人準(zhǔn)備的充滿營養(yǎng)液的容器一樣。 實際上,每個進程只存活在自己的虛擬世界里,卻感覺自己獨占了所有的系統(tǒng)資源(內(nèi)存)。 當(dāng)一個進程要使用某塊內(nèi)存時,它會將自己世界里的一個內(nèi)存地址告訴操作系統(tǒng),剩下的事情就由操作系統(tǒng)接管了。 操作系統(tǒng)中的內(nèi)存管理策略將決定映射哪塊真實的物理內(nèi)存,供應(yīng)用使用。操作系統(tǒng)會竭盡全力滿足所有進程合法的內(nèi)存訪問請求。 一旦發(fā)現(xiàn)應(yīng)用試圖訪問非法內(nèi)存,它將會把進程殺死,防止它做“壞事”影響到系統(tǒng)或其他進程。 這樣做,一方面為了安全,防止進程操作其他進程或者系統(tǒng)內(nèi)核的數(shù)據(jù);另一方面為了保證系統(tǒng)可同時運行多個進程,且單個進程使用的內(nèi)存空間可以超過實際的物理內(nèi)存容量。 該做法的另一結(jié)果則是降低了每個進程內(nèi)存管理的復(fù)雜度,進程只需關(guān)心如何使用自己線性排列的虛擬地址,而不需關(guān)心物理內(nèi)存的實際容量,以及如何使用真實的物理內(nèi)存。 1.2 虛擬空間地址排布在 32 位系統(tǒng)下,進程的虛擬地址空間有 4G,其中的 1G 分配給了內(nèi)核空間,用戶應(yīng)用可以使用剩余的 3G。 在 64 位的 Linux 系統(tǒng)上,進程的虛擬地址空間可以達到 256TB,內(nèi)核和應(yīng)用分別占用 128TB。目前看來,這樣的地址空間范圍足夠用了。 一個典型的內(nèi)存排布結(jié)構(gòu)如下圖所示: 圖#1中,《深入程序布局內(nèi)部》中討論過的內(nèi)容,是按照 ELF 文件中的程序頭信息,加載文件內(nèi)容所得到的。除此之外,加載器還會為每個應(yīng)用分配棧區(qū)(Stack)、堆區(qū)(Heap)和動態(tài)鏈接庫加載區(qū)。棧和堆分別向相對的方向增長,系統(tǒng)會有相應(yīng)的保護措施,阻止越界行為發(fā)生。 在 Linux 系統(tǒng)中,使用如下命令可查看一個運行中的進程的內(nèi)存排布。 稍微修改上一篇中的示例代碼,在 main 函數(shù)返回之前,增加一個無限循環(huán),保持程序一直運行。 啟動程序并查看該進程的內(nèi)存布局,可以看到如下所示的信息: 從以上輸出的內(nèi)容中,可以直觀看到進程的段、堆區(qū),動態(tài)鏈接庫加載區(qū),棧區(qū)的邏輯地址排布,以及每塊內(nèi)存區(qū)分配到的權(quán)限等。 除此之外,還有兩塊 vdso 和 vsyscall 內(nèi)存區(qū)。它們是一部分內(nèi)核數(shù)據(jù)在用戶空間的映射,為了提高應(yīng)用的性能而創(chuàng)建。在《攻克 Linux 系統(tǒng)編程》中,我們再專門詳細(xì)討論。 02進程的啟動從用戶角度來看,啟動一個進程有許多種方式,可以配置開機自啟動,可以在 Shell 中手動運行,也可以從腳本或其他進程中啟動。 而從開發(fā)人員角度看,無非就是兩個系統(tǒng)調(diào)用,即 fork() 和 execve()。下面就來探究下這兩個系統(tǒng)調(diào)用的行為細(xì)節(jié)。 2.1 fork() 系統(tǒng)調(diào)用fork() 系統(tǒng)調(diào)用將創(chuàng)建一個與父進程幾乎一樣的新進程,之后繼續(xù)執(zhí)行下面的指令。程序可以根據(jù) fork() 的返回值,確定當(dāng)前處于父進程中,還是子進程中——在父進程中,返回值為新創(chuàng)建子進程的進程 ID,在子進程中,返回值是 0。 一些使用多進程模型的服務(wù)器程序(比如 sshd),就是通過 fork() 系統(tǒng)調(diào)用來實現(xiàn)的,每當(dāng)新用戶接入時,系統(tǒng)就會專門創(chuàng)建一個新進程,來服務(wù)該用戶。 fork() 系統(tǒng)調(diào)用所創(chuàng)建的新進程,與其父進程的內(nèi)存布局和數(shù)據(jù)幾乎一模一樣。在內(nèi)核中,它們的代碼段所在的只讀存儲區(qū)會共享相同的物理內(nèi)存頁,可讀可寫的數(shù)據(jù)段、堆及棧等內(nèi)存,內(nèi)核會使用寫時拷貝技術(shù),為每個進程獨立創(chuàng)建一份。 在 fork() 系統(tǒng)調(diào)用剛剛執(zhí)行完的那一刻,子進程即可擁有一份與父進程完全一樣的數(shù)據(jù)拷貝。對于已打開的文件,內(nèi)核會增加每個文件描述符的引用計數(shù),每個進程都可以用相同的文件句柄訪問同一個文件。 深入理解了這些底層行為細(xì)節(jié),就可以順理成章地理解 fork() 的一些行為表現(xiàn)和正確使用規(guī)范,無需死記硬背,也可獲得一些別人踩過坑后才能獲得的經(jīng)驗。 比如,使用多進程模型的網(wǎng)絡(luò)服務(wù)程序中,為什么要在子進程中關(guān)閉監(jiān)聽套接字,同時要在父進程中關(guān)閉新連接的套接字呢? 原因在于 fork() 執(zhí)行之后,所有已經(jīng)打開的套接字都被增加了引用計數(shù),在其中任一個進程中都無法徹底關(guān)閉套接字,只能減少該文件的引用計數(shù)。 因此,在 fork() 之后,每個進程立即關(guān)閉不再需要的文件是個好的策略,否則很容易導(dǎo)致大量沒有正確關(guān)閉的文件一直占用系統(tǒng)資源的現(xiàn)象。 再比如,下面這段代碼是否存在問題?為什么在輸出文件中會出現(xiàn)兩行重復(fù)的文本? 輸入文本: 原因是 fputs 庫函數(shù)帶有緩沖,fork() 創(chuàng)建的子進程完全拷貝父進程用戶空間內(nèi)存時,fputs 庫函數(shù)的緩沖區(qū)也被包含進來了。 所以,fork() 執(zhí)行之后,子進程同樣獲得了一份 fputs 緩沖區(qū)中的數(shù)據(jù),導(dǎo)致“Message in parent”這條消息在子進程中又被輸出了一次。要解決這個問題,只需在 fork() 之前,利用 fflush 打開文件即可,讀者可自行驗證 。 另外,希望讀者自己思考下,利用父子進程共享相同的只讀數(shù)據(jù)段的特性,是不是可以實現(xiàn)一套父子進程間的通信機制呢? 2.2 execve() 系統(tǒng)調(diào)用execve() 系統(tǒng)調(diào)用的作用是運行另外一個指定的程序。它會把新程序加載到當(dāng)前進程的內(nèi)存空間內(nèi),當(dāng)前的進程會被丟棄,它的堆、棧和所有的段數(shù)據(jù)都會被新進程相應(yīng)的部分代替,然后會從新程序的初始化代碼和 main 函數(shù)開始運行。同時,進程的 ID 將保持不變。 execve() 系統(tǒng)調(diào)用通常與 fork() 系統(tǒng)調(diào)用配合使用。從一個進程中啟動另一個程序時,通常是先 fork() 一個子進程,然后在子進程中使用 execve() 變身為運行指定程序的進程。 例如,當(dāng)用戶在 Shell 下輸入一條命令啟動指定程序時,Shell 就是先 fork() 了自身進程,然后在子進程中使用 execve() 來運行指定的程序。 execve() 系統(tǒng)調(diào)用的函數(shù)原型為: filename 用于指定要運行的程序的文件名,argv 和 envp 分別指定程序的運行參數(shù)和環(huán)境變量。除此之外,該系列函數(shù)還有很多變體,它們執(zhí)行大體相同的功能,區(qū)別在于需要的參數(shù)不同,包括 execl、execlp、execle、execv、execvp、execvpe 等。它們的參數(shù)意義和使用方法請讀者自行查看幫助手冊。 需要注意的是,exec 系列函數(shù)的返回值只在遇到錯誤的時候才有意義。如果新程序成功地被執(zhí)行,那么當(dāng)前進程的所有數(shù)據(jù)就都被新進程替換掉了,所以永遠(yuǎn)也不會有任何返回值。 對于已打開文件的處理,在 exec() 系列函數(shù)執(zhí)行之前,應(yīng)該確保全部關(guān)閉。因為 exec() 調(diào)用之后,當(dāng)前進程就完全變身成另外一個進程了,老進程的所有數(shù)據(jù)都不存在了。 如果 exec() 調(diào)用失敗,當(dāng)前打開的文件狀態(tài)應(yīng)該被保留下來。讓應(yīng)用層處理這種情況會非常棘手,而且有些文件可能是在某個庫函數(shù)內(nèi)部打開的,應(yīng)用對此并不知情,更談不上正確地維護它們的狀態(tài)了。 所以,對于執(zhí)行 exec() 函數(shù)的應(yīng)用,應(yīng)該總是使用內(nèi)核為文件提供的執(zhí)行時關(guān)閉標(biāo)志(FD_CLOEXEC)。設(shè)置了該標(biāo)志之后,如果 exec() 執(zhí)行成功,文件就會被自動關(guān)閉;如果 exec() 執(zhí)行失敗,那么文件會繼續(xù)保持打開狀態(tài)。使用系統(tǒng)調(diào)用 fcntl() 可以設(shè)置該標(biāo)志。 2.3 fexecve() 函數(shù)glibc 從 2.3.2 版本開始提供 fexecv() 函數(shù),它與 execve() 的區(qū)別在于,第一個參數(shù)使用的是打開的文件描述符,而非文件路徑名。 增加這個函數(shù)是為了滿足這樣的應(yīng)用需求:有些應(yīng)用在執(zhí)行某個程序文件之前,需要先打開文件驗證文件內(nèi)容的校驗和,確保文件內(nèi)容沒有被惡意修改過。 在這種情景下,使用 fexecve 是更加安全的方案。組合使用 open() 和 execve() 雖然可以實現(xiàn)同樣的功能,但是在打開文件和執(zhí)行文件之間,存在被執(zhí)行的程序文件被掉包的可能性。 03監(jiān)控子進程狀態(tài)在 Linux 應(yīng)用中,父進程需要監(jiān)控其創(chuàng)建的所有子進程的退出狀態(tài),可以通過如下幾個系統(tǒng)調(diào)用來實現(xiàn)。
更詳細(xì)的信息請參考幫助手冊。 本文要重點討論的是:即使父進程在業(yè)務(wù)邏輯上不關(guān)心子進程的終止?fàn)顟B(tài),也需要使用 wait 類系統(tǒng)調(diào)用的底層原因。 這其中的要點在于:在 Linux 的內(nèi)核實現(xiàn)中,允許父進程在子進程創(chuàng)建之后的任意時刻用 wait() 系列系統(tǒng)調(diào)用來確定子進程的狀態(tài)。 也就是說,如果子進程在父進程調(diào)用 wait() 之前就終止了,內(nèi)核需要保留該子進程的終止?fàn)顟B(tài)和資源使用等數(shù)據(jù),直到父進程執(zhí)行 wait() 把這些數(shù)據(jù)取走。 在子進程終止到父進程獲取退出狀態(tài)之間的這段時間,這個進程會變成所謂的僵尸狀態(tài),在該狀態(tài)下,任何信號都無法結(jié)束它。如果系統(tǒng)中存在大量此類僵尸進程,勢必會占用大量內(nèi)核資源,甚至?xí)?dǎo)致新進程創(chuàng)建失敗。 如果父進程也終止,那么 init 進程會接管這些僵尸進程并自動調(diào)用 wait ,從而把它們從系統(tǒng)中移除。但是對于長期運行的服務(wù)器程序,這一定不是開發(fā)者希望看到的結(jié)果。所以,父進程一定要仔細(xì)維護好它創(chuàng)建的所有子進程的狀態(tài),防止僵尸進程的產(chǎn)生。 04進程的終止正常終止一個進程可以用 _exit 系統(tǒng)調(diào)用來實現(xiàn),原型為: 其中的 status 會返回 wait() 類的系統(tǒng)調(diào)用。進程退出時會清理掉該進程占用的所有系統(tǒng)資源,包括關(guān)閉打開的文件描述符、釋放持有的文件鎖和內(nèi)存鎖、取消內(nèi)存映射等,還會給一些子進程發(fā)送信號(后面課程再詳細(xì)展開)。該系統(tǒng)調(diào)用一定會成功,永遠(yuǎn)不會返回。 在退出之前,還希望做一些個性化的清理操作,可以使用庫函數(shù) exit() 。函數(shù)原型為: 這個庫函數(shù)先調(diào)用退出處理程序,然后再利用 status 參數(shù)調(diào)用 _exit() 系統(tǒng)調(diào)用。這里的退出處理程序可以通過 atexit() 或 on_exit() 函數(shù)注冊。 其中 atexit() 只能注冊返回值和參數(shù)都為空的回調(diào)函數(shù),而 on_exit() 可以注冊帶參數(shù)的回調(diào)函數(shù)。退出處理函數(shù)的執(zhí)行順序與注冊順序相反。它們的函數(shù)原型如下所示: 通常情況下,個性化的退出處理函數(shù)只會在主進程中執(zhí)行一次,所以 exit() 函數(shù)一般在主進程中使用,而在子進程中只使用 _exit() 系統(tǒng)調(diào)用結(jié)束當(dāng)前進程。 05總結(jié)本文深入探究了 Linux 進程在用戶空間的一些內(nèi)部細(xì)節(jié),包括邏輯內(nèi)存排布、進程創(chuàng)建和變身的內(nèi)部細(xì)節(jié)、進程狀態(tài)監(jiān)控的目的和接口,以及終止進程的正確姿勢等。 對這些底層實現(xiàn)細(xì)節(jié)的充分理解,能幫助讀者更好地理解各個系統(tǒng)調(diào)用的行為表現(xiàn),并根據(jù)具體的應(yīng)用需求選擇正確、合適的實現(xiàn)方案。 |
|
|