跳到内容

8.2 fork、exec 与 wait

进程上下文列出了 task 与共享资源。本篇把 forkexec、exit 和 waitpid 串成一条生命周期,顺带解释 COW、zombie 与 orphan。

shell 启动命令需要完成三件事

调度室收到 grep error app.log。Unix shell 通常先创建 child,在 child 中安排重定向与管道,再把 child 的 program image 替换成 grep;parent 决定等待前台任务,还是记录后台 job 后继续读命令。

text
shell process
  └─ fork -> child context
                ├─ dup2/close: 安排 stdin/stdout/stderr
                └─ exec: 装入 grep

shell --waitpid--> 取得 termination status 并回收 child

fork 负责“产生一条新的执行线”,exec 负责“让当前 process 运行另一个 program”,wait 负责“读取 child 的结束结果”。三个 API 分开,给 child 在 exec 前设置 fd、credentials、working directory 等留下空间。

fork 一次调用,在两个执行流返回

POSIX fork() 成功后:

  • parent 得到 child PID;
  • child 得到 0;
  • 两者都从 fork 调用之后继续;
  • 失败只在 parent 原执行流返回 -1 并设置 errno

谁先运行没有保证。把输出顺序写进断言,会得到偶发失败。

c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }

    if (child == 0) {
        printf("child: pid=%ld parent=%ld\n",
               (long)getpid(), (long)getppid());
        return 7;
    }

    int status;
    pid_t waited;
    do {
        waited = waitpid(child, &status, 0);
    } while (waited < 0 && errno == EINTR);

    if (waited < 0) {
        perror("waitpid");
        return 1;
    }
    if (WIFEXITED(status)) {
        printf("parent: child %ld exited with %d\n",
               (long)child, WEXITSTATUS(status));
        return WEXITSTATUS(status) == 7 ? 0 : 1;
    }
    if (WIFSIGNALED(status)) {
        printf("parent: child terminated by signal %d\n", WTERMSIG(status));
    }
    return 1;
}

这里的 child 从 main 返回,C runtime 最终执行正常进程退出流程。若 child 即将 execexec 失败,通常应调用 _exit,避免重复 flush parent 在 fork 前缓冲的 stdio,也避免运行只属于 parent 的 atexit handler。

child 继承的是资源语义,不是一份简单 memcpy

fork 后 parent 与 child 有不同 PID、独立 virtual address-space 语义和各自 pending signal 集。很多属性来自 parent:working directory、umask、resource limit、environment、signal disposition 等按 POSIX 规则继承。

file descriptor table 是复制出的 descriptor 集合,但对应 entry 常指向相同 open file description。因此 parent/child 对 regular file 的 read 可能推进同一个 file offset。若不想让 descriptor 进入后续 exec,应设置 close-on-exec;创建时使用 O_CLOEXECpipe2(O_CLOEXEC) 等原子选项能避免多线程中“open 后再 fcntl”的泄漏窗口。

mutex、condition variable 和用户内存的字节在 child 里也有复制后的逻辑状态。多线程 parent 中,只有调用 fork 的 thread 出现在 child;其他 thread 消失,却可能在快照里留下已锁住的 mutex。这是 multi-threaded fork 最危险的边界之一。

POSIX 因而限制 child 在 forkexec 之间可安全调用的函数,通常只能依赖 async-signal-safe 操作。复杂程序更适合 posix_spawn,或使用平台提供的专门 process-launch API。

COW 延迟复制 writable private page

内核不必在 fork 时复制 parent 的全部 physical memory。对 private writable mapping,parent 与 child 可暂时指向相同 physical page,并让 PTE 阻止直接写入:

text
parent VPN --read-only COW--+
                             +--> physical page
child VPN  --read-only COW--+

一方写入时触发 protection fault。内核若确认 page 仍被共享,就分配新 frame、复制内容、更新写入方 PTE 后重试指令。另一方仍看到旧值。

并非“fork 后所有页都标成只读”。原本只读 code page 无需为 COW 改变,MAP_SHARED mapping 按共享语义继续共享,device mapping 与 huge page 还有各自规则。page-table structure 本身也要建立 child 视图,会消耗时间和内存。

c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int value = 42;
    if (fflush(NULL) == EOF) {
        perror("fflush");
        return 1;
    }

    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }
    if (child == 0) {
        value = 100;
        printf("child value=%d address=%p\n", value, (void *)&value);
        return 0;
    }

    int status;
    while (waitpid(child, &status, 0) < 0) {
        if (errno != EINTR) {
            perror("waitpid");
            return 1;
        }
    }
    printf("parent value=%d address=%p\n", value, (void *)&value);
    return value == 42 && WIFEXITED(status) && WEXITSTATUS(status) == 0
         ? 0 : 1;
}

两个进程通常打印相同 virtual address、不同值。这证明它们有独立 address-space 语义;它本身不能观察 physical frame。要研究真实 page sharing,需要受权限控制的 OS instrumentation,并考虑 compiler、THP 与 kernel 版本。

exec 成功后不会返回

execve(path, argv, envp) 用新 executable 初始化当前 process image。成功后,旧代码不会继续,调用者也收不到“成功返回值”;新程序从它的 entry point 启动。失败才返回 -1

process PID 保持不变,但“替换一切”也过头了。常见变化与保留包括:

项目exec
virtual address mappings、user stack替换为新 image 与运行时布局
PID、parent relationship保留
open fd保留,除非设了 FD_CLOEXEC
current directory、umask保留
caught signal disposition通常重置为默认
ignored signal disposition通常保持忽略
signal mask保留
threads只留下调用 exec 的 thread
environmentenvpexec* variant 决定

