JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

行程不是程式

程式是硬碟上一個沒生命的檔案;行程則是那個檔案被喚醒、正在執行的樣子。本篇要在兩者之間畫出界線,給那個「正在跑的東西」一個名字、一個在家族樹裡的位置,並擺出一小組動作——fork、exec、wait——本階接下來會一篇一個地把它們拆開來看。

那個檔案,與那個正在跑的東西

你已經知道一支程式是怎麼誕生的了:你寫下 C 原始碼,工具鏈把它編譯、連結,掉出來一個躺在硬碟上的執行檔——幾千位元組的機器碼、一些初始資料,外加一個標頭,告訴系統該怎麼把這一切擺放好。那個檔案,就是一支程式(program)。而本階整章繞著轉的那個安靜卻重要的真相是這樣的:那個檔案,沒有在跑任何東西。它是不動的。它是一份被寫下來的食譜、一張紙上的樂譜、一抽屜裡的藍圖。你可以複製它、用電子郵件寄出它、整整一年都不去碰它,什麼也不會發生,因為一支程式,只是一堆靜止的位元組。

行程(process)則是當核心拿起那個沒生命的檔案、把它喚醒之後,你得到的東西。要啟動一個行程,核心會把程式的位元組從硬碟讀進來,把它們鋪排進一個全新的位址空間裡——程式碼放這、全域資料放那、一塊往上長的堆積、一塊往下長的堆疊——替它打開標準串流,把程式計數器設到程式的進入點,然後把 CPU 放開去跑它。位元組開始流動了。那個有生命的、正在執行的個體,帶著它自己私有的記憶體與它自己在程式碼裡當前的位置,就是一個行程。程式是名詞;行程是運動中的動詞。

每個行程都有一個名字:PID 與它的父親

如果行程是核心所管理的有生命的東西,它就需要一個辦法去指稱每一個。所以在一個行程誕生的那一刻,核心會替它蓋上一個獨一無二的號碼,叫做行程識別碼(process ID),或 PID——就只是一個正整數,比如 4127。PID 是整個系統用來指稱那一個執行實例的把手:當你打 `kill 4127`、當核心替它排程、當除錯器掛上它時,PID 就是它被稱呼的方式。明天再把同一支程式跑一次,它會拿到一個不同的 PID,因為 PID 命名的是這一次特定的執行,而不是那支程式。PID 之於行程,就像位址之於位元組:是系統用來精確地指向其中一個的方式。

但一個 PID 並不是孤伶伶地到來的。每一個行程同時也帶著「創造它的那個行程」的 PID——它的父行程識別碼(parent process ID),或 PPID。這之所以重要,是因為本階整章最令人意外的一件事,也就是下一篇整篇要講的那件事:一個全新的行程,並不是從虛無中冒出來的。要造出一個行程,唯一的辦法,是讓一個既有的行程用 fork() 把自己複製一份,生出一個幾乎一模一樣的子行程。所以除了最最開頭那一個以外,每一個行程都剛好有一個父親,而那個父親的 PID 就記在子行程裡頭。PPID 就是那條把整個系統繫成一個家族的線,而那也正是我們接下來要去的地方。

那棵家族樹,與它根部的始祖

順著那些「父」連結走下去,一個形狀就浮現了。你的 shell 是被終端機程式 fork 出來的;終端機是被你的桌面工作階段 fork 出來的;那個工作階段一個父親接一個父親地往回追,朝向單獨一個最初的行程。因為每個行程剛好有一個父親、卻可以有許多孩子,這些連結永遠不會繞成一個圈——它們構成一棵。這就是行程樹,而在任何一個瞬間,它都是你機器上一切正在執行之物的真實地圖:誰啟動了誰,一路往上。指令 `pstree` 會替你把它畫出來,而 `ps -ef` 會把它列成一張扁平的表格,帶著 PID 與 PPID 兩欄,讓你可以用手追下去。

每棵樹都有一個根,而這一棵的根很特別。當核心開機完成時,它會親手打造出最最開頭的那個行程——那個從來不是被任何東西 fork 出來的行程——並給它 PID 1。這就是 init(在大多數現代 Linux 系統上,是一支叫 systemd 的程式)。機器上其他每一個行程,都是 PID 1 的後代;它是整棵樹共同的始祖。PID 1 還有一份我們在本階結尾會用到的第二份工作:它是孤兒行程——那些父親比它們先死掉的行程——被指定的監護人。當一個父親結束、留下一些孩子時,核心會悄悄地把那些孩子改認到 PID 1 底下,這樣,永遠不會有一個正在執行的行程被丟在那兒沒有父親。

那三個動作:fork、exec、wait

接下來這一段,幾乎每個人第一次看到都會嚇一跳,所以現在先溫和地認識它,後面幾篇我們再好好地把它掙來。你大概會以為,會有單獨一個呼叫,名字像是「執行這支程式」——把一個檔名交給它,拿回一個新的行程。Unix 不是這樣運作的。它反而把這件工作切成兩半。首先,fork() 複製當前的行程:一個行程呼叫它,然後——令人吃驚地——兩個行程從它那裡返回——一個父親、一個幾乎一模一樣的孩子,同樣的程式碼、同樣的資料、同樣已開啟的檔案,唯一的差別只在於:哪一個被告知它是孩子。fork() 做的是把你複製一份;它本身,並不會去跑任何新的程式。

