跳转至

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 waitpid
  • man 2 clone — fork 的底层实现
  • man 3 posix_spawn — 现代替代方案