﻿# 反面案例：uv_file_process 僵尸进程堆积 —— 四个致命 BUG 逐层拆解

## 案例摘要

生产环境中某后台服务（`vpsmsr2`/`emumgr` 用户）出现大量 `[uv_file_process] <defunct>` Z 状态进程，僵尸持续累积时间跨度从 `Jul14` 到当天。该文档从现场 `ps` 截图和 `poll_signal_child_handler()` 源码出发，以**四个致命 BUG** 为主线，逐层拆解回收失败根因，并给出修复与监控方案。

> 前置阅读：[process-group-and-child-reap.md](/concepts/process/process-group-and-child-reap.md)（六种回收策略）→ [signal-multithread.md](/concepts/process/signal-multithread.md)（多线程信号投递）→ [task-resources/task-private.md](/concepts/process/task-resources/task-private.md)（`do_exit` / `EXIT_ZOMBIE`）

---

## 一、现场现象

```bash
USER     PID      %CPU %MEM  VSZ  RSS   TTY  STAT START  TIME COMMAND
vpsmsr2  250353   0.0  0.0   0    0    ?    Z    09:00  0:02 [uv_file_process] <defunct>
emumgr   250696   0.0  0.0   0    0    ?    Z    Jul14  1:09 [uv_file_process] <defunct>
vpsmsr2  250955   0.0  0.0   0    0    ?    Z    09:00  0:02 [uv_file_process] <defunct>
... 数百行 ...
```

| 诊断指标 | 含义 |
|----------|------|
| `STAT = Z` | 进程已 `do_exit()`，但 `task_struct` 未被回收 |
| `COMMAND = [uv_file_process] <defunct>` | 父进程没有调用 `wait()` |
| `VSZ/RSS = 0` | `mm_struct` 已释放，仅剩内核描述符 |
| 时间跨度数天 | 不是一次性泄漏，持续累积 |
| 多用户同时出现 | 不同父进程实例共同缺陷 |

> **定位父进程**：`ps -eo pid,ppid,stat,comm | awk '$3=="Z"{p[$2]++} END{for(i in p) print p[i],i}' | sort -rn`

---

## 二、现场代码及其整体逻辑

```c
// === 信号处理函数 ===
void signal_child_handler() {
    g_signalchild_flag = true;   // 仅置位全局标记
}
// === 主循环轮询回收 ===
void poll_signal_child_handler()
{
    int status;
    pid_t pid;
    if (g_sigchld_flag) return;        // ← BUG①：条件反了
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status))
            pr_init(LOG_LEVEL_INFO, "child %d exit with %d\n",
                    pid, WEXITSTATUS(status));
        else if (WIFSIGNALED(status))
            pr_init(LOG_LEVEL_INFO, "child %d killed by the %dth signal\n",
                    pid, WTERMSIG(status));
    }
    g_sigchld_flag = false;              // ← BUG②：无条件覆盖
}
// === 主循环 ===
while (b_runtime_daemon_enabled) {
    std::this_thread::sleep_for(std::chrono::seconds(1));  // ← BUG③：1s 窗口
    poll_signal_handler();
    poll_signal_child_handler();
    // ... 业务逻辑 ...
}
```

整体逻辑链路：收到 `SIGCHLD` → `signal_child_handler()` 置位 `g_signalchild_flag = true` → 主循环每秒轮询 `poll_signal_child_handler()` → 检查 flag 并调用 `waitpid()` 回收。

---

## 三、四个致命 BUG（按严重程度排序）

### BUG①：`if (g_sigchld_flag) return;` —— 条件判断反向，形成死锁（最严重）

**核心问题**：收到 `SIGCHLD` 后 `g_sigchld_flag` 被设为 `true`，而 `poll_signal_child_handler()` 第一行就是：

```c
if (g_sigchld_flag) return;   // flag 为真 → 直接返回，跳过所有 waitpid！
```

**死锁链路**：

```bash
step 1: SIGCHLD 到达 → signal_child_handler() → g_signalchild_flag = true
step 2: 主循环调用 poll_signal_child_handler()
step 3: if (g_sigchld_flag) return;  ← 直接返回！waitpid 从来不被调用
step 4: g_signalchild_flag 永远为 true
step 5: 此后每次 poll_signal_child_handler() 都在 step 3 直接返回
→ 所有 uv_file_process 永远无法被回收，持续堆积
```

