行程控制 (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 */

8.4.2 建立與終止行程 (p.775-778)

從程式設計師觀點,行程處於三種狀態之一:

                +----------------------------------+
                |     SIGSTOP/SIGTSTP/             |
                |     SIGTTIN/SIGTTOU              v
          +---------+                         +---------+
          | Running |                         | Stopped |
          | (執行中或 |                         | (暫停,不被 |
          |  等待排程) |<------ SIGCONT --------|  排程)    |
          +---------+                         +---------+
                |
                | (1) 收到預設動作為終止的 signal
                | (2) 從 main 返回
                | (3) 呼叫 exit()
                v
         +------------+
         | Terminated |  --(被父行程 reap 前是 zombie)--> 消滅
         +------------+
#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):

「相同副本」的例外

父子最大的差異是 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
Topological sort 判斷可行輸出

單一處理器上,process graph 的任何 topological sort 都是敘述的可行全序。判斷法:把頂點排成一列,畫出所有有向邊;每條邊都由左指向右 ⟺ 該排列是合法的 topological sort。這是判斷「某輸出序列是否可能」的標準工具(如 Practice Problem 8.3 的 printf 交錯)。

計算 fork 產生的行程數

直線程式碼中 n 個連續 fork() 產生 2n 個行程。畫 process graph 是最不易出錯的方法,尤其是 if (fork() == 0) 只讓子行程走某分支時。

8.4.3 回收子行程 (Reaping) (p.779-785)

行程終止後 kernel 不會立即移除它,而是保持在 terminated 狀態直到被父行程 reap(回收):父回收時 kernel 把子的 exit status 交給父,然後銷毀該行程。

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):

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 為正常結束 同左
Nondeterminism 的正確心態 (p.784)

無序回收的兩種結果同樣正確;程式設計師永遠不能假設某一結果必然發生,無論另一結果看起來多不可能。唯一正確的假設是:每種可能結果等可能。這是併發推理困難的根源(呼應 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 回傳值不可忽略

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

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)

操作環境變數陣列的函式:

#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);  /* 無回傳值 */
Program vs. Process(p.789 Aside)

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",立刻回到迴圈讀下一行
這個簡易 shell 的缺陷 (p.790)

從不回收背景子行程,背景工作終止後會累積成 zombie。修正需要 signals(SIGCHLD handler),見 08-Exceptional-Control-Flow/05-Signals

Exam/Test Patterns

情境 / 關鍵字 答案
fork 回傳值? 子行程得 0、父行程得子的 PID、錯誤得 −1;「call once, return twice」
n 個連續 fork 產生幾個行程? 2n 個;畫 process graph 驗證
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 殺掉的判讀 WIFSIGNALEDWTERMSIG 取 signal 編號
waitpid 回 −1 的兩種 errno 無子行程 ECHILD;被 signal 中斷 EINTR
sleep 回傳非零 被 signal 提早喚醒,值 = 剩餘秒數
pause 回傳值 永遠 −1(收到 signal 才返回)
execve 成功時回傳什麼? 不回傳;只有失敗才回 −1
execve 後 PID 會變? 不變;覆寫位址空間但不建立新行程,file descriptor 保留
main 三引數放哪些暫存器 argc%rdiargv%rsienvp%rdx
getenv 回傳 指向 value 的指標,查無回 NULL;setenv 只在 overwrite 非零時覆寫
shell 命令列尾端 & parseline 回傳 1 → 背景執行,shell 不 waitpid、直接讀下一行
Fork() 大寫、fork() 小寫差在哪 大寫是 Stevens 風格 error-handling wrapper:呼叫小寫版並檢錯,出錯即 unix_error 終止 (p.774)