第二個動作是 exec()。如果說 fork() 做的是複製一份,那 exec() 做的就是一次脫胎換骨:它拿起呼叫它的那個行程,並把它整副內容——程式碼、資料、堆積、堆疊,全部——替換成另一支從硬碟重新載入的程式。PID 不會改變;那個有生命的行程保住了它的身分與它已開啟的檔案,但裡頭的那副身體,如今已是一支完全不同的程式,從那支程式的第一條指令開始跑。exec() 並不創造一個行程;它覆寫掉那個已經在那裡的行程。把這兩個動作合起來,你就得到了 fork 之後 exec(fork-then-exec)這個模式——這正是你這輩子打過的每一條指令被執行起來的方式,其核心就是這一個動作:

pid_t pid = fork();              /* one process becomes two */
if (pid == -1) {                 /* fork failed: no child was made */
    perror("fork");
    return 1;
}
if (pid == 0) {                  /* this branch runs in the CHILD */
    execlp("ls", "ls", "-l", (char *)0);  /* become a different program */
    perror("execlp");            /* only reached if exec FAILED */
    _exit(127);
}
/* this branch runs in the PARENT; pid holds the child's PID */
int status;
waitpid(pid, &status, 0);        /* parent waits for the child to finish */
fork 之後 exec:shell 把自己 fork 一份,子行程變身成 ls,父行程則等待。注意 fork() 在子行程裡回傳 0、在父行程裡回傳子行程的 PID——就是這唯一的差別,讓每一份複本知道自己是哪一個。每一個呼叫都有檢查。

第三個動作把這個迴圈收尾。當子行程做完它的工作、呼叫 exit() 時,它並不是就這麼消失——它會留下一張小紙條,一個結束狀態(exit status),一個說明「它跑得怎麼樣」的單一數字(0 代表成功,非零代表出了某個問題)。父行程藉由呼叫 wait() 來把那張紙條收回來,這個呼叫既把子行程的狀態交回給父親,也告訴核心:現在終於可以安全地把那個子行程最後的痕跡丟掉了。這個「收回」的動作叫做回收(reaping),而略過它是有後果的:一個已結束卻沒被回收的子行程,會變成一個殭屍,一筆空殼般的條目,滯留在行程表裡。fork 用來分裂、exec 用來變身、wait 用來回收——這就是整個生命週期,而每一個動作,前頭都有它自己的一篇。

孩子繼承了什麼,又帶走了什麼

一個新的行程並不是一塊空白的石板。當 fork() 造出子行程時,子行程繼承了父行程所擁有的幾乎一切的一份複本:同樣的記憶體內容、同樣已開啟的檔案描述符(所以子行程與父行程共用同一個標準輸出,這也是為什麼一個被 fork 出來的孩子,不必做任何特別的事就能印到你的終端機上)、同樣的當前工作目錄,以及還有一樣值得單獨點名的東西——環境環境是一串 NAME=VALUE 形式的字串,像 PATH=/usr/bin:/bin 或 HOME=/home/you,它隨著行程一同移動,並被傳承給它的孩子。它是父行程用來設定子行程、卻不必經由引數傳遞的那條安靜的旁路。

這份繼承,也正是為什麼「fork 之後 exec」這個拆分是有用的、而不是笨拙的。在 fork 與 exec 之間的那道縫隙裡——也就是那幾行只在子行程裡跑的程式碼——子行程可以在變成新程式之前,先調整它所繼承到的東西:關掉一個新程式不該看到的檔案描述符、把它的標準輸出重新導向到一個檔案裡、改變它的工作目錄、微調一個環境變數。每當你打一條帶重新導向的指令,像 `ls > out.txt`,shell 做的正是這件事:fork,然後在子行程裡把描述符 1 重新指向 out.txt,然後才 exec ls。一個把 fork 與 exec 一起做完的呼叫,就會留不下任何空間給這場手術。這兩個動作之間的那道接縫,正是整件事的重點所在。

在我們推近之前,先握住整幅圖

退一步,整個本階就是一幅清爽的圖。程式是一個靜止的檔案;行程是那個檔案在跑,帶著它自己私有的記憶體與它自己的 PID。行程從不憑空出現——每一個都是從一個父親 fork 出來的,所以它們全都繫在同一棵以 PID 1 為根的樹上。要真正去跑一支不同的程式,一個行程 fork 出一個孩子,孩子再 exec 進那支新程式;父行程稍後 wait,把孩子的結束狀態收回來並回收它。一切都在 fork 的那一刻繼承父行程的記憶體、檔案與環境,是複製的,從不往回共享。這就是整個模型。

從這裡開始,本階只是依序推近到每一個動作。〈fork():一個行程變成兩個〉鑽進那個看似不可能的瞬間——一個呼叫返回了兩次。〈exec():變身成另一支程式〉跟著那場「保住 PID、卻換掉身體」的脫胎換骨。〈wait()、結束狀態與回收子行程〉講的是把那張紙條收回來,以及你為什麼非收不可。而〈殭屍、孤兒與行程樹〉則正面迎向:當這份家族的帳被疏忽時會出什麼錯——孩子被丟著沒回收,或是父親先死了。你現在握住了這張地圖;接下來四篇,會走過它上面的每一條路。