**PlantUML 时序图 —— BUG① 死锁全景**：

```plantuml
@startuml
title BUG①: g_sigchld_flag 条件反向导致回收死锁
participant "子进程" as Child
participant "内核" as Kernel
participant "signal_child_handler" as Handler
participant "poll_signal_child_handler" as Poll
participant "task_struct" as TSK
== 子进程正常退出 ==
Child -> Kernel : _exit(0)
Kernel -> Kernel : do_exit()\n释放 mm_struct / files / fs
Kernel -> Kernel : exit_state = EXIT_ZOMBIE
note over TSK : task_struct 变为僵尸
Kernel -> Handler : 发送 SIGCHLD
== 信号处理函数 ==
Handler -> Handler : g_signalchild_flag = true
note right : flag 置位，表示"有事需要处理"
== 主循环轮询（每秒 1 次） ==
Poll -> Poll : if (g_sigchld_flag) return;
note over Poll #FFB3B3 : BUG! flag 为 true 却 return\nwaitpid 永远不被调用！
== 后续所有轮询 ==
loop 每秒一次，持续数天
    Poll -> Poll : if (g_sigchld_flag) return;  ← 永远短路
end
note over TSK #FF9999 : task_struct 持续存在\n僵尸进程持续堆积
@enduml
```

**正确逻辑应该是**：

```c
if (!g_signalchild_flag) return;   // 没有信号才返回
g_signalchild_flag = false;         // 先清标志，防止漏收
while (waitpid(-1, &status, WNOHANG) > 0) { ... }
```

---

### BUG②：`g_signalchild_flag = false` 无条件覆盖 —— 新事件被丢帧

假设 BUG① 被修复，仍有第二个竞态问题。

```c
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
    // 正在回收子进程 A...
}
g_signalchild_flag = false;  // 无条件直接置 false
```

**竞态时序**：

```bash
poll_signal_child_handler()
├─ while(waitpid()) 回收子进程 A         ← 在此过程中...
├─ <<< SIGCHLD 到达！子进程 B 退出 >>>
│   └─ signal_child_handler() → g_signalchild_flag = true
└─ g_signalchild_flag = false;           ← BUG! 新事件被覆盖为 false！
   子进程 B 的 SIGCHLD 事件丢失 → B 变成永久僵尸
```

**PlantUML 时序图 —— BUG② 竞态丢帧**：

```plantuml
@startuml
title BUG②: 并发 SIGCHLD 事件被 flag 无条件覆盖
participant "子进程 A" as ChildA
participant "子进程 B" as ChildB
participant "内核" as Kernel
participant "signal_handler" as Handler
participant "poll_signal_child_handler" as Poll
== 子进程 A 退出 ==
ChildA -> Kernel : _exit(0)
Kernel -> Handler : SIGCHLD (子进程 A)
== poll 开始回收 A ==
Poll -> Poll : while(waitpid()) 回收子进程 A...
note over Poll : 正在 while 循环体中
== 子进程 B 也退出！ ==
ChildB -> Kernel : _exit(0)
Kernel -> Handler : SIGCHLD (子进程 B)
Handler -> Handler : g_signalchild_flag = true
note over Handler #FFEB3B : flag 被重新置位！
== poll 退出 while 循环 ==
Poll -> Poll : g_signalchild_flag = false
note over Poll #FFB3B3 : BUG! 子进程 B 的事件被抹除！
note over Poll #FF9999 : 子进程 B → 永久僵尸
@enduml
```

**修复**：

```c
void poll_signal_child_handler() {
    if (!g_signalchild_flag) return;
    // 先原子地清除标志（CAS 思想）
    g_signalchild_flag = false;
    // 循环收割所有已退出子进程
    // 即使在此期间有新 SIGCHLD 到来，handler 会重新置位
    // 下一轮循环会再次被触发
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status))
            pr_debug("child %d exit with %d\n", pid, WEXITSTATUS(status));
        else if (WIFSIGNALED(status))
            pr_debug("child %d killed by sig %d\n", pid, WTERMSIG(status));
    }
}
```

---

### BUG③：`std::this_thread::sleep_for(1s)` —— 轮询粒度放大漏收概率

```cpp
std::this_thread::sleep_for(std::chrono::seconds(1));
```

