超越 fork() + exec()

fork()+exec() 的成本与行为

  • 多条评论批评文章把 fork 说成“复制整个内存”的表述;他们强调的是写时复制(copy-on-write),但也指出真正的成本在于复制页表和 VMA,并设置 COW,其复杂度为 O(进程大小)。
  • 对于非常大的进程(几十 GB),fork 的暂停时间可能达到数秒;例子包括 Redis 和大型 JVM。人们常用 zygote 辅助进程来避免从巨型进程中 fork。
  • vfork() 通过避免 COW 减少了一些成本,但使用约束很严格,并且在某些系统上与线程的配合很差。
  • 大家普遍认为,超量提交(overcommit)和 COW 几乎是让 fork 保持快速所必需的,但这也反过来限制了操作系统设计。

进程创建模型与替代方案

  • 许多人支持 spawn 风格的原语:posix_spawn(在某些操作系统上是原生的)、Windows 上的 CreateProcessNtCreateProcess、XNU 的非 fork posix_spawn、Mach 的“task/address space/threads”模型。
  • 提出的想法包括:通过一次系统调用创建一个“空白”进程,然后再通过其他系统调用对其进行配置(可能接受 pidfd);或者使用一个很小的加载器进程来运行配置然后 exec
  • 有人希望现有 API(setuid、fd 操作等)能够接受目标进程句柄,这样就可以在外部完成配置,而不是在 fork 出来的子进程里做。
  • 还有人建议使用类似 ptrace 的机制或 eBPF 钩子来驱动配置,而不是为每个功能都新增 spawn 标志。

状态继承与配置

  • 一个反复出现的抱怨是:大多数真实用例想要的是“用一小组明确的继承项启动新程序”,但 fork 的默认行为却是“继承一切,然后费力地排除”。
  • 文件描述符是最大的痛点;O_CLOEXEC 和 fd 遍历被认为容易出错。
  • 一个真正“共享为空”的 spawn 很难定义:容器、cgroups、命名空间、UID/gid、信号、环境变量等都会以不明显的方式相互作用。
  • 多线程父进程让 fork 变得脆弱:子进程里只保留一个线程,因此内存中的锁和 malloc 状态可能处于不一致状态;这也是 POSIX 规定 fork 和 exec 之间只能使用 async-signal-safe 函数的原因。

使用场景与性能敏感性

  • 有人认为“spawn 不该是热路径”;但也有人指出,构建系统、fuzzer、重度进程隔离、浏览器和移动平台在实践中都与此相矛盾。
  • Zygote 模式(预先 fork 的辅助进程)被广泛使用,但被认为很难后补进现有系统,而且脆弱。

优雅性 vs “历史包袱”

  • 一派赞扬 fork+exec 简单、可组合,是 Unix shell 强大的关键;带巨大参数结构的替代 spawn API 被视为丑陋且扩展性较差。
  • 另一派把 fork 称为过时的 20 世纪 70 年代包袱,它强迫人们采用别扭的抽象(例如通过编号 fd 编码 API),使线程处理复杂化,并使内核内部机制僵化。
  • 还有人指出,如果 Unix 一开始就采用 spawn+configure,许多系统调用本可以按显式进程参数来设计,从而简化调试和监督。

共享库与内存

  • 更正了一个误解:像 libgcc 这样的共享库是通过 mmap 在多个进程间映射并共享的;只有重定位/GOT 数据是每个进程独有的。
  • 这种共享被认为是动态链接的一大优点。