﻿# vfork 机制深度分析与实战避坑

> **先读 [process-creation.md](/concepts/process/process-creation.md) §三** 了解 fork/vfork/clone 的家族关系和基本语义。本篇是实战补充：为什么 `fork` 会在特定场景下卡死、`vfork` 为什么能"恢复"、vfork 的风险及标准化根治方案。

**场景**：生产环境 `fork + 父同步 waitpid` 流程卡死；替换为 `vfork` 后流程正常。这不是巧合——背后是四类根因的叠加。

---

## 零、fork vs vfork 核心机制对比

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<fork>> #FFE0B2
  BorderColor<<fork>>     #EF6C00
  BackgroundColor<<vfork>> #E1BEE7
  BorderColor<<vfork>>     #7B1FA2
}
skinparam ArrowColor #37474F
rectangle "fork() 机制" <<fork>> {
  card "父进程继续执行\n（不阻塞）" as PF
  card "子进程独立运行\n（CoW 地址空间副本）" as CF
  PF -right-> CF : 复制 mm_struct\n(VMA+页表,物理页CoW)
}
rectangle "vfork() 机制" <<vfork>> {
  card "父进程挂起\nTASK_UNINTERRUPTIBLE" as PV
  card "子进程临时运行\n（借用父地址空间）" as CV
  PV .down.> CV : 内核强制阻塞父进程\n直到子 execve/_exit
  note right of CV : 子进程直接读写父进程内存\n不能调用 malloc/pthread/IO
}
@enduml
```

| 维度 | `fork()` | `vfork()` |
|------|---------|-----------|
| 地址空间 | 完整复制（VMA 复制，物理页 CoW） | **共享同一份**（不复制，不 CoW） |
| 父进程状态 | fork 返回后父正常运行 | **子运行期间父完全挂起** |
| mm_struct | 新分配，`mm_users=1` | **借用父进程 mm**，`mm_users` 不加 |
| 子进程约束 | 无限制，可任意执行 | **只能 `_exit()` 或 `execve()`**，禁一切复杂操作 |
| SIGCHLD 交互 | 信号 handler + `waitpid` 可产生内核唤醒冲突 | 无，内核同步唤醒，**绕开信号逻辑** |
| 锁安全性 | 子进程继承持有状态的锁（子进程内无持有线程）→ 易死锁 | 规范禁止子操作锁，无此问题 |
| 大 VMA exit 开销 | 子进程 exit 需销毁全部 VMA（20TiB 场景可阻塞） | 无 VMA 复制，exit 瞬间完成 |

---

## 一、根因一：SIGCHLD 信号 handler + 同步 waitpid 的内核唤醒冲突（最主要）

### 1.1 你的现有信号架构

父进程全局注册了 `SIGCHLD` 信号 handler：

```c
// 父全局信号逻辑：收到 SIGCHLD 置 g_signalchild_flag = true
void signal_child_handler(int sig)
{
    g_signalchild_flag = true;
}
```

主线每秒轮询 `poll_signal_child_handler()`，内部执行 `waitpid(-1, &stat, WNOHANG)` 收割。

### 1.2 fork 带来的致命信号继承

`fork()` 时，**子进程完整继承父进程所有信号处理配置**：

```plantuml
@startuml
skinparam shadowing false
skinparam sequenceMessageAlign center
participant "父进程\n(SIGCHLD handler 已注册)" as Parent
participant "子进程 A\n(继承了 handler)" as ChildA
participant "子A的孙子进程\nuv_file_process" as GrandChild
Parent -> ChildA : fork() 创建子A\n(完整继承信号配置)
activate ChildA
ChildA -> GrandChild : fork() 创建孙子\n(孙子也继承 handler)
activate GrandChild
GrandChild -> ChildA : 孙子退出，发送 SIGCHLD
deactivate GrandChild
ChildA -> ChildA : 执行继承来的 handler\n置位子A自己的 g_signalchild_flag
ChildA -> Parent : 子A 退出，发送 SIGCHLD
deactivate ChildA
Parent -> Parent : handler 置位父进程的\n g_signalchild_flag
note over Parent : ⚠️ 父用 waitpid(childA, 0) 同步阻塞等待\n此时进入死锁
@enduml
```

### 1.3 死锁触发根因

父使用**同步阻塞 `waitpid(child_pid, &stat, 0)`** 等待子 A：

```bash
父调用 waitpid(childA, &stat, 0) → 线程阻塞休眠，等待子A退出信号
                                         ↓
