行程控制 (Process Control)
Overview Table
| 小節 | 主題 | 核心 API | 一句話重點 |
|---|---|---|---|
| 8.4.1 (p.775) | 取得行程 ID | getpid() / getppid() |
每個行程有唯一正整數 PID;pid_t 在 Linux 是 int |
| 8.4.2 (p.775) | 建立與終止行程 | fork() / exit() |
fork 呼叫一次、回傳兩次;三種行程狀態 Running/Stopped/Terminated |
| 8.4.3 (p.779) | 回收子行程 (reaping) | waitpid() / wait() |
未被回收的終止行程 = zombie;佔用記憶體資源 |
| 8.4.4 (p.785) | 讓行程休眠 | sleep() / pause() |
sleep 可被 signal 提早打斷;pause 永遠回傳 −1 |
| 8.4.5 (p.786) | 載入並執行程式 | execve() |
呼叫一次、永不回傳(成功時);同一行程換新程式 |
| 8.4.6 (p.789) | fork + execve 合體 | shell 的 eval() |
shell = read/evaluate 迴圈;fork 子行程 → execve 載入程式 |
8.4.1 取得行程 ID (p.775)
每個行程都有一個唯一的正(非零)行程 ID(PID)。兩個查詢函式:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); /* 回傳呼叫者自己的 PID */
pid_t getppid(void); /* 回傳父行程(建立自己的行程)的 PID */
- 回傳型別
pid_t,Linux 上在types.h定義為int。 - PID 非零這件事,是
fork能用回傳值區分父子的關鍵(見 8.4.2)。
8.4.2 建立與終止行程 (p.775-778)
從程式設計師觀點,行程處於三種狀態之一:
+----------------------------------+
| SIGSTOP/SIGTSTP/ |
| SIGTTIN/SIGTTOU v
+---------+ +---------+
| Running | | Stopped |
| (執行中或 | | (暫停,不被 |
| 等待排程) |<------ SIGCONT --------| 排程) |
+---------+ +---------+
|
| (1) 收到預設動作為終止的 signal
| (2) 從 main 返回
| (3) 呼叫 exit()
v
+------------+
| Terminated | --(被父行程 reap 前是 zombie)--> 消滅
+------------+
- Running:正在 CPU 上執行,或等待被 kernel 排程。
- Stopped:被 SIGSTOP / SIGTSTP / SIGTTIN / SIGTTOU 暫停,收到 SIGCONT 才恢復 Running(signal 詳見 08-Exceptional-Control-Flow/05-Signals)。
- Terminated:永久停止;三種原因:收到預設動作為終止的 signal、從
main返回、呼叫exit。
#include <stdlib.h>
void exit(int status); /* 不回傳;以 status 為 exit status 終止行程 */
設定 exit status 的另一途徑是從 main 回傳整數值。
fork:呼叫一次、回傳兩次
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
/* 回傳:子行程得到 0;父行程得到子行程的 PID;錯誤回傳 -1 */
子行程與父行程「幾乎但不完全」相同,四個必考重點 (p.776-777):
- Call once, return twice:父呼叫一次,父、子各回傳一次。父得到子的 PID,子得到 0;因 PID 恆非零,回傳值可無歧義地分辨身分。
- Concurrent execution:父子是獨立行程並行執行,kernel 可任意交錯其指令;不可假設任何執行順序。
- Duplicate but separate address spaces:子取得父的 user-level 虛擬位址空間的相同但獨立的副本(code、data、heap、shared libraries、user stack)。fork 後任一方修改變數(如例中
x)不影響另一方。 - Shared files:子繼承父所有開啟的 file descriptor——所以父子的
printf都印到同一個螢幕(stdout 在 fork 時已開啟)。
父子最大的差異是 PID 不同。且「複製位址空間」是邏輯語意;實務上以 copy-on-write 實作(見 09-Virtual-Memory/04-Memory-Mapping)。file descriptor 是共享同一 open file table 項目(含檔案偏移),不是各自獨立,細節見 10-System-IO/03-File-Sharing-Redirection-and-Standard-IO。
Process graph(行程圖)(p.777-778)
Process graph 是一種 precedence graph:頂點 = 一條敘述的執行;邊 a → b = a「happens before」b。起點是父行程呼叫 main(無 inedge),每條行程鏈終於 exit(無 outedge)。
Figure 8.15(fork.c)對應的 process graph(即 Figure 8.16;fork 前 x=1,子印 ++x=2、父印 --x=0):
child: x=2
+----> printf ----> exit (Child)
|
x==1 | parent: x=0
main ---> fork -+----> printf ----> exit (Parent)
巢狀 fork(Figure 8.17,兩個連續 Fork() → 4 個行程,各印一次 hello):
hello
+---> printf -> exit
| hello
+-> fork-+---> printf -> exit
| hello
| +---> printf -> exit
| | hello
main --> fork -+-> fork-+---> printf -> exit
單一處理器上,process graph 的任何 topological sort 都是敘述的可行全序。判斷法:把頂點排成一列,畫出所有有向邊;每條邊都由左指向右 ⟺ 該排列是合法的 topological sort。這是判斷「某輸出序列是否可能」的標準工具(如 Practice Problem 8.3 的 printf 交錯)。
直線程式碼中 fork() 產生 if (fork() == 0) 只讓子行程走某分支時。
8.4.3 回收子行程 (Reaping) (p.779-785)
行程終止後 kernel 不會立即移除它,而是保持在 terminated 狀態直到被父行程 reap(回收):父回收時 kernel 把子的 exit status 交給父,然後銷毀該行程。
- Zombie:已終止但尚未被回收的行程。雖不執行,仍佔用系統記憶體資源。
- init 行程:PID = 1,由 kernel 在開機時建立、永不終止、是所有行程的祖先。父行程先終止時,kernel 安排 init 收養 (adopt) 孤兒行程並代為回收 zombie。
- 長時間執行的程式(shell、server)必須自行回收 zombie 子行程,不能依賴 init。
waitpid
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options);
/* 回傳:成功為子行程 PID;WNOHANG 且無子終止時為 0;錯誤為 -1 */
預設(options = 0):暫停呼叫者直到 wait set 中某個子行程終止;若已有子行程先終止則立即回傳。回傳造成回傳的那個子行程的 PID,此時該子已被回收、kernel 抹除其所有痕跡。
Wait set 成員由 pid 參數決定 (p.780):
pid 值 |
wait set |
|---|---|
pid > 0 |
單一子行程,PID 等於 pid |
pid = -1 |
呼叫者的所有子行程 |
(waitpid 也支援以 process group 定義 wait set,書中未討論。)
options 修改預設行為(可用 | 組合):
| 常數 | 行為 |
|---|---|
WNOHANG |
wait set 中無子行程終止時立即回傳 0(不阻塞);適合邊等邊做事 |
WUNTRACED |
等到 wait set 中行程 terminated 或 stopped 都回傳(預設只理會 terminated) |
WCONTINUED |
等到 wait set 中行程終止,或 stopped 行程收到 SIGCONT 恢復執行 |
WNOHANG | WUNTRACED |
立即回傳:無 stopped/terminated 子時回 0,否則回該子 PID |
檢查 exit status:若 statusp 非 NULL,waitpid 把狀態資訊編碼進 *statusp,以 wait.h 巨集解讀:
| 巨集 | 意義 | 前提 |
|---|---|---|
WIFEXITED(status) |
子行程正常終止(exit 或 return)為 true | — |
WEXITSTATUS(status) |
取得正常終止的 exit status | WIFEXITED 為 true 才有定義 |
WIFSIGNALED(status) |
子因未被捕捉的 signal 終止為 true | — |
WTERMSIG(status) |
造成終止的 signal 編號 | WIFSIGNALED 為 true 才有定義 |
WIFSTOPPED(status) |
造成回傳的子目前是 stopped 為 true | — |
WSTOPSIG(status) |
造成 stop 的 signal 編號 | WIFSTOPPED 為 true 才有定義 |
WIFCONTINUED(status) |
子因收到 SIGCONT 重新啟動為 true | — |
錯誤條件 (p.781):
- 呼叫者沒有子行程 → 回傳 −1,
errno = ECHILD。 - waitpid 被 signal 中斷 → 回傳 −1,
errno = EINTR。
wait:簡化版
pid_t wait(int *statusp); /* 回傳:子行程 PID 或 -1 */
wait(&status) 等價於 waitpid(-1, &status, 0)。
兩種回收模式(Figure 8.18 vs. 8.19)
| waitpid1.c(無序回收) | waitpid2.c(依建立順序回收) | |
|---|---|---|
| 第一參數 | waitpid(-1, &status, 0) |
waitpid(pid[i++], &status, 0)(父先把子 PID 存入陣列) |
| 回收順序 | 由系統決定,nondeterministic | 與建立順序相同,消除輸出順序的不確定性 |
| 迴圈結束 | 回傳 −1 且 errno == ECHILD 為正常結束 |
同左 |
無序回收的兩種結果同樣正確;程式設計師永遠不能假設某一結果必然發生,無論另一結果看起來多不可能。唯一正確的假設是:每種可能結果等可能。這是併發推理困難的根源(呼應 12-Concurrent-Programming/01-Process-and-Event-Based-Concurrency)。
8.4.4 讓行程休眠 (p.785-786)
#include <unistd.h>
unsigned int sleep(unsigned int secs); /* 回傳:剩餘未睡足的秒數 */
int pause(void); /* 永遠回傳 -1 */
- sleep:暫停行程指定秒數。睡足回傳 0;被 signal 中斷而提早返回時,回傳剩餘秒數(此即 Practice Problem 8.5 wakeup 包裝的素材)。
- pause:讓呼叫者睡到收到 signal 為止;always returns −1。
sleep 回傳非零代表沒睡滿——寫可靠計時邏輯時要以迴圈補睡;與 signal 的互動見 08-Exceptional-Control-Flow/05-Signals。
8.4.5 載入並執行程式 (p.786-789)
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
/* 成功不回傳;錯誤(如找不到 filename)回傳 -1 */
execve 在「目前行程的 context」載入並執行可執行目的檔 filename,帶入引數列 argv 與環境變數列 envp。
- 與 fork 對比:fork 呼叫一次回傳兩次;execve 呼叫一次、成功時永不回傳。
argv:指向 null-terminated 指標陣列,每個元素指向一個引數字串;慣例argv[0]是可執行檔名。envp:同樣結構,每個字串是name=value形式的名值對。
execve 載入 filename 後,呼叫 start-up code(見 07-Linking/03-Executable-Loading-and-Dynamic-Linking 的 7.9 節),start-up code 設好 stack 後把控制交給新程式的 main:
int main(int argc, char **argv, char **envp);
新程式啟動時 user stack 的組織(Figure 8.22,由高位址往低位址):
高位址(Bottom of stack)
+--------------------------------------+
| Null-terminated 環境變數字串 |
+--------------------------------------+
| Null-terminated 命令列引數字串 |
+--------------------------------------+
| envp[n] == NULL |
| envp[n-1] | <-- environ(全域變數)指向 envp[0]
| ... |
| envp[0] | <-- envp(傳入 %rdx)
+--------------------------------------+
| argv[argc] == NULL |
| argv[argc-1] |
| ... |
| argv[0] | <-- argv(傳入 %rsi)
+--------------------------------------+
| libc_start_main 的 stack frame | argc 傳入 %rdi
+--------------------------------------+
| (main 未來的 stack frame) |
+--------------------------------------+
低位址(Top of stack)
- main 的三個引數依 x86-64 慣例放暫存器:
argc→%rdi、argv→%rsi、envp→%rdx。 argc= argv[] 陣列中非 NULL 指標的數目。- 全域變數
environ指向envp[0]。
操作環境變數陣列的函式:
#include <stdlib.h>
char *getenv(const char *name); /* 找到回傳指向 value 的指標;否則 NULL */
int setenv(const char *name, const char *newvalue, int overwrite);
/* 成功 0,錯誤 -1 */
void unsetenv(const char *name); /* 無回傳值 */
getenv:在環境陣列搜尋name=value,回傳指向 value 的指標。setenv:若name=oldvalue已存在,僅當overwrite非零才以 newvalue 取代;若 name 不存在則新增name=newvalue。unsetenv:刪除name=oldvalue。
Program 是 code + data 的集合,可存在於磁碟上的 object file 或位址空間的 segments;process 是程式執行中的特定實例。fork 在新的子行程中執行同一個程式(複製父);execve 在目前行程的 context 載入並執行新程式——它覆寫位址空間,但不建立新行程:PID 不變,且繼承呼叫 execve 當時所有開啟的 file descriptor。
8.4.6 用 fork 和 execve 執行程式:簡易 shell (p.789-792)
Shell 是代表使用者執行其他程式的互動式應用層程式(sh → csh、tcsh、ksh、bash)。它執行一連串 read/evaluate 步驟後終止:read 讀取命令列,evaluate 解析並代為執行程式。
shellex.c 的 eval 流程:
cmdline
|
v
parseline(buf, argv) --- 解析空白分隔的 token,建 argv;
| 尾端 '&' → 回傳 1 (background),否則 0 (foreground)
v
argv[0]==NULL? --yes--> return(忽略空行)
|no
v
builtin_command(argv)? --yes(如 quit)--> 立即執行內建命令
|no
v
Fork()
|--- 子行程: execve(argv[0], argv, environ)
| 失敗 → 印 "Command not found." 後 exit(0)
|
`--- 父行程: bg? --no--> waitpid(pid, &status, 0) 等前景工作結束
|
`--yes--> 印出 "pid cmdline",立刻回到迴圈讀下一行
main(Figure 8.23):無窮迴圈印提示字元>、Fgets讀 stdin、EOF 則exit(0)、否則呼叫eval。parseline(Figure 8.25):把結尾\n換成空白、跳過前導空白、以strchr切出各引數、argv[argc] = NULL收尾;最後一個引數是&則從 argv 移除並回傳 1(背景執行)。builtin_command(Figure 8.24):第一個引數是內建命令就立即解譯並回傳 1;此簡易 shell 只有quit(真實 shell 還有pwd、jobs、fg等)。- 非內建命令:fork 子行程,在子行程內 execve 載入使用者程式;前景 (foreground) 工作用
waitpid等它終止,背景 (background) 工作不等、直接回到迴圈。
它從不回收背景子行程,背景工作終止後會累積成 zombie。修正需要 signals(SIGCHLD handler),見 08-Exceptional-Control-Flow/05-Signals。
Exam/Test Patterns
| 情境 / 關鍵字 | 答案 |
|---|---|
| fork 回傳值? | 子行程得 0、父行程得子的 PID、錯誤得 −1;「call once, return twice」 |
| n 個連續 fork 產生幾個行程? | |
| fork 後父子改同名變數會互相影響? | 不會;位址空間是相同但獨立的副本(duplicate but separate) |
| fork 後子行程能寫父開啟的檔案? | 能;子繼承父的 open file descriptors(shared files) |
| 判斷某輸出序列是否可能 | 畫 process graph,檢查該序列是否為其 topological sort(所有邊左→右) |
| 「zombie」定義 | 已 terminated 但尚未被父 reap 的行程;仍佔記憶體 |
| 父行程先死,孤兒誰收? | init(PID 1) 收養並回收 |
waitpid(-1, &status, 0) |
等待任一子行程終止;等價於 wait(&status) |
| waitpid 想不阻塞 | options = WNOHANG,無子終止時立即回傳 0 |
| 也想等到「stopped」的子 | WUNTRACED;等 SIGCONT 恢復用 WCONTINUED |
| 子正常終止的 status 判讀 | 先 WIFEXITED(status) 為 true,再用 WEXITSTATUS(status) 取值 |
| 子被 signal 殺掉的判讀 | WIFSIGNALED → WTERMSIG 取 signal 編號 |
| waitpid 回 −1 的兩種 errno | 無子行程 ECHILD;被 signal 中斷 EINTR |
sleep 回傳非零 |
被 signal 提早喚醒,值 = 剩餘秒數 |
pause 回傳值 |
永遠 −1(收到 signal 才返回) |
| execve 成功時回傳什麼? | 不回傳;只有失敗才回 −1 |
| execve 後 PID 會變? | 不變;覆寫位址空間但不建立新行程,file descriptor 保留 |
| main 三引數放哪些暫存器 | argc → %rdi、argv → %rsi、envp → %rdx |
getenv 回傳 |
指向 value 的指標,查無回 NULL;setenv 只在 overwrite 非零時覆寫 |
shell 命令列尾端 & |
parseline 回傳 1 → 背景執行,shell 不 waitpid、直接讀下一行 |
| Fork() 大寫、fork() 小寫差在哪 | 大寫是 Stevens 風格 error-handling wrapper:呼叫小寫版並檢錯,出錯即 unix_error 終止 (p.774) |
Related Notes
- 08-Exceptional-Control-Flow/01-Exceptions
- 08-Exceptional-Control-Flow/02-Processes-and-Context-Switches
- 08-Exceptional-Control-Flow/03-Unix-Error-Handling
- 08-Exceptional-Control-Flow/05-Signals
- 07-Linking/03-Executable-Loading-and-Dynamic-Linking
- 09-Virtual-Memory/04-Memory-Mapping
- 10-System-IO/03-File-Sharing-Redirection-and-Standard-IO
- 12-Concurrent-Programming/01-Process-and-Event-Based-Concurrency