# 多线程程序里 fork —— 子进程只继承"调用 fork 的那一个线程"

> 本篇是 [process-creation.md](/concepts/process/process-creation.md)（fork / CoW / clone 基础）和 [thread-creation.md](/concepts/process/thread-creation.md)（线程模型 / clone 标志）的**进阶避坑篇**。CoW、clone 的机制那两篇已经讲过，这里**不重复**，只回答一个反直觉、又极易踩坑的问题：

> 配套实战案例：[vfork.md](/concepts/process/vfork.md) —— fork 在生产上卡死的四类根因分析与根治方案。


> **"fork 一个进程时，如果原进程已经启动了很多线程，子进程完全继承了吗？要注意什么？"**

一句话先给结论：**地址空间完整继承（CoW），但线程不是——子进程里只剩下"调用 fork 的那一个线程"，其他线程全都不见了。** 这一"内存全在、线程没了"的不对称，正是所有坑的根源。

---

## 一、核心答案：fork 只复制"调用 fork 的那个线程"

很多人以为 fork 会把父进程的所有线程一起复制到子进程——**错**。POSIX 明确规定：

> A process shall be created with a single thread. If a multi-threaded process calls `fork()`, the new process shall contain a replica of the **only the calling thread**.

也就是说：

- 父进程有 N 个线程，thread-1 调用 `fork()`；
- 子进程创建出来后**只有 1 个线程**——就是 thread-1 的副本；
- thread-2、thread-3 …… 在子进程里**根本不存在**，它们不会在子进程里继续跑，也不会被"停在某处等待恢复"，而是**从未出现**。

```plantuml
@startuml
skinparam shadowing false
skinparam roundcorner 8
skinparam rectangle {
  BackgroundColor #E3F2FD
  BorderColor     #1976D2
}
rectangle "父进程 (PID 100)" as P {
  card "thread-1\n（调用 fork）" as P1 #C8E6C9
  card "thread-2\n（跑任务A，持有锁X）" as P2 #FFE0B2
  card "thread-3\n（跑任务B）" as P3 #FFCDD2
}
rectangle "子进程 (PID 200)" as C {
  card "thread-1'\n（thread-1 的副本）" as C1 #C8E6C9
  card "  ??? \nthread-2 没了" as C2 #EEEEEE
  card "  ??? \nthread-3 没了" as C3 #EEEEEE
}
P1 -down-> C1 : fork() 复制这一个
P2 ..> C2 : 消失
P3 ..> C3 : 消失
note bottom of C
  子进程里只有一个线程在跑。
  但 thread-2/thread-3 留下的
  "内存痕迹"（锁、全局变量）都还在！
end note
@enduml
```

**关键矛盾**：线程没了，但它们**执行到一半留下的内存状态**（尤其是**它们持有的锁**）却被 CoW 原封不动继承了下来。下面第三节的死锁就出在这里。

---

## 二、地址空间是完整继承的（CoW 快照）

线程虽然只剩一个，但**内存内容是 fork 那一瞬间的完整快照**，通过写时复制（CoW，详见 [process-creation.md](/concepts/process/process-creation.md)）继承：

- 全局变量、静态变量的值——继承；
- 堆（malloc 出来的内存）——继承；
- 调用线程的栈——继承；
- **各种锁（mutex/spinlock）当时的"锁 / 未锁"状态——也照原样继承**；
- 文件描述符表——复制（见第六节）。

问题就在最后两点：子进程继承了一把**处于"已锁定"状态**的锁，但**当初锁上它的那个线程（比如 thread-2）在子进程里不存在了**——于是这把锁在子进程里**永远不会有人来解锁**。

---

## 三、【重点】经典死锁：fork 后为什么"只能调用 async-signal-safe 函数"

这是本题最需要讲透的地方。看一个最常见的例子——**`malloc` 内部锁**。

