Learning Objectives
- 說明 fork 為什麼回傳兩次,以及子行程改變數為什麼不影響父行程
- 算出一段含 fork 的程式會產生幾個行程、印出幾行
- 說明 exec 換掉了什麼、留下了什麼
- 用 wait 取得結束碼,解釋殭屍與孤兒是怎麼出現的
- 用 fork、exec、wait 寫出一個迷你 Shell
Shell 也只是一支普通的程式
在終端機打 ls 按下 Enter,發生了什麼事?ls 是磁碟上的另一支程式,Shell(bash、zsh)也是一支普通程式,它沒有任何特權。它做的事只有三步:
fork() 複製自己,得到一個子行程
exec() 子行程把自己換成 ls
wait() 父行程(Shell)等 ls 結束,再印下一個提示字元
在 Linux 這類 Unix 系統上,新程式傳統上都是這樣跑起來的。建立行程和換掉程式是兩個分開的動作,這個設計看起來繞路,下一課會看到它的好處:在 fork 和 exec 之間,子行程可以先把輸出導到檔案或管線。
這一課會把三個呼叫各自拆開看,最後你會自己寫一個迷你 Shell。
fork 回傳兩次
fork() 複製目前的行程。呼叫一次,回來時變成兩個行程都從 fork 的下一步繼續,差別只有回傳值:
| 誰 | fork 回傳 |
|---|---|
| 父行程 | 子行程的 pid(大於 0) |
| 子行程 | 0 |
| 失敗時(只有父行程,沒有子行程) | -1 |
所以程式都寫成這樣,用回傳值分流(pid_t 就是存 pid 的整數型別):
pid_t pid = fork();
if (pid == 0) {
// 只有子行程會走這裡
} else {
// 只有父行程會走這裡
}
子行程得到一份父行程位址空間的複本:全域變數、堆疊、堆積,連 stdio 緩衝區都一起。之後兩邊各改各的,互不影響。(真實的核心不會真的把記憶體全部複製一份,它用「寫入時複製」,第 14 課會看到。)
fork 之後誰先跑沒有保證,由排程器決定。程式如果需要特定的順序,要自己用 wait 這類方法安排。
算 fork 會產生幾個行程
每個執行到 fork() 的行程都會多出一個。連續 n 個 fork,行程數每次翻倍:
fork();
fork();
printf("hi\n");
第一個 fork 之後:A、B
第二個 fork 之後:A 生出 C,B 生出 D
A
├── B
│ └── D
└── C
四個行程都會執行 printf,真正的 Linux 上印出四行:
hi
hi
hi
hi
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
算的方法是畫出樹:從第一行開始,遇到 fork 就分叉,兩條路都從下一步繼續走。別只用公式,有 if 的時候只有一邊會繼續:
if (fork() == 0) {
fork();
}
printf("hi\n");
父行程不進 if,子行程再 fork 一次,總共三個行程、三行。
這也是為什麼不要跑 fork 炸彈:while (1) fork(); 的行程數會不斷翻倍,很快用完系統的行程數上限,連登入新的 Shell 都不行。真實系統用 ulimit -u 和 cgroup 限制行程數來防範。
緩衝區跟著 fork 複製
剛才的 hello 出現兩次,是因為它還在 stdio 緩衝區,而緩衝區就是行程記憶體的一部分,fork 把它一起複製了。真正的 Linux 上也一樣:
printf("hello ");
pid_t pid = fork();
if (pid == 0) printf("child\n");
else { wait(NULL); printf("parent\n"); }
hello child
hello parent
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
有換行也可能中招。輸出接到檔案或管線時是全緩衝,換行不會觸發 write。下面這支程式迴圈裡只有父行程在印,子行程什麼都沒印就 exit(0),輸出卻是這樣:
for (int i = 1; i <= 3; i++) {
pid_t pid = fork();
if (pid == 0) exit(0);
wait(NULL);
printf("第 %d 個子行程結束了\n", i);
}
第 1 個子行程結束了
第 1 個子行程結束了
第 2 個子行程結束了
第 1 個子行程結束了
第 2 個子行程結束了
第 3 個子行程結束了
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
每個子行程都帶著父行程緩衝區裡還沒送出的內容,exit 會在結束前清空緩衝區,內容就多印了一次。兩個常見的解法:fork 之前 fflush,或者子行程用 _exit 結束,_exit 直接請核心結束行程,不清 stdio 緩衝區。
exec 換掉程式,pid 和 fd 留著
exec 系列把目前行程的程式換成另一支。舊的程式碼、全域變數、堆疊、堆積全部消失,新程式從 main 開始。成功時不會回傳,因為呼叫它的程式已經不在了;回傳了就代表失敗。
execl("./worker", "worker", NULL); // 路徑;接著是 argv 的每一項,最後一定要 NULL
perror("execl"); // 走到這裡一定是失敗了
名字裡的字母說明傳參數的方式:
| 字母 | 意思 | 例子 |
|---|---|---|
l |
參數一個一個列出來(list) | execl、execlp |
v |
參數放在陣列(vector) | execv、execvp |
p |
第一個參數不含 / 時,到 PATH 找程式 |
execlp("ls", "ls", NULL) |
注意 "worker" 寫了兩次:第一個是要執行的檔案,第二個是新程式看到的 argv[0]。
有些東西不會被換掉:
| 換掉 | 留著 |
|---|---|
| 程式碼、資料、堆積、堆疊 | pid、父行程 |
| stdio 緩衝區(還沒送出的內容就消失了) | 開著的檔案描述子(除非設了 close-on-exec) |
| 自訂的訊號處理函數(恢復預設) | 被設成忽略的訊號 |
fd 留著是 Shell 能做重新導向的關鍵:先把 fd 安排好,再 exec,新程式不知不覺就接到了。
真正的 Linux 上執行自己一次,pid 沒有變;第一次 exec 的路徑不存在,所以回傳失敗、程式繼續走:
exec 之前 pid = 2
exec 失敗才會走到這裡:No such file or directory
exec 之後 pid = 2,argv[0] = new-name
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
(/proc/self/exe 是指向自己這個執行檔的路徑,這裡拿來示範 exec 自己。這個環境沒有 echo 這些指令可以 exec,所以成功 exec 別的程式的畫面在下面的練習裡看。)
fd 留著也可以驗證:exec 之前打開一個檔案,拿到 fd 3,exec 之後新程式直接讀 fd 3。
exec 之前 fd = 3
exec 之後讀 fd 3:回傳 5,內容 note
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
wait、結束碼、殭屍與孤兒
wait(&status) 讓父行程等任一個子行程結束,核心把結果寫到 status 指到的地方(&status 就是把變數的位址交給它),回傳那個子行程的 pid。status 不是結束碼本身,要用巨集拆開:
| 巨集 | 意思 |
|---|---|
WIFEXITED(status) |
是不是自己正常結束 |
WEXITSTATUS(status) |
結束碼;只有低 8 位元 |
WIFSIGNALED(status) |
是不是被訊號終止 |
WTERMSIG(status) |
被哪個訊號終止 |
結束碼只有 8 位元,所以 exit(256) 在父行程看來是 0,會被當成成功:
exit(7) 父行程看到 7
exit(256) 父行程看到 0
exit(300) 父行程看到 44
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
殭屍(zombie):子行程結束了,父行程可能還沒來問結果。核心不能馬上把這個行程的資料全部丟掉,要留著 pid 和結束碼等父行程來收,這段時間它就是殭屍。它的記憶體已經還給系統,只佔一個行程表的位置。真正的 Linux 上看得到:
子行程結束了、父行程還沒 wait:
State: Z (zombie)
wait 之後,結束碼 7:
/proc/<pid>/status 已經不存在
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
少數幾個殭屍無傷大雅。長期執行的伺服器如果不斷 fork 又不 wait,殭屍會累積到把行程數上限用完,新的行程就建立不起來。
孤兒(orphan):父行程先結束,子行程還在跑。子行程會被 init(pid 1)或指定的 subreaper 行程收養,將來由新的父行程來 wait,所以孤兒不會永遠變成殭屍。
為什麼 cd 不是一支程式
迷你 Shell 的結構和 bash 相同,真正的 Shell 只是多了解析指令、重新導向、管線、背景執行。
不過有些指令不能用 fork 加 exec 做。cd 要改變的是 Shell 自己的工作目錄,如果交給子行程,改的是子行程那一份,子行程結束之後 Shell 還在原地。所以 cd、exit 這些是 Shell 自己內建的指令。這也是 fork 語意的直接結果:子行程改不到父行程。
確認一下
這段程式會印出幾行?
fork();
fork();
fork();
printf("x\n");
這段程式會印出幾行?
if (fork() == 0) {
fork();
}
printf("x\n");
這段程式印出什麼?
int x = 1;
if (fork() == 0) {
x = 2;
exit(0);
}
wait(NULL);
printf("%d\n", x);
execlp("ls", "ls", NULL) 成功之後,下一行程式什麼時候執行?
子行程已經結束,核心為什麼還要保留它的殭屍資料?
為什麼 cd 必須是 Shell 的內建指令,不能做成外部程式?
重點整理
fork回傳兩次:父行程拿到子行程的 pid,子行程拿到 0。之後兩邊各有一份記憶體,包含 stdio 緩衝區。- 算 fork 次數要畫樹,每個走到 fork 的行程都會分叉。
exec換掉程式,保留 pid 和開著的 fd。成功就不回傳,後面的程式碼只有失敗才會跑。wait取得結束資訊,結束碼只有 8 位元。還沒被 wait 的已結束行程是殭屍;父行程先走的是孤兒,會被收養。- Shell 就是一個重複 fork、exec、wait 的迴圈。
下一課利用「fd 會留過 exec」,把兩支互不認識的程式接起來:管線、重新導向與訊號。