﻿# 进程组与子进程回收：从概念到正确实践

## 概述

POSIX 进程组（Process Group）是 Unix 进程管理的基础抽象，它直接关联到 **shell 作业控制**、**信号分发** 和 **子进程回收** 三个核心机制。本文从"进程组是什么"出发，澄清 `fork` 后的 PGID 继承规则，并系统论述正确回收子进程 `task_struct` 的六种策略及其在低延时场景下的选择。

> 前置阅读：[task-struct.md](/concepts/process/task-struct.md)（`task_struct` 状态机）→ [process-creation.md](/concepts/process/process-creation.md)（fork/exec 流程）→ [task-resources/task-private.md](/concepts/process/task-resources/task-private.md)（`do_exit` 退出流程）

---

## 一、进程组是什么

### 1.1 四个核心概念

| 概念 | 内核对象/系统调用 | 说明 |
|------|------------------|------|
| **进程组（Process Group）** | `setpgid()` / `getpgid()` | 一组进程的集合，以 PGID（Process Group ID）标识。leader 的 PID = PGID |
| **会话（Session）** | `setsid()` / `getsid()` | 一组进程组的集合，以 SID（Session ID）标识。用于终端作业控制 |
| **控制终端（Controlling Terminal）** | `open("/dev/tty")` | 一个会话最多关联一个终端。`SIGHUP` 在终端挂断时发给会话中的所有进程 |
| **前台进程组（Foreground PG）** | `tcsetpgrp()` / `tcgetpgrp()` | 会话中唯一能读取终端输入的进程组。`Ctrl+C` → 前台 PG 收 `SIGINT`；`Ctrl+Z` → `SIGTSTP` |

### 1.2 进程组创建的完整流程

以 shell 启动管道 `cat a.txt | sort | uniq` 为例：

```bash
shell (SID=100, PGID=100, 前台PG)
  │
  ├─ fork → child1 (PGID=100) → setpgid(0, 100) → exec("cat")
  ├─ fork → child2 (PGID=100) → setpgid(0, 100) → exec("sort")
  └─ fork → child3 (PGID=100) → setpgid(0, 100) → exec("uniq")
shell 调用 tcsetpgrp(tty, 100) 将 PGID=100 设为前台进程组
shell 自身暂停（SIGTTIN/SIGTTOU），等管道退出后恢复前台
```

关键点：

- 管道中所有进程应属于 **同一个进程组**，这样 `Ctrl+C` 能一次性终止整个管道
- 父子竞争：父 shell 和子进程都调用 `setpgid(pid, pgid)`，先到先设，避免竞态
- shell 创建它们之前，三者的 PGID 默认继承自 shell（即 PGID=100）

### 1.3 `task_struct` 中的相关字段

```bash
task_struct
├── pid_t pid;              // 本进程 PID
├── pid_t tgid;             // 线程组 ID（主线程的 pid）
├── struct task_struct *group_leader;   // 指向线程组的 leader
├── struct signal_struct *signal;       // 共享的信号描述符
│   ├── pid_t pgrp;         // ← 进程组 ID (PGID)
│   ├── pid_t session;      // ← 会话 ID (SID)
│   ├── struct tty_struct *tty;         // 控制终端
│   └── ...
├── struct list_head thread_group;      // 线程组链表
├── struct list_head children;          // 子进程链表
├── struct list_head sibling;           // 兄弟进程链表
└── struct task_struct *real_parent;    // 实际父进程
```

进程组 ID 存储在 `task->signal->pgrp`，属于 **线程组共享** 的数据，即同一进程的所有线程必然属于同一进程组。

### 1.4 系统调用速查

| 系统调用 | 内核函数 | 功能 |
|----------|---------|------|
| `pid_t getpgid(pid_t pid)` | `sys_getpgid()` | 获取指定进程的 PGID。`pid=0` 表示调用者自身 |
| `pid_t getpgrp(void)` | `sys_getpgrp()` | 获取调用者自身的 PGID，等价于 `getpgid(0)` |
| `int setpgid(pid_t pid, pid_t pgid)` | `sys_setpgid()` | 将 `pid` 移到进程组 `pgid`。「pid=0」= 自身；「pgid=0」= 以 `pid` 为 PGID（创建新组） |
| `pid_t setsid(void)` | `sys_setsid()` | 创建新会话 + 新进程组。调用者成为新会话 leader + 新进程组 leader，且脱离控制终端 |
| `pid_t getsid(pid_t pid)` | `sys_getsid()` | 获取会话 ID |
| `int tcsetpgrp(int fd, pid_t pgrp)` | — | 将 `pgrp` 设为与 fd 关联终端的前台进程组 |

`setpgid()` 的限制：