`malloc`/`free`/`printf` 这些 libc 函数内部都有**自己的全局锁**（比如 glibc 的 arena lock）来保护内部数据结构。设想这个时序：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "父 thread-2" as T2
participant "malloc 内部锁 L" as L
participant "父 thread-1" as T1
participant "子进程 thread-1'" as C
T2 -> L : malloc() 拿到锁 L（正在分配内存）
note over L : 锁 L 现在 = 已锁定，持有者 = thread-2
T1 -> T1 : 此刻调用 fork()
note over C : 子进程诞生：\n内存快照里 L 依然是"已锁定"\n但持有者 thread-2 **不存在**了
C -> L : 子进程调用 malloc()（想拿锁 L）
note over C #FFCDD2 : L 已被"幽灵 thread-2"锁住，\n永远没人解锁 → **子进程永久死锁**
@enduml
```

- 父进程 thread-2 正持有 `malloc` 的内部锁时，thread-1 恰好 `fork()`；
- 子进程继承了这把**处于"已锁"状态**的锁，但持锁的 thread-2 在子进程里不存在，**没人能解锁**；
- 子进程接下来只要再调用任何会拿这把锁的函数（`malloc`、`free`、甚至 `printf` 内部也可能 malloc）→ **卡死等一把永远不会释放的锁**。

### POSIX 的硬性规定

正因如此，POSIX 规定：**在多线程程序中，`fork()` 之后、`exec()` 之前，子进程只能调用 async-signal-safe（异步信号安全）函数。**

- async-signal-safe 函数是一份**白名单**（如 `_exit`、`write`、`read`、`open`、`close`、`execve`、`dup`、`fork` 等），它们**不依赖任何可能被中途打断的全局锁/全局状态**；
- 而 `malloc`、`free`、`printf`、`fprintf`、绝大多数 `stdio`、`std::cout`、`new`/`delete`、大部分 C++ 标准库——**都不在白名单里**，fork 后调用它们都可能踩到上面的锁死。

> 这也解释了一个常见现象：多线程程序 fork 后子进程里 `printf` 有时正常、有时卡死——取决于 fork 那一刻有没有别的线程正好持有 stdio/malloc 的锁，是个**非确定性**的雷。

> 与 [../cache/atomic.md](/concepts/cache/atomic.md) 对比：那篇讲的是**多核并发**下一条指令的原子性；这里讲的是**fork 语义**导致的锁被"孤儿化"。两者都是并发安全问题，但根因不同——一个是硬件层的 RMW，一个是进程模型层的"线程消失、锁状态残留"。

---

## 四、`pthread_atfork`：在 fork 前后帮库"交接锁"

为缓解上面的问题，POSIX 提供 `pthread_atfork`，注册三个回调：

```c
int pthread_atfork(void (*prepare)(void),   // fork 前，在【父进程】里调用
                   void (*parent)(void),    // fork 后，在【父进程】里调用
                   void (*child)(void));    // fork 后，在【子进程】里调用
