fork 是 Unix
最古老的原语之一。它的”神奇”设计——把 “启动一个新程序” 拆成
“复制 + 换壳” 两步——让 shell 的管道、重定向、job control
变得极其简洁。它也让 Unix 程序的结构有了 “父子树”
的自然骨架。
但 fork 也是 Linux 上最容易中雷的系统调用之一。大内存进程
fork 卡住秒级;多线程 fork 让 mutex 留在诡异状态;容器里
fork 时 pid namespace 的 PID 1 是个 subreaper 陷阱。本文梳理
fork 的来龙去脉、它在现代工程的变体,以及为什么 2020
年代的新代码应该优先考虑 posix_spawn。
一、fork 的经典语义
pid_t pid = fork();
if (pid == 0) {
// 子进程
execvp("/bin/ls", argv);
_exit(127);
} else if (pid > 0) {
// 父进程
waitpid(pid, &status, 0);
} else {
perror("fork");
}fork 在调用时把当前进程”克隆”一份:
- 所有页被复制(现代系统是 COW 标记)
- 所有 fd 被复制(除非设了
FD_CLOEXEC) - 所有信号处理函数被继承
- 所有 mmap 区域被继承
- 所有 namespace、cgroup、cred 被继承
- 仅调用 fork 的那个线程被复制,其他线程消失
父子只在返回值上区分:父得 child pid,子得 0,出错父得 -1。
这个设计在 1971 年是绝妙的——那时候进程就是一段连续的内存 + 几个寄存器,“复制”是物理操作。50 年后的问题在于:进程的状态变得极其庞大,但 fork 的语义没变。
二、COW 与页表放大
现代 Linux 的 fork
不会立刻复制每一页数据;它只复制页表,所有页被标记为
PTE_WRPROT(只读),哪方先写就 page fault →
COW(Copy-On-Write)分配新页。
flowchart LR
subgraph Before[fork 前]
P[父进程 task] --> PT1[页表]
PT1 --> PG[(物理页 RW)]
end
subgraph After[fork 后]
P2[父 task] --> PT2[父页表 RO]
C[子 task] --> PT3[子页表 RO]
PT2 --> PG2[(物理页 RO 共享)]
PT3 --> PG2
end
subgraph Write[子写入后]
P3[父] --> PT4[父 RO]
C3[子] --> PT5[子 RW]
PT4 --> PG3[(原页)]
PT5 --> PG4[(新复制页)]
end
COW
节约了拷贝开销,但没有节约页表本身的复制。页表在
x86_64 上是 4 级,每 512 个 PTE
需要一个中间表。512GB 地址空间的页表占用大约几
GB。对大 JVM 或大 Redis 进程,fork 的”页表复制 + RO
标记”这一步本身就要几百毫秒到几秒。
而且 COW 不是”零成本”:
- 写放大:子进程(或父进程)写一页就 fault + alloc + copy。Redis 做 BGSAVE fork 的子进程遍历数据,父进程被写流量持续 COW,内存可能翻倍,超过阈值触发 OOM
- TLB 压力:两个页表对同一页有不同权限,TLB 条目在两进程间不能共享
- 写时
PTE_RW翻转:不是原子,并发场景有 race
因此大内存进程 fork 在生产上被视作”仅限 fork 后立刻 exec” 的场景——Redis 的 BGSAVE 是少数例外,它接受这个代价换取”不停服持久化”。
三、exec:把 COW 瞬间清零
execve
让进程丢弃当前地址空间,加载新二进制。语义上:
- mmap 区域全部清除
- 堆/栈重置
- 除
FD_CLOEXEC外的 fd 保留 - 信号处理恢复默认(SIG_DFL)
- credentials、pid 不变
- namespace、cgroup 不变
fork + exec 的”异味”在于:fork
花费大量时间复制页表/标 RO,exec 立刻丢掉。这就是
vfork / posix_spawn
试图解决的问题。
四、vfork:为 exec 而生
vfork 的语义是 fork 的”危险简化版”:
- 不复制页表(甚至不复制 mm)
- 子进程与父共享地址空间
- 父进程阻塞直到子进程 exec 或 _exit
- 子进程在此期间几乎不能做任何事(不能返回父栈帧、不能改全局变量)
因为这种危险,早期 POSIX 把 vfork 标记为 obsolete。但
glibc 仍有 vfork 实现;Linux 的 clone
CLONE_VFORK 是现代路线。musl 的
posix_spawn 内部用 clone + CLONE_VFORK +
CLONE_VM 实现。
五、clone / clone3:内核视角的”统一 fork”
Linux 内核里根本没有 fork
这个原语。fork、vfork、pthread_create
都翻译到同一个 syscall:clone。
clone 的参数是一组 flags,控制”子进程和父进程共享什么”:
| flag | 含义 |
|---|---|
CLONE_VM |
共享地址空间(pthread) |
CLONE_FS |
共享 fs_struct(cwd、root) |
CLONE_FILES |
共享 file descriptor table |
CLONE_SIGHAND |
共享 signal handlers |
CLONE_THREAD |
同一 thread group(tgid 相同) |
CLONE_VFORK |
父阻塞到子 exec/exit |
CLONE_PARENT |
子的父是 caller 的父 |
CLONE_NEWNS/PID/NET/... |
新建 namespace |
CLONE_PIDFD |
分配 pidfd |
CLONE_IO |
共享 I/O context |
fork() =
clone(SIGCHLD);pthread_create() =
clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|...)。
5.1 clone3
clone3 (2019, 5.3) 用
struct clone_args 取代长串参数,带
size 字段便于演进:
struct clone_args {
__u64 flags;
__u64 pidfd;
__u64 child_tid;
__u64 parent_tid;
__u64 exit_signal;
__u64 stack;
__u64 stack_size;
__u64 tls;
__u64 set_tid; // 指定子进程 pid(需要 cap)
__u64 set_tid_size;
__u64 cgroup; // 直接把子放到目标 cgroup
};set_tid 允许
CRIU(checkpoint-restore)恢复进程时重建原始
pid——这在以前几乎不可能。cgroup 字段让”fork
进新 cgroup”原子化,去除 fork→move 的竞争窗口。
六、posix_spawn:合并 fork+exec 的现代替代
posix_spawn 是 POSIX.1-2001
加入的接口,语义上等价于 fork+exec
但允许实现跳过中间态:
posix_spawn(&pid, "/bin/ls", &file_actions, &attr, argv, envp);file_actions:声明性的 fd 操作列表(open/dup/close)attr:调度策略、signal mask、sid/pgid
glibc 2.24+ 在 Linux 上用 clone + CLONE_VFORK + CLONE_VM 实现;musl 一开始就这么做。避免了 fork 的页表复制 → 对大进程的启动延迟降低几个数量级。
为什么生态没有迁移过来:
- 历史代码数量巨大,fork 几乎无处不在
- 很多应用需要在 fork 和 exec 之间插入自定义代码(setpgrp、close extra fd、drop privs)——file_actions 能表达的有限
- 错误处理路径不如 fork 直观
- 在 pre-fork 服务器(Postgres、Apache prefork)里 fork 的”共享地址空间 + 懒复制”正好是所需语义
Python 3.12 默认用 forkserver 而不是
fork,是正确方向。Rust 的 std::process::Command
在 glibc 上走 fork + exec;在 musl 走 posix_spawn。Go
完全绕开,用自己的 syscall.forkAndExecInChild +
RawSyscall + vfork-like 约束。
七、fork 与多线程:async-signal-safe 诅咒
POSIX 规定:多线程程序在 fork 后,child 只能调用 async-signal-safe 函数直到 exec。原因是:
- fork 只复制调用线程,其他线程消失,但它们持有的互斥锁仍处于锁定状态
- 子进程再调 malloc → 尝试锁 malloc 的 arena mutex → 死锁
- glibc 的几乎所有函数都可能调 malloc 或持锁
async-signal-safe 的函数清单(POSIX
规定):_exit、_Exit、execve、read、write、close、dup、fork、signal、waitpid
等几十个。不包含
printf、malloc、pthread_mutex_lock、stdio
任何函数。
后果:复杂程序不能 fork 后做任何重要事,只能立刻 exec 或 _exit。
pthread_atfork 允许注册 prepare/parent/child
钩子,但在多库嵌套时无法保证顺序正确,几乎所有大型项目的建议都是别用
pthread_atfork。
八、fork 与 JIT、GC、大堆
JIT 编译器(V8、JVM、Go runtime)生成的代码页有
PROT_EXEC,持有指针、引用表。fork
后这些内部表在两个进程间共享但可能被异步修改:
- V8:fork 后一般只能 exec
- JVM:G1/ZGC 有针对 fork 的安全处理;CMS 时代经常崩溃
- Go runtime:内置 sysmon 监控线程,fork
后行为未定义;
os/exec内部用特殊路径
CRIU 为了能 checkpoint JIT 进程,做了大量特殊处理(freeze GC、marshal JIT page)。
九、fork 与容器:PID 1 陷阱
在 pid namespace 里,PID 1 有特殊语义:
- 它的子进程退出后没人 reap,变成僵尸
- 发送它 SIGTERM、SIGINT 时必须自己设置 handler;否则默认被 kernel 忽略(PID 1 对默认信号免疫)
- 它死了,整个 pid namespace 被销毁
Docker 的入口进程常常是应用本身(PID 1)。如果应用 fork 子进程且不 reap,会攒僵尸;如果被 SIGTERM 没 handler,Docker stop 超时后 SIGKILL。
解决方案:
tini或dumb-init作为 PID 1,负责 reap 和转发 signaldocker run --init默认给你一个 init(其实就是 tini)- systemd 作为 PID 1(较重)
PR_SET_CHILD_SUBREAPER 让进程成为”子
reaper”,接收孤儿进程的 SIGCHLD——不是 PID 1
也能做这件事。systemd、supervisord、Docker
用它管理子树。
十、fork 在工程上的正确形态
- 一旦决定 exec,就用 posix_spawn(现代 libc 的实现已经够好)
- pre-fork 服务器(Nginx、Postgres、Gunicorn):fork 一次,子进程长期跑;共享只读的初始化数据(权重、配置)。相性好
- GC 语言的进程池(Python
multiprocessing):用
spawn或forkserver启动方式,避免多线程 fork - Redis BGSAVE 风格:fork 做快照,接受 COW 放大的代价,关键是监控 rss
- 容器入口:确保 PID 1 能处理 SIGTERM 和
reap;不行就用
--init - exec 前用 close_range(CLOSE_RANGE_CLOEXEC)(5.11+)一次性关闭不需要的 fd,代替遍历 /proc/self/fd
十一、fork 不会消失
即使 posix_spawn 更好,fork 不会被移除——它是稳定 ABI 的一部分,数十亿行代码依赖它。Linus 多次拒绝”废弃 fork”的提议。新代码可以选择更好的接口,但 fork 永远会在那里。
“好的抽象不是最先被发明的,而是在漫长时间后仍然能被安全使用的那一种。” fork 早已不属于前者;我们能做的是理解它、绕过它、或在必须时谨慎使用它。
下一篇 B-10 进入线程模型:1:1、N:1、M:N、虚拟线程——相同的”并发”问题在不同调度哲学下的不同答案。
参考文献
- Ritchie, D. M., Thompson, K. “The UNIX Time-Sharing System.” CACM, 1974
- Baumann, A., et al. “A fork() in the road.” HotOS, 2019 —— 现代反思 fork 的论文
- Kerrisk, M. The Linux Programming Interface, Chapter 24–25, 27
- Corbet, J. “A new API for fork+exec? posix_spawn().” LWN.net
- Corbet, J. “clone3(): Cleaning up a messy syscall.” LWN.net 2019
- Linux Documentation/admin-guide/mm/overcommit-accounting.rst
- Linux source:
kernel/fork.c,kernel/exit.c,fs/exec.c - Redis docs: “BGSAVE and memory usage”, redis.io
工具
strace -f -e trace=%process—— fork/exec/clone 追踪pidfd_open(2)、pidfd_send_signal(2)—— 现代 pid 引用prlimit --as—— 限制 fork 时的地址空间放大cat /proc/<pid>/status | grep -E 'VmRSS|VmPeak|Threads'—— fork 监控perf trace -a -e 'clone*'—— 系统级 fork 观察
上一篇:关于 OS 的工程常识错觉 下一篇:线程模型:1:1 / N:1 / M:N 与虚拟线程
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【操作系统百科】内存回收
Linux 内存回收是 VM 最复杂的子系统之一。本文讲 active/inactive LRU、kswapd 与 direct reclaim、watermark 三线、swappiness 的真实含义、MGLRU 改造、memcg 回收与 PSI。
【操作系统百科】交换
swap 还值得开吗?本文讲 swap area 基础、swap cache、zram 压缩内存、zswap 前端压缩池、swappiness 的真实含义、容器里的 swap 策略,以及为什么现代 Android 全靠 zram 不靠磁盘。
【操作系统百科】Slab/SLUB 分配器
buddy 只管页粒度(4K+),内核大多数对象只有几十到几百字节。slab/SLUB 在 buddy 之上做对象级缓存。本文讲 slab 历史、SLUB 接手、SLOB 退场、kmem_cache、per-CPU cache、KASAN 集成。
【操作系统百科】用户态分配器
glibc malloc、tcmalloc、jemalloc、mimalloc 各有哲学。本文讲 arena、thread cache、size class、madvise 返还策略、碎片与 RSS 膨胀、如何根据负载选分配器。