Linux-21 进程的诞生:fork 为什么是 fork
fork() 是 Unix 最有辨识度的设计:一次调用,两次返回。它经常被当作「优雅」的范例,也经常被批评为「过时的错误」。
这一篇要说清楚:它为什么长这样、它的优雅之处到底在哪、以及它在多线程时代付出了什么代价。
先看五个问题:
- 为什么 Unix 用
fork+exec两步创建进程,而 Windows 用CreateProcess一步? - COW(写时复制)是
fork的原始设计,还是后来的补丁? - 为什么
fork()之后子进程只有一个线程?多线程程序调用fork有什么风险? - 为什么必须有僵尸进程这个状态?
- 为什么 Go 程序不能在
fork()之后的子进程里继续跑 Go 代码?
1. 接口本身:一次调用两次返回
#include <unistd.h>
pid_t fork(void);
// ^^^^^ 没有任何参数!
pid_t pid = fork();
if (pid < 0) {
perror("fork"); // 失败
} else if (pid == 0) {
// ✅ 这里是【子进程】
printf("我是子进程,我的 PID=%d,父进程=%d\n", getpid(), getppid());
_exit(0);
} else {
// ✅ 这里是【父进程】,pid 是子进程的 PID
printf("我是父进程,创建了子进程 %d\n", pid);
waitpid(pid, NULL, 0);
}
这个接口的奇特之处:同一行代码在两个不同的进程里各返回一次,靠返回值区分身份。
它「优雅」在哪? 三点:
① 没有参数 —— 因为「新进程长什么样」的答案是「和我一模一样」
对比 CreateProcess 的 10 个参数:可执行文件、命令行、安全属性、
是否继承句柄、创建标志、环境变量、工作目录、启动信息、进程信息……
fork 一个都不需要,因为全部继承自调用者。
② 概念极简 —— 只有一个动作「复制自己」,正交于「加载新程序」(exec)
两个原语可以自由组合:
fork 不 exec -> 多进程并发(nginx 的 worker、PostgreSQL 的连接进程)
exec 不 fork -> 替换自身(09 篇的 exec、容器 entrypoint)
fork + exec -> 启动另一个程序(shell 的默认行为)
③ 中间留出了一个可编程的窗口 —— 这才是真正的精髓,见第 3 章
2. 对比其他设计
2.1 Windows 的 CreateProcess
// Windows:一步创建,所有配置都是参数
BOOL CreateProcessW(
LPCWSTR lpApplicationName, // 可执行文件
LPWSTR lpCommandLine, // 命令行
LPSECURITY_ATTRIBUTES lpProcessAttributes, // 进程安全属性
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 线程安全属性
BOOL bInheritHandles, // 是否继承句柄(只能全继承或全不继承!)
DWORD dwCreationFlags, // 创建标志(挂起、优先级、控制台…)
LPVOID lpEnvironment, // 环境变量块
LPCWSTR lpCurrentDirectory, // 工作目录
LPSTARTUPINFOW lpStartupInfo, // 启动信息(窗口、标准句柄重定向…)
LPPROCESS_INFORMATION lpProcessInformation // 输出
);
这个设计的取舍很清晰:
fork + exec |
CreateProcess |
|
|---|---|---|
| 调用次数 | 2 | 1 |
| 参数数量 | 0 + 3 | 10 个 |
| 配置子进程的方式 | 在子进程里用普通代码做 | 必须表达成参数 |
| 灵活性 | ✅ 任意操作(凡是进程能做的都能做) | ❌ 只能做参数支持的事 |
| 性能 | ⚠️ 要复制地址空间(COW 缓解) | ✅ 不复制 |
| 概念复杂度 | ✅ 两个简单原语 | ❌ 一个复杂原语 |
| 出错处理 | 子进程里出错要用 _exit + 管道传错误码 |
返回值直接告知 |
Windows 的方式为什么必须这样? 因为它没有 fork 这个中间态,所有定制必须提前声明。而现实需求是无穷的 —— 想设置资源限制?想加入 cgroup?想改 umask?想只继承部分 fd?CreateProcess 的参数列表只能不断膨胀(后来确实又加了 CreateProcessAsUser、CreateProcessWithLogonW、STARTUPINFOEX 属性列表等一堆变体)。
2.2 fork 与 exec 之间:真正的设计精髓
这个窗口里,进程「已经是新进程,但还没变成新程序」 —— 你可以用普通代码做任何配置:
pid_t pid = fork();
if (pid == 0) {
// ── 此时:新的进程,但仍在运行父进程的代码 ──
// 可以做任何事,然后才 exec
// ① 重定向(09 篇 2.1 讲的 shell 重定向就在这里做)
int fd = open("/tmp/out.log", O_WRONLY|O_CREAT|O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
close(fd);
// ② 改变身份(降权 —— 10 篇的服务账号)
setgid(1000); setgroups(0, NULL); setuid(1000);
// ^^^^^^^ 注意顺序:先 setgid 再 setuid(反了就没权限改组了)
// ③ 改工作目录、umask
chdir("/var/lib/myapp");
umask(0027);
// ④ 资源限制(13 篇的 ulimit)
struct rlimit rl = { .rlim_cur = 65535, .rlim_max = 65535 };
setrlimit(RLIMIT_NOFILE, &rl);
// ⑤ 关闭不该继承的 fd
for (int i = 3; i < 1024; i++) close(i);
// ⑥ 恢复信号处理(父进程可能忽略了某些信号)
signal(SIGINT, SIG_DFL);
sigprocmask(SIG_SETMASK, &empty_set, NULL); // 清空信号掩码
// ⑦ 调整优先级(13 篇的 nice)
setpriority(PRIO_PROCESS, 0, 10);
// ⑧ 加入 cgroup / 换 namespace(容器的做法,36 篇)
write(cgroup_procs_fd, "0", 1);
setns(netns_fd, CLONE_NEWNET);
// ⑨ 设置进程组与会话(13 篇 1.1 的会话模型)
setsid();
// ── 最后才加载新程序 ──
execve("/usr/local/bin/myapp", argv, envp);
_exit(127); // exec 失败才会走到这里(01 篇讲的退出码 127)
}
关键洞察:这些操作没有一个需要新的 API。 它们都是「进程对自己做的事」,本来就存在。fork 只是提供了一个「以子进程身份执行普通代码」的机会。
fork+exec 的哲学:
不要设计「创建进程时可以配置什么」的接口,
而是让子进程【自己配置自己】——
于是「能配置什么」自动等于「进程能对自己做什么」,无需任何额外设计。
这与 18 篇的「机制与策略分离」是同一个思路:
内核提供 fork(机制),配置策略完全交给用户态代码。
这就是开篇第一问的答案。 而 shell 的实现正是这个模式的直接体现:
# 这一行 shell 命令
sudo -u nobody sh -c 'ulimit -n 100; cd /tmp; exec myapp' > out.log 2>&1 &
# 对应的 fork 后操作序列:
# fork -> dup2(重定向) -> setuid(降权) -> setrlimit -> chdir -> exec
# 每一步都是普通的系统调用,不需要任何「创建进程专用」的参数
2.3 vfork:性能补丁
早期 fork() 真的会完整拷贝父进程的地址空间。而最常见的用法是「fork 完立刻 exec」—— 拷贝的内容马上就被丢弃,纯属浪费。
pid_t vfork(void);
// 语义:
// ① 【不拷贝】地址空间,子进程直接【借用】父进程的内存
// ② 父进程被【挂起】,直到子进程 exec 或 _exit
// ③ 子进程在 exec 之前【不能修改任何内存、不能 return、不能调用除 _exit/exec 外的函数】
// vfork 的正确用法极其受限
pid_t pid = vfork();
if (pid == 0) {
execve(path, argv, envp); // ✅ 只能做这个
_exit(127); // ✅ 或者这个
// ❌ 不能改任何变量(那是父进程的内存!)
// ❌ 不能 return(会破坏父进程的栈)
// ❌ 不能调用 malloc、printf(它们会修改内存)
}
vfork 是典型的「以正确性换性能」的补丁:它把「不要碰内存」这个约束交给了程序员,一旦违反就是极难调试的内存破坏。POSIX 后来把它标记为已废弃。
2.4 posix_spawn:折中方案
int posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *file_actions, // fd 操作列表
const posix_spawnattr_t *attrp, // 属性(信号、进程组…)
char *const argv[], char *const envp[]);
它的思路是:把「fork 与 exec 之间的常见操作」编码成一份声明式的「动作列表」,然后一次性提交给内核/libc:
posix_spawn_file_actions_t fa;
posix_spawn_file_actions_init(&fa);
posix_spawn_file_actions_addopen(&fa, 1, "/tmp/out.log", O_WRONLY|O_CREAT, 0644);
posix_spawn_file_actions_adddup2(&fa, 1, 2);
posix_spawn_file_actions_addclose(&fa, 3);
posix_spawnattr_t attr;
posix_spawnattr_init(&attr);
posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETPGROUP);
posix_spawn(&pid, "/usr/bin/myapp", &fa, &attr, argv, envp);
| 优点 | 缺点 | |
|---|---|---|
posix_spawn |
不用拷贝地址空间(glibc 内部用 clone(CLONE_VM|CLONE_VFORK));多线程程序里安全;嵌入式/无 MMU 系统也能用 |
❌ 只支持预定义的动作(想 setns、写 cgroup、改 rlimit 就做不到)—— 退化成了 CreateProcess 那类问题 |
所以三种方案是一条光谱:
fork+exec ←──────────────────────────────────→ CreateProcess
最灵活 最直接
(任意代码) posix_spawn(预定义动作) (固定参数)
有拷贝开销 无拷贝,多线程安全 无拷贝
2.5 clone:Linux 的真身
Linux 上 fork、vfork、pthread_create 全都是 clone() 的不同参数组合:
long clone(unsigned long flags, void *stack, ...);
// 关键 flag(决定「共享什么」)
CLONE_VM // 共享地址空间 -> 线程
CLONE_FS // 共享 cwd/umask/root
CLONE_FILES // 共享 fd 表 -> 20 篇讲的「线程共享 fd」
CLONE_SIGHAND // 共享信号处理函数
CLONE_THREAD // 同一个线程组(共享 PID)
CLONE_VFORK // 父进程挂起直到子进程 exec
CLONE_NEWNS // 新的 mount namespace ┐
CLONE_NEWPID // 新的 PID namespace ├─ 容器(36 篇)
CLONE_NEWNET // 新的 network namespace │
CLONE_NEWUSER // 新的 user namespace ┘
CLONE_PIDFD // 返回一个 pidfd(见 9.2)
fork() = clone(SIGCHLD) 什么都不共享
vfork() = clone(CLONE_VM|CLONE_VFORK|SIGCHLD) 共享内存 + 父进程挂起
pthread_create() = clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|...)
容器 = clone(CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|...)
这是一个非常 Linux 的设计:不为「进程」和「线程」提供两套机制,而是提供一个可以精细控制共享程度的原语,让「进程」和「线程」变成同一光谱上的两个点。
# 验证:线程和进程在内核里是同一种东西(task_struct)
ps -eLf | head -3 # L = 显示线程
# UID PID PPID LWP NLWP ...
# ^^^^ ^^^ 线程有自己的 LWP(就是内核里的 PID),
# 但共享同一个 PID(线程组 ID = TGID)
ls /proc/$$/task/ # 每个线程一个目录(20 篇提过)
# 用 strace 看这些调用的真身
strace -f -e trace=clone,clone3 bash -c 'true' 2>&1 | head -3
strace -f -e trace=clone,clone3 python3 -c 'import threading; threading.Thread(target=lambda:0).start()' 2>&1 | grep CLONE_VM
3. COW:从性能灾难到「看起来免费」
3.1 开篇第二问:COW 是补丁
答案:COW 不是原始设计,是后来的优化。
1970s:早期 Unix(PDP-11)
fork() 真的把父进程的全部内存拷一遍。
内存只有几十 KB,而且没有 MMU 支持写保护 —— 甚至要把父进程【换出到磁盘】!
|
v
1979:3BSD 引入 vfork
作为「不拷贝」的逃生舱,代价是把正确性责任交给程序员(2.3 节)
|
v
1980s:SunOS / 4.4BSD 引入 COW
利用 MMU 的写保护能力:fork 时【只拷贝页表】,把双方的页都标记为只读;
任何一方写入时触发缺页异常,内核才真正复制那一页。
|
v
今天:COW 让 fork 的开销从 O(内存大小) 降到 O(页表大小)
vfork 因此失去了大部分意义(但 posix_spawn 内部仍在用 CLONE_VFORK)
3.2 COW 的实现
fork() 之前
父进程页表 物理内存
[虚拟页 A] --RW--> [物理页 1]
[虚拟页 B] --RW--> [物理页 2]
fork() 之后(只拷贝了页表,物理页没动)
父进程页表 物理内存
[虚拟页 A] --RO--> [物理页 1] <--RO-- [虚拟页 A] 子进程页表
[虚拟页 B] --RO--> [物理页 2] <--RO-- [虚拟页 B]
^^ 双方都被标记为【只读】,物理页的引用计数变成 2
子进程写入虚拟页 A
① CPU 发现页表项是只读 -> 触发【缺页异常】(write protect fault)
② 内核检查:这是 COW 页且引用计数 > 1
③ 分配一个新物理页,把内容拷过去
④ 修改子进程的页表项指向新页,权限改回 RW
⑤ 原物理页引用计数减 1(若变成 1,父进程的页表项也恢复 RW)
⑥ 从异常返回,重新执行那条写指令
父进程页表 物理内存
[虚拟页 A] --RW--> [物理页 1] [物理页 3] <--RW-- [虚拟页 A] 子进程
[虚拟页 B] --RO--> [物理页 2] <--RO-- [虚拟页 B]
^^ 只有被写的那一页真正复制了
# 观察 COW:次要缺页(minor fault)会因为写入 COW 页而增加
ps -o pid,min_flt,maj_flt,rss,comm -p $$
# ^^^^^^^ 次要缺页数(含 COW 触发的)
# 用一个实验直观感受
cat > /tmp/cow.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define SZ (256*1024*1024) // 256MB
int main() {
char *buf = malloc(SZ);
memset(buf, 'a', SZ); // 真正占用物理内存
printf("父进程 PID=%d,已分配并写入 256MB,按回车 fork\n", getpid());
getchar();
pid_t p = fork(); // ✅ 这里【不会】拷贝 256MB
if (p == 0) {
printf("子进程 PID=%d 已创建,此时几乎没有额外物理内存消耗\n", getpid());
sleep(10);
memset(buf, 'b', SZ); // ✅ 写入 -> 触发 256MB 的 COW 复制
printf("子进程写完,现在真的占了 256MB\n");
sleep(30);
_exit(0);
}
wait(NULL);
}
EOF
gcc -o /tmp/cow /tmp/cow.c && /tmp/cow &
# 在另一个终端观察(13 篇 2.3 的 PSS/USS)
watch -n1 'ps -o pid,rss,min_flt,comm -C cow; grep -E "^(Pss|Private_Dirty)" /proc/$(pgrep -n cow)/smaps_rollup'
# fork 后:两个进程 RSS 都显示 256MB,但 PSS 各约 128MB(共享)
# 子进程写入后:Private_Dirty 涨到 256MB,系统总内存占用真的翻倍
3.3 COW 不是免费的
这是很多人误解的地方:COW 让 fork「变快」,但没让它「免费」。
fork 的实际开销(即使有 COW):
① 复制 task_struct、files_struct、signal_struct 等内核结构
② 【复制页表】—— 这是主要开销,与进程的【虚拟内存映射数量】成正比
一个 64GB 内存的进程,页表本身可能就有上百 MB
③ 遍历所有 VMA(虚拟内存区域),逐个标记只读并增加引用计数
④ 刷 TLB(因为页表权限变了)
⑤ 之后每次 COW 缺页 = 一次异常 + 一次内存拷贝
Redis 的 BGSAVE 是最经典的案例:
Redis 用 fork 做 RDB 快照:子进程遍历(COW 保护下的)数据集写盘,
父进程继续服务请求。
代价一:fork 本身的延迟(页表复制)
内存越大越慢 —— 几十 GB 的实例上 fork 可能耗时【几百毫秒到秒级】,
这段时间 Redis 主线程完全阻塞(表现为请求延迟尖刺)
可以用 INFO 里的 latest_fork_usec 观察
代价二:写入放大导致内存翻倍风险
父进程在快照期间的写入会不断触发 COW,最坏情况内存占用翻倍
-> 这就是「Redis 需要预留 50% 内存」这条运维建议的来源
缓解手段:
- 开启透明大页会【加剧】问题(COW 粒度从 4KB 变成 2MB!)
-> 这就是 Redis 官方要求关闭 THP 的原因之一
- 用 vm.overcommit_memory=1(避免 fork 因为「内存不够」失败)
# 生产上的对应配置(Redis 官方建议)
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled # 关 THP
sudo sysctl -w vm.overcommit_memory=1
redis-cli info stats | grep latest_fork_usec # 观察 fork 耗时
同理,任何「大内存进程频繁 fork」的模式都有这个问题:
# ❌ 一个 8GB 的 Java/Go 进程用 fork+exec 调用 shell 命令
# 每次 fork 都要复制庞大的页表 —— 在高频场景下开销显著
# ✅ 解法:
# ① 用 posix_spawn(内部用 CLONE_VM|CLONE_VFORK,不复制页表)
# ② 启动时 fork 一个小的「执行器」子进程,后续命令都让它去 fork
# (这是 Go 里 os/exec 在某些场景的优化思路,也是 zygote 模式的原理)
4. fork 的语义细节:继承什么
pid_t pid = fork();
| 继承(子进程与父进程相同) | 不继承 / 重置 |
|---|---|
| 内存内容(COW) | PID、PPID(子进程 PID 是新的,PPID 是父进程) |
打开的 fd(**共享 struct file,即共享偏移量!**20 篇 2.1) |
待处理的信号(pending signals 被清空) |
| 文件偏移量、fd 标志 | 定时器(alarm/setitimer/timer_create 全部清空) |
| cwd、root 目录、umask | 未完成的异步 I/O |
| uid/gid/附加组、capabilities | 内存锁(mlock 不继承) |
| 信号处理函数、信号掩码 | 信号量的 adjust 值 |
| 环境变量、命令行参数 | 文件锁(flock/fcntl 锁不继承,避免死锁) |
| nice 值、调度策略与优先级 | 其他线程(只复制调用 fork 的那个线程!) |
| 资源限制(rlimit) | 进程的 CPU 时间统计(times 归零) |
| cgroup 归属、namespace 归属 | CLOEXEC 标记的 fd(在 exec 时才关闭,fork 时仍继承) |
| 共享内存映射、mmap 区域 | — |
三个最容易踩的点:
# ① fd 共享偏移量(20 篇验证过)
# 父子进程写同一个重定向目标不会互相覆盖 —— 因为共享 f_pos
# ② 定时器不继承(很多人以为会)
# 这是有意的:子进程不该收到「父进程设置的闹钟」
# ③ 只复制调用 fork 的那个线程 —— 这是第 5 章的全部内容
5. 多线程 + fork:一个地狱
5.1 开篇第三问:为什么只复制一个线程
因为复制所有线程在语义上无法定义。 设想 fork 时另一个线程正在:
线程 B 正持有一把 mutex,做到操作的一半
-> 如果复制它,子进程里那个线程从「中间状态」继续跑,操作的另一半基于错误假设
-> 如果不复制它,那把 mutex 【永远不会被释放】(持有者不存在了)
两种选择都是错的。POSIX 选了「只复制调用线程」,
于是把问题变成了:【子进程继承了所有锁的状态,但没有继承能解锁它们的线程】
5.2 malloc 死锁:最经典的案例
// 一个真实会死锁的程序
void *worker(void *arg) {
while (1) { void *p = malloc(1024); free(p); } // 不断加解 malloc 的锁
}
int main() {
pthread_t t;
pthread_create(&t, NULL, worker, NULL);
sleep(1);
pid_t pid = fork();
if (pid == 0) {
// 💀 如果 fork 发生的瞬间,worker 线程正持有 malloc 的内部锁,
// 那么子进程继承的是一把【已锁定且永远不会被解锁】的锁
printf("hello\n"); // printf 内部会 malloc -> 死锁,永久挂住
_exit(0);
}
wait(NULL);
}
这个坑的范围远超 malloc:任何在内部使用锁的库都有同样问题 —— printf(stdio 有锁)、syslog、dlopen、getaddrinfo(DNS 解析有缓存锁,15 篇讲过)、以及几乎所有 C++ 标准库容器的分配器。
5.3 POSIX 的规定与 pthread_atfork
POSIX 的立场:多线程程序 fork() 之后,子进程里只能调用 async-signal-safe 的函数(一个很短的白名单:_exit、exec 家族、read、write、open、close、dup2、signal、sigaction……不含 malloc、printf、pthread_*)。
// pthread_atfork 提供了一个补救机制
int pthread_atfork(void (*prepare)(void), // fork 前在父进程执行(加锁)
void (*parent)(void), // fork 后在父进程执行(解锁)
void (*child)(void)); // fork 后在子进程执行(重置锁)
// 库可以注册回调,在 fork 前后把自己的锁处理好
pthread_atfork(my_lock_all, my_unlock_all, my_reinit_all);
但它的局限很致命:
✗ 你无法为【第三方库】注册回调(它们的锁你看不见、拿不到)
✗ 回调的执行顺序在多个库之间难以协调
✗ glibc 自己的锁在某些版本里也不完全 atfork-safe
✗ 现代 C++/Rust 库大量使用锁,几乎无法穷举
所以工程结论只有一条:
多线程程序里,fork() 之后【只应该做两件事】:
① 极少量 async-signal-safe 的操作(dup2、close、setuid…)
② 立刻 exec
绝对不要在 fork 后的子进程里「继续运行原程序的逻辑」。
需要「多进程并发」的话,应该【在创建任何线程之前】就 fork 好
(nginx、PostgreSQL 的做法:master 进程单线程,先 fork 出所有 worker)。
# nginx 的模式:先 fork 再各自干活(13 篇 2.3 看过它的进程树)
pstree -p $(pgrep -o nginx)
# nginx(1000)─┬─nginx(1001)
# ├─nginx(1002)
# └─nginx(1003)
# master 在 fork 之前不创建线程,从根本上避开了这个问题
6. Go 与 fork
6.1 开篇第五问:Go 为什么不暴露 fork
Go runtime 天生是多线程的(GMP 模型里的 M 就是 OS 线程),所以它正好撞在第 5 章那个地狱上:
Go 程序启动后,runtime 至少有几个 OS 线程:
- 执行 goroutine 的 M
- sysmon(监控线程:抢占、netpoll、GC 触发)
- GC 的工作线程
- 处理信号的线程
fork() 之后子进程只有一个线程,但:
✗ runtime 的全局状态(调度器、堆、GC 状态)是【多线程共同维护】的
✗ 那些数据结构可能正处于中间状态(比如 GC 正在标记、有 M 持有调度器锁)
✗ 子进程里没有其他线程来完成它们的工作,也没人释放它们持有的锁
-> 子进程里的 Go runtime 处于【不可恢复的损坏状态】
所以 Go 标准库【根本没有暴露 fork()】
// ❌ Go 里没有 os.Fork() 这个函数,这是有意的设计
// syscall.ForkExec 存在,但它的实现【fork 之后立刻 exec】,
// 中间只做 async-signal-safe 的操作
// ✅ Go 创建进程的唯一正确方式
cmd := exec.Command("myapp", "--flag")
cmd.Stdout = os.Stdout // 内部会用 dup2
cmd.Dir = "/var/lib/myapp" // 内部会用 chdir
cmd.Env = append(os.Environ(), "K=V")
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // setpgid(13 篇的进程组)
Credential: &syscall.Credential{Uid: 1000, Gid: 1000}, // setuid/setgid
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID, // namespace(容器)
Pdeathsig: syscall.SIGKILL, // ✅ 父进程死时自动杀掉子进程(很实用)
}
if err := cmd.Start(); err != nil { ... }
defer cmd.Wait() // ✅ 必须 Wait(否则僵尸,13 篇 3.2)
SysProcAttr 本质上就是「fork 与 exec 之间要做的操作」的声明式表达 —— Go 把第 3 章那段 C 代码封装成了结构体字段,思路与 posix_spawn 一致。
// Go runtime 内部的实现(syscall/exec_linux.go 的 forkAndExecInChild)
// 在子进程里【只调用】原始系统调用,绝不碰 Go 的内存分配器和调度器:
// - 不能有函数调用产生栈增长(会触发 morestack -> 需要 runtime)
// - 不能分配内存(会触发 GC)
// - 所以那段代码用 //go:norace、//go:nosplit 标记,并且极其小心地手写
6.2 CGO 与 fork 的注意事项
// 如果用了 cgo,情况更复杂:
// C 库可能有自己的线程和锁,fork 后同样处于损坏状态
// 典型例子:某些数据库客户端库、图像处理库
// ⚠️ 已知的坑:cgo 程序里通过 C 代码调用 fork 然后继续跑 C 逻辑
// 可能因为 Go runtime 的信号处理器被继承而行为异常
// ✅ 结论:需要多进程模型时,用 exec.Command 起独立的进程,
// 通过管道/socket/共享文件通信,而不是试图 fork
6.3 一个实用推论:Pdeathsig
// Linux 特有的 PR_SET_PDEATHSIG:父进程死亡时给自己发信号
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
# 它解决的是 13 篇提过的「孤儿进程继续跑」问题:
# 父进程被 kill -9 后,子进程会被托孤给 PID 1 并【继续运行】
# 在「父进程是管理者」的场景下这通常不是期望行为
# 用 Pdeathsig 让子进程随父进程一起消失
# 验证
sleep 300 & # 普通子进程
kill -9 $$ # 杀掉 shell(父进程)
# sleep 仍然活着,PPID 变成 1
7. 僵尸与孤儿的必然性
7.1 开篇第四问:为什么必须有僵尸状态
因为「进程的退出状态」必须有人接收,而接收者只能是父进程。
进程 exit(42) 时,内核需要保存:
- 退出码(42)
- 是否被信号杀死、哪个信号
- 是否产生 core dump
- CPU 时间统计(用户态/内核态)
这些信息给谁?
-> 父进程有权知道「我的子进程是怎么结束的」(这是 fork 的对称性要求)
-> 但父进程可能正在忙,不会立刻来取
-> 所以内核必须【保留】这条记录,直到父进程 wait() 领取
-> 这个「已死但记录还在」的状态就是僵尸(Z)
如果没有僵尸状态会怎样? 那么「子进程退出码」这个信息就必须立即推送给父进程,或者干脆丢弃:
方案 A:立即推送(信号携带数据)
-> 信号可能丢失(不可靠信号,29 篇讲)
-> 父进程可能没准备好接收
方案 B:丢弃退出码
-> shell 无法实现 $?,脚本无法判断命令成败(17 篇的错误处理全部失效)
-> make/CI/任何编排系统都无法工作
所以「保留一条记录等父进程来取」是唯一可行的设计。
7.2 wait 家族
pid_t wait(int *wstatus); // 等任意一个子进程
pid_t waitpid(pid_t pid, int *wstatus, int options); // 等指定的
int waitid(idtype_t idtype, id_t id, siginfo_t *info, int options); // 更多信息
// options
WNOHANG // ✅ 非阻塞:没有可回收的子进程就立即返回 0
WUNTRACED // 也报告被停止(T 状态)的子进程
WCONTINUED // 也报告被 SIGCONT 恢复的
__WALL/__WCLONE // 线程相关
// 解析 wstatus(不要直接看数字)
WIFEXITED(s) / WEXITSTATUS(s) // 正常退出 / 退出码
WIFSIGNALED(s) / WTERMSIG(s) // 被信号杀死 / 哪个信号
WCOREDUMP(s) // 是否产生了 core
WIFSTOPPED(s) / WSTOPSIG(s) // 被停止 / 哪个信号
// 典型的信号驱动回收(避免阻塞主逻辑)
void sigchld_handler(int sig) {
// ✅ 循环 + WNOHANG:因为多个子进程可能同时退出,而信号会合并(29 篇)
while (waitpid(-1, NULL, WNOHANG) > 0) { }
}
signal(SIGCHLD, sigchld_handler);
// 或者干脆声明「我不关心退出码」
signal(SIGCHLD, SIG_IGN); // ✅ 内核会自动回收子进程,不产生僵尸
// (这是 POSIX 明确规定的行为,不是 undefined)
7.3 双 fork 惯用法
传统的「守护进程化」需要 fork 两次,每一步都有明确目的:
// 第一次 fork:让父进程退出,子进程被 init 收养
pid_t pid = fork();
if (pid > 0) _exit(0); // 父进程退出
// -> shell 认为命令已结束,返回提示符
// -> 子进程成为孤儿,被 PID 1 收养
setsid(); // ✅ 创建新会话:脱离控制终端
// -> 不会再收到终端的 SIGHUP(13 篇 6.2)
// -> 自己成为会话首进程
// 第二次 fork:让自己【不再是会话首进程】
pid = fork();
if (pid > 0) _exit(0);
// 为什么?因为【会话首进程可以重新获得控制终端】,
// 而非会话首进程永远不能 —— 这样彻底断绝了意外获得终端的可能
chdir("/"); // 不占用任何挂载点(否则无法 umount)
umask(0);
close(0); close(1); close(2); // 关闭继承的 fd
open("/dev/null", O_RDWR); // 重新占位 fd 0/1/2(避免后续 open 拿到 0)
dup2(0, 1); dup2(0, 2);
现代做法:不要自己做这些。
systemd 的 Type=simple(14 篇 4.1):
程序【保持前台运行】,systemd 负责:
- 脱离终端(服务本来就没有控制终端)
- 重定向 stdout/stderr 到 journald
- 设置 cwd、umask、资源限制
- 用 cgroup 追踪进程(不需要 pid 文件)
-> 「双 fork 守护进程化」在 systemd 时代是【反模式】:
它让 systemd 追踪不到主进程(这就是 Type=forking 需要 PIDFile= 的原因)
8. 现代形态:clone3 与 pidfd
8.1 clone3:参数结构体化
// 老 clone 的问题:参数是位标志 + 平台相关的参数顺序,已经加不下新特性了
long clone3(struct clone_args *args, size_t size);
struct clone_args {
__u64 flags; // CLONE_* 标志
__u64 pidfd; // ✅ 输出:pidfd
__u64 child_tid;
__u64 parent_tid;
__u64 exit_signal;
__u64 stack;
__u64 stack_size;
__u64 tls;
__u64 set_tid; // ✅ 指定子进程的 PID(容器迁移/CRIU 需要)
__u64 set_tid_size;
__u64 cgroup; // ✅ 直接在指定 cgroup 里创建(避免「创建后再移入」的竞态)
};
这体现了 18 篇讲的 ABI 演进规律:不能改老接口,只能新增(clone → clone3),而且新接口用「可扩展的结构体 + size 参数」来避免将来再次撞墙。
8.2 pidfd:解决 PID 复用竞态
老问题:PID 会被复用
① 你拿到子进程 PID = 1234
② 子进程退出,被 wait 回收
③ 系统创建了新进程,恰好也分到 PID 1234
④ 你执行 kill(1234, SIGKILL) -> 💀 杀掉了【无关的进程】
这个竞态在进程创建频繁的系统上是真实存在的(PID 空间会绕回)
// pidfd:用一个 fd 稳定地引用一个进程
int pidfd = pidfd_open(pid, 0); // 或 clone3 直接返回
pidfd_send_signal(pidfd, SIGTERM, NULL, 0); // ✅ 永远不会误杀(fd 绑定到具体进程实例)
// 还能用 epoll 等待进程退出(不需要 SIGCHLD 处理函数!)
struct epoll_event ev = { .events = EPOLLIN, .data.fd = pidfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, pidfd, &ev);
// pidfd 可读 = 进程已退出 -> 这让「进程管理」融入了事件循环(31 篇)
# 观察 pidfd(在 /proc/PID/fd 里显示为 anon_inode:[pidfd])
sudo ls -l /proc/$(pgrep -o systemd)/fd | grep pidfd | head -3
# PID 复用的空间有多大
cat /proc/sys/kernel/pid_max # 默认 32768(可调到 4194304)
# 一个高频创建进程的系统几分钟就能绕回一圈
// Go 1.23+ 提供了 os.Process 的 pidfd 支持(内部自动使用)
// 更早的版本可以用 x/sys/unix
fd, _ := unix.PidfdOpen(pid, 0)
unix.PidfdSendSignal(fd, unix.SIGTERM, nil, 0)
9. 面试题
Q:为什么 Unix 用 fork+exec 两步创建进程,而 Windows 用 CreateProcess 一步?
关键不在「几步」,而在 fork 与 exec 之间那个可编程的窗口。
在那个窗口里,进程「已经是新进程,但还在运行父进程的代码」,于是可以用普通的系统调用做任何配置:dup2 重定向、setuid 降权、chdir、umask、setrlimit、关闭多余 fd、恢复信号处理、setsid、写 cgroup、setns 换命名空间……
这些操作没有一个需要新 API —— 它们本来就是「进程对自己做的事」。于是「创建进程时能配置什么」自动等于「进程能对自己做什么」,内核完全不需要为此设计任何接口。这与 18 篇的「机制与策略分离」是同一思路。
CreateProcess 没有中间态,所以所有定制必须提前表达成参数 —— 它有 10 个参数,而且现实需求无穷(后来不得不加 CreateProcessAsUser、STARTUPINFOEX 属性列表等一堆变体)。而且它的 bInheritHandles 只能「全继承或全不继承」,做不到 fork 那种精细控制。
代价是 fork 要复制地址空间(COW 缓解但没消除),以及子进程里出错只能靠 _exit + 管道传递错误码。
Q:COW 是 fork 的原始设计吗?它真的免费吗?
不是原始设计,是后来的优化。 1970 年代早期 Unix 的 fork 真的完整拷贝内存(PDP-11 上甚至要把父进程换出到磁盘)。1979 年 3BSD 引入 vfork 作为「不拷贝」的逃生舱,1980 年代 SunOS/4.4BSD 才引入 COW —— 利用 MMU 的写保护能力,fork 时只拷页表并把双方标记只读,写入时触发缺页才真正复制那一页。
COW 让开销从 O(内存大小) 降到 O(页表大小),但不是免费的:
- 仍要复制
task_struct、files_struct、页表(64GB 内存的进程页表本身可能上百 MB) - 要遍历所有 VMA 标记只读、刷 TLB
- 之后每次 COW 缺页 = 一次异常 + 一次内存拷贝
Redis 的 BGSAVE 是最典型的受害者:大内存实例上 fork 可能耗时几百毫秒(主线程阻塞,表现为延迟尖刺,INFO 里的 latest_fork_usec 可观察);快照期间父进程的写入不断触发 COW,最坏内存翻倍 —— 这就是「Redis 要预留 50% 内存」的来源。而开启透明大页会加剧问题(COW 粒度从 4KB 变成 2MB),这是 Redis 官方要求关闭 THP 的原因之一。
Q:fork、vfork、pthread_create、容器创建,在 Linux 内核里是什么关系?
它们全都是 clone() 的不同参数组合。 Linux 没有为「进程」和「线程」提供两套机制,而是提供一个可以精细控制共享程度的原语:
fork() = clone(SIGCHLD) 什么都不共享
vfork() = clone(CLONE_VM|CLONE_VFORK|SIGCHLD) 共享内存 + 父进程挂起
pthread_create() = clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|...)
容器 = clone(CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|CLONE_NEWUSER|...)
于是「进程」和「线程」成了同一光谱上的两个点,内核里都是 task_struct。这解释了 20 篇讲的「线程共享 fd」(CLONE_FILES 复用同一个 files_struct),以及 ps -eLf 里线程有自己的 LWP 但共享 TGID。
vfork 是「以正确性换性能」的补丁(子进程在 exec 前不能碰任何内存,违反就是极难调试的内存破坏),已被 POSIX 标记废弃;但 glibc 的 posix_spawn 内部仍用 CLONE_VM|CLONE_VFORK 来避免复制页表。
Q:为什么 fork() 之后子进程只有一个线程?这带来什么问题?
因为复制所有线程在语义上无法定义。 设想 fork 时另一个线程正持有 mutex 做到操作一半:复制它 → 子进程里那个线程从中间状态继续跑;不复制它 → 那把 mutex 永远不会被释放(持有者不存在了)。两种选择都是错的。
POSIX 选了「只复制调用线程」,于是问题变成:子进程继承了所有锁的状态,但没有继承能解锁它们的线程。
最经典的案例是 malloc 死锁:如果 fork 的瞬间另一个线程正持有 malloc 的内部锁,子进程里任何调用 malloc 的操作(包括 printf!)都会永久挂住。范围远超 malloc —— stdio、syslog、dlopen、getaddrinfo(DNS 缓存锁)、几乎所有 C++ 容器的分配器都有锁。
POSIX 的规定是:多线程程序 fork 后只能调用 async-signal-safe 函数(_exit、exec、read、write、dup2、close……不含 malloc 和 printf)。pthread_atfork 提供了补救回调,但无法为第三方库注册,实践中不可靠。
工程结论:多线程程序 fork 之后只应该做极少量 async-signal-safe 操作然后立刻 exec。需要多进程并发的,应该在创建任何线程之前就 fork 好 —— nginx、PostgreSQL 就是这么做的(master 单线程,先 fork 出所有 worker)。
Q:Go 为什么不提供 os.Fork()?
因为 Go runtime 天生是多线程的(GMP 的 M 是 OS 线程,还有 sysmon、GC 工作线程、信号处理线程),正好撞在上一题那个地狱上:fork 后子进程只有一个线程,但 runtime 的全局状态(调度器、堆、GC 状态)是多线程共同维护的,可能正处于中间状态,且没有其他线程来完成它们的工作或释放它们持有的锁 —— 子进程里的 runtime 处于不可恢复的损坏状态。
所以 Go 根本没有暴露 fork。syscall.ForkExec 存在,但它 fork 之后立刻 exec,中间那段代码(forkAndExecInChild)只调用原始系统调用,并用 //go:nosplit 等标记确保不触发栈增长和内存分配。
正确方式是 exec.Command + SysProcAttr —— 它本质上是「fork 与 exec 之间要做的操作」的声明式表达(Setpgid、Credential、Cloneflags、Pdeathsig),思路与 posix_spawn 一致。
顺带一个很实用的字段:Pdeathsig: syscall.SIGKILL(Linux 的 PR_SET_PDEATHSIG)—— 父进程死亡时子进程自动收到信号,解决「父进程被 kill -9 后子进程被托孤给 PID 1 继续跑」的问题。
Q:为什么必须有僵尸进程这个状态?
因为进程的退出状态必须有人接收,而按 fork 的对称性,接收者只能是父进程。
进程 exit(42) 时内核需要保存:退出码、是否被信号杀死、是哪个信号、是否 core dump、CPU 时间统计。父进程有权知道这些,但可能正在忙、不会立刻来取,所以内核必须保留一条记录直到 wait() —— 这个「已死但记录还在」的状态就是僵尸。
如果没有它:要么立即用信号推送(信号可能丢失,29 篇会讲不可靠信号),要么丢弃退出码 —— 而丢弃退出码意味着 shell 的 $? 无法实现、脚本无法判断命令成败(17 篇整套错误处理失效)、make/CI/任何编排系统都无法工作。所以「保留记录等父进程来取」是唯一可行的设计。
回收方式:waitpid 配 WNOHANG 在 SIGCHLD 处理函数里循环回收(因为多个子进程同时退出时信号会合并),或者干脆 signal(SIGCHLD, SIG_IGN) 让内核自动回收(这是 POSIX 明确规定的行为)。Go 里必须 cmd.Wait()。
Q:传统的「双 fork 守护进程化」每一步在做什么?为什么现在是反模式?
三步各有明确目的:
- 第一次 fork + 父进程退出 → 子进程成为孤儿被 PID 1 收养,同时 shell 认为命令已结束(返回提示符)
setsid()→ 创建新会话,脱离控制终端,不再收到终端的 SIGHUP(13 篇 6.2)- 第二次 fork → 让自己不再是会话首进程。因为会话首进程可以重新获得控制终端,而非首进程永远不能 —— 彻底断绝意外获得终端的可能
之后还要 chdir("/")(不占用挂载点,否则无法 umount)、umask(0)、关闭并重新占位 fd 0/1/2。
在 systemd 时代这是反模式:Type=simple 让程序保持前台运行,systemd 负责脱离终端、重定向到 journald、设 cwd/umask/rlimit、用 cgroup 追踪进程(不需要 pid 文件)。自己做双 fork 反而让 systemd 追踪不到主进程 —— 这正是 Type=forking 必须配 PIDFile= 的原因(14 篇 4.1)。
Q:pidfd 解决了什么问题?
PID 复用竞态:你拿到子进程 PID 1234 → 它退出并被回收 → 系统创建新进程恰好也分到 1234 → 你 kill(1234, SIGKILL) 杀掉了无关进程。这在进程创建频繁的系统上是真实风险(pid_max 默认只有 32768,几分钟就能绕回一圈)。
pidfd 用一个 fd 稳定地引用一个具体的进程实例:pidfd_open(pid, 0) 或 clone3 直接返回,然后 pidfd_send_signal() 永远不会误杀。
额外的价值是它能被 epoll 监听 —— pidfd 可读表示进程已退出,于是「进程管理」可以融入事件循环,不再需要 SIGCHLD 信号处理函数(信号与事件循环的混用一直是个麻烦,29 篇会讲)。
配套的 clone3 则体现了 18 篇的 ABI 演进规律:老接口不能改,只能新增(clone → clone3),且新接口改用「可扩展结构体 + size 参数」避免将来再撞墙。它还支持 set_tid(指定子进程 PID,CRIU 迁移需要)和 cgroup(直接在指定 cgroup 里创建,避免「创建后再移入」的竞态)。
小结
fork的精髓不是「一次调用两次返回」,而是 fork 与 exec 之间那个可编程窗口 —— 子进程用普通系统调用配置自己,于是内核不需要为「创建进程的配置项」设计任何接口CreateProcess把配置变成参数 → 灵活性受限且参数无限膨胀;posix_spawn是折中(预定义动作列表,多线程安全但表达力有限)- COW 是后来的优化不是原始设计,而且不免费:页表复制的开销与虚拟内存规模成正比 → Redis fork 的延迟尖刺、THP 加剧 COW 放大
fork/vfork/pthread_create/容器 全是clone()的参数组合 —— 进程与线程是同一光谱上的两点- fork 只复制调用线程,因为复制所有线程语义上无法定义;代价是继承了锁状态但没有解锁者 → malloc 死锁
- 多线程 + fork 之后只能 exec;需要多进程就在创建线程之前 fork(nginx/PostgreSQL 的模式)
- Go 不暴露 fork 是因为 runtime 多线程;用
exec.Command+SysProcAttr,注意Pdeathsig - 僵尸状态是必然的:退出码必须有人接收,否则
$?和一切编排都无法工作 - 双 fork 在 systemd 时代是反模式;
pidfd解决 PID 复用竞态并让进程管理融入事件循环
下一篇讲 凭证模型:内核为什么只认数字 —— 10 篇学的 uid/gid 操作,在这里能看到它们为什么长成这样:为什么需要 ruid/euid/suid/fsuid 四套 uid、setuid() 的历史漏洞、capabilities 如何把 root 拆成 40 多个能力却没能取代 SUID。
xingliuhua