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

殭屍、孤兒,與行程樹

你現在會 fork 出子行程、用 exec 換上一支新程式,也會 wait 等它結束。這篇導引把那些線索綁在一起:機器上每一個行程都掛在一棵以 init 為根的樹上,而那棵樹的規則,正好就是父行程忘了 wait 時產生殭屍、父行程先死時產生孤兒的原因。

每個行程都掛在同一棵樹上

到現在你手上的零件都齊了。從第 2 篇你知道 fork() 把一個行程變成兩個——父行程與子行程,兩者只在 fork() 的返回值上不同。從第 4 篇你知道每個行程都有一個 PID,也記著它父行程的 PID,也就是它的 PPID。把這兩個事實湊在一起,一個形狀就自己浮現出來:子行程的 PPID 指向父行程,父行程的 PPID 又指向它的父行程,從任何一個行程順著 PPID 往上爬,總是會爬向單一的根。那個結構就是行程樹,它不是作業系統事後硬加上去的比喻——它就是那些 PPID 連結本來的樣子。

根上坐著什麼?當核心開機完成時,它親手打造出恰好一個行程,傳統上是 PID 1,也就是 init 行程(在現代 Linux 上這通常是 systemd)。PID 1 不是任何人 fork 出來的——核心直接造了它——而機器上其餘一切都靠 fork 從它繁衍下來。你的命令殼是從一個登入行程 fork 出來的,那個登入行程又是從 init fork 出來的;你的程式則是從命令殼 fork 出來的。所以整個運行中的系統就是一棵樹,init 在根上,而你的 `./a.out` 是底下遠處的某片葉子。這點值得具體記牢:除了 PID 1 之外,沒有哪個行程是沒有父行程的,而那單一的例外,正是本篇其餘部分賴以懸掛的錨點。

殭屍:已經結束,卻還沒被收屍

現在精確回想第 4 篇的規則,因為殭屍正是從它直接掉出來的。當一個子行程呼叫 exit() 或從 main 返回時,它停止運行,並交出幾乎一切:它的記憶體、它開啟的檔案描述符、它在 CPU 上的位置。但它並未徹底消失,因為核心必須留住最後一小片資訊——子行程的結束狀態——直到父行程用 wait() 來索取。一個已經終止、但狀態還沒被收走的行程,就是一個殭屍。這名字很精確:它死了(沒有程式碼在跑,它不占記憶體),卻仍在行程表裡占著一格,拒絕徹底離去。

為什麼連那一小片都要留?因為有一場真實的競賽。子行程可能在 fork() 返回後一微秒就結束,遠早於父行程輪到去呼叫 wait()。如果核心在子行程一死的瞬間就把結束狀態丟掉,一個動作慢的父行程就會永遠失去「我的孩子是怎麼結束的?」這個答案。所以核心把狀態保留著、以殭屍的形式撐住,直到 wait() 把它收屍——而 wait() 一返回,狀態就交了出去,殭屍最後那一格也就釋放了。收屍指的正是這個收取動作,每個守規矩的父行程,對它 fork 出的每個子行程都欠著一次 wait()。在子行程結束與父行程 wait() 之間短暫存在的殭屍,完全正常且無害;你會在 `ps` 裡看見它們一閃而過。

孤兒:父行程先走了

殭屍是父行程活得比子行程久、卻疏忽了它時發生的事。孤兒則是這個的鏡像:子行程活得比父行程久。沒有任何規則逼父行程在結束前要先 wait 它的子行程——父行程大可 fork 一個子行程、做完自己的事,然後在子行程還快樂地運行時呼叫 exit()。父行程一消失,那個還在運行的子行程,它的 PPID 就指向一個不再存在的行程了。它現在是個孤兒:活著、在工作,卻在一棵除了根以外每個節點都該有父行程的樹裡,沒了父行程。

樹容不下一根懸空的枝幹,所以核心會自動修補它。父行程一死的瞬間,核心便走訪它的子行程,把每一個都改認新父——它把每個孤兒的 PPID 改寫成 1,將孤兒們上交給 init。這正是 PID 1 在身為根之外、還有第二份工作的全部原因:init 的存在就是為了收養孤兒。從孤兒自己的角度看,幾乎什麼都沒變;它繼續跑它的程式碼,唯一看得見的差別是,此刻快查一下它的 PPID,返回的會是 1,而不是那個已離去之父行程的 PID。

