卷 II · 分身CH 06深度 06/24

fork 与 exec:进程是怎么生出来的

世界上绝大多数函数返回一次。fork() 返回两次——一次在你这儿,一次在一个刚刚诞生、和你一模一样的进程里。这个听起来像魔法的设计,是 Unix 最核心的一个决定,而且它比看上去更聪明。

forkexec返回两次僵尸与孤儿

一个返回两次的函数

先看这段代码,它是理解 fork 的全部:

int pid = fork();

if (pid == 0) {
  printf("我是孩子\n");
} else {
  printf("我是爹,孩子的 pid 是 %d\n", pid);
}

执行到 fork() 那一刻,内核做的事是:把当前进程整个复制一份。复制之后,机器上有两个进程,它们:

  • 执行同一份代码
  • 停在同一行(就是 fork() 这一行);
  • 拥有一模一样的变量、栈、打开的文件;
  • 唯一的区别:这一行返回的值不一样。

父进程拿到孩子的 pid(一个正数),孩子拿到 0就这一个数字的差别,让两个完全相同的进程走上不同的分支。

◆ 为什么孩子拿到的是 0,而不是自己的 pid

这个设计经常被当成随意约定,其实很讲究:

  • 孩子想知道自己是谁,随时可以 getpid()。这个信息它自己拿得到。
  • 父进程想知道孩子是谁,就只有这一次机会——如果 fork 不告诉它,它永远查不到。孩子的 pid 是内核刚分配的,父进程没有别的途径知道。

所以稀缺的信息给父进程,不稀缺的用一个哨兵值 0 代替。0 不是「孩子的 pid」,它是「你是孩子」这条消息。

▶ 动手 · fork 解释器

下面这台机器真的会解析你写的 C——词法分析、递归下降、然后用一个会分裂的求值器跑起来。七个示例请从头点到尾,尤其是「分岔:谁是谁」那个,注意父子两行输出里的 pid。

代码框可以改。把循环那个例子的 i < 3 改成 i < 4,看进程数从 8 变成 16。

那道经典面试题

你大概见过这个:

fork();
fork();
printf("A\n");

打几次 A?答案是 4,上面那台引擎会真的跑出 4 个进程、4 行输出。

推导过程值得走一遍,因为它暴露了 fork 最容易搞错的地方:

  1. 一开始 1 个进程,执行第一个 fork() → 变成 2 个
  2. 这 2 个进程都要执行第二个 fork()——因为子进程是从 fork()下一句接着跑的,第二个 fork 就在它前面等着。→ 变成 4 个
  3. 4 个进程各打一次 A

关键在第 2 步:子进程不是从头开始执行程序的,它是从 fork 的位置接着往下跑。这是最常见的误解来源。

循环那个例子把这一点放得更大:

for (int i = 0; i < 3; i++) {
  fork();
}

结果是 8 个进程(2³)。因为子进程继承了当时的 i 值,剩下几轮循环它也照做。i=0 时分裂出的孩子,还要经历 i=1i=2 两轮。

⚠ fork 炸弹

把上面那个循环去掉边界,就是著名的 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 会负责收尸

直觉上「孤儿」听起来更惨,但实际上僵尸才是要处理的那个。孤儿有人接管;僵尸的父进程还活着、只是不作为,没人能替它收尸。

上面那台引擎里最后两个示例分别演示了这两种情况,你可以对照着看进程树的变化。

¤ 价目表 · fork 到底贵不贵

「复制整个进程」听起来极其昂贵——一个占 1 GB 内存的进程,复制一份要拷 1 GB?

不用。fork 只复制页表,不复制内存本身,然后把两边都标成只读。真正的复制推迟到某一方某一页的时候,而且只复制那一页。这叫写时复制(COW),第 13 章有一台专门的引擎。

所以 fork 一个 1 GB 的进程,代价是 几百 μs,而且几乎与进程有多大无关——它只跟页表条目数成正比。

这是本书最喜欢的那类设计:把一笔昂贵的开销,推迟到「真的需要」的那一刻,而大多数时候那一刻永远不来。

✎ 掌故 · fork 是个「意外」

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 线程」这个二分法。