- `pid` 必须是调用者自身或其子进程（且子进程还未 `exec`）
- `pid` 不能是会话 leader（会话 leader 不能改 PGID）
- `pid` 和 `pgid` 必须在同一会话
- `pgid` 对应的进程组必须已经存在于同一会话（或 `pgid == pid` 创建新组）

---

## 二、父进程创建子进程后，是否在同一进程组？

### 2.1 答案：是的，默认情况下

```bash
fork() 在 copy_process() 中：
子进程继承父进程的 signal_struct 中的 pgrp 和 session
→ 子进程的 PGID = 父进程的 PGID（除非后续显式调用 setpgid()）
```

**内核实现路径**（`kernel/fork.c`）：

```c
static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
{
    // ...
    // 子进程共享 signal_struct 引用或创建副本
    // pgrp 和 session 直接被继承
    spin_lock_irq(&current->sighand->siglock);
    sig->pgrp = task_pgrp(current);      // 继承父进程的 PGID
    sig->session = task_session(current);  // 继承父进程的 SID
    // ...
}
```

### 2.2 为什么不直接设成同一个值？并发创建子进程的竞态

考虑 shell 同时创建三个管道成员的场景：

```bash
时间线：shell 连续 fork 3 次
T1: shell fork cat   → child1 (PGID=shell_PGID)  // 继承
T2: shell setpgid(child1, new_pgid)               // shell 尝试改 child1 的 PGID
T3: shell fork sort  → child2 (PGID=shell_PGID)  // 继承
T4: shell setpgid(child2, new_pgid)               // shell 尝试改 child2 的 PGID
T5: child1 setpgid(0, 0)                          // child1 自身也调 setpgid 确保
```

shell 和子进程 **同时** 调用 `setpgid()`：谁先执行谁生效，另一个的调用返回成功（因为 PGID 已经正确）。这是 POSIX 明确允许的竞态消除模式。

### 2.3 什么时候不在同一进程组？

`fork()` 后子进程默认继承父进程的 PGID（见 2.1），但在以下四类场景中子进程会脱离父进程的进程组：

| 场景 | PGID 关系 | 触发机制 |
|------|----------|---------|
| Daemon 化 | 子进程 ≠ 父进程（且无控制终端） | `setsid()` 创建新会话和新进程组 |
| Shell 管道分组 | 管道成员属于新 PG（不等于 shell 的 PG） | `setpgid(pid, new_pgid)` |
| 进程主动离组 | 子进程 ≠ 父进程 | `setpgid(0, 0)` 以自身 PID 新建进程组 |
| init/subreaper 收养 | 孤儿进程 PGID 不变，但父进程变了 | 父进程退出，子进程被 init/subreaper 收养 |
| 终端作业控制 | 前台/后台进程组在会话内切换 | 终端驱动 + `tcsetpgrp()` 控制 |

---

#### 2.3.1 Daemon 化 —— `setsid()` 彻底脱离

守护进程需要完全脱离启动它的终端和 shell 会话，这是最常见的"父子不在同一进程组"场景。

**典型模式（Double Fork）**：

```c
// Linux 标准 daemon 化流程
pid_t pid = fork();
if (pid > 0)  exit(0);           // 父进程退出，子进程变孤儿
// pid == 0: 子进程继续
setsid();                         // ★ 创建新会话 — 子进程的 PGID 就此 != 祖父
pid = fork();
if (pid > 0)  exit(0);           // 二级 fork 确保不是会话首进程
// 最终守护进程：新 PGID、新 SID、无控制终端
```

**`setsid()` 的内核行为**（`kernel/sys.c`）：

```bash
sys_setsid()
  ├─ 前提：当前进程不能已是进程组组长（PGID == PID）
  │   → 这就是为什么要先 fork：子进程 PID ≠ 父 PGID
  ├─ s->leader = 1                        // 标记为新会话首进程
  ├─ s->pgrp  = task_pid_vnr(current)      // PGID = 自己的 PID
  ├─ s->session = task_pid_vnr(current)     // SID  = 自己的 PID
  └─ 与控制终端解除关联
```

**关键影响**：

| 维度 | 后果 |
|------|------|
| **进程组** | 新 PGID = 子进程的 PID，与原来 shell 的进程组完全无关 |
| **会话** | 新 SID = 子进程的 PID，不再被终端信号（`SIGHUP`、`SIGINT`）打扰 |
| **控制终端** | 无控制终端，`/dev/tty` 打开失败 |
| **作业控制** | 不再受 shell 的 `fg`/`bg`/`jobs` 管理 |
| **`waitpid(-PGID)`** | 旧 PGID 上的 `waitpid` 不会再收到该进程的 `SIGCHLD` |
| **孤儿进程组** | 如果会话首进程退出，组内进程收到 `SIGHUP`+`SIGCONT` → 避免遗留进程 |