在主循环中，`poll_signal_child_handler()` 每秒只被调用一次。在这个 1 秒窗口内，即使 BUG①② 都已修复，仍有最长 1 秒的回收延迟。

**量化影响**：

| 子进程退出频率 | 1s 窗口内堆积僵尸数 | 风险 |
|---------------|-------------------|------|
| 每 10ms 1 个 | ~100 个 | 中 |
| 每 1ms 1 个 | ~1000 个 | 高 |
| 突发性批量退出 | 全部 | 极高（PID 耗尽） |

> 注意：这是**加剧因子**而非根因。优先修复 BUG①②。在与 BUG①② 叠加后，效果被指数级放大。

---

### BUG④：多线程环境下 SIGCHLD 投递的不确定性

主程序使用了 `std::this_thread::sleep_for`，说明在多线程环境中运行。

`SIGCHLD` 是**进程级信号**，进入进程共享的 `shared_pending`。内核选择一个**没有阻塞该信号**的线程来执行 handler。但如果：

- 所有线程都阻塞了 `SIGCHLD`（`pthread_sigmask(SIG_BLOCK, ...)`）
- 信号投递到正在执行关键路径的线程，handler 被延迟

则 `g_signalchild_flag` 可能永远不被置位，`poll_signal_child_handler()` 永远不触发回收。

**验证方法**：

```bash
# 检查父进程中各线程的信号掩码
grep -E '^(SigBlk|SigCgt|SigPnd|SigIgn):' /proc/<PPID>/status
grep -E '^(SigBlk|SigCgt):' /proc/<PPID>/task/*/status | grep SigBlk
```

如果 `SigBlk` 中 SIGCHLD 的位（第 17 位）在所有线程中都为 1，则信号被完全阻塞。

---

## 四、完整修复方案

### 修复后正确的回收时序

```plantuml
@startuml
title 修复后：子进程正确回收的全流程
participant "子进程" as Child
participant "内核" as Kernel
participant "signal_handler" as Handler
participant "poll_signal_child_handler" as Poll
participant "task_struct" as TSK
== 子进程退出 ==
Child -> Kernel : _exit(0)
Kernel -> Kernel : do_exit()
Kernel -> Kernel : exit_state = EXIT_ZOMBIE
note over TSK : task_struct 变为僵尸
== 异步通知 ==
Kernel -> Handler : SIGCHLD
Handler -> Handler : g_signalchild_flag = true
== 回收（事件驱动） ==
Poll -> Poll : if (!g_signalchild_flag) return;\n→ flag 为 true，继续执行
Poll -> Poll : g_signalchild_flag = false\n(先清空，防止丢帧)
Poll -> Poll : while (waitpid(-1, &status, WNOHANG) > 0)
Poll -> TSK : waitpid 成功回收
Kernel -> Kernel : release_task()\n从 pid hash 移除
note over TSK : task_struct 释放\n僵尸进程消除 ✓
== 验证 ==
Poll -> Poll : waitpid 返回 -1，errno = ECHILD\n无更多子进程
@enduml
```

### 最小修复（推荐）

```c
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>
static volatile sig_atomic_t g_signalchild_flag = 0;
void signal_child_handler(int sig) {
    g_signalchild_flag = 1;             // 仅置位标志
}
void poll_signal_child_handler() {
    if (!g_signalchild_flag) return;    // 修正：没有信号才返回
    g_signalchild_flag = 0;             // 先清除，再收割，防止丢帧
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status))
            pr_debug("child %d exit with %d\n", pid, WEXITSTATUS(status));
        else if (WIFSIGNALED(status))
            pr_debug("child %d killed by sig %d\n", pid, WTERMSIG(status));
    }
}
int main() {
    struct sigaction sa = {
        .sa_handler = signal_child_handler,
        .sa_flags   = SA_RESTART | SA_NOCLDSTOP,
    };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGCHLD, &sa, NULL);
    // 确保 SIGCHLD 在所有线程中不被阻塞
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGCHLD);
    pthread_sigmask(SIG_UNBLOCK, &mask, NULL);
    // ... 主循环 ...
    // 轮询频率 > 子进程最大退出频率即可
}
```

### 更稳健的方案：signalfd + epoll（避免信号处理器副作用）

