fork 每 GB 内存要花 10 毫秒,而你 fork 出来的东西,exec 下一秒就全扔了

你在终端敲下 ls,回车,屏幕上出现几行文件名。
为了这几十个字节的输出,shell 干了一件很夸张的事:它先把自己完整复制了一份 ------ 页表(把虚拟地址翻译成物理地址的那张映射表)、内存映射、文件描述符表、信号处理、环境变量。然后它立刻把这份副本几乎全部丢弃,换成 ls 的代码和数据。
新进程真正继承下来并且用得上的,只有三样:文件描述符、环境变量、当前工作目录。
几十年来,教科书把这件事称为"廉价的进程创建"。
我们后来给它加了一层叫写时复制(copy-on-write,COW)的优化 ------ 让物理内存不必真的复制。于是"fork 很便宜"变成了常识。
但这个常识有一张很少有人算过的账单。Redis 的官方延迟文档里就摆着:
| 运行环境 | RSS(常驻物理内存) | fork 耗时 | 折算 |
|---|---|---|---|
| 物理机 Linux(Xeon 2.27 GHz) | 6.9 GB | 62 ms | 9 ms/GB |
| VMware Linux VM | 6.0 GB | 77 ms | 12.8 ms/GB |
| 物理机 Linux(未知硬件) | 6.1 GB | 80 ms | 13.1 ms/GB |
| EC2 老实例(Xen) | 6.1 GB | 1460 ms | 239 ms/GB |
| Linode 虚拟机(Xen) | 0.9 GB | 382 ms | 424 ms/GB |
裸机上,fork 的代价大约每 GB 内存 10 毫秒。按这个系数线性推算,一个 64 GB 的 Redis 实例,fork 一次要 640 毫秒。
这 640 毫秒里 Redis 完全停止服务 ------ 因为 fork 是同步的,它必须在主线程里把页表复制完。
而这一切,是为了一个几乎什么都不继承的副本。
所以真正要问的不是"fork 便宜不便宜",而是:
一个这么贵的东西,为什么成了 Unix 创建进程的唯一方式?而"把创建进程和加载程序解耦"这个设计,到底给了我们什么,值得付这个价?
一、解耦的价值不在"便宜",在那段可编程的中间态
先看这个设计有多干净。
Unix 把"创建一个进程"和"给这个进程装一个程序"拆成了两个独立原语:
fork()只负责造进程 ------ 复制当前进程exec()只负责换程序 ------ 用新的可执行映像覆盖当前地址空间
两者完全正交。你可以 fork 之后不 exec(复制自己干活),也可以 exec 之前做无数件事。
而这"无数件事",才是整个设计的价值所在。
因为 fork 返回之后、exec 调用之前,存在一段 Unix 里独一无二的状态:进程已经存在了,但它跑的还是父进程的代码。
在这段窗口里,你可以对一个即将成型的新进程为所欲为:
c
pid = fork();
if (pid == 0) {
dup2(fd_out, 1); // 重定向 stdout
dup2(fd_err, 2); // 重定向 stderr
close(unused_fd); // 关掉不该继承的句柄
chdir("/some/dir"); // 换工作目录
setrlimit(...); // 设资源上限
setgid(gid); setuid(uid); // 降权
execvp(cmd, argv); // 现在才装程序
}
shell 的全部魔法都住在这段窗口里。>、<、2>&1、|,没有一个是 shell 自己实现的 ------ 它们全是 dup2() 在这一刻改表的结果。
sudo 也一样:它本身带着 setuid 位启动(已经是 root),fork 之后先把身份切到目标用户,再 exec 命令。权限切换发生在程序加载之前,所以被启动的程序从一开始就没有多余权限。
〔分析〕这是本文想说的第一件事,也是最重要的一件:fork/exec 解耦的真正贡献,不是"创建进程很便宜",而是把"配置进程"从"配置程序"里分离了出来。
对照一下 Windows。CreateProcess() 是一次性调用,没有中间态,所有配置必须在那一刻说清,所以参数表长到要用 STARTUPINFO 结构体来装。
没有中间态,就没有"先改再启"的自由。 你只能在调用时把所有意图一次说完。
〔分析〕exec 系列有六个变体,看着不简约,但六个的差别全在琐事上:参数是列表还是数组、要不要搜 PATH、要不要自带环境变量。核心动作只有一个 ------ 用新映像覆盖地址空间。而 shell 能用几十行代码实现完整的作业控制,正是因为手里握着那段窗口。
(fork 不是 Unix 的原创:概念见于 Conway 1962 年的多处理器论文,Deutsch 在 GENIE 上实现,Thompson 借进了 Unix。)
二、COW 不是免单,是分期付款
现在回到成本。
没有 COW 的时代,fork() 就是把父进程的物理内存一页页复制过去,成本正比于内存大小。
COW 的做法是:父子共享同一批物理页,把两边的页表项都标成只读;谁要写,就触发缺页异常(page fault),内核这时才真正复制那一页。
所以 fork 时刻的成本,从"复制内存"变成了"复制页表"。
注意:是"变成",不是"变没"。
页表本身也是内存里的数据结构,而且它的大小正比于虚拟地址空间。加上内核要为子进程复制 VMA(虚拟内存区域)链表、文件描述符表、信号处理表、各种 per-process 结构 ------ 这些全都在 fork 那一刻同步做完。
Redis 那张表就是这笔账的实测结果:每 GB 约 10 毫秒,而且这个系数在虚拟化环境里会恶化到 20 倍以上。
〔分析〕打个比方:COW 像分期付款。但要说清它在哪里不像 ------
- 首付不能分期。 页表复制是 fork 当场付清的,一分钱都拖不了。这就是那 640 毫秒的来源。
- 如果从不写,第二期真的不会来。 这点比真实分期还爽:fork 之后只读的子进程,物理内存一个字节都没多占。
- 但如果大量写,总价可能超过一次性付款。 因为每一页的复制都要额外付一次"缺页异常"的入场费,那是一次用户态到内核态的陷入。
- 而最坏的情况是透明大页(THP)。 页大小从 4 KB 变成 2 MB 后,"写一个字节就复制一页"的规则不变,于是一次写操作会复制 2 MB ------ 放大了 512 倍。Redis 官方文档因此明确要求:必须关掉 THP。
这就是为什么"有 COW 所以 fork 很便宜"这句话不能细究。它只对了一半:省掉的是复制,没省掉的是记账。
三、"便宜"需要三个前提,1975 年全满足,2026 年全不满足
fork 真正流行起来是在 PDP-11 上。那台机器的参数,恰好让上面所有成本都可以忽略:
| 前提 | 1975 年的 PDP-11 | 2026 年的 Redis |
|---|---|---|
| 单线程 | 是 | 否(后台线程 + I/O 线程) |
| 地址空间小 | 几十 KB 级 | 64 GB → fork 约 640 ms |
| fork 之后立刻 exec | 是(约 80% 的 fork 来自 shell) | 否(fork 完就是干活) |
三个前提,缺一个 fork 就变贵。而 2026 年,一个都不剩。
前提一:单线程。 这是最阴的一条,因为它的失败不是变慢,是死锁。
fork() 只复制调用它的那个线程,其他线程直接消失。问题是它们消失的时候,可能正持有锁。
想象一下:线程 A 拿到 malloc 内部的锁还没释放,线程 B 同时调用 fork()。子进程里只有线程 B,那把锁的状态被原样复制了过来 ------ 永远是"已加锁",而唯一能解开它的线程 A 已经不存在了。
所以 POSIX 规定了一条极严的纪律:fork 之后到 exec 之前,子进程只能调用 async-signal-safe 的函数 ------ 指那些可重入、内部不碰任何锁的少数几个库函数。不能 printf(会碰 stdio 锁),不能 malloc(会碰分配器锁),不能碰任何自己写的加锁代码。
这条规则几乎没人能凭记忆守住。pthread_atfork() 就是为擦这个屁股而存在的。
前提二:地址空间小。 这是那 640 毫秒的来源。页表、VMA、per-process 状态,全部正比于进程规模。1975 年的进程只有几十 KB 的地址空间,复制页表几乎不花时间;今天的 Redis 有 64 GB。
前提三:紧接着 exec。 如果 fork 完立刻 exec,那 COW 的"第二期"永远不会来 ------ 因为 exec 会把整个地址空间直接换掉,所有共享页一次性作废。
〔分析〕所以"fork 很便宜"这句话,其实把两种完全不同的用法混在一起说了:
- fork + exec (换程序):便宜在"第二期不来",但贵在第一期白付 ------ exec 会把刚复制好的页表、VMA、内存映射全部丢弃。这是纯粹的浪费。
- fork only(复制自己干活):第一期、第二期全都要付,而且常常是写密集的,所以最贵。Redis 的 BGSAVE、Chrome 的 zygote,都走这条路。
两种用法都不便宜,只是贵在不同的地方。 而教科书讲的那个"便宜",恰好是最浪费的那一种。
四、四次绕开它的尝试,第一次在 1979 年
有意思的是,Unix 社区很早就知道这件事贵。
4.1 vfork:1979 年,他们在评估 COW 之后选择了另一条路
1979 年,Berkeley 把 Unix 从换页(swap)改成按页调页(demand paging),这就是 3BSD ------ 第一个带完整虚拟内存的 BSD。
改成虚拟内存之后,原来那个复制整个地址空间的 fork 立刻变成灾难。Babaoglu 和 Bill Joy 在 SOSP'81 的论文里记录了这个决策过程,其中有两句话非常关键:
We found that a vast majority (over 80 percent) of all forks executed in the system were due to the command interpreter. Since these forks only serve to establish the context for the new process, duplication of the entire address space was wasted effort.
(超过 80% 的 fork 来自命令解释器。这些 fork 只是为了给新进程建立上下文,复制整个地址空间纯属浪费。)
然后是更关键的一段 ------ 他们认真考虑过 COW,并且否决了:
A natural solution to the problem would have been to include a "copy-on-write" facility... However, this would have significantly increased the number of modifications to UNIX and hence delayed the completion of a workable and useful system. ... Copy-on-write paging seemed to introduce a good deal of complexity into the relatively simple system data structures.
(一个自然的解法是实现写时复制......但这会大幅增加对 Unix 的改动量,从而推迟一个可用系统的完成。写时复制分页看起来会给相对简单的系统数据结构引入大量复杂性。)
于是他们选了 vfork:父子共享同一套地址空间和内存页,不复制页表,父进程挂起等子进程 exec 或退出。
〔分析〕这段史料值得单独停一下。1979 年,全世界最懂这套系统的一批人,看着 COW,判断它"太复杂",然后选了一个语义上明显更危险的方案。
而今天,COW 是所有 Unix 的默认,vfork 在 POSIX 2004 年版里被标记为过时。
中间隔了大约二十年。这不是谁判断错了 ------ 这是工程上最典型的那种取舍:当时选"能跑起来的简单方案",把"更好的方案"留给下一个十年。
vfork 的代价现在看得很清楚:子进程改任何东西都会污染父进程的内存(没有 COW,是真共享)。所以 POSIX 只能规定,除"立即 exec"之外的一切用法都是未定义行为。一个系统调用要靠"你保证不这么用"来保证正确性,这本身就是设计失败的信号。
4.2 posix_spawn:把两步打包成一步
2001 年,POSIX 加了 posix_spawn()。思路很直白:既然绝大多数 fork 后面都跟着 exec,那就给一个合并版,让内核有机会一次性优化掉中间的浪费。
glibc 的实现就是 clone(CLONE_VM | CLONE_VFORK) ------ 本质上还是 vfork 那套。
但采用率极差。2016 年 Atlidakis 等人的 EuroSys 论文统计过:1304 个 Ubuntu 包(占总数 7.2%)调用 fork,而使用更现代的 posix_spawn() 的地方只有 41 处。
4.3 clone3:Linux 5.3,2019
Linux 的 clone() 本来就是 fork 的泛化(可以选共享地址空间、文件系统信息、fd 表、信号处理......)。clone3 把参数改成结构体,可扩展了。
4.4 io_uring 的 spawn 原语
最新的一层。逻辑相同:既然开销来自系统调用和状态复制,那就把"创建进程"也塞进共享内存队列里。
〔分析〕把四次尝试排在一起看,会发现一个规律:每一次都在做同一件事 ------ 绕开"复制一份完整的自己"这个动作。 vfork 绕开页表复制,posix_spawn 绕开两次系统调用,clone3 绕开参数不灵活,io_uring 绕开陷入开销。
四十年,四个方案,同一个目标。而 fork 本身,一次都没改过。
五、2019 年,四个人写了篇论文,要求把它废除
2019 年 5 月,HotOS 会议上有一篇论文,标题叫《A fork() in the road》(路上有个 fork)。作者是 Andrew Baumann(微软研究院)、Jonathan Appavoo、Orran Krieger(波士顿大学)、Timothy Roscoe(ETH Zurich)。
摘要第一句就把话说死了:
The received wisdom suggests that Unix's unusual combination of fork() and exec() for process creation was an inspired design. In this paper, we argue that fork was a clever hack for machines and programs of the 1970s that has long outlived its usefulness and is now a liability.
(公认的看法是,Unix 这套 fork + exec 的进程创建方式是个天才设计。本文认为,fork 只是针对 1970 年代的机器和程序的一个聪明的 hack,它早就超过了有用期,现在是个负担。)
四条指控:
- 不是线程安全的。 就是上一节那条
async-signal-safe纪律。 - 低效、不可扩展。 页表复制不随实际使用量增长,而随地址空间大小增长。
- 有安全问题。 论文只写了 "it introduces security concerns",没再展开。〔分析〕我的理解是:fork 让子进程继承父进程的全部地址空间内容,而"继承"这件事会把本该被清掉的内存(密钥、口令、临时缓冲区)带到新进程里去。
- 不组合(doesn't compose)。 这条最狠。
原文对第四点的说法是:
since it conflates the process and the address space in which it runs, fork is hostile to user-mode implementation of OS functionality, breaking everything from buffered IO to kernel-bypass networking. ... Perhaps most problematically, fork doesn't compose --- every layer of a system from the kernel to the smallest user-mode library must support it.
(因为它把"进程"和"进程运行的地址空间"混为一谈,fork 对用户态实现的操作系统功能充满敌意,破坏了从缓冲 IO 到内核旁路网络的一切。......最麻烦的是,fork 不能组合 ------ 从内核到最小的用户态库,系统的每一层都必须支持它。)
〔分析〕第四条不是在说 fork 慢,说的是更严重的事:fork 限制了整个系统的设计空间。 任何新抽象想在 Unix 上落地,都必须为 fork 做特例。共享内存、内核旁路、用户态驱动、GC 运行时 ------ 凡是"地址空间和进程不必一一对应"的东西,都被它的语义卡住。
论文的态度很硬:不是"加个新原语",而是"把 fork 从内核里拿掉"。它还批评了教育 ------ fork 该当历史文物讲,而不是学生遇到的第一个进程创建机制。
六、那又怎样
一、解耦的遗产不是"便宜",是那段中间态。
今天 Linux 上创建进程真正的入口是 clone() ------ fork 的泛化,能选共享什么。容器运行时(runc)就靠 clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET ...) 起容器的。
换句话说:容器的本质,是"fork 一个进程,但在 fork 的那一刻就把它关进一个新建的命名空间里"。 这依然是那段"进程已存在、程序未确定"的窗口 ------ 只是现在窗口里能干的事,从 dup2 扩展到了整个命名空间。
二、COW 是一种通用模式,不只属于操作系统。
"共享只读副本,直到有人要写"这个思路,在别处到处都是:
| 场景 | COW 的形态 |
|---|---|
| Git | 对象存储不可变,改动产生新对象 |
| Btrfs / ZFS 快照 | 共享数据块,写时重定向 |
| Kubernetes 镜像分层 | 层只读共享,容器层叠加 |
| React / Redux | state 不可变,靠引用比较判断变化 |
它们共享同一个账本结构:把复制成本推迟到第一次修改,并让"从不修改"的场景完全免费。
三、什么时候别用 fork。
- 进程大(GB 级 RSS)→ fork 成本按每 GB 约 10 ms 累加,而且是在主线程同步付
- 进程多线程 → 子进程只能碰 async-signal-safe 函数,等于自废武功
- fork 完不 exec → COW 的第二期全部落在你身上,写密集时比一次性复制还贵
- 跑在托管 GC 的运行时里 → GC 的堆、卡表、写屏障全都假设"线程只在本进程里",fork 出来的子进程很容易把 GC 弄崩(这也是 JVM 上不能随便 fork 的原因)
〔分析〕而 Redis 恰好前三条全中(它是 C 写的,第四条不适用)。它用 fork 是因为没有更好的选择 ------ 快照需要一个"时间点一致"的内存视图,而 COW 免费提供了这个语义。这是 fork 在 2026 年最大、也最贵的正当用途:不是创建进程,而是拿一份免费的一致性快照。
〔分析〕这件事本身很讽刺:fork 最初的设计目的是"创建进程",而它今天最有价值的用途,是"在不复制的条件下冻结一个瞬间"。一个为 1970 年代小型机设计的功能,靠一个 1979 年被明确否决过的优化,在 2026 年的大内存服务器上做着一件当年根本没人想过的事。
写在最后
回到 ls。
shell fork 了自己,复制了页表、映射、句柄表;子进程 dup2 了标准输出,然后 exec 成 ls,把刚才那份副本连同页表一起扔掉。
ls 真正用上的,只有三个整数:0、1、2。
这个流程被判定为"廉价",维持了半个多世纪。它廉价过 ------ 在只有几十 KB 地址空间、单线程、fork 完立刻 exec 的年代,它是真的廉价。
今天它一次要付每 GB 约 10 毫秒,还不够,COW 还留了一张后续账单。而我们至今没有换掉它,因为那段中间态太好用了:它把"配置一个进程"变成了一件可以用普通代码写完的事。
所以最后一句,留给这个设计的真实账本:
fork 从来没有变便宜。它只是把账单拆成了两期 ------ 而 fork + exec 这个组合最荒诞的地方是:它会在第二期到来之前,把第一期刚付的钱全部扔掉。
参考资料
- A. Baumann, J. Appavoo, O. Krieger, T. Roscoe, A fork() in the road , HotOS '19, 2019 年 5 月,pp. 14--22,DOI 10.1145/3317550.3321435 ------ 四条指控原文、1304 个 Ubuntu 包与 41 处
posix_spawn的统计(引自 Atlidakis et al., EuroSys 2016) - Ö. Babaoglu, W. Joy, Converting a Swap-Based System to do Paging in an Architecture Lacking Page-Referenced Bits , SOSP '81 ------ "80% 的 fork 来自命令解释器"、"评估后否决 COW"、
vfork的设计动机 - Redis 官方文档《Diagnosing latency issues》------ fork 耗时对照表(每 GB 9--13 ms,Xen 上 239--424 ms/GB)、必须关闭透明大页的要求