fork 与 exec:进程是怎么生出来的
世界上绝大多数函数返回一次。fork() 返回两次——一次在你这儿,一次在一个刚刚诞生、和你一模一样的进程里。这个听起来像魔法的设计,是 Unix 最核心的一个决定,而且它比看上去更聪明。
一个返回两次的函数
先看这段代码,它是理解 fork 的全部:
int pid = fork();
if (pid == 0) {
printf("我是孩子\n");
} else {
printf("我是爹,孩子的 pid 是 %d\n", pid);
}
执行到 fork() 那一刻,内核做的事是:把当前进程整个复制一份。复制之后,机器上有两个进程,它们:
- 执行同一份代码;
- 停在同一行(就是
fork()这一行); - 拥有一模一样的变量、栈、打开的文件;
- 唯一的区别:这一行返回的值不一样。
父进程拿到孩子的 pid(一个正数),孩子拿到 0。就这一个数字的差别,让两个完全相同的进程走上不同的分支。
这个设计经常被当成随意约定,其实很讲究:
- 孩子想知道自己是谁,随时可以
getpid()。这个信息它自己拿得到。 - 父进程想知道孩子是谁,就只有这一次机会——如果 fork 不告诉它,它永远查不到。孩子的 pid 是内核刚分配的,父进程没有别的途径知道。
所以稀缺的信息给父进程,不稀缺的用一个哨兵值 0 代替。0 不是「孩子的 pid」,它是「你是孩子」这条消息。
下面这台机器真的会解析你写的 C——词法分析、递归下降、然后用一个会分裂的求值器跑起来。七个示例请从头点到尾,尤其是「分岔:谁是谁」那个,注意父子两行输出里的 pid。
代码框可以改。把循环那个例子的 i < 3 改成 i < 4,看进程数从 8 变成 16。
那道经典面试题
你大概见过这个:
fork();
fork();
printf("A\n");
打几次 A?答案是 4,上面那台引擎会真的跑出 4 个进程、4 行输出。
推导过程值得走一遍,因为它暴露了 fork 最容易搞错的地方:
- 一开始 1 个进程,执行第一个
fork()→ 变成 2 个。 - 这 2 个进程都要执行第二个
fork()——因为子进程是从fork()的下一句接着跑的,第二个 fork 就在它前面等着。→ 变成 4 个。 - 4 个进程各打一次
A。
关键在第 2 步:子进程不是从头开始执行程序的,它是从 fork 的位置接着往下跑。这是最常见的误解来源。
循环那个例子把这一点放得更大:
for (int i = 0; i < 3; i++) {
fork();
}
结果是 8 个进程(2³)。因为子进程继承了当时的 i 值,剩下几轮循环它也照做。i=0 时分裂出的孩子,还要经历 i=1 和 i=2 两轮。
把上面那个循环去掉边界,就是著名的 fork 炸弹:
$ :(){ :|:& };:
这行 shell 定义了一个叫 : 的函数,它调用自己两次并放到后台,然后执行它。进程数以 2ⁿ 增长,几秒内耗尽 PID 和内存,整台机器失去响应。
上面那台引擎在 260 个进程时会拦下来。真机器上的防线是 ulimit -u(每用户最大进程数)和 cgroup 的 pids.max——后者是容器里唯一靠谱的那道,第 22 章会讲。
fork 造壳,exec 换芯
光有 fork 没什么用——你复制出来的是自己,不是你想运行的那个程序。所以 Unix 把「创建进程」拆成了两步:
fork() | exec() | |
|---|---|---|
| 做什么 | 复制出一个一模一样的进程 | 把当前进程的地址空间整个换掉 |
| 之后有几个进程 | 2 个 | 还是 1 个 |
| pid 变吗 | 孩子是新 pid | 不变——还是同一个进程,只是换了内容 |
| 返回吗 | 返回两次 | 不返回。成功了就没有「之后」了 |
| fd 表 | 复制一份 | 保留(除非标了 CLOEXEC) |
exec 那一行「不返回」值得强调。看这段:
execl("/bin/ls", "ls", NULL);
printf("这句永远打不出来\n");
perror("exec 失败了"); // ← 只有 exec 失败才会走到这儿
因为 exec 成功后,包含那句 printf 的代码段已经不存在了——整个地址空间被 /bin/ls 的内容覆盖了。所以 exec 之后的任何代码,都等价于错误处理。
你的 shell 每次都在干这件事
你在终端敲 ls -l,shell 做的是:
int pid = fork(); // 1. 造一个自己的分身
if (pid == 0) {
execvp("ls", args); // 2. 分身把自己换成 ls
_exit(127); // 只有 exec 失败才走到这里
}
waitpid(pid, &status, 0); // 3. 本体等着收尸
这三步就是 Unix 上运行一个命令的全部。你按下的每一条命令,都走了这么一遭。
Windows 的 CreateProcess() 一步到位,参数有十个。Unix 拆成两步,看起来更麻烦——但这个「麻烦」买到了一样非常值钱的东西:
在 fork 之后、exec 之前,有一个「我已经是新进程了,但还没变成新程序」的窗口期。在这个窗口里,你可以从容地为子进程布置环境:
int pid = fork();
if (pid == 0) {
// —— 窗口期:我还是我,但我马上要变成别人 ——
close(0); // 关掉标准输入
dup2(fd, 1); // 把标准输出重定向到某个文件
setuid(1000); // 降权
chdir("/tmp"); // 换工作目录
setrlimit(RLIMIT_AS, &lim); // 限制内存
execvp("untrusted", args); // 现在才变成那个程序
}
shell 的重定向(>)、管道(|)、sudo 的降权、容器运行时的全部准备工作——统统发生在这个窗口里。
如果是一步到位的 API,这些能力就必须全部变成参数,于是你会得到一个有几十个参数、还是覆盖不全的函数。Unix 用「返回两次」这个怪异的设计,换来了组合的自由。
僵尸与孤儿:两种不同的「没人管」
上一章说过僵尸,这里把它和孤儿放在一起对比,因为它们经常被搞混,而且危险程度完全相反:
| 僵尸 zombie | 孤儿 orphan | |
|---|---|---|
| 谁先死 | 子进程先死 | 父进程先死 |
| 状态 | 子进程是 Z | 子进程活得好好的 |
| 发生了什么 | 退出码没人读 | 被 PID 1 收养,ppid 变成 1 |
| 占资源吗 | 占一个 PID | 正常占用(它还在跑) |
| 危险吗 | ★ 危险——会累积,最终耗尽 PID | 不危险——PID 1 会负责收尸 |
直觉上「孤儿」听起来更惨,但实际上僵尸才是要处理的那个。孤儿有人接管;僵尸的父进程还活着、只是不作为,没人能替它收尸。
上面那台引擎里最后两个示例分别演示了这两种情况,你可以对照着看进程树的变化。
「复制整个进程」听起来极其昂贵——一个占 1 GB 内存的进程,复制一份要拷 1 GB?
不用。fork 只复制页表,不复制内存本身,然后把两边都标成只读。真正的复制推迟到某一方写某一页的时候,而且只复制那一页。这叫写时复制(COW),第 13 章有一台专门的引擎。
所以 fork 一个 1 GB 的进程,代价是 几百 μs,而且几乎与进程有多大无关——它只跟页表条目数成正比。
这是本书最喜欢的那类设计:把一笔昂贵的开销,推迟到「真的需要」的那一刻,而大多数时候那一刻永远不来。
Unix 的 fork 并不是深思熟虑的产物。Ken Thompson 后来回忆,它来自 1969 年 PDP-7 上的一个非常具体的处境:那台机器只能同时装下一个进程的内存。所谓「创建进程」,就是把当前进程整个换出到磁盘、复制一份、再换回来。
换句话说,「复制自己」是当时唯一实现得了的方案,而不是最优雅的方案。返回两次这个古怪的接口,是硬件限制的化石。
而它意外地成了 Unix 最有生命力的设计之一——因为那个 fork 与 exec 之间的窗口期,给了后来几十年的人无穷的组合空间。
当然它也有代价。2019 年有一篇著名的论文,标题就叫《A fork() in the road》,作者包括 Microsoft Research 和 Boston University 的研究者,系统地论证了 fork 在今天是个坏抽象:它和线程配合极差(fork 之后只有调用线程存活,其他线程持有的锁永远不会释放)、和现代内存管理配合极差、在多核上扩展性差。他们的建议是用 posix_spawn 这类接口取代它。这个批评相当有力——但 fork 已经长进了整个生态的肌肉里。
「Android 的应用启动为什么那么快?」
因为它不 exec。
Android 有一个叫 Zygote(受精卵)的进程,它在开机时启动,预先加载好整个 Android framework、常用类、资源、以及 ART 运行时。然后它就坐在那儿等着 fork。
你点开一个 App,系统不是从头启动一个 JVM——而是让 Zygote fork() 一份,子进程直接就有了全套 framework,然后只需要加载你的 APK 代码。没有 exec 那一步,所以不用重新初始化运行时。
更妙的是配合 COW:那些预加载的类和资源在物理内存里只有一份,所有 App 进程共享,直到某个 App 真的去写它们。几十个 App 进程,framework 的内存只算一次。
这是「fork 便宜」和「COW」两个机制叠在一起的一个漂亮应用——也解释了为什么 Android 的进程模型看起来这么奢侈(每个 App 一个进程)却还能在 2 GB 内存的手机上跑。
这一章的一句话
fork 复制出一个一模一样的进程,靠返回值的差别让它们分道扬镳;exec 把地址空间整个换掉、永不返回。拆成两步换来的那个窗口期,是 shell 重定向、sudo 降权、容器启动的共同土壤——而 COW 让「复制整个进程」这件事便宜到几乎与进程大小无关。
下一章:如果 fork 是「什么都不共享地复制」,那把某些东西共享会得到什么?答案是线程——而 Linux 处理这件事的方式会让你重新审视「进程 vs 线程」这个二分法。