```c
#include <sys/signalfd.h>
#include <sys/epoll.h>
void setup_sigchld_fd(int epfd) {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGCHLD);
    pthread_sigmask(SIG_BLOCK, &mask, NULL);    // 阻塞默认处理
    int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
}
void handle_sigchld_event(int sfd) {
    struct signalfd_siginfo fdsi;
    read(sfd, &fdsi, sizeof(fdsi));               // 消费信号
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        // 记录日志
    }
}
```

| 对比维度 | SIGCHLD handler + 轮询 | signalfd + epoll |
|----------|----------------------|-----------------|
| flag 竞态风险 | 存在（需小心处理） | 不存在 |
| 多线程安全 | 需要确保掩码一致 | 原生安全 |
| 与 epoll 集成 | 需额外 self-pipe | 原生 epoll fd |
| 每次回收开销 | ~3-7μs | ~1μs |

---

## 五、僵尸进程的验证与确认

### 用 `/proc` 快速诊断

```bash
# 任选一个僵尸 PID 查看
cat /proc/250353/status
# 预期输出：
# Name:   uv_file_process
# State:  Z (zombie)
# PPid:   12345       ← 父进程 PID
```

### 用 `strace` 确认父进程是否调用 waitpid

```bash
strace -f -p <PPID> -e trace=process -o wait_trace.log
# 关注是否有 waitpid()/waitid() 调用
```

### 用 `bcc` / `bpftrace` 跟踪信号生成

```bash
# 跟踪 signal_generate 事件
bpftrace -e 't:signal:signal_generate { printf("pid=%d sig=%d\n", args->pid, args->sig); }'
# 检查信号掩码
grep -E '^(SigBlk|SigCgt|SigPnd):' /proc/<PPID>/status
```

---

## 六、复盘 checklist

### 为什么线上没有及时发现

| 原因 | 说明 |
|------|------|
| 功能测试不足 | 只验证一次 fork/wait，未做批量并发退出压测 |
| 缺少僵尸监控 | 没有 prometheus `node_processes_state{state="Z"}` 指标告警 |
| 日志误判 | `waitpid` 从不被调用，自然也不会有错误日志 |

### 子进程回收完整 checklist

```bash
□ SIGCHLD 信号处理器已注册（sa_handler 不为 SIG_IGN/SIG_DFL）
□ 信号处理器中仅设置 volatile sig_atomic_t 标志，不做复杂操作
□ poll 函数中先清标志再循环 waitpid，避免丢帧
□ waitpid 使用 -1 参数（等待任意子进程）+ WNOHANG（非阻塞）
□ while 循环调用 waitpid 直到返回 -1（回收所有已退出子进程）
□ errno 在信号处理器中保存和恢复
□ 多线程程序中所有线程不阻塞 SIGCHLD
□ 监控：node_exporter process_zombies_total，阈值 > 10 告警
```

### 监控命令

```bash
# 僵尸总数
ps aux | awk '$8 ~ /Z/ {z++} END {print z}'
# 按进程名聚合
ps aux | awk '$8 ~ /Z/ {c[$11]++} END {for (i in c) print c[i], i}'
# 快速定位责任父进程
ps -eo pid,ppid,stat,comm \
  | awk '$3=="Z"{p[$2]++} END{for(i in p) print p[i],i}' \
  | sort -rn | head -5
# Prometheus 告警规则
# node_processes_state{state="Z"} > 10
```

---

## 七、与量化低延时系统的关联

在量化交易/行情系统中，常见 fork 子进程的场景：

- 日志压缩归档、行情数据落盘
- 热升级（fork 子进程加载新策略）
- 外部脚本调用（`system()` / `popen()`）

**最低风险实践**：将子进程回收逻辑放在独立线程中，使用 `signalfd` + epoll 事件驱动，避免信号中断影响核心交易延迟路径。

---

## 八、参考资料

- [process-group-and-child-reap.md](/concepts/process/process-group-and-child-reap.md) —— 六种回收策略完整论述
- [signal-multithread.md](/concepts/process/signal-multithread.md) —— 多线程信号投递机制
- [task-resources/task-private.md](/concepts/process/task-resources/task-private.md) —— `do_exit` 与 `EXIT_ZOMBIE` 状态机
- `man 2 waitpid` / `man 2 signalfd` / `man 2 sigaction`
- Linux kernel: `kernel/exit.c` — `release_task()`, `do_notify_parent()`

