图 1:本文封面,线程没跟过来,锁跟过来了。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:一个进程有 20 个线程,某个线程里调用 fork,新进程会有 20 个线程吗?答案先放出来:不会,子进程里只有一个线程——发起 fork 的那一个。但这个答案只是引子,值钱的是它引发的后果。
fork 复制了什么,没复制什么。
Linux man 手册的规则写得很清楚:fork 创建子进程时,整个虚拟地址空间原样复制——堆、栈、打开的文件描述符,以及互斥锁、条件变量等 pthreads 对象的状态;但子进程只带着调用 fork 的那个线程降生,其余 19 个线程的栈与寄存器状态随之蒸发。此外还有一串减法:内存锁不继承、待处理信号清空、定时器不继承。
「线程没了、锁状态还在」一碰,就撞出多线程 fork 的经典死锁。
锁陷阱:状态是已锁,持有者已蒸发。
设想父进程里线程 A 正持有一把锁(比如内存分配器的内部锁)做临界区操作,此刻线程 B 调用了 fork。子进程的内存快照里,那把锁的状态是「已被线程 A 持有」——可子进程里根本没有线程 A。唯一的线程 B 再去碰这把锁,就是永久等待:持有者永远不会来解锁。
这不是理论担忧。手册因此给了一条硬规则:多线程程序里 fork 之后、exec 之前,子进程只能安全调用异步信号安全(async-signal-safe)函数——内存分配、标准 IO 这些日常函数都不在名单上。
锁的复制与三种对策,用代码演示。
下面这段教学模型复刻手册描述的规则,把死锁成因和三条出路各演一遍,用 Node.js 22.15.0 直接运行(node forkthreads.js):
'use strict';
// 多线程进程 fork 的语义模拟(教学模型,复刻手册描述的规则):
// 1) 子进程只复制发起 fork 的那个线程;2) 内存映像原样复制(含锁状态);
// 3) 锁的持有者没跟过来,锁就永远解不开。
class Mutex { // 互斥锁:lockedBy 记录持有者线程号
constructor() { this.lockedBy = null; }
lock(t) {
if (this.lockedBy === null) { this.lockedBy = t; return `线程${t} 拿到锁`; }
return `线程${t} 申请锁 -> 锁被线程${this.lockedBy} 持有,等待`;
}
unlock(t) { if (this.lockedBy === t) this.lockedBy = null; }
}
// 父进程:20 个线程;线程7 正持有一把分配器内部锁
function makeParent() {
return { threads: Array.from({ length: 20 }, (_, i) => i + 1), allocLock: new Mutex() };
}
const parent = makeParent();
console.log(`父进程:${parent.threads.length} 个线程在跑`);
console.log(parent.allocLock.lock(7)); // 线程7 分配内存,持有分配器锁
console.log('线程7 正在临界区内(尚未释放锁)……');
// 此刻线程3 调用 fork:内存原样复制(锁仍「被线程7持有」),线程只剩线程3
const child = { threads: [3], allocLock: Object.create(Object.getPrototypeOf(parent.allocLock), Object.getOwnPropertyDescriptors(parent.allocLock)) };
console.log(`\n线程3 调用 fork -> 子进程只有 ${child.threads.length} 个线程(就是调用者线程3)`);
console.log(`子进程内存快照里:allocLock.lockedBy = 线程${child.allocLock.lockedBy}(原样复制)`);
// 子进程里唯一的线程也要分配内存:去抢那把「已锁、持有者却蒸发」的锁
console.log('\n子进程线程3 调用分配函数:');
console.log(' ' + child.allocLock.lock(3));
console.log(' 持有者线程7 在子进程里不存在,永远不会来 unlock -> 永久等待(fork 死锁)');
// 对策一:fork 后立即 exec,旧映像整个丢弃,锁状态随之作废
console.log('\n对策一:子进程 fork 后立刻 exec -> 内存映像被新程序整体替换,锁问题不存在');
// 对策二:pthread_atfork——fork 前由调用线程把关键锁拿到手(prepare),
// fork 后在父子两侧分别释放(parent/child),保证快照里的锁处于空闲状态
const parent2 = makeParent();
const prepare = () => parent2.allocLock.lock(3); // fork 前:调用线程先拿锁(确保快照中锁状态已知)
const atParent = () => parent2.allocLock.unlock(3); // fork 后父进程:释放
const atChild = (c) => { c.allocLock.lockedBy = null; }; // fork 后子进程:直接置闲
parent2.allocLock.lock(7); // 场景重置:父进程里线程7 再次持有分配器锁
prepare();
const child2 = { threads: [3], allocLock: Object.create(Object.getPrototypeOf(parent2.allocLock), Object.getOwnPropertyDescriptors(parent2.allocLock)) };
atParent(); atChild(child2);
console.log('对策二:atfork 处理器在 fork 前后把锁收回/置闲 -> 子进程拿到的是「空闲锁」');
console.log(' ' + child2.allocLock.lock(3) + '(成功,子进程正常继续)');
// 对策三:预加载场景——fork 前停掉线程、重建连接与锁,再 fork
console.log('对策三:预加载模型后先清理线程与连接再 fork,worker 起来后各自重建');
实测输出:
父进程:20 个线程在跑
线程7 拿到锁
线程7 正在临界区内(尚未释放锁)……
线程3 调用 fork -> 子进程只有 1 个线程(就是调用者线程3)
子进程内存快照里:allocLock.lockedBy = 线程7(原样复制)
子进程线程3 调用分配函数:
线程3 申请锁 -> 锁被线程7 持有,等待
持有者线程7 在子进程里不存在,永远不会来 unlock -> 永久等待(fork 死锁)
对策一:子进程 fork 后立刻 exec -> 内存映像被新程序整体替换,锁问题不存在
对策二:atfork 处理器在 fork 前后把锁收回/置闲 -> 子进程拿到的是「空闲锁」
线程3 拿到锁(成功,子进程正常继续)
对策三:预加载模型后先清理线程与连接再 fork,worker 起来后各自重建
三条对策对应三种场景:起子进程跑新程序,fork 后立刻 exec,旧映像连同锁状态一起作废,最干净;必须在 fork 后继续用旧映像的,用 pthread_atfork 注册 prepare/parent/child 三段处理器——手册也坦白这套机制「实际操作起来通常难到不可行」,它是兜底不是首选;预加载场景(先加载再 fork 出 worker)把顺序倒过来:fork 之前停掉多余线程、关掉连接,worker 起来后各自重建。
图 2:复制的是内存快照,不是线程班组。
可能的追问。「文件描述符会继承吗?」会,且父子共享同一份打开文件描述——文件偏移量、状态标志都共享,两边交替写同一个文件会互相影响。「为什么 Linux 下 fork 很快?」写时复制:fork 只复制页表,页面在任一方写入时才真正复制。「推理服务 fork 预加载要注意什么?」正是对策三的场景:后台监控线程、心跳线程要在 worker 里重新拉起,数据库连接重新建立,凡依赖其他线程状态的资源一律重建。
一句话收尾:fork 复制的是内存快照,不是线程班组。你在多线程程序里做过 fork 或进程预加载吗?有没有踩过「子进程莫名其妙卡死」这种坑?欢迎交流。