子A exit → 发送 SIGCHLD 给父
              ↓
           父的 SIGCHLD handler 被触发，置位 flag
              ↓
           ❌ 但阻塞的 waitpid 不会被唤醒！
              （Linux 规则：进程阻塞在 waitpid() 时，
                收到的 SIGCHLD 只触发 handler，
                不会让 waitpid 返回）
              ↓
           父线程永久卡死在 waitpid(childA, &stat, 0)
```

### 1.4 为什么 vfork 不会卡住

`vfork` 内核有强制约束：

- 子进程运行期间，**父进程全程挂起**，根本不会事前进入 `waitpid` 阻塞；
- 子进程完成业务调用 `_exit()` 后，**内核直接唤醒父进程**，同步返回子退出状态；
- **完全绕开了 SIGCHLD 信号 + waitpid 的冲突逻辑**，不存在信号唤醒失效。

---

## 二、根因二：fork 子进程持有父进程锁/互斥量，exit 前死锁

### 2.1 fork 锁继承机制

`fork()` 只会复制**持有锁的状态**，不会复制线程：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor #FFCDD2
  BorderColor     #C62828
}
skinparam ArrowColor #37474F
rectangle "父进程（多线程）" {
  card "thread-1\n（调用 fork）" as T1 #C8E6C9
  card "thread-2\n（持有 mutex X）" as T2 #FFE0B2
}
rectangle "子进程（单线程）" {
  card "thread-1 副本" as CT1 #C8E6C9
  note right of CT1 : mutex X = LOCKED\n但没有任何线程持有它！
}
T1 -down-> CT1 : fork() 时\nmutex X 状态被完整复制
@enduml
```

- 父进程某条线程持有 `pthread_mutex_t`，此时 fork
- 子进程副本内该 mutex 标记为**已锁定**，但子进程没有任何线程在持有它
- 子进程业务逻辑中一旦尝试 `lock` 该 mutex，**直接永久死锁**
- 子进程卡死无法执行 exit → 父 `waitpid` 永久阻塞

### 2.2 vfork 规避该问题的原理

vfork 不复制完整地址空间，且规范要求子进程不做复杂逻辑、不操作锁，直接 exit/exec。而 fork 允许子长期执行业务，极易踩锁副本死锁坑。

---

## 三、根因三：fork 子进程 mmap 超大地址空间（如 20TiB VIRT）exit 耗时异常

```plantuml
@startuml
skinparam shadowing false
skinparam ArrowColor #37474F
rectangle "fork 场景" #FFE0B2 {
  card "父进程\n(20TiB VMA 链)" as PFV
  card "子进程\n(20TiB VMA 副本)" as CFV
  PFV -down-> CFV : fork 复制 VMA 链
  card "子 exit → 遍历销毁 20TiB VMA\n内核回收阻塞" as CFVE #FFCDD2
  CFV -down-> CFVE
  card "父 waitpid 收不到退出事件\n→ 卡死" as PFW #FFCDD2
  CFVE -up-> PFW
}
rectangle "vfork 场景" #E1BEE7 {
  card "父进程" as PVV
  card "子进程\n(无 VMA 副本)" as CVV
  PVV .down.> CVV : 不复制地址空间
  card "子 exit → 无 VMA 销毁开销\n→ 瞬间返回" as CVVE #C8E6C9
  CVV -down-> CVVE
  card "父立即被唤醒" as PVW #C8E6C9
  CVVE -up-> PVW
}
@enduml
```

- 父 fork 出子进程，子继承 20TiB 虚拟地址区间
- 子进程 exit 时，内核需要遍历销毁**全部 `vm_area_struct` 链表**
- 海量 mmap 段极大拉长 exit 执行耗时，甚至内核回收逻辑阻塞
- 父 `waitpid` 长期收不到退出事件，业务流程卡死
- vfork 不复制地址空间，子 exit 无海量 VMA 销毁开销，瞬间完成退出

---

## 四、根因四：`SA_RESTART` 标志干扰 `waitpid` 唤醒

父注册 SIGCHLD 时如果带了 `SA_RESTART` 标志：

```c
sa.sa_flags = SA_NOCLDSTOP | SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
```

**`SA_RESTART` 规则**：被信号中断的系统调用会**自动重试**，不会返回 `EINTR`。

