多线程程序里 fork —— 子进程只继承"调用 fork 的那一个线程"
本篇是 process-creation(fork / CoW / clone 基础)和 thread-creation(线程模型 / clone 标志)的进阶避坑篇。CoW、clone 的机制那两篇已经讲过,这里不重复,只回答一个反直觉、又极易踩坑的问题:
配套实战案例:vfork —— 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 …… 在子进程里根本不存在,它们不会在子进程里继续跑,也不会被"停在某处等待恢复",而是从未出现。
关键矛盾:线程没了,但它们执行到一半留下的内存状态(尤其是它们持有的锁)却被 CoW 原封不动继承了下来。下面第三节的死锁就出在这里。
二、地址空间是完整继承的(CoW 快照)
线程虽然只剩一个,但内存内容是 fork 那一瞬间的完整快照,通过写时复制(CoW,详见 process-creation)继承:
- 全局变量、静态变量的值——继承;
- 堆(malloc 出来的内存)——继承;
- 调用线程的栈——继承;
- 各种锁(mutex/spinlock)当时的"锁 / 未锁"状态——也照原样继承;
- 文件描述符表——复制(见第六节)。
问题就在最后两点:子进程继承了一把处于"已锁定"状态的锁,但当初锁上它的那个线程(比如 thread-2)在子进程里不存在了——于是这把锁在子进程里永远不会有人来解锁。
三、【重点】经典死锁:fork 后为什么"只能调用 async-signal-safe 函数"
这是本题最需要讲透的地方。看一个最常见的例子——malloc 内部锁。
malloc/free/printf 这些 libc 函数内部都有自己的全局锁(比如 glibc 的 arena lock)来保护内部数据结构。设想这个时序:

- 父进程 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 的锁,是个非确定性的雷。
与 atomic 对比:那篇讲的是多核并发下一条指令的原子性;这里讲的是fork 语义导致的锁被"孤儿化"。两者都是并发安全问题,但根因不同——一个是硬件层的 RMW,一个是进程模型层的"线程消失、锁状态残留"。
四、pthread_atfork:在 fork 前后帮库"交接锁"
为缓解上面的问题,POSIX 提供 pthread_atfork,注册三个回调:
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 后的子进程里干活,而是尽快换掉整个地址空间:
fork 后立刻 exec(最推荐)
exec系列会用新程序整个替换子进程的地址空间——继承来的堆、全局变量、残留的锁状态统统一笔勾销,重新开始,自然不存在锁死问题。fork()到exec()之间只允许调用 async-signal-safe 函数(如dup2重定向 fd、close关闭多余 fd、_exit处理错误),绝不要malloc/printf。cpid_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) }用
posix_spawn/std::system等封装posix_spawn把"fork + 在受限环境里配置 + exec"打包成一个原语,内部实现规避了上述陷阱,是"起子进程跑另一个程序"的更安全选择。多线程程序尽量不要"裸 fork 不 exec"
如果确实需要 fork 出来继续跑同一份代码(而非 exec 新程序),要么保证 fork 时其他线程都处于安全点(没持任何锁),要么在子进程里只碰 async-signal-safe 函数——这非常难保证,所以能避则避。
六、继承 / 不继承 对照表
fork 时,"什么被继承、什么没被继承"是这道题的完整答案。汇总如下:
| 资源 | 子进程是否继承 | 说明 |
|---|---|---|
| 调用 fork 的那个线程 | ✅ | 以副本形式存在,作为子进程的主线程 |
| 其他线程 | ❌ | 全部消失,不在子进程运行——本题核心 |
| 地址空间(代码/数据/堆/栈) | ✅ | CoW 快照,见 process-creation |
| 全局/静态变量的当前值 | ✅ | 按 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 观察卡死。
// 伪代码/思路:复现 "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(...)上再无进展(锁等待)。用法见 strace。gdb -p <子进程PID>后bt:会看到栈停在pthread_mutex_lock/__lll_lock_wait,坐实"等一把没人放的锁"。- 若把
pthread_mutex_lock(&g_lock)换成真实的malloc(),同样会卡在 arena lock 上;进一步可结合 core-dump 用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。详见 shared-memory。
九、一句话总结
一句话概括:fork 只克隆"调用 fork 的那个线程",其他线程当场蒸发;但地址空间(含它们持有的锁)被 CoW 完整继承——于是子进程可能拿到一把"永远没人解锁的锁"。所以多线程程序 fork 后、exec 前只能调用 async-signal-safe 函数,最安全的做法就是 fork 完立刻 exec(或用 posix_spawn)。