**观测命令**：

```bash
ps -eo pid,ppid,pgid,sid,comm | head -5   # 守护进程的 PGID 和 SID 通常是自己的 PID
ls /proc/<pid>/fd/ | grep /dev/tty        # daemon 进程没有控制终端
```

---

#### 2.3.2 Shell 管道分组 —— `setpgid()` 构建作业

Shell 执行管道（`cmd1 | cmd2 | cmd3`）时，会将所有管道成员放入同一个新进程组，以实现统一的作业控制。

```bash
$ cat /var/log/syslog | grep error | wc -l &
[1] 12345
ps -eo pid,pgid,comm
  PID  PGID COMMAND
10000 10000 bash           ← shell 在自己的 PG
12340 12345 cat             ← 管道成员
12342 12345 grep            ← 都在同一个
12343 12345 wc               ← 新 PG (12345)
```

**内核路径**：

```c
// shell 在 fork 每个管道成员后调用：
setpgid(child_pid, new_pgid);    // 以管道第一个成员的 PID 作为 PGID
// 同时告诉每个子进程自己也调（消除竞态，见 2.2）：
// 在子进程中：
setpgid(0, 0);                   // PGID = 自己的 PID
```

**这个场景对 `waitpid` 的实际影响**：

- `waitpid(0, ...)`（等待任意子进程）：shell 仍能等到，因为 `do_wait_thread` 遍历的是 `children` 链表，与 PGID 无关
- `waitpid(-PGID, ...)`：如果 shell 用 `-shell_PGID` 等待，就**等不到**管道成员
- `SIGCHLD`：仍然发送给父进程（shell），因为信号发送目标是 `real_parent`

---

#### 2.3.3 进程主动离组 —— `setpgid(0, 0)`

任何非进程组组长的进程都可以调用 `setpgid(0, 0)` 以自身 PID 创建新进程组：

```c
// 脱离父进程的进程组
setpgid(0, 0);  // 新 PGID = 自己的 PID
```

**前提条件：调用者不能已是进程组组长。** 如果 `pgid == pid`（组长），调用返回 `-EPERM`。

**与 `setsid()` 的区别**：

| | `setpgid(0, 0)` | `setsid()` |
|---|---|---|
| 新 PGID | = 当前 PID | = 当前 PID |
| 新 SID | 不变 | = 当前 PID |
| 控制终端 | 保留 | 失去 |
| 调用者要求 | 不能是组长 | 不能是组长 |
| `SIGHUP` 终端断开 | 仍会收到 | 不会收到 |

**使用场景**：不想完全脱离会话（仍需终端），但想独立编组。例如一个进程既是某个会话的成员又希望自己的子进程被独立管理。

---

#### 2.3.4 init / subreaper 收养 —— PGID 不变但父进程变了

父进程先于子进程退出时，子进程成为孤儿，被 `init`（PID 1）或最近的 subreaper 收养。**但子进程的 PGID 保持不变。**

```bash
fork → 子进程 PGID=200
父进程 PGID=200
  父进程退出
    → do_exit() → forget_original_parent()
      → 子进程的 real_parent = init (PID 1)
      → 子进程的 PGID 仍然是 200（不变！）
```

**对 `waitpid` 的隐性影响**：

| 行为 | 说明 |
|------|------|
| `waitpid(-200)` 由原父进程调用 | **失败** —— 原父进程已退出 |
| `waitpid(-200)` 由 init 调用 | init 会反复 `wait()` 收养的子进程，回收僵尸 |
| `SIGCHLD` 发送给谁 | 新的 `real_parent`（init 或 subreaper） |
| 进程组的生命周期 | PG 200 仍然存在，直到最后一个成员退出 |

**极端场景 —— 孤儿进程组**：

如果进程组的所有父进程都退出了，组内剩余成员形成孤儿进程组。内核会对孤儿进程组的每个 stopped 成员发送 `SIGHUP` + `SIGCONT`，防止遗留的 stopped 进程永远无人唤醒。

```bash
// kernel/exit.c: will_become_orphaned_pgrp()
// 判断进程组是否即将成为孤儿：组内每个进程的父进程都不在同一组
```

---

#### 2.3.5 终端作业控制 —— 前台/后台切换

在交互式 shell 中，作业控制会在同一会话内创建多个进程组：前台进程组从终端接收输入和信号，后台进程组不能读取终端。

```bash
SID=500
  ├─ PG=500 (shell 自身)          ← 后台
  │    └─ bash (PID 500)
  │
  ├─ PG=12345 (作业 1: vim)       ← 前台（有控制终端访问权）
  │    └─ vim (PID 12345)
  │
  └─ PG=12346 (作业 2: make)      ← 后台
       ├─ make (PID 12346)
       └─ gcc  (PID 12347)
```