set-user-ID、capability、tracing、timer、shared-memory attachment 等还有额外规则。安全敏感程序必须查 execve(2),不能靠一张入门表覆盖。

execlp/execvp 会按 PATH 搜索,execve 使用明确路径并显式传入 environment。运行不可信环境时,应避免继承攻击者控制的 PATH、loader variable 与意外 fd。

在调度室走完 fork + exec + waitpid

现在让 shell 启动一条真实命令:parent 创建 child,child 整理 file descriptor 后替换 program image,parent 最后收取 termination status。三个阶段必须从输出和返回值上分别验证。

下面的程序启动 /bin/sh -c 'exit 7',parent 完整解码 status。真实应用若不需要 shell 语法,应直接 execv 目标程序,避免额外 parsing 与 command-injection 面。

c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

static int run_and_wait(char *const arguments[]) {
    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return -1;
    }
    if (child == 0) {
        execv(arguments[0], arguments);
        static const char message[] = "execv failed\n";
        (void)write(STDERR_FILENO, message, sizeof message - 1);
        _exit(127);
    }

    int status;
    pid_t waited;
    do {
        waited = waitpid(child, &status, 0);
    } while (waited < 0 && errno == EINTR);

    if (waited < 0) {
        perror("waitpid");
        return -1;
    }
    if (WIFEXITED(status)) {
        return WEXITSTATUS(status);
    }
    if (WIFSIGNALED(status)) {
        fprintf(stderr, "child terminated by signal %d\n",
                WTERMSIG(status));
    }
    return -1;
}

int main(void) {
    char *arguments[] = {"/bin/sh", "-c", "exit 7", NULL};
    int result = run_and_wait(arguments);
    printf("decoded exit status=%d\n", result);
    return result == 7 ? 0 : 1;
}

shell pipeline 会创建多个 child、连接 pipe endpoint、关闭每个进程不使用的 fd,并分别回收整个 job。遗漏一个写端 fd 就可能让 reader 永远等不到 EOF,这比 fork 本身更常见。

zombie 是尚未被 parent 收取的结束记录

child 结束时,address space、fd 等大部分运行资源被释放,kernel 保留 PID、termination status 和 accounting 供 parent wait。这段残留称为 zombie;它不执行指令,也不占用原来的用户内存,却会占用 process-table/PID 等有限资源。

parent 应:

  • 对已知 child 调用 waitpid,正确处理 EINTR
  • 管理多个 child 时循环回收所有已结束对象;
  • event loop 可结合 SIGCHLD、pidfd 或平台机制,但标准 signal 会合并,不能一次 handler 只 wait 一个 child;
  • 若明确不需要 status,可按平台/POSIX 语义配置 SIGCHLD/SA_NOCLDWAIT,并验证可移植性。

只安装一个空 SIGCHLD handler 不会自动回收 child。长期服务最稳妥的是让 child ownership 清晰,并把 reap 逻辑当作资源管理的一部分。

orphan 会交给 reaper,不保证新 parent 是 PID 1

parent 先结束时,仍存活的 child 会被 reparent。传统系统由 init(PID 1)接管;Linux subreaper、PID namespace 和 service manager 可以让另一个 ancestor 成为 reaper。因此示例里“5 秒后 PPID 必为 1”没有通用保证。

reparenting 解决的是谁负责最终 wait,不会把 daemon 自动配置正确。现代服务更适合由 systemd、container init 或 supervisor 明确管理 lifecycle、signal forwarding 与日志。

double-fork 曾用于脱离 terminal/session 并让 descendant 被 init 收养,但在 service manager 环境中往往不需要,反而让 supervisor 难以跟踪真实 worker。

exit status 不是一个普通整数返回值

waitpid 写入的 status 是编码结果,必须先判断:

  • WIFEXITED 后才读 WEXITSTATUS
  • WIFSIGNALED 后可读 WTERMSIG
  • 使用 WUNTRACED/WCONTINUED 时还可能观察 stop/continue;
  • shell 常把 signal termination 映射为 128 + signal,这是 shell convention,不是 waitpid 原始格式。

C main 返回值或 exit 参数最终只保留平台规定的 status 范围。把任意 32-bit 业务错误码塞进 process exit status 会丢信息,复杂结果应通过 pipe、file、socket 或 shared memory 返回。

动手走完生命周期

  1. 在第一个示例中加入随机 sleep,证明 parent/child 输出顺序不受 PID 大小保证。
  2. 用一个 file descriptor 演示 parent/child 共享 open-file offset,再用两次独立 open 对比。
  3. run_and_wait 增加 exec failure 测试,区分约定的 127 与目标程序主动 exit 127 的歧义。
  4. 构造一个 child 被 SIGTERM 结束的场景,验证不能直接调用 WEXITSTATUS
  5. 写两级 pipeline,列出 parent 与两个 child 各自必须关闭的 pipe endpoint。
  6. 在 multi-threaded parent 中说明另一个 thread 持锁时 fork 会给 child 留下什么状态,并评估 posix_spawn

共享地址空间会把问题从复制变成同步

process 之间默认隔离,thread 则在同一 address space 中并发执行。下一章进入线程与同步:先从 C memory model 定义 data race,再讨论 mutex、condition variable 与 atomic,而不是把“看起来能跑”当作并发正确性。

Built with VitePress | Software Systems Atlas