```bash
父阻塞在 waitpid
  → 子退出，发送 SIGCHLD
    → 触发 handler（置位 flag）
      → 因为 SA_RESTART，waitpid 不会被唤醒
        → 父持续阻塞
```

vfork 是内核同步机制，**不受 `SA_RESTART` 信号标志影响**。

---

## 五、为什么 vfork 只是临时修复，不能长期替代 fork

vfork 有严格使用约束，长期业务会引入更严重隐患：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor #FFCDD2
  BorderColor     #C62828
}
rectangle "vfork 的四大致命风险" as V {
  card "共享栈" as R1
  card "禁 IO/malloc" as R2
  card "多线程下毁全局变量\n毁互斥量" as R3
  card "POSIX 已弱化\n仅用于快速 exec" as R4
}
note bottom of V : vfork 的正确场景只有一种：\nfork+exec 对，且 fork 前的锁环境复杂\n→ 用 posix_spawn 更安全
@enduml
```

1. 父子共享栈，子进程任何局部变量修改会**直接篡改父栈**，引发随机内存崩溃
2. 子进程不能调用任何 IO、`malloc`、`pthread`、复杂库函数，仅允许 `_exit()` / exec
3. 多线程程序中 vfork 极易造成全局变量、互斥量损坏
4. POSIX 标准已弱化 vfork，仅用于快速 exec 场景，不适合长期执行业务逻辑

---

## 六、标准化根治方案（保留 fork，彻底解决阻塞卡死）

### 方案 1：删除同步阻塞 waitpid，统一改用异步 SIGCHLD 循环收割

1. 父 fork 后**不再同步阻塞等待子进程**，主线继续运行业务
2. SIGCHLD handler 内使用 `while(waitpid(-1, &stat, WNOHANG) > 0)` 批量收割所有退出子进程
3. 如果业务需要等待子完成，改用**条件变量 + 子进程管理表**异步等待，不阻塞主线程

### 方案 2：fork 前临时屏蔽 SIGCHLD 信号

```c
sigset_t mask, old;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
// fork 前屏蔽 SIGCHLD
sigprocmask(SIG_BLOCK, &mask, &old);
pid_t child = fork();
if (child > 0) {
    // 父阻塞等待子，期间不会触发 SIGCHLD handler
    waitpid(child, &stat, 0);
    // 恢复信号掩码
    sigprocmask(SIG_SETMASK, &old, NULL);
} else if (child == 0) {
    sigprocmask(SIG_SETMASK, &old, NULL);
    // 子业务逻辑
    _exit(0);
}
```

核心：阻塞 `waitpid` 期间屏蔽 SIGCHLD，消除信号唤醒冲突。

### 方案 3：多线程架构专用 —— 独立 reaper 线程

专用收割线程统一处理所有子进程退出，调用 `waitpid(-1, &stat, 0)`；主线业务完全不碰 `waitpid`，彻底规避信号阻塞冲突。

### 方案 4：fork 后子进程第一时间调用 `setsid()`

隔离 SIGCHLD 传递——子内部子进程退出信号不会传递给父，减少 SIGCHLD 风暴。

---

## 七、fork vs vfork 行为对比总表

| 行为 | `fork()` | `vfork()` |
|------|---------|-----------|
| 地址空间 | 完整复制，子独立内存副本 | 父子共享同一份地址空间 |
| 父进程状态 | fork 返回后父正常运行，可同步 wait 阻塞 | 子运行期间父完全挂起休眠 |
| SIGCHLD 冲突风险 | 极高，搭配同步 waitpid 极易死锁 | 无，内核同步唤醒，跳过信号逻辑 |
| 锁/线程资源 | 复制锁持有状态，子易死锁 | 规范不允许子复杂操作，无锁问题 |
| 超大 mmap 场景 | 子 exit 销毁海量 VMA，耗时阻塞 | 无地址复制，exit 极快 |
| 长期业务可用性 | 安全，无限制 | **不可用**，仅用于 exec 场景 |

---

> **一句话**：`vfork` 能绕过 fork 的四种死锁（SIGCHLD+waitpid 冲突 / 锁继承死锁 / 大 VMA exit 阻塞 / SA_RESTART 干扰），核心原因是它让父进程在子进程生命周期内**完全不参与信号交互**——子进程 exit 时内核直接同步唤醒父进程。但 vfork 不是根治方案，应选择异步收割（方案 1）或屏蔽信号 + 同步 wait（方案 2）来保留 fork 的业务灵活性。