**父子不在同一进程组的场景**：

- shell `fork` → 子进程被 `setpgid()` 移到作业的进程组 → `fork` + `exec` 运行命令
- 此时进程运行在作业 PG 中，而 shell 在自己的 PG 中

**终端信号分发**：

- `Ctrl+C` → 终端驱动向**前台进程组**所有进程发 `SIGINT`
- `Ctrl+Z` → 向**前台进程组**发 `SIGTSTP`
- 终端断开 → 向**会话首进程**发 `SIGHUP`（再由会话首进程转发给后台作业）

**内核数据结构视角**：

```bash
tty_struct (控制终端)
  ├─ pgrp = 前台进程组的 PGID    ← 由 tcsetpgrp() 设置
  └─ session = 控制会话的 SID
```

---

#### 2.3.6 小结：对 `waitpid` 和子进程回收的影响

| 场景 | `waitpid(-PGID)` 能否等到 | SIGCHLD 发给谁 | 核心内存影响 |
|------|--------------------------|---------------|-------------|
| Daemon 化 | 旧 PG \| 等不到 / 新 PG \| 能 | 最终父进程（如 init） | 子进程变为独立进程组 |
| Shell 管道 | 管道 PG \| 能，shell PG \| 不能 | shell（仍是父进程） | 无特殊影响 |
| 主动离组 | 旧 PG \| 不能，新 PG \| 能 | 父进程 | 无特殊影响 |
| init 收养 | PG 不变 \| 能等到（通过 init） | init / subreaper | PG 生命周期延长 |
| 终端作业 | 作业 PG \| 能    | 父进程 | 终端信号分发受影响 |
| Shell 显式分组 | 管道中进程组成新 PG | `setpgid(pid, new_pgid)` |
| `init` (PID 1) 收养 | 孤儿进程 PGID 不变 | 父进程退出后被 init 收养，PGID 不变 |

---

## 三、为什么需要正确回收子进程的 task_struct

### 3.1 僵尸进程的本质

```bash
子进程调用 _exit() / 被信号杀死
  │
  ├─ do_exit()
  │   ├─ exit_mm()   ← 释放 mm_struct（用户态地址空间）
  │   ├─ exit_files() ← 释放 files_struct（文件描述符表）
  │   ├─ exit_fs()    ← 释放 fs_struct（cwd, root）
  │   ├─ exit_sighand() ← 释放信号处理器
  │   └─ exit_notify()
  │       ├─ forget_original_parent()  ← 子进程的子进程被 init 收养
  │       ├─ 设 exit_state = EXIT_ZOMBIE
  │       ├─ 设 exit_code = 退出码
  │       └─ do_notify_parent() → 向父进程发 SIGCHLD
  │
  └─ 调度器不再选择此 task（EXIT_ZOMBIE 不可运行）
     但 task_struct 仍然存在，等待父进程回收！
```

**僵尸进程留存的数据**：

- `task_struct` 结构体本体（≈ 5-10KB on x86-64）
- `exit_code`（退出码，父进程通过 `wait()` 读取）
- `rusage`（资源使用统计：CPU 时间、缺页次数、上下文切换次数等）
- PID（仍占用，不可复用，直到回收）

### 3.2 僵尸进程的危害

| 危害类别 | 具体影响 |
|----------|---------|
| **PID 耗尽** | 每个僵尸占用一个 PID。Linux 默认 `pid_max = 32768`（32 位最大上限 `4194304`）。POD 容器中可分配 PID 更有限 |
| **内存泄漏** | 每个 `task_struct` 约 5-10KB（含内核栈），几百个僵尸 → MB 级浪费 |
| **进程表污染** | `/proc/sched_debug`、`ps`、`top` 遍历变慢 |
| **`fork()` 失败** | PID 耗尽时 `fork()` 返回 `EAGAIN` |

### 3.3 父进程先退出的情况

```bash
父进程退出
  │
  ├─ do_exit() → exit_notify() → forget_original_parent()
  │   └─ 遍历 children 链表
  │       └─ 对每个未回收的子进程：
  │           reparent_leader(false) // 将子进程收养给所在线程组的 "subreaper"
  │           // 如果没有 subreaper，最终收养给 init (PID 1)
  │
  └─ init (PID 1) 循环中调用 waitpid(-1, ...)
      → 自动回收所有被收养的孤儿僵尸
```

**结论**：如果子进程的父进程先退出，子进程会被 `init` 收养并自动回收。但如果父进程是长期运行的服务进程（如量化交易策略引擎），僵尸会一直累积到服务重启。

---

## 四、正确回收子进程 task_struct 的六种策略

### 4.1 策略对比总览

