1一句话 + 关键问题

如何创建并控制进程?OS 应该提供什么样的进程创建与控制接口?怎样设计这些接口,才能兼顾强大的功能、易用性和高性能?

CRUX: HOW TO CREATE AND CONTROL PROCESSES. What interfaces should the OS present for process creation and control? How should these interfaces be designed to enable powerful functionality, ease of use, and high performance?

一句话:UNIX 用一对奇特的系统调用创建进程——fork() 复制出一个几乎一模一样的子进程,exec() 让它变身为另一个程序;wait() 让父进程等子进程结束。

第 4 章说任何 OS 都要提供"创建、销毁、等待、其他控制、状态"这几类进程 API。本章是一段插叙(interlude),专讲 UNIX 的真实接口,偏重实践。你会看到:把"创建"拆成 fork 和 exec 两步看似古怪,却恰恰让 shell 能轻松实现重定向和管道。本章的文件描述符概念会在第 39 章(文件和目录)再次登场。

2核心概念速览

PID

进程标识符(process ID),UNIX 用这个数字给进程命名,比如要停止它时。

fork()

创建一个新进程:调用者(父进程)的几乎完全相同的副本。调用一次,返回两次。

返回值区分父子

父进程得到子进程的 PID;子进程得到 0;失败返回负数。

wait() / waitpid()

父进程阻塞,直到子进程运行结束;由此可以让输出顺序变得确定。

exec()

用另一个程序覆盖当前进程的代码和静态数据,重新初始化堆和栈。不创建新进程,成功时永不返回。

shell

只是一个用户程序:读命令 → fork → (子进程里)exec → (父进程里)wait → 再次提示。

重定向与管道

fork 与 exec 之间可以改动子进程的文件描述符,从而把输出接到文件或管道。

信号、用户与 root

kill() 向进程发信号;用户只能控制自己的进程,超级用户(root)可以控制所有进程。

3fork():调用一次,返回两次

原书图 5.1 的 p1.c:

int main(int argc, char *argv[]) {
    printf("hello (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {            // fork 失败
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {    // 子进程(新进程)
        printf("child (pid:%d)\n", (int) getpid());
    } else {                 // 父进程走这条路
        printf("parent of %d (pid:%d)\n", rc, (int) getpid());
    }
    return 0;
}
prompt> ./p1
hello (pid:29146)
parent of 29147 (pid:29146)
child (pid:29147)
prompt>

进程先打印 hello,带上自己的 PID 29146。然后调用 fork()——OS 创建一个新进程,它是调用进程的(几乎)精确副本。在 OS 看来,现在有两份 p1 在运行,而且两者都即将从 fork() 返回。新创建的子进程(child)不从 main() 开始(所以 hello 只打印了一次),而是像它自己调用了 fork() 一样"出生"。

fork() 之前 父进程 PID 29146 代码(p1) 数据、堆 栈 PC → fork() fd 0 1 2 fork() 之后:父进程 PID 29146 代码(p1) 数据、堆 栈 rc = 29147 fd 0 1 2 之后:子进程 PID 29147(新) 代码(副本) 数据、堆(副本) 栈(副本) rc = 0 fd 0 1 2(副本)
图 A:子进程有自己的地址空间副本、自己的寄存器、自己的 PC——所以它并不是"精确"副本:fork() 的返回值不同。父进程得到子进程的 PID,子进程得到 0,代码据此走不同分支。

还有一点:p1 的输出不是确定的(not deterministic)。子进程创建后,系统里有两个我们关心的活跃进程。假设只有一个 CPU,此刻父进程和子进程都可能先运行;上面是父进程先打印,另一次运行可能是:

prompt> ./p1
hello (pid:29146)
child (pid:29147)
parent of 29147 (pid:29146)
prompt>

决定此刻运行谁的是 CPU 调度器(scheduler)。调度器很复杂,我们通常不能对它的选择做强假设。这种不确定性(non-determinism)在多线程程序中会引出许多有趣的问题(第 26 章起)。

模拟器

你来当调度器:fork 之后谁先跑?

选一个程序和一种调度方式,逐步执行,看代码里每个进程的"PC"走到哪、终端输出如何交错。点"枚举所有可能的输出",让模拟器穷举所有调度顺序——看看 wait() 如何把多种可能压缩成一种。
终端

4wait():让父进程等一等

有时父进程需要等子进程把事情做完。wait()(或更完整的 waitpid())就是干这个的。原书图 5.2 的 p2.c 只是在父进程分支里加了一行:

    } else {                        // 父进程
        int rc_wait = wait(NULL);   // 阻塞,直到某个子进程结束;返回该子进程的 PID
        printf("parent of %d (rc_wait:%d) (pid:%d)\n", rc, rc_wait, (int) getpid());
    }
