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

建立行程:fork、exec 與行程樹

行程到底從哪裡來?在類 Unix 系統上,答案出奇地有趣:一個行程是靠複製自己誕生的,接著常常把這個分身原本的程式丟掉、換上一支全新的。一起認識 fork、exec,以及把你機器上每個行程都串在一起的那棵單一家族樹。

一種奇特的造行程方法

你已經知道行程是什麼、它棲身於何處,以及核心如何讓它在就緒、執行中、等待之間穿梭。但有個我們只稍微提過的問題還沒解決:一個全新的行程,到底是從哪裡冒出來的?你也許會猜,作業系統從磁碟讀進一支程式,再憑空圍著它變出一個新行程。在類 Unix 系統上,真正的答案更奇特、也優雅得多:一個行程是靠複製一個既有的行程誕生的。沒有任何咒語能從無到有變出行程——每一個行程,都是某個要求自己被複製的行程的後代。

這個複製的呼叫叫做 fork。當一個執行中的行程呼叫 fork() 時,核心會做出一份近乎完美的複本:相同的程式碼、相同的堆積與堆疊內容、相同的開啟檔案,甚至連在程式裡的位置都相同——這個複本會從 fork 之後的那一行原封不動地繼續執行下去。原本那個是父行程(parent),複本則是子行程(child),而這層父子關係正是把整個系統串起來的那條線。想像一位廚師煮到一半,突然分裂成兩位一模一樣的廚師,站在兩間一模一樣的廚房裡,各自把一根手指按在同一個步驟上。

一次呼叫,兩個回傳

這裡有個第一次接觸會讓人腦袋打結的地方:fork() 只被呼叫一次,卻回傳兩次——一次在父行程裡、一次在子行程裡。它們跑的是一模一樣的程式碼,那麼任何一方要怎麼知道自己是誰?核心的答案是:回傳給各自一個「不同的」回傳值。在子行程裡,fork 回傳 0。在父行程裡,fork 回傳子行程的行程識別碼(PID)(一個正整數)。而萬一核心根本無法建立子行程,fork 就只在父行程裡回傳 -1。於是一條簡單的 if 判斷,便能乾淨俐落地把兩條路徑分開。

pid = fork();                 // ONE call, TWO returns

if (pid < 0) {
    // fork failed: no child was created
} else if (pid == 0) {
    // we are the CHILD  (fork returned 0)
} else {
    // we are the PARENT (fork returned the child's PID = pid)
}
fork 的經典寫法。父子兩個行程跑的都是這同一段程式碼,但因為 fork 給了它們不同的回傳值,各自會走進不同的分支。

還有一個誠實的微妙之處:fork 之後,現在有兩個就緒的行程,而 CPU 先跑哪一個,由排程器決定——這「不保證」。一個假設父行程總是先於子行程執行(或反過來)的初學者,遲早會被一支「跑九十九次都對、第一百次卻出錯」的程式咬一口。如果順序真的重要,你就必須把它「明確寫出來」,而這正是接下來的兩塊拼圖——exec 與 wait——讓你能做到的事。

exec:遞給分身一份新食譜

複製只說了故事的一半。光是複製,只會給你同一支程式無止盡的複本——有時有用,但當你在 shell 裡敲一道指令時,你想要它跑「另一支不同的程式」,而不是再來一個 shell。這就是 exec 的工作。像 exec("/bin/ls") 這樣的呼叫,告訴核心:保留這個行程——相同的 PID、相同的父行程、在行程樹裡相同的位置——但把它目前的程式映像丟掉,換上一支全新的。舊程式的程式碼區、資料區、堆積與堆疊都被抹除並替換掉,執行從新程式的進入點重新開始。

標準的作法把兩者結合起來:先 fork 再 exec。父行程 fork 出一個子行程;子行程立刻呼叫 exec,搖身一變成為你真正想跑的那支程式;父行程則繼續當它自己。你的 shell 啟動一道指令,靠的正是這個。它複製自己,而這個分身——不再停留為 shell 的複本——轉化成了 ls、你的編輯器,或一個瀏覽器。把「建立」(fork)和「載入程式」(exec)拆開,是一項刻意而強大的設計:在兩者之間那道狹小的縫隙裡,子行程仍然是它自己,於是它可以悄悄重新安排自己開啟的檔案與設定,然後才變成那支新程式。

