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