| 策略 | 复杂度 | 延迟影响 | 适用场景 | 僵尸风险 |
|------|--------|---------|---------|---------|
| **阻塞 wait/waitpid** | 低 | 父进程阻塞 | 简单顺序程序、短期子任务 | 无 |
| **SIGCHLD + waitpid(WNOHANG)** | 中 | 中断开销 | 通用服务进程 | 无（需正确处理信号中断） |
| **SA_NOCLDWAIT** | 低 | 无 | fork-and-forget 场景 | 无 |
| **Double fork（双 fork）** | 中 | 零 | 传统 Daemon | 无（init 回收） |
| **signalfd + epoll** | 高 | 极小 | 低延时事件循环 | 无 |
| **PR_SET_CHILD_SUBREAPER** | 中 | 零 | 容器/进程管理器 | 无 |

### 4.2 策略一：阻塞 wait / waitpid

最基础的回收方式，父进程阻塞等待任一或指定子进程退出。

```c
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
void demo_blocking_wait() {
    pid_t pid = fork();
    if (pid == 0) {
        // child
        sleep(2);
        exit(42);
    }
    // parent: 阻塞等待子进程退出
    int status;
    pid_t w = wait(&status);                  // 等待任意子进程
    // pid_t w = waitpid(pid, &status, 0);   // 等价，等待指定子进程
    if (WIFEXITED(status))
        printf("child exit code: %d\n", WEXITSTATUS(status)); // 42
    else if (WIFSIGNALED(status))
        printf("child killed by signal %d\n", WTERMSIG(status));
}
```

**`waitpid()` 的第一个参数选项**：

| pid 参数 | 含义 |
|----------|------|
| `pid > 0` | 等待 PID = pid 的**单个**子进程 |
| `pid == 0` | 等待**任意**与调用者同进程组的子进程 |
| `pid == -1` | 等待**任意**子进程（等价于 `wait()`） |
| `pid < -1` | 等待**任意**进程组 ID = |pid| 的子进程 |

**`waitpid()` 的 `options` 标志位**：

| 标志 | 含义 |
|------|------|
| `WNOHANG` | **非阻塞**：没有已退出的子进程则立即返回 0 |
| `WUNTRACED` | 也报告已停止（`SIGSTOP`/`SIGTSTP`）的子进程 |
| `WCONTINUED` | 也报告被 `SIGCONT` 恢复的子进程 |
| `__WCLONE` | 仅等待 clone 创建的子进程（不常用） |

**内核路径**：

```c
// kernel/exit.c
SYSCALL_DEFINE3(waitpid, pid_t, pid, int __user *, stat_addr, int, options)
    → kernel_wait4(pid, stat_addr, options, NULL)
        → do_wait_thread(current, ...)    // 遍历线程组的所有子进程
            → wait_consider_task()
                → wait_task_zombie()      // 如果子进程是 zombie
                    → release_task()      // 释放 task_struct！
                        ├─ __exit_signal()    // 清理信号
                        ├─ __exit_sighand()   // 清理信号处理
                        ├─ __unhash_process() // 从 pid hash 移除
                        ├─ sched_exit()       // 调度器退出
                        └─ free_task()        // 释放 task_struct 内存
```

**适用场景**：顺序执行的短生命周期程序，父进程天然地在最后 `wait()` 回收。

**不适用场景**：需要并发处理多个子进程/请求的服务器。

---

### 4.3 策略二：SIGCHLD 信号处理器 + waitpid(WNOHANG) 循环回收

这是通用服务进程的最常见模式。通过 `SIGCHLD` 信号异步通知，在信号处理器中非阻塞地回收所有已退出的子进程。

```c
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>
#include <stdio.h>
// ⚠️ 信号处理器中必须循环 waitpid，因为标准信号不排队
//    多个子进程可能只触发一次 SIGCHLD
void sigchld_handler(int sig) {
    int saved_errno = errno;   // 保存 errno，避免干扰主程序
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status))
            printf("child %d exited with %d\n", pid, WEXITSTATUS(status));
        else if (WIFSIGNALED(status))
            printf("child %d killed by signal %d\n", pid, WTERMSIG(status));
    }
    // pid == -1 && errno == ECHILD → 没有更多子进程需要回收，正常
    errno = saved_errno;
}
int main() {
    struct sigaction sa = {
        .sa_handler = sigchld_handler,
        .sa_flags   = SA_RESTART | SA_NOCLDSTOP,
        // SA_RESTART:   被信号中断的系统调用自动重启
        // SA_NOCLDSTOP: 子进程被 SIGSTOP 停止时不触发 SIGCHLD
    };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGCHLD, &sa, NULL);
    // ... 业务逻辑，自由 fork 子进程
    // 子进程退出时会自动被 sigchld_handler 回收
    pause(); // 或事件循环
}
```

**必须注意的三个要点**：

