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

fork():一個行程一分為二

第 1 篇教你什麼是一個正在執行的行程。現在來認識 Unix 裡最奇怪的系統呼叫:fork()——它是那個會回傳兩次的呼叫,一次在父行程裡、一次在一個全新的子行程裡,而那個子行程幾乎是父行程的完美複本。我們會看清楚到底有哪些東西被複製、這兩半各自如何分辨出自己是誰,以及那些誠實到會絆倒每個初學者的陷阱。

那個回傳兩次的呼叫

你寫過的幾乎每個函式都只回傳一次。你呼叫它、它做完事、你拿回一個值。fork() 用最令人不安的方式打破了這個習慣:你呼叫它一次,它卻回傳兩次——一次在原本的行程裡,一次在核心建立的一個新行程裡,而那個新行程是呼叫者的近乎完全複本。那個新行程就是子行程;呼叫 fork() 的那個則是父行程。呼叫之後,有兩個行程在執行同一支程式,兩者都停在 fork() 之後那一行、都即將執行下一個敘述。你一開始那條單一的控制流,已經分裂成兩條。

那麼,每一半怎麼知道自己是哪一個?靠回傳值,而這正是整個觀念的核心。在子行程裡,fork() 回傳 `0`。在父行程裡,fork() 回傳新子行程的行程識別碼——一個正數,你之後可以用它來等待或對它送訊號。而如果核心根本無法建立子行程(記憶體用盡,或撞上行程數上限),fork() 在父行程裡回傳 `-1`,而且沒有子行程誕生。所以同一行程式碼會產生三種可能的結果,全靠回傳的那個數字來區分。

#include <unistd.h>
#include <stdio.h>

int main(void) {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");           /* -1: no child was created */
        return 1;
    } else if (pid == 0) {
        printf("child:  my pid is %d\n", getpid());
    } else {
        printf("parent: my child is %d\n", pid);
    }
    return 0;
}
fork() 的標準骨架。三個分支分別是 -1(失敗,最先檢查)、0(我們是子行程),以及正數(我們是父行程,而那個值就是子行程的 PID)。

子行程繼承了什麼——是複本,不是共用

子行程是父行程行程映像的複本:它得到自己一份完整的位址空間——同樣的程式碼、同樣的堆積、同樣的堆疊與其中持有相同值的相同區域變數、同樣的全域變數——在 fork 的那一瞬間被凍結下來。關鍵在於這些是複本,不是共用的儲存。如果父行程現在把一個 `int n = 5` 的變數設成 `7`,子行程仍然看到 `5`;如果子行程改了它,父行程也永遠不會察覺。這兩個行程有各自獨立的虛擬位址空間,所以 `0x601040` 這個位址在每一個裡都對應到不同的實體位元組。一微秒前還讀起來一模一樣的變數,現在是兩個各自獨立、只不過起點相等的變數。

但有一種資源在 fork 之後是真正共用的:已開啟的檔案。子行程繼承了父行程檔案描述符表的一份複本,所以子行程裡的描述符 1(標準輸出)所指的,是與父行程描述符 1 相同的那個底層已開啟檔案。它們是指向同一個共用「已開啟檔案」項目的兩個指標——連同一個共用的讀寫位置在內。這種共用不是意外;它正是讓 shell 管線與輸出重導向得以運作的機制,因為 shell fork 出來的子行程能寫進父行程設好的檔案或管線。等到後面幾篇 fork() 遇上 exec() 與標準串流時,我們會大量倚賴這一點。

寫入時複製:為何複製一整個位址空間其實很便宜

讀完上一節,一個合理的問題隨之而來:如果 fork() 要複製整個位址空間——堆積、堆疊、全域變數,也許好幾百 MiB——它不是慢得讓人受不了嗎?在最早的 Unix 裡它確實把所有東西都複製一遍,而且很慢。現代核心用了一個來自虛擬記憶體那個章節的把戲:寫入時複製。在 fork() 當下,核心並不複製那些分頁。它反而讓父與子共用完全相同的實體分頁,並把每一頁都標記成唯讀。任一方的讀取都照常運作。只有當某一方試著寫入某一頁時,記憶體管理單元才會陷入核心,核心這時才為那個寫入者單獨複製那一頁、再讓寫入完成。

回報很大,而且值得理解而非死記。fork() 現在的成本大約只是複製分頁表的代價,而不是分頁本身,所以它很快、一開始也只多用一點點記憶體。一個讀很多但寫很少的子行程幾乎是免費的。而現實中最常見的情況——fork() 之後緊接著用 exec() 去執行另一支程式——根本碰不到那些共用分頁的絕大部分,因為 exec() 會把整個繼承來的位址空間丟掉。寫入時複製正是為什麼 fork-then-exec 這個在紙面上看似浪費的模式,實際上卻是每一個 Unix shell 啟動命令時那條有效率的骨幹。

每個初學者都會踩到的陷阱

fork() 雖小卻很鋒利,所以容我誠實地談談它會怎麼出錯,而不是只給你看順遂的那條路。第一,永遠檢查回傳值。當系統承受記憶體壓力、或你撞上每位使用者的行程數上限時,fork() 可能以 `-1` 失敗;一支假設它一定成功的程式,會把父行程當成子行程來對待,把自己的邏輯弄壞。這正是把檢查每一個呼叫的紀律,套用在一個容易被觸發、又難以除錯的系統呼叫上。

第二,別假設執行順序。在 fork() 之後,核心可以隨意先跑父行程、先跑子行程,或在不同核心上交錯執行它們。如果兩者都印東西,那些行可能以任一順序出現——這不是 fork() 的臭蟲,而是兩個各自獨立行程的非決定性,任何倚賴某一方先跑的程式碼都有一個競爭條件。如果你真的需要順序,就必須用 wait()(第 4 篇)或某種通訊方式明確地安排它;你沒辦法從 fork() 免費得到它。

fork() 接下來通往哪裡

兩點澄清能讓各層保持分明。fork() 建立的是一個新行程,不是新執行緒:子行程有自己的位址空間、有自己一份所有東西的複本,這正是為什麼一方的改動對另一方是看不見的。一個執行緒(後面的章節)則共用同一個位址空間——那是為了不同工作而存在的不同工具。而 fork() 是一個系統呼叫,不是普通函式:它跨進核心,這正是它能做到使用者模式程式碼永遠做不到的事的原因——憑空從一個行程變出第二個。

光靠它自己,fork() 只給你同一支程式的第二份複本——有用,但還不是你啟動 `ls` 或 `gcc` 的方式。真正的威力,要等到 fork() 與 exec() 配對、把子行程的程式換成一支新的時才會出現,那就是下一篇要搭建的 先 fork 再 exec 模式。而一旦你能生出子行程,你就有義務追蹤它們:第 4 篇談 wait() 與讀取子行程的結束結果,第 5 篇談你若忘記就會製造出來的殭屍行程孤兒行程。fork() 是一個 Unix 系統為了執行另一支程式所做的幾乎一切事情的前半段;你現在已經理解了負責「分裂」的那一半。