跳到内容

第 3 卷 / 第 8 章 / 第 2 课

8.2 fork、exec 与 wait ​

进程上下文列出了 task 与共享资源。本篇把 fork、exec、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。

fork 后谁先获得 CPU 没有保证。不过下面的单线程示例中,parent 在打印结果前先 wait;child 正常退出会先刷新自己的输出,所以这两行输出有明确先后。保存为 fork_status.c,用 cc -std=c17 -Wall -Wextra -Werror fork_status.c -o fork_status 编译:

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 即将 exec 且 exec 失败,通常应调用 _exit,避免重复 flush parent 在 fork 前缓冲的 stdio,也避免运行只属于 parent 的 atexit handler。

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

fork 后 parent 与 child 有不同 PID、独立 virtual address-space 语义和独立 pending signal 集,其中 child 初始为空。很多属性来自 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_CLOEXEC、pipe2(O_CLOEXEC) 等原子选项能避免多线程中“open 后再 fcntl”的泄漏窗口。

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

POSIX 因而限制 child 在 fork 与 exec 之间可安全调用的函数,通常只能依赖 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
environment由 envp 或 exec* variant 决定

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

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

运行一次 fork + exec + waitpid ​

这个程序创建 child,child 继承当前 file descriptor 并执行固定命令,parent 最后收取 termination status。退出码只能表达约定范围内的结果,不是把任意复杂异常塞进一个整数的捷径。

下面的单线程示例保存为 run_child.c,用 cc -std=c17 -Wall -Wextra -Werror run_child.c -o run_child 编译。它启动 /bin/sh -c 'exit 7',parent 解码终止 status;假设 SIGCHLD 配置允许等待 child,arguments 是有效的 NULL 结尾 argv。真实应用若不需要 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";
        ssize_t diagnostic_bytes =
            write(STDERR_FILENO, message, sizeof message - 1);
        (void)diagnostic_bytes;  /* Best-effort diagnostic; always exit below. */
        _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;
}

若 execv 失败,child 尝试写出固定诊断并以 127 退出;目标程序自己 exit 127 也会得到相同结果。要区分两者,启动协议可另用带 close-on-exec 的错误 pipe:exec 成功会关闭写端,失败则传回 errno。单靠本例退出码无法消除歧义。

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 与日志。

传统 daemon 流程曾把 double-fork 与 setsid 等步骤组合,用来脱离旧 session/terminal 并安排 descendant 收养;fork 本身不会创建新 session。但在 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. 先解释第一个示例为何保证 child 输出在前;再让 parent 在 wait 前打印一行,多次运行,区分调度不确定性与 wait 建立的顺序。
  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