prompt> ./p2
hello (pid:29266)
child (pid:29267)
parent of 29267 (rc_wait:29267) (pid:29266)
prompt>

现在输出是确定的:子进程总是先打印。为什么?如果子进程先运行,它自然先打印;如果父进程先运行,它会立即调用 wait(),这个系统调用在子进程运行完并退出之前不会返回——所以即使父进程先运行,它也会礼貌地等子进程结束,然后才打印。(原书脚注提醒:有少数情况 wait() 会在子进程退出前返回,详见 man page;也要警惕书中"总是""绝对"之类的断言。)在上面的模拟器里选 p2 再点"枚举所有可能的输出"验证这一点。

wait() 与第 4 章的僵尸(zombie)状态是一对:子进程退出后停留在僵尸状态,保存着返回码;父进程的 wait() 取走返回码,OS 随即清理子进程的数据结构。

5exec():变身为另一个程序

只有 fork 的话,只能运行同一个程序的多个副本。想运行不同的程序,就要用 exec()(Linux 上有六个变体:execl()、execlp()、execle()、execv()、execvp()、execvpe())。原书图 5.3 的 p3.c 让子进程运行字数统计程序 wc:

    } else if (rc == 0) {                  // 子进程
        printf("child (pid:%d)\n", (int) getpid());
        char *myargs[3];
        myargs[0] = strdup("wc");          // 程序名:"wc"
        myargs[1] = strdup("p3.c");        // 参数:要统计的文件
        myargs[2] = NULL;                  // 数组结束标记
        execvp(myargs[0], myargs);         // 运行 wc
        printf("this shouldn't print out");
    } else { ... wait(NULL) ... }
prompt> ./p3
hello (pid:29383)
child (pid:29384)
29 107 1030 p3.c
parent of 29384 (rc_wait:29384) (pid:29383)
prompt>
exec 之前:PID 29384 运行 p3 p3 的代码 p3 的静态数据 堆(myargs 字符串) 栈 fd 0 1 2 → 终端 execvp("wc",…) 从磁盘载入 wc exec 之后:同一个 PID 29384 wc 的代码(覆盖) wc 的静态数据(覆盖) 堆(重新初始化) 栈:argv = {"wc","p3.c"} fd 0 1 2 → 终端(保留!)
图 B:exec 从可执行文件载入代码和静态数据,覆盖当前的代码段和静态数据;堆、栈等内存区域被重新初始化;然后运行新程序,把参数作为 argv 传入。它不创建新进程——PID 不变;打开的文件描述符也跨越 exec 保留下来。

exec 之后,就好像 p3.c 从来没有运行过一样;成功的 exec() 永远不会返回,所以 "this shouldn't print out" 永远不会被打印。

6为什么要这样设计:shell、重定向与管道

为什么把"创建新进程"这件简单的事拆成 fork 和 exec 这么古怪的两步?因为这样 shell 就能在 fork 之后、exec 之前运行一段代码,改变即将运行的程序的环境,从而轻松实现许多有趣的功能。

显示提示符读一行命令 找到可执行文件fork() 子进程改 fd(重定向/管道) → exec() 父进程(shell)wait() 命令运行结束 → exit 子进程退出,wait 返回 再次提示
图 C:shell 就是个普通用户程序(tcsh、bash、zsh……)。"fork 之后、exec 之前"那一小段子进程代码,就是实现重定向和管道的地方。