不複製的複製:寫入時複製

關於這一切複製,有個顯而易見的隱憂。一個行程可能正在使用好幾 GB 的記憶體。如果 fork 真的把每一個位元組都複製一遍,那麼 fork 一個大行程會慢得令人抓狂——更糟的是,在常見的「先 fork 再 exec」模式裡,那整份昂貴的複本在幾微秒後就被 exec 抹掉程式映像時丟棄了。為了立刻丟掉而把全部都複製一遍,實在荒謬。真正的核心用一個漂亮的把戲閃過了這件事,叫做寫入時複製(copy-on-write)

  1. 在 fork 的那一刻,核心「不」複製記憶體。它改讓父行程與子行程「共用」同一份實體分頁,並在兩個行程裡都把每一個共用的分頁標記為唯讀。
  2. 只要兩個行程都只「讀取」那些分頁,就什麼也不必複製——它們愉快地共用著,而無論行程有多大,fork 基本上等於免費。
  3. 當任何一個行程第一次試圖「寫入」一個共用分頁時,硬體會陷入核心(對唯讀分頁的寫入)。「直到此時」,核心才替那位寫入者,只為「那一個」分頁做一份私有複本,再讓寫入繼續進行。
  4. 從未被寫入的分頁,永遠不會被複製。在「先 fork 再 exec」的情況裡,exec 幾乎立刻就替換掉整個映像,所以實務上幾乎沒有任何分頁真的被複製到——這正是我們想要的好處。

用日常的畫面來看:與其在第二位廚師一到就把一本厚厚的共用手冊整本影印一份,不如讓兩位廚師一起讀那唯一一本手冊,只在某人「真的」要在某一頁上塗改時,才為「那一頁」跑一趟影印機。寫入時複製把 fork 從一個沉重、令人發怵的操作,變成了便宜的操作,也正是「先複製再替換」這套設計之所以行得通的幕後安靜原因。它是作業系統裡一個反覆出現的概念的可愛範例:偷懶,把昂貴的工作拖到最後一刻才做,而且只在真正需要時才做。

人人都有父母:行程樹

因為每個行程都是被某個別的行程 fork 出來的,而那一個又是被另一個 fork 出來的,於是一台機器上所有的行程,便串接成一棵單一的家族樹——行程樹。每個行程恰好只有一個父行程(fork 出它的那個),卻可能有許多子行程。沿著任何一個行程往上追它的父行程、祖父行程,依此類推;這條鏈往上永遠不分岔,它總是收斂到同一個樹根。那個樹根,是一個 PID 為 1 的特別行程:init 行程(在現代 Linux 上常是 systemd)。核心在開機時親手把它啟動,作為最早的那個使用者行程,而系統上其他所有行程都是它的後代。

可以把 init 想成那位在任何房客搬進來之前就已經在場的大樓管理員,往後每一位住戶都能把自己的租約一路追溯回他。只要系統還在運行,它就永不退出——萬一它死了,整棵樹就會沒了樹根。而 init 還安靜地做著另一件關鍵的工作:當父行程在子行程仍在執行時就先死去,這些子行程本會變得無根可依,於是核心把它們重新認領給 init,由它收養這些行程,並在它們結束後替它們善後。

一同誕生,終末分離

建立有一個鏡像:一個父行程往往想「等待」它所建立的子行程。fork 之後,父行程可以呼叫 wait,這會阻塞父行程,直到那個子行程結束,然後把子行程的結束狀態交回來——一個回報它成功與否的小數字。你的 shell 之所以知道你的指令做完了、才印出下一個提示符號,靠的正是這個:它 fork,子行程 exec 你的指令,而 shell 就坐在 wait 裡,直到子行程退出。在這裡,我們終於得到了 fork 本身無法承諾的那種明確順序。

但關於這對搭檔,有個誠實的提醒。如果一個子行程結束了、父行程卻始終沒呼叫 wait,這個子行程就無法徹底消失:核心必須保留它的一小段紀錄,好讓結束狀態仍可被收取——一個「已結束卻未被收取」的子行程,明明煮完了,卻還掛在帳上。這件事,再加上一個失去父行程的子行程會怎麼樣,以及兩個各自獨立的行程能彼此交談的完整方法菜單,正是本階梯下一篇、也是最後一篇導覽的起點。