fork() 沒談完的那筆交易
到第 3 篇結尾,你已經能孵出一支真正的程式:父行程呼叫 fork(),子行程呼叫 exec 家族之一,於是兩個行程並肩跑著——父行程繼續走自己的路,子行程則去當 `ls`、`gcc` 或你啟動的任何東西。那就是 先 fork 再 exec 的模式,而它確實正是 shell 啟動每一道命令的方式。但它留下了一個沒收尾的線頭。那個子行程終究會結束——它會從 main() 跑出 `return 0`、或呼叫 `exit()`、或崩潰。當它結束時,兩個自然的問題冒了出來,而 fork() 和 exec() 都沒回答:它成功了嗎?還有,程式既已落幕,這個行程接下來會怎樣?
回答這兩者的機制就是 wait()。父行程呼叫 `pid_t child = wait(&status)`,這個呼叫會阻塞——父行程就在那裡停住——直到它的某個子行程結束。當一個子行程死去,wait() 喚醒父行程、回傳那個子行程的 PID,並把一個小小的狀態碼寫進你指給它的那個 `int` 裡。這就是等待與回收:父行程收下一個已完成子行程的消息。這層關係刻意是單向且僅限家族的——一個行程只能等它自己的子行程,永遠不能等手足、父行程,或毫無關係的行程。fork() 之所以建立起親子的羈絆,正是為了讓這份報告能沿著家族的脈絡往上送達。
結束狀態是個小數字,裡頭裝著一些位元
當一支程式正常結束時,它交回一個單一的小整數——它的結束狀態。從 main() `return 0` 和 `exit(0)` 意思一樣:零說「我成功了」,而任何非零值都說「有東西出錯了」,那個特定的數字則是程式自己對是哪件事出錯的編碼。這是整個 Unix 世界都同意的慣例,所以 `$ ./a.out && echo ok` 只有在 a.out 以零結束時才印出 `ok`,也所以 `make` 會在某個編譯器一回傳非零的瞬間就停下整個建置。結束狀態是程式的遺言——它告訴啟動它的那一方,這份工作究竟做完了沒。
這裡有個絆倒每個初學者的陷阱:wait() 填進去的那個 `int`,並不是結束狀態本身。核心把不只一則消息塞進那個字裡,因為一個子行程可以用不只一種方式結束——它可能帶著某個狀態正常退出,也可能是被某個信號殺掉的(一次區段錯誤、一個從終端機來的 `kill`)。所以那些位元是被編碼過的,你絕不可直接去讀那個原始整數。取而代之,你用一小組標準巨集來解開它。歷史上的排法是把結束碼放在高位元組、把(若有的)殺手信號放在低位元,但你永遠不該自己靠那套算術——那些巨集存在的理由,正是讓你不必這麼做。
int status;
pid_t child = wait(&status); /* block until a child ends */
if (child == -1) { /* no children, or interrupted */
perror("wait");
} else if (WIFEXITED(status)) { /* child called exit()/returned */
int code = WEXITSTATUS(status); /* the 0..255 it handed back */
printf("pid %d exited with %d\n", child, code);
} else if (WIFSIGNALED(status)) { /* child was killed by a signal */
int sig = WTERMSIG(status);
printf("pid %d killed by signal %d\n", child, sig);
}為什麼父行程必須回收:殭屍短暫的一生
現在來談 wait() 為何不是可有可無的更深理由。當一個子行程結束時,核心不能就這樣把一切都丟掉——因為結束狀態得保留在某處,直到父行程來問,而父行程可能正忙著、要過很久才來問。於是核心拆掉了死去子行程的大部分(它的記憶體、它開著的檔案、它的行程映像),卻留下一個極小的軀殼:行程表裡的一筆紀錄,只裝著 PID 與結束狀態。一個處在這種中間狀態的行程——程式已落幕、但狀態還沒被取走——就是一個 殭屍。它死了卻還沒下葬,正等著它的父行程來讀它留下的狀態。
呼叫 wait() 這個動作,正是最終讓核心釋放掉那最後一個軀殼的東西——這就是「等待與回收」裡的回收。當 wait() 回傳子行程的狀態時,核心移除那筆行程表紀錄,PID 變得可以重用,殭屍也就永遠消失了。一個只在子行程死去到父行程回收它之間活幾毫秒的殭屍,完全正常、毫無害處。危險的是從不呼叫 wait() 的父行程:那時軀殼就會堆積起來,每個結束的子行程留一個,每個都扣著一個 PID 當人質。一個會 fork 出工人卻忘了回收它們的長命伺服器,會慢慢漏掉 PID,直到系統用罄、再也 fork 不出任何東西。回收是一件你跳不過的家務。
waitpid():挑哪個子行程,以及不阻塞
單純的 wait() 有兩個限制,只要你有超過一個子行程就會被它咬到。第一,它等的是任何一個子行程——你沒得指定是哪個——而它回傳的是剛好最先結束的那一個。第二,它總是阻塞,所以若你還有別的事要做就被卡住了。較完整的呼叫 waitpid(pid, &status, options) 把這兩點都解除了。給它一個特定的 PID,它就只等那個確切的子行程;給它 `-1`,它的行為就和 wait() 一樣,等任何一個。而第三個引數,才是真正彈性所在之處。
改變一切的那個選項是 WNOHANG:它告訴 waitpid()「如果還沒有子行程結束,就別阻塞——立刻回傳 0,讓我繼續走」。這把回收從一面你硬撞的牆,變成了一次快速的輪詢。一個伺服器可以去做它真正的工作,然後三不五時在一個迴圈裡呼叫 `waitpid(-1, &status, WNOHANG)`,掃掉自上次以來死去的任何子行程,一個個回收,直到那個呼叫回傳 0(沒有更多就緒的)或 -1(根本沒有子行程)。那個迴圈,就是任何「管理一池子行程、又不在它們跑時凍住自己」的程式背後的標準模式。
- 先決定你要什麼:單一子行程的簡單情形用 wait()(「啟動它、阻塞、讀它的結果」);當你有好幾個子行程、或必須持續工作時用 waitpid()。
- 永遠檢查回傳值。wait()/waitpid() 回傳被回收的 PID、0(只有搭 WNOHANG 時:還沒有就緒的)、或出錯時的 -1——而當為 -1 時去讀 errno,其中 ECHILD 代表「你已經沒有任何子行程可等了」。
- 用巨集照順序解碼:正常退出時先 WIFEXITED 再 WEXITSTATUS;被殺掉時先 WIFSIGNALED 再 WTERMSIG。絕不要自己去解讀那個原始的狀態整數。
- 要不阻塞地回收一整批,就用迴圈反覆呼叫 waitpid(-1, &status, WNOHANG),一直回收到它回傳 0 或 -1 為止;這會在一次掃蕩中清空每一個已結束的子行程。
另一邊:把 exit() 做對
我們一直站在父行程的窗邊;讓我轉過身,從子行程這側來看。結束是一個行程如何收場、並產出父行程將會讀到的那個狀態。乾淨的做法是從 main() `return n`——它的行為就好像把 main 的值傳給了 exit()——以及在任何地方直接呼叫 `exit(n)`。exit() 在行程死去之前會做些真正的事:它執行用 atexit() 登記過的函式,而且關鍵地,它沖刷 C 標準函式庫的 I/O 緩衝區,好讓你 printf 過、卻還躺在緩衝區裡的東西真的抵達檔案或終端機。那次沖刷,正是為什麼 `exit()` 與較低階的 `_exit()` 不能互換。
這件事在剛 fork() 完之後特別要緊。子行程繼承了父行程帶緩衝輸出的一份拷貝,所以若一個子行程要中途放棄、而不是去跑它原本打算的 exec(),它通常應該呼叫 _exit() 而非 exit()——_exit() 立刻結束行程、不做沖刷,這就避免了子行程把父行程已經握著的緩衝區再印出第二份。這是這個章節裡真正微妙的互動之一,也正是那種會產出「這行為什麼印了兩次?」臭蟲的細節。我們在此不多糾纏,但先插上一面旗:在一個剛 fork 出來、又沒去 exec 的子行程裡,優先用 _exit()。
退一步看,整個循環就閉合了。fork() 把行程一分為二成父與子;exec() 把子行程變成一支新程式;子行程跑起來、最終呼叫 exit(n),把它的裁決打包成一個狀態,由核心擱在一個短暫的殭屍裡;父行程呼叫 wait()、讀那份裁決、回收那個軀殼。啟動、執行、回報、收取。你的 shell 跑的每一道命令都走這條確切的弧線,而你現在已經看過了它全部四個角——這正是你迎接最後一篇所需要的立足點,那一篇我們會去追:當時序出了差錯、父與子彼此活得比對方久時,會發生什麼事。