fork() 複製你;exec() 替換你
從上一篇導引你已知道 fork() 做什麼:它把一個行程裂成兩個近乎相同的行程,一個父、一個子,各自擁有同一份記憶體的副本,跑著同一個程式。那很強大,但請注意它的極限——兩半仍然是同一個程式。光靠 fork(),你頂多只能得到你已經在跑的那段程式碼的更多副本。它沒有辦法去啟動,比方說,`ls` 程式或 `gcc` 編譯器。然而執行別的程式,正是一個外殼(shell)、或任何啟動器,非做不可的事。那道缺口,正是 exec() 要補上的缺口。
這個念頭,直說了吧。exec() 拿起你此刻所在的這個行程,把它裡面的程式換掉。它丟棄你目前的程式碼、你目前的資料、你的堆積、你的堆疊——整個行程映像——並在那原地,從磁碟載入一個截然不同的執行檔,從那個程式的起點開始跑它。關鍵而奇異的地方在於:這個行程並沒有死去,也沒有一個新行程誕生。從頭到尾都是同一個行程。把它想成不太像「被替換」,而更像同一個演員穿著一套戲服走下舞台、再以一個完全不同的角色走回來——同一具身體,新的角色。
什麼倖存、什麼被抹去
如果 exec() 把行程裡的整個程式都丟掉,那究竟還剩下什麼站著?答案是那些屬於行程、而非屬於程式的部分。行程編號不變——在一次成功的 exec() 前後各呼叫一次 getpid(),你會拿到同一個數字,這是它是「一個連續的行程」最乾淨的證明。父子關係不變,所以原本在等這個行程的那一位,仍然在等它。而且,預設情況下,已開啟的檔案會倖存:你原本開著的每一個檔案描述符,都跨過這次 exec 繼續開著,指著同一個檔案或管線。
最後這一點不是趣聞——它正是外殼重導向與管線背後的機制,而我們等一下就會倚靠它。然而其他的一切都不見了。新程式拿到的是一個全新、剛初始化好的記憶體映像:一段從新執行檔載入的新程式碼區、全新的全域資料、一個空的堆積、以及一個全新的堆疊。你設過的任何變數、你 malloc() 出來的任何記憶體、你做到一半的任何函式——這一切都在 exec() 成功的那一瞬間消失,因為那些記憶體屬於舊程式,而舊程式已不復存在。沒有「回到我們原本所在之處」這回事;根本無處可回。
這就是為什麼一次成功的 exec() 永不返回。尋常的函式會返回給它的呼叫者;exec() 沒有呼叫者可返回,因為呼叫它的那段程式碼剛剛已被覆寫掉。所以在你的原始碼裡,寫在 exec() 呼叫之後的任何一行,只有在 exec 失敗時才會跑。如果執行檔不存在、或你沒有權限,exec 會返回 -1 並設定 errno;如果它成功,控制權就乾脆再也不回來。這個顛倒的邏輯——「exec 之後的程式碼是錯誤處理路徑」——每個人頭一次都會被嚇到,所以讓它現在就嚇到你,而不是稍後在除錯器裡才嚇到你。
先 fork 再 exec:每個命令究竟怎麼跑起來
現在把兩者合起來。一個外殼想跑 `ls`,卻不想把自己毀掉——它必須啟動新程式,但事後仍然在那裡,準備好接你的下一個命令。光靠 fork() 能讓外殼活著,卻只會跑出更多外殼。光靠 exec() 能跑 `ls`,卻會在這麼做的同時把外殼抹掉。答案是那個經典的 先 fork 再 exec 模式:先 fork() 造出一份子副本,然後讓那個子行程呼叫 exec() 去變成 `ls`,而父外殼則保持它自己、等候著。子行程是那個換戲服的演員;父行程則從不離開舞台。
pid_t pid = fork();
if (pid < 0) {
perror("fork"); /* fork itself failed */
return 1;
}
if (pid == 0) {
/* --- child: become a different program --- */
char *args[] = { "ls", "-l", NULL }; /* argv, NULL-terminated */
execvp("ls", args);
/* only reached if exec FAILED: */
perror("execvp");
_exit(127); /* child must not fall back into parent's code */
}
/* --- parent: still itself, wait for the child to finish --- */
int status;
waitpid(pid, &status, 0);把這幅圖走一遍。fork() 之後,有兩個行程在跑這段程式碼。在子行程裡,pid 是 0,於是它進入那個分支、組出一份引數清單、呼叫 execvp("ls", args)——就在那一刻,子行程不再是你的程式,開始成為 ls,帶著它一微秒前的同一個 PID。在父行程裡,pid 是子行程的 PID,於是它跳過子行程分支、直接走到 waitpid(),暫停下來直到子行程(現在是 `ls`)結束。請注意已開啟的檔案如何跨過 exec 倖存:子行程同時跨過 fork 與 exec 繼承了外殼的標準輸出,這正是為什麼 `ls` 會印到同一個終端機——也正是為什麼在 exec 之前重導向那個描述符,恰恰就是 `ls > out.txt` 的運作方式。
exec 家族:傳遞名字、引數與環境
其實沒有一個函式真的就叫 exec;有的是一個小小的家族——execl、execv、execlp、execvp、execle、execve——而「exec」後面那些字母,是一套整齊的代碼,表示每一個各自期待什麼。家族的基底是 execve(),而在底下,其他每一個變體都只是個更友善的包裝、最終會去呼叫它。後綴的意思是:`l`(list,清單)把引數當成一個個分開拼出的參數;`v`(vector,向量)把它們當成單一一個字串陣列;`p`(path,路徑)會去搜尋你 PATH 裡的目錄,讓你能說「ls」而不必給完整路徑;而 `e`(environment,環境)讓你把一組自訂的環境變數交給新程式。
那些引數裡有兩個,正是抵達新程式 main() 的那同樣兩個——那個 `int argc` 計數與那個 `argv` 字串陣列——這個連結到此刻才終於接上。你傳給 execvp() 的那個字串陣列,會變成新程式所見的 argv,而它的長度會變成 argc。依一個由來已久的約定,第一個元素是程式自己的名字,這正是為什麼上面那個 `args[0]` 是「ls」,即使我們已經把「ls」指名為要跑的檔案。那個陣列,就像 argv 本身,必須以一個 NULL 指標結尾,好讓新程式知道它的引數清單在哪裡結束。(那個參數確切的 C 宣告該放進程式碼、而非散文裡——就是你在上面片段裡看到的 `char ** argv`。)
`e` 那幾個變體、以及那個預設會倖存下來的環境,值得清楚說一句。環境是第二份、與引數平行的「名字=值」字串清單——PATH、HOME、LANG 等等——每個程式都會繼承、也都能讀。多數 exec 變體會把你目前的環境自動傳遞給新程式,這正是為什麼子行程看到的 PATH 跟你一樣。execve() 與 execle() 則讓你提供一份明確的環境,於是你能用一組刻意控制過的變數來啟動一個程式。這正是外殼如何把一個變數匯出(export)給它所跑的命令、以及你如何能用比方說一個不同的 LANG 去跑某個程式、卻不擾動你自己的方式。
把它做對:值得知道的那些出錯模式
因為 exec() 是一個行程與一個新程式之間的門口,它出錯的種種方式值得誠實地點名、而非藏起來。第一個、也最常見:exec 會就這麼失敗,而你必須處理它之後的那幾行。當檔案不存在、不可執行、不是一個有效的程式、或 PATH 找不到它時,exec 就會失敗。當這在一個 fork 出來的子行程裡發生,那個子行程仍在跑你的程式——所以它必須在失敗的 exec 之後立刻呼叫 _exit()(而不是只繼續往下走),否則它會掉下去、開始以一個脫韁的副本身分執行父行程的邏輯。上面那個骨架就這麼做了;抄一段省掉這步的程式碼,是個真實又討厭的臭蟲。
第二個誠實的提醒:檔案描述符預設跨過 exec 倖存,通常正是你要的,但並非總是如此。有時一個子行程不該繼承某個特定的已開啟檔案——把一個敏感的描述符洩漏進一個不相干的程式,是個真實的安全顧慮。為此有一個逐描述符的旗標,「exec 時關閉」(close-on-exec),它告訴核心在任何 exec 一成功的那一瞬間自動關掉那個描述符;你用 fcntl()、或用 O_CLOEXEC 開檔來設它。值得帶走的重點只是:「描述符跨過 exec 倖存」是個預設、而非鐵律,謹慎的程式會刻意挑選哪些該越過這道邊界。
最後,握住那個把本級串起來的心智模型。fork() 回答「再造一個行程」;exec() 回答「跑一個不同的程式」;兩者合起來,正是新程式得以開始的方式。下一篇導引會接起我們上面在 waitpid() 那裡留著沒收的線頭——父行程如何在 exec 出來的那個程式結束後,去收取子行程的結束狀態,以及若它忘了會出什麼亂子。把那個換戲服的影像記在心裡:子行程換了角色、演完它的場景,而父行程此刻正在側幕等著聽聽結果如何。