```

调用时机与典型用法：

| handler | 运行在 | 运行时机 | 典型动作 |
|---------|--------|----------|----------|
| `prepare` | 父进程 | fork 真正发生**之前** | **加锁**所有内部锁（保证 fork 时锁处于"我持有"的干净状态） |
| `parent`  | 父进程 | fork 之后（父侧） | **解锁**这些锁，父进程恢复正常 |
| `child`   | 子进程 | fork 之后（子侧） | **重新初始化/解锁**这些锁（因为子进程里只有一个线程，直接重置为未锁状态即可） |

思路：库（如 glibc、某些线程安全库）在 `prepare` 里先把自己的锁全部锁住，确保 fork 那一刻**没有别的线程正持锁**；fork 完成后，父进程 `parent` 解锁、子进程 `child` 把锁重置为初始状态。这样子进程继承到的就是"干净"的锁。

### 局限性（别把它当银弹）

- **只能救"注册了 handler 的锁" **：你自己代码里、第三方库里没注册 atfork 的锁，照样会残留成死锁；
- **顺序/嵌套复杂**：多个库各自注册，`prepare` 的调用顺序与解锁顺序容易出错，反而可能引入新的死锁；
- **无法覆盖所有 libc 情况**，社区共识是：**不要依赖 atfork 来让"fork 不 exec"变安全**，它只是缓解，不是根治。

---

## 五、正确姿势：fork 后立刻 exec（或干脆别裸 fork）

真正安全的做法，是**别在 fork 后的子进程里干活**，而是尽快换掉整个地址空间：

1. **fork 后立刻 exec**（最推荐）

   `exec` 系列会用新程序**整个替换**子进程的地址空间——继承来的堆、全局变量、**残留的锁状态统统一笔勾销**，重新开始，自然不存在锁死问题。

   `fork()` 到 `exec()` 之间**只允许调用 async-signal-safe 函数**（如 `dup2` 重定向 fd、`close` 关闭多余 fd、`_exit` 处理错误），**绝不要** `malloc`/`printf`。

   ```c

   pid_t pid = fork();

   if (pid == 0) {          // 子进程

       // 这里只能用 async-signal-safe 函数

       dup2(fd, STDOUT_FILENO);

       execve("/bin/xxx", argv, envp);

       _exit(127);          // exec 失败也别用 exit()（会跑 atexit/flush stdio）

   }

   ```

2. **用 `posix_spawn` / `std::system` 等封装**

   `posix_spawn` 把"fork + 在受限环境里配置 + exec"打包成一个原语，内部实现规避了上述陷阱，是"起子进程跑另一个程序"的更安全选择。

3. **多线程程序尽量不要"裸 fork 不 exec"**

   如果确实需要 fork 出来继续跑同一份代码（而非 exec 新程序），要么保证 fork 时**其他线程都处于安全点**（没持任何锁），要么在子进程里**只碰 async-signal-safe 函数**——这非常难保证，所以能避则避。

---

## 六、继承 / 不继承 对照表

fork 时，"什么被继承、什么没被继承"是这道题的完整答案。汇总如下：

| 资源 | 子进程是否继承 | 说明 |
|------|:--:|------|
| 调用 fork 的那个线程 | ✅ | 以副本形式存在，作为子进程的主线程 |
| **其他线程** | ❌ | **全部消失**，不在子进程运行——本题核心 |
| 地址空间（代码/数据/堆/栈） | ✅ | CoW 快照，见 [process-creation.md](/concepts/process/process-creation.md) |
| 全局/静态变量的当前值 | ✅ | 按 fork 瞬间的值 |
| **锁（mutex）的锁定状态** | ✅（危险） | 连"已锁"状态一起继承，但**持锁线程没了** |
| 文件描述符表 | ✅ | 复制 fd，**父子共享同一份文件偏移**（同一 open file description） |
| 内存映射（mmap） | ✅ | 私有映射 CoW，共享映射（MAP_SHARED）继续共享 |
| 信号处理函数（handler） | ✅ | 继承 `sigaction` 设置的 handler 和信号屏蔽字 |
| **pending（未决）信号** | ❌ | 子进程的未决信号集**清空** |
| **定时器**（timer/alarm/setitimer） | ❌ | 不继承，子进程需重设 |
| 文件锁（flock/fcntl 锁） | ❌ | 记录锁不被子进程继承 |
| 进程 ID / 父进程 ID | ❌ | 子进程有新 PID，PPID 指向父进程 |
| 资源统计（CPU 时间等） | ❌ | 子进程从 0 计 |
| 未刷新的 stdio 缓冲区 | ✅（坑） | 缓冲区内容被复制 → **父子可能各自 flush 一次，输出重复**（fork 前先 `fflush`） |

> 记忆法：**"内存和 fd 全带走，线程和挂起态全丢掉"**。带走的里面藏着"锁"这颗雷。

---

## 七、观测 / 验证：亲手复现这个死锁

思路（Linux 上运行；本机 macOS 只能编译，见仓库 CLAUDE.md 约束）：让一个后台线程持有某把会被 `malloc` 用到的锁，主线程随即 fork，子进程再 `malloc` 观察卡死。

```c
// 伪代码/思路：复现 "fork 后子进程 malloc 死锁"
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
void* worker(void* _) {
    pthread_mutex_lock(&g_lock);   // 后台线程一直持锁（模拟 malloc 内部锁被占）
    sleep(100);
    pthread_mutex_unlock(&g_lock);
    return NULL;
}
int main() {
    pthread_t t;
    pthread_create(&t, NULL, worker, NULL);
    sleep(1);                       // 确保 worker 已经拿到锁
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程里 worker 线程不存在，g_lock 永远锁着
        pthread_mutex_lock(&g_lock); // ← 永久阻塞，子进程死锁
        write(1, "child got lock\n", 15); // 到不了这里
        _exit(0);
    }
    sleep(200);
    return 0;
}
```

观察手段：

- **`strace -f ./a.out`**：`-f` 跟踪子进程，能看到子进程停在 `futex(...)` 上再无进展（锁等待）。用法见 [../code/strace.md](/tools/code/strace.md)。
- **`gdb -p <子进程PID>`** 后 `bt`：会看到栈停在 `pthread_mutex_lock` / `__lll_lock_wait`，坐实"等一把没人放的锁"。
- 若把 `pthread_mutex_lock(&g_lock)` 换成真实的 `malloc()`，同样会卡在 arena lock 上；进一步可结合 [../crash/core-dump.md](/crash/core-dump.md) 用 `gcore` 抓下现场再离线分析。

---

## 八、与共享内存的关联

fork 对 `MAP_SHARED` 映射的继承语义不同于 `MAP_PRIVATE`：

- **`MAP_PRIVATE`**：父子进程间走 CoW——谁写谁分裂，各有一份。
- **`MAP_SHARED`**（包括 `shm_open` + mmap 的 POSIX 共享内存、`shmat` 的 System V 共享内存）：**fork 后父子进程的 PTE 仍然指向同一个 PFN**，不走 CoW，是真正的共享——这和 §三`MAP_PRIVATE` 内存的 fork 行为完全不同。

所以 `fork()` 前建的共享内存，父子进程都可以直接读写同一块物理页、不需要任何同步唤醒——这是最轻量的父子 IPC。详见 [../elf/shared-memory.md](/concepts/elf/shared-memory.md)。

---

## 九、一句话总结

一句话概括：**fork 只克隆"调用 fork 的那个线程"，其他线程当场蒸发；但地址空间（含它们持有的锁）被 CoW 完整继承——于是子进程可能拿到一把"永远没人解锁的锁"。所以多线程程序 fork 后、exec 前只能调用 async-signal-safe 函数，最安全的做法就是 fork 完立刻 exec（或用 posix_spawn）。**
