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() 一样"出生"。
还有一点:p1 的输出不是确定的(not deterministic)。子进程创建后,系统里有两个我们关心的活跃进程。假设只有一个 CPU,此刻父进程和子进程都可能先运行;上面是父进程先打印,另一次运行可能是:
prompt> ./p1
hello (pid:29146)
child (pid:29147)
parent of 29147 (pid:29146)
prompt>
决定此刻运行谁的是 CPU 调度器(scheduler)。调度器很复杂,我们通常不能对它的选择做强假设。这种不确定性(non-determinism)在多线程程序中会引出许多有趣的问题(第 26 章起)。
你来当调度器:fork 之后谁先跑?
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 之后,就好像 p3.c 从来没有运行过一样;成功的 exec() 永远不会返回,所以 "this shouldn't print out" 永远不会被打印。
6为什么要这样设计:shell、重定向与管道
为什么把"创建新进程"这件简单的事拆成 fork 和 exec 这么古怪的两步?因为这样 shell 就能在 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 如何实现 > 和 |:文件描述符表逐步图解
原书图 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-C | SIGINT(interrupt) | 中断进程,通常使其终止 |
| Ctrl-Z | SIGTSTP(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先预测,再验证
prompt> 之后?看答案
看答案
看答案
a+b,b+c,c+d,c+e,c-。c 退出后 d 和 e 挂在哪里?勾选 -R 再看。看答案
看答案
close(1) 后调用 open("newfile.txt")。如果漏掉了 close(1),open 会返回几?wc 的输出去了哪里?看答案
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自测题
wc p3.c > newfile.txt 时,关闭标准输出并打开文件的操作发生在哪里?close(STDOUT_FILENO) 之后的 open() 为什么恰好得到文件描述符 1?hi?fork(); fork(); fork(); printf("hi\n");a+b,b+c,c+d,c- 后,d 的父进程是谁?12常见误区与本章小结
- "子进程从 main() 开始运行":不,它从 fork() 返回处开始,拥有父进程此刻状态的副本。
- "fork 之后父进程总是先运行":Linux 上常见如此,但没有保证;需要顺序就用 wait() 等同步手段。
- "父子进程共享变量":各自有独立的地址空间副本,一方修改另一方看不到(打印的虚拟地址相同也一样)。但打开的文件(及其偏移量)是共享的。
- "exec 创建了新进程":exec 不创建进程,只替换当前进程运行的程序;成功时不返回。
- "fork 前 printf 过的内容不会重复":若 stdout 的缓冲区里还有没刷出的数据,它会随地址空间一起被复制,导致重复输出。
- "父进程退出,子进程也随之结束":不会,子进程成为孤儿,被过继给 init。
- UNIX 用 PID 命名进程。
fork()创建子进程——父进程几乎完全相同的副本;父进程得到子进程 PID,子进程得到 0。 - fork 之后父子谁先运行由调度器决定,输出不确定;
wait()让父进程等待子进程完成,从而使顺序确定。 exec()家族让子进程摆脱与父进程的相似,执行全新的程序:覆盖代码和静态数据,重新初始化堆和栈,保留打开的文件描述符,成功时永不返回。- shell 用 fork + exec + wait 启动命令;fork 与 exec 分离使得 shell 能在二者之间修改子进程环境,实现重定向、管道等功能,而被运行的程序无需任何修改。
- 进程控制通过信号实现(kill、Ctrl-C、Ctrl-Z);用户只能控制自己的进程,超级用户可以控制所有进程,应谨慎使用。
下一步:我们已经会"创建进程"和"切换进程的请求"了,但 OS 究竟怎样在不失去控制的前提下让进程直接在 CPU 上高速运行、又怎样把 CPU 从一个进程手里拿回来交给另一个?这是第 6 章"受限直接执行"的主题。