1. **循环 `waitpid(-1, ..., WNOHANG)` 直到返回 -1/ECHILD**：因为标准信号不排队（`SA_NODEFER` 未设置时），多个子进程几乎同时退出可能只触发一次 `SIGCHLD`。不循环就会漏回收。
2. **保存和恢复 `errno`**：信号处理器可能在主程序的任何点插入执行。如果在 `printf` 和 `write` 之间被信号打断，`errno` 可能被信号处理器修改，导致主程序看到错误的 `errno`。
3. **`SA_NOCLDSTOP` 标志**：避免子进程被 `SIGSTOP` 暂停时也不必要地触发 `SIGCHLD`。通常我们只关心子进程退出。

**量化分析**：`SIGCHLD` 信号处理的开销

| 环节 | 近似延迟 | 说明 |
|------|---------|------|
| 信号生成（内核） | ~100ns | `do_notify_parent()` → `__send_signal()` |
| 用户态抢占 + 保存上下文 | ~1-3μs | 取决于当前是否在可被抢占点 |
| 信号帧构建（`setup_rt_frame`） | ~500ns | 在内核栈上构建 `sigframe` |
| 用户态 handler 执行 | ~1-2μs | `waitpid(WNOHANG)` 非常快 |
| `sigreturn` 恢复上下文 | ~500ns | 恢复保存的寄存器 |
| **总开销** | **~3-7μs** | 对延时敏感的场景不是零开销 |

---

### 4.4 策略三：SA_NOCLDWAIT — 零操作自动回收

设置 `SIGCHLD` 的处理动作为 `SIG_DFL` 并添加 `SA_NOCLDWAIT` 标志，内核在子进程退出时**直接回收**，不产生僵尸。

```c
#include <signal.h>
void demo_sa_nocldwait() {
    struct sigaction sa = {
        .sa_handler = SIG_DFL,    // 必须为 SIG_DFL
        .sa_flags   = SA_NOCLDWAIT,
    };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGCHLD, &sa, NULL);
    // fork 子进程
    if (fork() == 0) {
        _exit(42);
    }
    // 父进程继续运行
    // 子进程自动回收，waitpid() 返回 -1，errno = ECHILD
    sleep(1);
    int status;
    pid_t w = waitpid(-1, &status, WNOHANG);
    // w == -1, errno == ECHILD ← 找不到任何子进程！已自动回收！
}
```

**内核实现路径**：

```c
// kernel/exit.c: do_notify_parent()
if (tsk->ptrace)
    // ptrace 跟踪：必须保留为 zombie 供 debugger wait
    ...
else if (tsk->exit_signal != -1 && thread_group_empty(tsk))
    // 正常情况：设置 EXIT_ZOMBIE，向父进程发 SIGCHLD
    ...
else if (tsk->parent->signal->flags & SIGNAL_UNKILLABLE)
    // 父进程是 init (PID 1)
    ...
else if (tsk->exit_signal == -1)     // SA_NOCLDWAIT 效果：
    wake_up_parent = 0; // 不通知父进程
    autoreap = true;    // 直接自动回收！
```

SA_NOCLDWAIT 设置后，内核把子进程的 `exit_signal` 设为 `-1`（`kernel/signal.c: do_sigaction()`），使得 `do_notify_parent()` 走 `autoreap` 路径，直接调用 `release_task()`。

**优缺点**：

| 优点 | 缺点 |
|------|------|
| 零延迟、零代码开销 | **无法获取子进程退出码和资源使用统计** |
| 完全不产生僵尸 | 不适合需要知道子进程退出状态的场景 |
| 适合 `fork` + 立即 `exec` 后 forget 的场景 | 与需要 `waitpid()` 获取退出状态的代码不兼容 |

**适用场景**：量化交易系统中，父进程 fork 出数据采集/日志写入等辅助进程，不关心其退出原因。

---

### 4.5 策略四：Double Fork（双 fork）— 经典 Daemon 模式

父进程 fork 一个子进程，子进程再 fork 一个孙进程后立即退出，孙进程被 `init` (PID 1) 收养。

```c
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
pid_t daemonize_fork() {
    pid_t pid = fork();
    if (pid < 0) return -1;
    if (pid > 0) {
        // 第一层父进程：等待中间子进程退出
        int status;
        waitpid(pid, &status, 0);  // ← 中间子进程立即退出，快速回收
        return 0;                   // 可以继续自己的业务
    }
    // 中间子进程
    if (fork() > 0) {
        _exit(0);  // ← 立即退出，被第一层 waitpid 回收
    }
    // 孙进程：现在真正的父进程已退出，被 init 收养
    setsid();  // 创建新会话，脱离终端
    chdir("/");
    // ... 成为 Daemon，执行业务逻辑
    return getpid();  // 返回新 Daemon 的 PID
}
```

**时序图**：

