fork / exec¶
是什么 / 解决什么问题¶
fork 创建当前进程的副本,execve 用新程序替换当前进程映像。两者组合是 Unix 创建新进程运行新程序的基本范式。
使用模式¶
fork 基本用法¶
#include <unistd.h>
#include <sys/wait.h>
pid_t pid = fork();
if (pid == -1) {
perror("fork");
} else if (pid == 0) {
// 子进程
printf("I'm child, pid=%d\n", getpid());
exit(0);
} else {
// 父进程
printf("Created child pid=%d\n", pid);
int status;
waitpid(pid, &status, 0); // 等待子进程结束
}
fork + exec 运行新程序¶
pid_t pid = fork();
if (pid == 0) {
// 子进程:替换为新程序
execvp("ls", (char *[]){"ls", "-la", "/tmp", NULL});
// execvp 成功则不会到这里
perror("execvp");
_exit(127);
}
// 父进程继续
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("child exited with %d\n", WEXITSTATUS(status));
}
exec 家族¶
// execve: 最基础,指定完整路径 + 环境变量
execve("/bin/ls", argv, envp);
// execvp: 搜索 PATH
execvp("ls", argv);
// execlp: 参数列表形式
execlp("ls", "ls", "-la", NULL);
// execvpe: 搜索 PATH + 自定义环境变量
execvpe("ls", argv, envp);
posix_spawn(fork+exec 替代品)¶
#include <spawn.h>
pid_t pid;
char *argv[] = {"ls", "-la", NULL};
extern char **environ;
posix_spawn(&pid, "/bin/ls", NULL, NULL, argv, environ);
waitpid(pid, &status, 0);
// 优势:避免 fork 的 COW 开销
// 内核可以优化为 vfork + exec 的组合
行为与语义¶
fork 的 Copy-on-Write (COW)¶
fork() 前:
父进程页表 → [物理页A] [物理页B] [物理页C]
fork() 后(不复制物理页,只复制页表):
父进程页表 → [物理页A(只读)] [物理页B(只读)] [物理页C(只读)]
子进程页表 → [物理页A(只读)] [物理页B(只读)] [物理页C(只读)]
父进程写入物理页A时:
触发 page fault → 分配新物理页 → 拷贝内容 → 父进程指向新页
父进程页表 → [物理页A'(读写)] [物理页B(只读)] [物理页C(只读)]
子进程页表 → [物理页A(只读)] [物理页B(只读)] [物理页C(只读)]
fork 的实际开销: - 复制页表(不是物理页) - 复制进程描述符、fd 表等内核数据结构 - 对于大内存进程,页表本身也很大
fork 后子进程继承什么¶
| 继承 | 不继承 |
|---|---|
| 内存空间(COW) | PID |
| 所有打开的 fd | 文件锁(flock) |
| 信号处理设置 | 挂起的信号 |
| 环境变量 | 定时器(setitimer) |
| 当前工作目录 | 异步 I/O 操作 |
| uid/gid | 内存锁(mlock) |
exec 后保留什么¶
| 保留 | 重置 |
|---|---|
| PID, PPID | 内存映射 |
| 打开的 fd(除非设置了 CLOEXEC) | 信号处理(恢复默认) |
| 工作目录 | 线程(只保留调用线程) |
| uid/gid | 定时器 |
| 环境变量(如果传入) | — |
wait 的状态检查¶
int status;
pid_t ret = waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
int code = WEXITSTATUS(status); // exit 的参数
}
if (WIFSIGNALED(status)) {
int sig = WTERMSIG(status); // 被哪个信号杀死
int core = WCOREDUMP(status); // 是否 core dump
}
if (WIFSTOPPED(status)) {
int sig = WSTOPSIG(status); // 被什么信号暂停
}
僵尸进程¶
子进程 exit → 变成 zombie(保留退出状态等待父进程 wait)
父进程 wait → 回收 zombie
如果父进程不 wait:zombie 一直存在,占用 PID
如果父进程先退出:zombie 被 init(1) 收养并回收
// 方法1: 父进程及时 wait
// 方法2: 双 fork(子进程立即退出,孙进程被 init 收养)
// 方法3: 忽略 SIGCHLD
signal(SIGCHLD, SIG_IGN); // 子进程退出时自动回收,不产生 zombie
性能考量¶
fork 的开销¶
- 小进程:几十微秒(页表小,COW 设置快)
- 大内存进程(数 GB):可能几十毫秒(页表复制开销)
- Redis 的 BGSAVE 用 fork 做快照,大实例 fork 可能导致延迟抖动
减少 fork 开销¶
// 1. vfork: 不复制页表,子进程直接共享父进程地址空间
// 只能用于立即 exec 的场景
pid_t pid = vfork();
if (pid == 0) {
execvp(...); // 必须立即 exec 或 _exit
_exit(127);
}
// 2. posix_spawn: 让内核选择最优实现
posix_spawn(&pid, path, actions, attrs, argv, envp);
// 3. clone + CLONE_VM: 共享地址空间(本质上创建线程)
exec 后的开销¶
- 加载 ELF 文件、动态链接(ld.so)
- 动态链接器解析所有共享库可能耗时数毫秒
- 优化:静态链接避免动态链接开销
常见陷阱与 FAQ¶
1. fork 后文件描述符共享¶
// fork 后父子进程共享同一个 file description
// 包括文件偏移量!
int fd = open("file", O_RDWR);
if (fork() == 0) {
write(fd, "child", 5); // 这会影响父进程的偏移量
}
// 如果需要独立操作,子进程应重新 open
2. fork 在多线程程序中的危险¶
// fork 只复制调用线程!其他线程"消失"
// 如果其他线程持有锁(mutex),fork 后锁永远不会释放 → 死锁
// 安全做法:
// - fork 后立即 exec(不使用任何可能加锁的库函数)
// - 使用 pthread_atfork 注册清理函数
// - 或者不在多线程程序中 fork
3. 子进程中用 _exit 而非 exit¶
if (fork() == 0) {
// exec 失败时
_exit(127); // 不刷新 stdio 缓冲区,不运行 atexit 处理器
// exit(127) 会刷新父进程继承的 stdio 缓冲区 → 重复输出
}
4. CLOEXEC 防止 fd 泄漏¶
// 不设 CLOEXEC:exec 后 fd 仍然打开
// 可能泄漏敏感文件/socket 给子程序
int fd = open("secret", O_RDONLY | O_CLOEXEC);
// 或
fcntl(fd, F_SETFD, FD_CLOEXEC);
观测与调试¶
# 查看进程树
pstree -p <pid>
# 查看僵尸进程
ps aux | grep Z
# strace 看 fork/exec
strace -f -e trace=clone,execve,wait4 ./app
# 查看 fork 延迟
bpftrace -e '
kprobe:kernel_clone { @start[tid] = nsecs; }
kretprobe:kernel_clone /@start[tid]/ {
@fork_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'
延伸阅读¶
man 2 fork/man 2 execve/man 2 waitpidman 2 clone— fork 的底层实现man 3 posix_spawn— 现代替代方案