例如 wc p3.c > newfile.txt:wc 的输出被重定向(redirect)到文件 newfile.txt。shell 的做法很简单:子进程创建后、调用 exec() 之前,关闭标准输出,然后打开 newfile.txt。这样一来,即将运行的 wc 的所有输出都进了文件而不是屏幕(打开的文件描述符在 exec() 后保持打开)。

它之所以奏效,依赖于 OS 管理文件描述符的一个约定:UNIX 从 0 开始寻找空闲的文件描述符。关掉 STDOUT_FILENO(即 1)之后,它就是最小的空闲描述符,open() 必然分到它。此后子进程往标准输出写(如 printf()),就被透明地送进新文件。下面的模拟器逐步展示每个进程的文件描述符表如何变化:

模拟器

shell 如何实现 > 和 |:文件描述符表逐步图解

每个进程方框里是它的 fd 表,箭头指向内核中的打开文件对象。注意 fork 复制 fd 表、close/open 修改 fd 1、exec 保留 fd 表。
终端屏幕

原书图 5.4 的 p4.c 就是用代码实现同样的事:

    } else if (rc == 0) {
        // 子进程:把标准输出重定向到文件
        close(STDOUT_FILENO);
        open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
        // 现在 exec "wc"……
        char *myargs[3];
        myargs[0] = strdup("wc");     // 程序:wc
        myargs[1] = strdup("p4.c");   // 参数:要统计的文件
        myargs[2] = NULL;
        execvp(myargs[0], myargs);
    } else {
        int rc_wait = wait(NULL);
    }
prompt> ./p4
prompt> cat p4.output
32 109 846 p4.c
prompt>

运行 p4 时看起来什么都没发生——shell 直接打印了提示符。其实 p4 确实 fork 了子进程、通过 execvp() 运行了 wc,只是输出被重定向到了 p4.output,所以屏幕上什么也没有;cat 一下就能看到结果。

UNIX 管道(pipe)的实现方式类似,只是用 pipe() 系统调用:一个进程的输出连到内核中的一个管道(即队列),另一个进程的输入连到同一个管道。于是一个进程的输出无缝地成为下一个的输入,长长的命令链就能串起来。例如在文件中找一个单词并数它出现了几次:grep -o foo file | wc -l。

Lampson 定律:"Get it right."抽象和简洁都不能替代"做对"。设计进程创建 API 的方式有很多,而 fork() 与 exec() 的组合既简单又极其强大——UNIX 的设计者这里做对了。(原书也提醒:并非所有人都这么看。微软、波士顿大学和 ETH 的研究者在论文 "A fork() in the road" 中细数了 fork() 的问题,主张改用 spawn() 这类更简单的接口。)

7进程控制、信号与用户

除了 fork、exec、wait,UNIX 还有许多与进程交互的接口。kill() 系统调用向进程发送信号(signal),包括暂停、终止等各种指令。多数 shell 把某些按键组合配置成向前台进程发送特定信号:

操作信号效果
Ctrl-CSIGINT(interrupt)中断进程,通常使其终止
Ctrl-ZSIGTSTP(stop)在执行途中暂停进程;之后可用 shell 内置命令 fg 恢复
kill <pid>、killall <名字>任意信号向指定进程发信号——小心别误杀了窗口管理器
进程里调用 signal()—"捕获"某个信号:信号到来时暂停正常执行,转而运行一段指定代码

谁可以给谁发信号?如果任何人都能随意向别人的进程发 SIGINT,系统的可用性和安全性就完了。所以现代系统有很强的用户(user)概念:用户输入密码登录后,可以启动进程并完全控制自己的进程;OS 负责把 CPU、内存、磁盘等资源分给各个用户及其进程。

系统需要一个不受普通限制的管理员:能杀掉任何进程(即使不是自己启动的),能运行 shutdown 之类的强大命令。UNIX 中这个角色叫超级用户(superuser),即 root。能力越大,责任越大——为了安全(也为了避免代价高昂的失误),平时最好以普通用户身份工作。