現在這兩個概念扣在一起了,而這正是整篇導引的回報所在。孤兒自己遲早會結束、變成一個殭屍——那現在誰負責替它收屍?它的新父行程,init。而 init 的整套設計,就是坐在一個迴圈裡反覆呼叫 wait(),所以它會立刻替任何死在它看顧之下的孤兒收屍。這就是為什麼孤兒永遠不會變成卡住的殭屍:改認 init 為父,就保證了它們會被收屍。這也是為什麼當一個父行程洩漏殭屍時,殺掉那個父行程就能修好——被洩漏的殭屍會改認 init 為父,而 init 立刻把它們全收了。

一次一個 fork,走一遍

讓我們追一支極小的程式,好讓那些狀態不再是字詞、而成為你指得出來的事件。父行程 fork 一次,兩邊都印東西,而我們刻意讓父行程睡一下,好讓你抓住處於中間狀態的子行程。這就是你已見過的最精簡 fork 後接某事骨架,少了 exec——並請注意父行程檢查了 fork() 的返回值,因為這裡漏掉檢查,正是第 2 篇警告過的那種錯誤。

pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }

if (pid == 0) {
    /* child */
    printf("child pid=%d ppid=%d\n", getpid(), getppid());
    return 0;                 /* child terminates -> becomes a ZOMBIE
                                 until the parent reaps it */
} else {
    /* parent */
    sleep(10);                /* during this nap the dead child is a zombie */
    int status;
    wait(&status);            /* reap: the zombie is now fully gone */
}

/* If instead the PARENT returns here while the child still runs,
   the child is re-parented to init (PID 1): an ORPHAN. */
同一副骨架,兩種結局:子行程先死,以殭屍之姿等到父行程的 wait();或父行程先死,子行程被改認 init 為父、成為孤兒。
  1. fork() 返回。此刻有兩個行程;子行程的 PPID 就是父行程的 PID。
  2. 子行程印完並返回 0。它終止、釋放記憶體,並變成一個殭屍——它的結束狀態被留住,等著被收取。
  3. 父行程還在睡。此刻列出行程,你會看見子行程被標記為 Z(殭屍)或 <defunct>。
  4. 父行程醒來並呼叫 wait()。它讀走結束狀態,核心釋放子行程最後那一格,殭屍就此徹底消失。
  5. 另一種結局:若父行程在子行程結束前就返回了,核心會把子行程的 PPID 設為 1,而稍後替它收屍的,會是 init 而非原本的父行程。

誠實的邊角,與一張收尾的圖

幾個誠實的但書,因為這個整齊的故事有真實的稜角。第一,「孤兒」與「殭屍」不是對立、也不互斥——孤兒是父行程已死的活著的子行程,殭屍是還沒被收屍的死去的子行程;一個孤兒後來死了,就變成殭屍(接著被 init 收屍)。第二,在現代 Linux 上,收養孤兒的不一定真的是 PID 1:一個行程可以把自己設成「子收割者」(subreaper),讓它自己那些變成孤兒的後代被改認為父、而非 init,服務管理員就是這樣對自己的樹保持整齊的掌控。改寫 PPID 的機制是同一套;只是目的地不同。把「改認 init 為父」當成有明文例外的預設規則,而不是不可破的鐵律。

第三,初學者常見的反射是怕殭屍、想去殺掉它們——但一個殭屍早就死了,所以訊號毫無作用;解法永遠是讓父行程去收屍,而不是去攻擊屍體。第四,別把一個被阻塞睡著的子行程(活著,只是此刻沒在跑)和一個殭屍(已終止、正等著被收屍)搞混;第 1 篇講的行程狀態能把這些理清,而瞄一眼 `ps` 裡的狀態欄就能立刻分辨它們。

退一步看,整個級別就成了一幅連貫的圖。行程不是程式;fork() 把一個行程裂成兩個;exec() 把一個行程改塑成另一支程式;wait() 收取一個結束之子行程的狀態。這篇導引是圍著這四者的框:那些操作建起、又修剪一棵以 init 為根、活生生的樹,而那棵樹的規則,正好賦予了殭屍與孤兒它們精確而毫不靈異的含意。你現在握有完整的行程模型——而從這裡開始,下一個級別會讓這許多行程真正彼此交談。