```bash
第一层父进程(P)          中间子进程(M)            孙进程(G)
    │                       │                      │
    ├─ fork() ──────────────┤                      │
    │                       ├─ fork() ──────────────┤
    │                       │                      │
    │                       ├─ _exit(0) ────────────┤
    │  waitpid(M) 回收M     │  (M 的 task_struct   │
    │  ← M 已不在进程表中    │   被 P 回收)          │
    │                       X                      │ (G 被 init 收养)
    │ (P 继续业务)           │                      ├─ setsid() (现在安全了)
    │                       │                      ├─ 业务逻辑...
    │                       │                      │
    │                       │                      ├─ 某天 G 退出
    │                       │                      │  init waitpid() 回收 G
    │                       │                      X
```

**关键点**：孙进程不需要任何僵尸处理代码——`init` (PID 1) 的主循环天然包含 `waitpid(-1, ..., WNOHANG)`，自动回收所有养子。

| 优点 | 缺点 |
|------|------|
| 零信号处理开销 | fork 两次（各有开销） |
| 彻底脱离父进程和终端 | 无法从原父进程获知孙进程退出状态 |
| 经典、可靠 | 不适合需要父子通信的场景 |

---

### 4.6 策略五：signalfd + epoll — 低延时事件驱动回收

将 `SIGCHLD` 转换为文件描述符事件，统一进入 epoll 事件循环。避免了信号处理器中断带来的上下文污染。

```c
#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
void demo_signalfd() {
    // 1. 阻塞 SIGCHLD，使其不按默认/处理器方式递送
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGCHLD);
    sigprocmask(SIG_BLOCK, &mask, NULL);  // ← 关键：必须阻塞信号
    // 2. 创建 signalfd，将 SIGCHLD 转为 fd 事件
    int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
    // 3. 加入 epoll
    int epfd = epoll_create1(0);
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
    // 4. 事件循环
    struct epoll_event events[64];
    for (;;) {
        int n = epoll_wait(epfd, events, 64, -1);
        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == sfd) {
                struct signalfd_siginfo fdsi;
                read(sfd, &fdsi, sizeof(fdsi));  // 消费信号
                if (fdsi.ssi_signo == SIGCHLD) {
                    // 循环回收所有已退出的子进程
                    pid_t pid;
                    int status;
                    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
                        // 记录退出信息...
                    }
                }
            }
            // 处理其他 fd 事件...
        }
    }
    close(sfd);
}
```

**为什么低延时场景推荐 signalfd？**

| 对比维度 | SIGCHLD 信号处理器 | signalfd + epoll |
|----------|-------------------|-----------------|
| 上下文切换 | 需要保存/恢复所有寄存器（sigframe） | 不需要（普通 fd read） |
| 执行时机 | 不可控——可能在任何机器指令间插入 | 可控——事件循环主动 pull |
| 与 epoll 集成 | 需要额外 self-pipe trick | 原生 epoll fd |
| errno 污染 | 必须手动保存/恢复 | 不存在 |
| 开销量化 | ~3-7μs（含 sigframe + sigreturn） | ~0.5-1μs（仅 fd read） |
| 不排队问题 | 必须循环 waitpid | signalfd 自动排队，每次 read 返回一个信号 |

---

### 4.7 策略六：PR_SET_CHILD_SUBREAPER — 容器/进程树管理

Linux 3.4+ 特性：将某个进程标记为"子收割者"（subreaper），它的后代（孙子辈及以下）如果父进程退出，会被收养给这个 subreaper 而非 init。

```c
#include <sys/prctl.h>
#include <unistd.h>
#include <sys/wait.h>
void demo_subreaper() {
    prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0);
    pid_t child = fork();
    if (child == 0) {
        // 子进程再创建孙子
        if (fork() == 0) {
            sleep(1);   // 孙进程：父进程（child）很快退出
            _exit(0);   // 此时被 subreaper 收养
        }
        _exit(0);  // 中间子进程退出
    }
    // 父进程：循环回收所有后代
    for (;;) {
        pid_t pid = waitpid(-1, NULL, 0);
        if (pid == -1 && errno == ECHILD)
            break;  // 全部回收完毕
        printf("reaped %d\n", pid);
    }
}
```

**subreaper 链**：如果进程树中有多个 subreaper，取最近的祖先 subreaper 收养。形成 `进程 → subreaper_1 → subreaper_2 → ... → init` 的收养链。

**适用场景**：

- 容器运行时（Docker 的 `containerd-shim` 使用此特性）
- 进程管理器 / supervisor
- 需要追踪所有后代的监控进程

---

## 五、`wait()` / `waitpid()` 的宏详解

正确回收不只是调用 `wait()`，更重要的是正确解析退出状态。

```c
int status;
pid_t pid = waitpid(child_pid, &status, 0);
```