实用工具:ps 查看正在运行的进程;top 显示进程及其资源占用(它常常声称自己就是头号资源大户);kill、killall 发信号;各种 CPU 负载监视器让你一眼看出系统有多忙。还有一条:RTFM——读 man page(手册页)。关于 shell 和各个系统调用的返回值、错误条件,man page 里藏着大量有用的细节。

8进程树:fork.py 网页版

每次 fork 都在"家族树"上加一个孩子。原书作业用 fork.py 模拟一串动作:a+b 表示进程 a fork 出 b,c- 表示进程 c 退出。当一个进程退出时,它的孩子成了孤儿(orphan),会被过继给别人:默认过继给根进程 a(相当于 UNIX 的 init);用 -R 时过继给退出进程自己的父进程("本地过继")。

模拟器

进程树生长器

输入动作序列(或随机生成),逐步观察进程树。遮住"下一棵树"先自己画,再按"下一步"验证。
随机生成:

9先预测,再验证

在"你来当调度器"里选 p1,分别用"父进程先"和"子进程先"跑一遍。然后点"枚举所有可能的输出"。一共几种?为什么有一种输出里 child 那行出现在 prompt> 之后?
看答案
3 种:parent 先、child 先,以及"parent 打印后父进程退出,shell 立刻打印了提示符,子进程这时才打印 child"。shell 只 wait 了它启动的 p1(父进程),并不等孙子进程。你在真实终端里偶尔也能看到这种提示符"插队"的情况。
选 p2(加了 wait),再枚举一次。结果有几种?把调度方式改成"父进程先"单步执行,父进程执行 wait() 时发生了什么?
看答案
只剩 1 种。父进程先跑时,执行 wait() 发现子进程还活着,于是进入阻塞状态;调度器只能运行子进程。子进程打印、退出(变成僵尸)后父进程被唤醒,wait 返回子进程的 PID 29267,父进程才打印。
选"fork 两次"。预测:hi 会被打印几次?一共有几个进程?它们的父子关系是什么?
看答案
4 次、4 个进程。第一次 fork 后有 2 个进程;它们都执行第二次 fork,于是变成 4 个。原进程是两个孩子的父亲,第一次 fork 出来的孩子又是第四个进程的父亲。n 次连续 fork 会得到 2n 个进程。
作业 4:进程树里输入 a+b,b+c,c+d,c+e,c-。c 退出后 d 和 e 挂在哪里?勾选 -R 再看。
看答案
默认:d、e 过继给 a(init),树变成 a 下面挂 b、d、e。-R:过继给 c 的父进程 b,树变成 a → b → {d, e}。Linux 上孤儿通常过继给 init(PID 1)或最近的"子进程收割者(subreaper)"。
作业 3、6 风格:随机生成(种子 10、动作数 5),只看最终的树,能否唯一确定动作序列?什么情况下无法确定?
看答案
如果没有任何进程退出,树与 fork 序列基本一一对应(同一父进程的孩子按字母序出生)。一旦有进程退出,它就从树上消失了,而它的孩子被过继走——你无法从最终的树上看出"曾经存在过谁、谁 fork 了谁"。
重定向模拟器里,第 5 步子进程 close(1) 后调用 open("newfile.txt")。如果漏掉了 close(1),open 会返回几?wc 的输出去了哪里?
看答案
返回 3(最小的空闲描述符)。fd 1 仍指向终端,wc 照常把结果打印到屏幕上,newfile.txt 是空文件。重定向完全依赖"先腾出 fd 1,再让 open 占用最小空闲编号"。现代代码常用 dup2(fd, 1) 显式指定。

10动手实验

原书的 p1–p4,以及作业(Code)里的几道题。源码与说明见 labs/05-process-api/。

cd labs/05-process-api && make
./p1; ./p2; ./p3
make redirect   # ./p4 && cat p4.output
make order      # 跑 20 次 p1,统计 fork 后谁先打印
make buffer     # ./p1 | cat —— hello 为什么出现两次?
./var           # 作业 1:fork 后变量各自独立
./sharedfd      # 作业 2:父子共享打开的文件(及偏移量)
./pipe          # 作业 8:用 pipe() 把两个孩子连成 ls -1 / | wc -l

