第 24 章 进程创建 (Process Creation)

      +

      核心结论

      • fork 是「自我复制」:子进程获得父进程 stack/data/heap 的副本(COW);返回两次——父进程返回子 PID,子进程返回 0。

      • fork 后父子进程调度不确定——「哪个先跑」是内核调度器决定;写并发程序需显式同步(wait、pipe、信号等),不要依赖 sleep 凑顺序。

      • COW (Copy-on-Write):fork 后父子共享物理页(标为只读);任一方写入时触发页错误,内核复制页给 faulting 进程;高效利用内存——绝大多数 fork 后立即 exec。

        • 文件描述符共享:fork 后父子共享 open file description(文件偏移、状态标志);写并发时需要协调(O_APPEND 或原子 write 或 wait 同步)。

        • vfork 是历史接口:保证子进程先运行且共享内存;POSIX.1-2008 标记 obsolete;现代代码用 posix_spawn 或 fork + _exit 或直接 pthread_create。

      本章主旨

      本章开启「进程生命周期」四章的第一章——fork 创建新进程。读者应掌握:fork 的语义(父子均执行、返回值区分、COW 内存)、fork 后父子共享文件描述符与 open file description(影响并发写)、fork 与子进程内存独立性的正确使用(隔离危险操作)、以及 vfork/posix_spawn 的角色。本章是后续 exec(第 27 章)、wait(第 26 章)的基础;理解了 fork 才能理解「为什么 shell 能并发执行多个命令」「为什么 daemon 化需要 fork 两次」。

      一、核心概念

      本章围绕 6 个核心概念展开:从 fork 语义入手,到 fork 返回值、COW 内存、文件描述符共享、父子同步,最后到 vfork/posix_spawn。

      概念 定义 + 重要性 实现提示

      fork() 语义

      父子进程「自我复制」;子获得父 stack/data/heap 副本;返回两次(父→子 PID,子→0);fork 后两进程独立运行。

      §24.2;fork 失败返回 -1(RLIMIT_NPROC / 系统进程数超限);典型 switch-case 模式分发父子逻辑。

      COW (Copy-on-write)

      fork 后父子共享物理页,标 read-only;写入触发页错误,内核复制页给 faulting 进程;高效——fork 通常紧接 exec,物理页永不复制。

      §24.2.2;早期 UNIX 真实复制整个地址空间,浪费内存 + 慢;COW 让 fork 几乎无开销。

      文件描述符共享

      fork 复制 fd 表项,但父子共享同一 open file description(偏移、状态标志);子写入偏移,父可见。

      §24.2.1;并发写需协调:O_APPEND、原子 write、wait 同步、关闭未使用 fd;这是「pipe 通信」的基础。

      父子进程调度不确定

      fork 后哪个先跑由内核调度器决定;不能依赖 sleep 凑顺序——会导致 race condition(特别在多核系统)。

      §24.4;正确同步:wait/pipe/signal/semaphore;sleep(3) 在父、子先后顺序上有时管用但不可靠。

      vfork 历史接口

      保证子进程先运行 + 共享内存(直到 exec/_exit);POSIX.1-2008 标记 obsolete;早期用于「fork+exec」避免 COW 开销。

      §24.5;现代 Linux 仍支持但几乎无理由再用——fork+exec 已足够高效;man 手册建议用 posix_spawn。

      posix_spawn

      POSIX 标准的「fork+exec 合一」API;专为缺少 MMU/交换的嵌入式系统设计;现代 Linux 也支持。

      §24.1;API 较复杂;不常用;理解 fork+exec 比理解 posix_spawn 更基础。

      二、详细笔记

      24.1 进程生命周期总览

      What:UNIX 进程生命周期由四个核心系统调用组成——fork、exit、wait、execve;它们组合构成进程创建、执行新程序、终止、回收的全部模式。

      Why:理解这四者的关系是写「多进程程序」的起点——shell、网络服务器、daemon、管道都基于此。

      How:四调用职责(§24.1):

      调用 职责 备注

      fork()

      创建子进程(几乎完整副本)

      返回两次

      exit(status)

      终止进程、释放资源

      status 供父 wait 读取

      wait(&status)

      等待子进程终止、回收资源

      获取终止状态

      execve(path, argv, envp)

      在当前进程加载新程序

      替换 text/data/heap/stack

      典型 shell 执行命令流程:

      shell 主循环
        1. 读命令
        2. fork() → 子进程
        3. 子进程 execve(命令)
        4. 父进程 wait() 回收
        5. 显示下一个提示符

      fork 与 exec 分离的好处:API 简单(fork 无参数);fork 后可执行任意代码(如打开文件、设置环境);fork 不必 exec(可派生子进程做相同事情)。

      When

      • shell——每个命令 fork + exec + wait。

      • 网络服务器——accept 后 fork 处理每个连接。

      • daemon——fork 两次脱离控制终端。

      24.2 fork() 详解

      Whatfork() 创建子进程——几乎完整的父进程副本;返回两次(父返回子 PID,子返回 0)。

      Why:让一个进程「分裂」成两个独立运行的进程;是 UNIX 并发模型的核心。

      How:核心用法(§24.2):

      pid_t pid = fork();
      switch (pid) {
      case -1: errExit("fork");
      case 0:  /* 子进程 */
          /* 子进程专属逻辑 */
          _exit(EXIT_SUCCESS);                /* 用 _exit,不用 exit */
      default: /* 父进程 */
          /* 父进程专属逻辑;pid 是子进程 PID */
      }

      要点:

      • 父子均执行 fork 之后的所有代码——必须用返回值区分。

      • 子进程获得 fork 时刻父进程的地址空间副本——之后的修改互不影响。

      • fork 失败返回 -1——原因:超过 RLIMIT_NPROC、系统进程数上限。

      • fork 不继承的少数属性RLIMIT_NPROC(子重置为 0)、进程时间(清零)、文件锁(不继承)、pending 信号(清空)、定时器(不继承)。

      When

      • 父子执行不同代码路径——用 fork + switch。

      • 子进程应调用 _exit() 而非 exit()——后者会运行 atexit handler、刷新 stdio buffer(可能破坏父进程的 buffer)。

      Example:第 24 章 Listing 24-1 t_fork.c——演示父子进程内存独立:

      // 摘自《The Linux Programming Interface》第 24 章(Listing 24-1)
      static int idata = 111;             /* data 段 */
      int main(void) {
          int istack = 222;                /* stack 段 */
          switch (fork()) {
          case 0:
              idata *= 3;                  /* 子:idata=333,istack=666 */
              istack *= 3;
              break;
          default:
              sleep(3);                    /* 给子进程时间运行(不可靠) */
              break;
          }
          printf("PID=%ld %s idata=%d istack=%d\n",
                 (long) getpid(),
                 (pid == 0) ? "(child) " : "(parent)",
                 idata, istack);
          exit(EXIT_SUCCESS);
      }

      输出:子进程 idata=333 istack=666;父进程 idata=111 istack=222——验证了 fork 后内存独立。

      24.2.1 文件描述符共享

      What:fork 后父子进程共享同一「open file description」(文件偏移、状态标志);写入偏移互相可见。

      Why:影响并发文件 I/O 的正确性——父子同时写同一文件可能错乱输出;理解 fd 共享才能正确设计并发 I/O。

      How:fork 复制 fd 表项(数字相同),但指向同一 open file description 实体:

      // 摘自《The Linux Programming Interface》第 24 章(Listing 24-2)
      int fd = mkstemp(template);
      switch (fork()) {
      case 0:
          lseek(fd, 1000, SEEK_SET);           /* 子:偏移 1000 */
          fcntl(fd, F_SETFL, flags | O_APPEND);
          _exit(EXIT_SUCCESS);
      default:
          wait(NULL);                          /* 父等待子结束 */
          long long off = lseek(fd, 0, SEEK_CUR);   /* 父:读到 1000 */
          /* flags 含 O_APPEND */
      }

      并发写同一文件的解决方案:

      • O_APPEND——内核保证 write 原子性;写偏移自动追加到末尾。

      • 原子 write——write 小于 PIPE_BUF 的数据保证原子(PIPE_BUF 通常 4096)。

      • wait 同步——父等子完成。

      • 关闭未使用的 fd——fork 后父关闭子使用的 fd,子关闭父使用的 fd(典型 pipeline)。

      When

      • daemon 监听 socket——fork 后父子共享 listening socket。

      • pipe——父子共享读写端(pipe 在 fork 前创建)。

      • 关闭未使用 fd——避免资源泄漏;典型见 pipe 通信模式。

      24.2.2 内存语义与 COW

      What:fork 后父子共享物理内存页(标 read-only);任一方写入触发 page fault,内核复制页给 faulting 进程——「Copy-on-Write」。

      Why:高效——fork 通常紧接 exec,物理页永不复制;如果 fork 后立即 exec,整个 fork 几乎没有开销。

      How:COW 工作流程:

      fork 时
        ↓
      父子共享同一物理页(标 read-only)
        ↓
      任一方写入该页 → 触发 page fault
        ↓
      内核复制该页给 faulting 进程
        ↓
      faulting 进程获得私有副本
        ↓
      后续修改互不影响

      When

      • 几乎所有场景——fork 立即 exec 是最常见用法,COW 让它高效。

        • 想利用 COW 隔离危险操作——「fork + exec 隔离」模式(如浏览器为每个标签 fork 一个进程)。

        • 不需要 COW 的场景——MAP_SHARED 的 mmap 仍是共享(不受 fork COW 影响)。

      24.3 fork 与内存隔离的应用

      What:利用 fork 创建「临时副本」——执行某些可能破坏状态的操作(如内存泄漏、外部影响),然后丢弃。

      Why:无需复杂机制就能实现「沙箱」——子进程破坏状态不影响父。

      How:「fork + wait + exec」沙箱模式(§24.2.2 提到):

      pid_t pid = fork();
      if (pid == 0) {
          /* 子进程执行危险操作 */
          func();                           /* 可能泄漏/破坏 */
          _exit(0);                         /* 子进程状态不影响父 */
      }
      wait(NULL);
      /* 父进程状态不变——func 的所有修改在子进程中被 _exit 抹去 */

      When

      • 调试第三方库内存泄漏——fork 子进程跑库函数,看内存增长但不影响父。

      • 游戏树搜索——fork 子进程尝试多种走法,不污染父的搜索状态。

      • 测试可能导致程序崩溃的代码——子进程崩了不影响父。

      Example:第 24 章 Listing 24-3 演示 fork 隔离搜索状态——子进程尝试搜索树,父保留干净状态。

      24.4 fork 后的调度竞争

      What:fork 后父子进程调度顺序不确定——内核调度器决定哪个先跑。

      Why:写并发代码必须假设「任意顺序」——sleep(3) 等「凑顺序」不可靠。

      How:典型错误模式:

      /* 错误:依赖 sleep 凑顺序 */
      pid_t pid = fork();
      if (pid == 0) {
          sleep(1);                          /* 让父先写文件 */
          read(fd, buf, sizeof(buf));        /* 然后子读 */
      }
      /* 不可靠:父可能在子 sleep 期间被抢占 */

      正确同步方式:

      • pipe——父等子写完 pipe 端再继续。

      • wait——父等子终止。

      • 信号——子完成时 kill(getppid(), SIGUSR1)

      • 文件锁——子获取锁,父等锁释放。

      • POSIX 信号量——子 post,父 wait。

      When

      • 单线程程序——父子进程间用 pipe 通信 + 同步。

      • daemon fork 父子——确保 daemon 完成初始化后才让父退出(pipe close-on-exec + 父 wait)。

      24.5 vfork

      Whatvfork() 是历史接口——保证子进程先运行 + 子与父共享内存(直到子 exec 或 _exit);POSIX.1-2008 标记 obsolete。

      Why:早期 fork 慢(真实复制),fork+exec 浪费——vfork 让子直接共享父内存,避免复制;但只能用于「立即 exec」。

      How

      pid_t pid = vfork();
      if (pid == 0) {
          /* 子:不能修改父共享内存(除栈变量);只能 exec/_exit */
          execlp("ls", "ls", NULL);
          _exit(EXIT_FAILURE);
      }
      /* 父:vfork 返回前阻塞,直到子 exec/_exit */

      要点:

      • 子进程对共享内存的修改会影响父——必须只用栈变量。

      • 子必须立即 _exitexec——不能 return(破坏父栈)。

      • 父在 vfork 返回前阻塞——直到子 exec/_exit。

      When:现代 Linux 几乎不需要——fork + exec 已足够高效;man 手册推荐用 posix_spawn。

      24.6 posix_spawn(可选)

      What:POSIX 标准的「fork + exec」合一 API;专为缺少 MMU/swap 的嵌入式系统设计。

      Why:在不能实现传统 fork 的硬件上也能创建新进程;现代 Linux 也有实现但通常用 fork+exec 更简单。

      How

      #include <spawn.h>
      posix_spawnattr_t attr;
      posix_spawn_file_actions_t file_actions;
      pid_t pid;
      
      posix_spawn(&pid, "/bin/ls", &file_actions, &attr, argv, envp);

      API 复杂(attribute、file_actions);通常 fork+exec 更直观。

      When

      • 跨平台嵌入式代码——某些架构不支持 fork。

      • 性能关键场景——可能比 fork+exec 略快(视实现而定)。

      三、关键图表

      (本章无独立编号图表)

      进程生命周期四调用
      调用 何时调用 关键事实

      fork

      创建子进程

      返回两次;COW;fd 共享

      execve

      在当前进程加载新程序

      替换 text/data/heap/stack;保留 PID

      exit(status)

      进程正常终止

      库函数;运行 atexit;刷新 stdio

      _exit(status)

      进程立即终止

      系统调用;不运行 atexit;不刷新 stdio

      wait(&status)

      父等待子终止

      回收子状态;可能阻塞

      fork 后父子继承与不继承
      继承 不继承

      地址空间(COW 副本)

      进程时间

      fd 表项(共享 OFT)

      文件锁

      进程凭证(real UID 等)

      pending 信号

      信号处置

      定时器

      nice 值、资源限制

      RLIMIT_NPROC(重置 0)

      共享内存段、消息队列

      ?

      四、思维导图

      mindmap
        root((第 24 章 进程创建))
          fork 语义
            自我复制
            返回两次
            子 0 父 PID
            失败 1
            switch case 模式
          COW
            共享物理页
            read only
            写入触发复制
            高效 fork exec
          fd 共享
            同一 OFT
            偏移共享
            O APPEND 协调
            关闭未用 fd
          调度不确定
            哪个先跑未知
            sleep 不可靠
            pipe 同步
            wait 同步
          内存隔离
            fork exec 隔离
            第三方库沙箱
            搜索树隔离
          vfork
            子先运行
            共享内存
            POSIX obsolete
            现代不用
          posix spawn
            fork exec 合一
            嵌入式设计
            API 复杂

      五、重点与易错点

      1. fork 返回两次——子进程返回 0,父进程返回子 PID;不能假设任何顺序——必须显式同步。

      2. fork 后用 _exit 而非 exit——子进程若用 exit,会运行 atexit handler、刷新 stdio buffer(父的 buffer 副本被冲刷,可能错乱)。

      3. COW 让 fork 几乎无开销——但 fork 后修改内存会触发页复制;fork + exec 是最优路径(COW 永不复制)。

      4. fd 表项复制但 OFT 共享——父子共享文件偏移;并发写需要 O_APPEND、原子 write、wait 同步、关闭未使用 fd。

      5. fork 不继承的少数属性——进程时间、文件锁、pending 信号、定时器;理解「父清空、child 重置 0」的特殊属性。

      6. fork 失败原因——RLIMIT_NPROC 超过(per real UID);系统进程数上限(/proc/sys/kernel/threads-max);errno = EAGAIN。

        • vfork 与现代 fork——POSIX.1-2008 标记 vfork obsolete;现代 fork + exec 已足够高效;man 手册推荐 posix_spawn。

      7. 父 wait 子终止才能避免僵尸——子进程终止但未 wait 会变僵尸(Z 状态);详见第 26 章。

      8. fork 与线程——fork 后只保留调用 fork 的线程;其他线程消失(pthread_atfork 可注册清理 hook)。

      9. close-on-exec (FD_CLOEXEC)——fork + exec 模式下,未设置此标志的 fd 会被子进程 exec 后的程序继承;常见 bug 来源。

      10. fork 父子调度不确定——不要用 sleep(N) 凑顺序;用 pipe/wait/signal/semaphore 同步。

        • fork 后写 stdio——父子共享 FILE* 但缓冲区分离;用 setvbuf 设 unbuffered 或 _exit 而非 exit

      11. 「fork 不安全」清单——malloc(不是 async-signal-safe)、printf、pthread 互斥锁(死锁)等;fork 后立即 exec 是最安全模式。

      12. daemon 化两步 fork——第一次 fork 让父退出(脱离 shell),第二次 fork 让 daemon 重新打开控制终端;详见第 37 章。

        • 跨章衔接:第 26 章父 wait 回收 + SIGCHLD;第 27 章 execve 与文件描述符;第 34 章 shell job control;第 37 章 daemon;第 25 章 exit/_exit 细节。