| 检查宏 | 返回真时的含义 | 提取宏 | 提取的值 |
|--------|--------------|--------|---------|
| `WIFEXITED(status)` | 子进程正常 `exit()` / `_exit()` / `return` | `WEXITSTATUS(status)` | 退出码的低 8 位 (0-255) |
| `WIFSIGNALED(status)` | 子进程被信号杀死 | `WTERMSIG(status)` | 信号编号 |
| `WCOREDUMP(status)` | 子进程产生了 core dump | — | —（需要 WIFSIGNALED 为真） |
| `WIFSTOPPED(status)` | 子进程被 `SIGSTOP`/`SIGTSTP` 暂停 | `WSTOPSIG(status)` | 停止信号编号 |
| `WIFCONTINUED(status)` | 子进程被 `SIGCONT` 恢复 | — | —（Linux 2.6.10+，需 `WCONTINUED` 选项） |

**退出码的坑**：`exit(256)` → `WEXITSTATUS(status)` = 0（因为只有低 8 位被保留）。`exit(-1)` → `WEXITSTATUS(status)` = 255。

---

## 六、子进程回收与低延时量化系统的实践建议

### 6.1 分层策略

量化交易系统的不同组件应使用不同的回收策略：

```bash
┌─────────────────────────────────────────────┐
│  策略引擎进程（核心延时路径）                   │
│  • 不直接 fork 子进程                         │
│  • 如有辅助进程需求，在启动阶段 double fork    │
│  • 运行时零子进程回收开销                      │
└─────────────────────────────────────────────┘
                    │
┌─────────────────────────────────────────────┐
│  交易网关进程                                  │
│  • 使用 signalfd + epoll 统一事件循环          │
│  • SIGCHLD 作为普通 fd 事件处理                │
│  • 延时敏感但可接受 1-2μs 的回收开销            │
└─────────────────────────────────────────────┘
                    │
┌─────────────────────────────────────────────┐
│  监控/管理进程（非延时敏感）                    │
│  • SIGCHLD handler + waitpid(WNOHANG)       │
│  • 或 prctl(PR_SET_CHILD_SUBREAPER) 集中管理  │
└─────────────────────────────────────────────┘
```

### 6.2 量化对比

| 场景 | 推荐策略 | 延迟影响 | 理由 |
|------|---------|---------|------|
| 策略引擎主线程 | 避免 fork 子进程 | 零 | 延时路径上不应有进程创建/回收 |
| 行情接入辅助进程 | SA_NOCLDWAIT | 零 | 辅助进程退出由 init 处理 |
| 日志异步写入 | Double fork | 零（启动时一次） | 运行时无任何回收开销 |
| 风控子进程 | signalfd + epoll | ~1μs / 次 | 可控的异步通知，需要获取退出状态 |
| 管理/部署脚本 | SIGCHLD handler | ~5μs / 次 | 非延时敏感，简单可靠 |
| 容器内多进程 | PR_SET_CHILD_SUBREAPER | 零（按需触发） | PID namespace 下替代 init |

### 6.3 常见错误

| 错误 | 后果 | 修复 |
|------|------|------|
| 信号处理器中只 `waitpid` 一次 | 漏回收僵尸 | 循环直到返回 -1 |
| SIGCHLD 处理器中不保存 errno | 主程序看到错误的 errno | `int saved = errno; ... errno = saved;` |
| 未设置 SA_NOCLDSTOP | SIGSTOP 时误触发回收逻辑 | 添加 `SA_NOCLDSTOP` |
| 核心延时线程上调用 waitpid | 微秒级阻塞 | 子进程回收移到独立线程或使用 signalfd |
| SA_NOCLDWAIT 后仍调用 waitpid 检查退出码 | 永远返回 ECHILD | 使用其他策略替代 |

---

## 七、参考资料

- `man 2 setpgid` / `man 2 setsid` / `man 2 waitpid` / `man 2 signalfd`
- `man 7 signal` / `man 7 credentials`
- POSIX.1-2008, Chapter 2 (General Terminal Interface), Chapter 11 (Job Control)
- Linux kernel: `kernel/exit.c` (`do_exit`, `release_task`, `wait_task_zombie`, `forget_original_parent`)
- Linux kernel: `kernel/signal.c` (`do_sigaction` — `SA_NOCLDWAIT` 的实现)
- Linux kernel: `kernel/fork.c` (`copy_signal` — `pgrp`/`session` 继承)
- Linux kernel: `fs/signalfd.c` (signalfd 实现)
- 《The Linux Programming Interface》第 26 章（进程组/会话/作业控制）、第 34 章（进程组/会话/作业控制）、第 36-38 章（进程资源与回收）
- 《Advanced Programming in the UNIX Environment》第 8-10 章