示例输出(WSL2;你的机器上 PID、地址、统计数字会不同):

$ ./p3
hello (pid:344568)
child (pid:344569)
  29  130 1176 p3.c
parent of 344569 (rc_wait:344569) (pid:344568)
$ make redirect
  32  138 1083 p4.c
$ make order
      1 child
     19 parent
$ make buffer
hello (pid:344440)
parent of 344442 (pid:344440)
hello (pid:344440)
child (pid:344442)
$ ./var
fork 前: x=100  *heap=100  &x=0x7ffe83774710  heap=0x5d82f3f722a0
child : x=101  *heap=101  &x=0x7ffe83774710  heap=0x5d82f3f722a0
parent: x=99   *heap=99   &x=0x7ffe83774710  heap=0x5d82f3f722a0
$ ./sharedfd && cat shared.txt
偏移量 lseek(fd,0,SEEK_CUR) = 140(= 10 行的总字节数,说明父子共用一个偏移量)
parent line 0
parent line 1
child  line 0
parent line 2
child  line 1
...

观察:① Linux 上 fork 后多数时候父进程先打印,但 20 次里也出现了子进程先——不能依赖顺序;② make buffer:输出接到管道时 stdout 是全缓冲,hello 还在用户态缓冲区里就被 fork 复制了一份,父子退出时各刷新一次。fork 前 fflush(stdout) 可避免;③ var 中父子打印的虚拟地址完全相同,值却各改各的——各自的地址空间;④ sharedfd 中两者的行互不覆盖:它们共享同一个"打开文件"及其偏移量(第 39 章细讲)。

11自测题

1. fork() 成功后,子进程从哪里开始执行?
子进程有父进程 PC 的副本,所以它"出生"时正要从 fork() 返回。这也是 p1 中 hello 只打印一次的原因。
2. fork() 在父进程和子进程中的返回值分别是?
正是这个差别让同一段代码可以分出两条路径。子进程想知道父进程的 PID 可调用 getppid()。
3. 为什么 p2.c 的输出顺序是确定的?
无论调度器先选谁,父进程的 printf 都位于 wait() 之后,而 wait() 在子进程结束前不会返回(除少数特殊情况)。
4. 关于 exec(),下列说法正确的是?
exec 把当前运行的程序"变身"成另一个程序。打开的文件描述符默认跨 exec 保留,重定向正依赖这一点。
5. shell 执行 wc p3.c > newfile.txt 时,关闭标准输出并打开文件的操作发生在哪里?
这正是把 fork 和 exec 分开的好处:wc 一行代码都不用改,也不知道自己的输出被重定向了。若在父进程里改,shell 自己的输出也会被重定向。
6. p4.c 中 close(STDOUT_FILENO) 之后的 open() 为什么恰好得到文件描述符 1?
fd 0 仍被标准输入占用,1 刚被关闭,于是 open 返回 1。
7. 一个普通用户能否用 kill 终止另一个用户的进程?
OS 用"用户"这一概念限制谁能控制哪些进程,否则可用性和安全性都会被破坏。
8. 下面的程序会打印几行 hi?fork(); fork(); fork(); printf("hi\n");
每次 fork 都让进程数翻倍:1 → 2 → 4 → 8,每个进程各打印一次。
9. 在 fork.py 默认设置下,执行 a+b,b+c,c+d,c- 后,d 的父进程是谁?
默认孤儿过继给根进程 a(相当于 init);只有加 -R 时才过继给 c 的父进程 b。子进程不会因为父进程退出而自动退出。

12常见误区与本章小结

下一步:我们已经会"创建进程"和"切换进程的请求"了,但 OS 究竟怎样在不失去控制的前提下让进程直接在 CPU 上高速运行、又怎样把 CPU 从一个进程手里拿回来交给另一个?这是第 6 章"受限直接执行"的主题。