多线程 + fork 这个组合,在我刚写 Linux 服务端程序那会儿就听说过是个大坑,但真正轮到自己线上环境踩一遍,才发现教科书里那几行警示根本不够用。最近又处理了一起诡异卡死,最终定位还是落在“线程安全函数和多线程的 fork”这个老问题上。这篇文章把这块彻底捋一遍:什么样的函数才算线程安全、fork 在多线程程序里到底复制了什么、为什么子进程会莫名死锁、以及我目前在项目里实际落地的一套规避方案和排查手法。适合做 Linux 服务端、C++ 多线程编程、以及偶尔需要在多线程进程里拉起子进程的同学,准备面试的话也可以把这几个点串起来看。
1. 先聊透线程安全:多线程编程的隐形地基
1.1 线程不安全的本质是共享状态
首先得说清楚一个概念:多线程并不是让多个“程序”同时跑,而是同一个进程里的多个执行流并发推进。它们共享地址空间、全局变量、堆内存、文件描述符,唯一的“隔离”主要在栈区——每个线程有自己的栈,所以函数里的局部变量天然是私有的。
但问题就出在共享上。一个函数只要在内部使用了静态存储期变量或没有同步保护的全局状态,那它在多线程环境下就有可能出乱子。最常见的例子是 strtok(),它内部用一个静态指针保存“剩余待分割字符串”的位置,两个线程同时调用它时,第二个线程会覆盖第一个线程的进度,分割结果直接错乱。
类似的函数有一长串,都是“看起来人畜无害,实则带静态缓冲区”的类型:
strtok/strtok_r,前者内部记录切割位置;localtime/localtime_r,前者返回静态struct tm指针;getenv/getenv_r(glibc 扩展),getenv在某些实现里用了全局环境缓冲;rand/rand_r,rand用静态种子;asctime/ctime,返回字符串也是静态缓冲;getpwnam/getpwnam_r这类用户查找函数,静态返回结构体。
遇到这些函数,多线程程序里要用的替换思路就是“找带 _r 后缀的可重入版本”。它们把原本藏在函数内部的静态状态改成由调用方传入的缓冲区,这样每个线程各存各的中间状态,自然就不打架了。
我实用下来的判断套路其实很简单:先用 man 手册查函数的“ATTRIBUTES”段落,里面有明确的线程安全说明;其次看函数是“返回内部静态存储的指针”还是“让调用方传入缓冲区”,后者通常是线程安全的。如果拿不准,直接换 _r 版本或加锁兜底,别赌运气。
1.2 线程安全函数和可重入函数不是一回事
很多刚接触多线程的同学会把“线程安全”和“可重入”混为一谈,实际是两个层次的东西。
线程安全(Thread-Safe)的标准比较宽:多个线程同时调用同一个函数,结果依然正确即可。实现方式可以是内部加锁,也可以是使用线程局部存储,还可以是根本不依赖共享状态。而可重入(Reentrant)要严格得多:它意味着函数在执行过程中被中断,然后又被重新调用,之后回来继续执行,结果仍然正确。这里说的“中断后重新调用”,在多线程里对应的场景类似于信号处理函数中再调用同一个函数,或者在持有某个锁的临界区里再次调用会去获取同一把锁的函数。
一个函数可以线程安全但不可重入。典型例子是加锁保护实现安全:线程 A 进入函数先加锁,执行到一半被信号打断,信号处理函数里又调用了同一个函数,此时锁还被 A 持有,于是信号处理函数直接死锁。所以信号处理时只能用 async-signal-safe 的函数,而不是“线程安全函数”。这也解释了为什么在 fork 之后的子进程里,最好当自己活在信号处理上下文中——这两个场景对函数安全性的要求其实是同等级的。
我日常判断函数能不能用于 fork 后的子进程时,唯一的依据就是 man 7 signal-safety 里那串 async-signal-safe 函数清单,而不是线程不线程安全的标签。
1.3 线程安全函数也要靠调用方自律
还有一层容易被忽略:函数本身线程安全,不代表你在代码里用它的方式就安全。最典型的就是“先检查后使用”和“先获取后释放”的非原子组合。
比如 getenv() 是线程安全的(当前 glibc 下),但如果你在多线程程序里先拿到返回值,然后调用 putenv() 修改环境变量,再回头用之前那个指针,那照样会出问题。因为 getenv 返回的指针可能指向进程环境区,一旦环境被修改,那块内存可能被重新分配或覆盖。
另一个典型是文件描述符的“存和用”。open() 本身线程安全,但如果线程 A 打开一个 fd 存到全局变量,线程 B 直接去读写这个全局变量中的 fd,而线程 A 又 close 掉了它,那 B 可能已经操作了一个被复用的 fd——文件描述符是整数,数值会被内核复用,这种问题极难排查。我经历过一次“打开的是日志文件,写进去的内容却到了新连接的 socket”的线上事故,根因就是 fd 复用。
所以线程安全函数只是地基,地基之上还需要你控制好“共享状态的进入和退出”。常用的手段包括:互斥锁(pthread_mutex_t)、读写锁(pthread_rwlock_t)、原子操作、以及线程局部存储(TLS)。特别强调一句:能用局部变量就不要用全局状态,能用栈上缓冲区就不要用 static,这是我对所有写服务端代码的同学的第一条建议。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多线程程序里调用 fork,究竟会发生什么
2.1 fork 只复制了调用线程
说完线程安全,再来看 fork。fork 的核心语义是创建一个当前进程的近乎完整副本,但它有一个让无数人栽跟头的特点:子进程只会复制“发起调用 fork 的那个线程”,其他所有线程都会在子进程里凭空消失。
这个消失是什么意思?内存、文件描述符、锁的状态、全局变量、堆里的数据,全都原封不动复制过来了。但其他线程的“执行现场”不在了——它们持有的锁还在,它们正在操作的临界区数据也可能处于中间状态,它们申请到的栈空间还在但已经不再运行。
我经常用一个不太严谨但很好理解的比喻:线程是“程序员”,资源是“办公室”。fork 相当于把办公室复制了一份,然后让复制出来的办公室里只留下一个程序员,其他人的工位、文件、锁在抽屉里的钥匙全都还在,可人到(线程)却没了。新办公室里的唯一程序员如果想去拿某个被“消失的同事”锁起来的抽屉钥匙,那就会卡在原地永远等。
这正是 fork 后子进程死锁的重要原因之一。
2.2 三个最容易原地爆炸的资源:锁、内存分配器、stdio
实际情况里,fork 后子进程死锁最常见的位置集中在下面三个。
第一个是用户态的互斥锁。假设线程 A 正在执行某个临界区,持有锁 L;线程 B 调用了 fork。子进程里锁 L 依然是“已加锁”状态,但没有任何线程能再来解锁它。如果子进程后续执行路径恰好需要锁 L,就会立刻卡死。虽然 glibc 中的 pthread_mutex_t 在 fork 后会有 robust 机制处理某些“持有者已死”的情况,但健壮锁只对进程崩溃有效,这里线程消失并不会让内核知道锁的持有者已经不存在了——因为锁本身没有内核对象关联。
第二个是内存分配器。glibc 的 malloc/free 内部维护堆区,并有一组锁保护堆分配元数据。如果在 fork 的瞬间,某个线程正执行到 malloc 中间,子进程里堆锁依然被锁住。随后子进程再去 malloc 任何一点内存,都会卡死。这是大量 fork 死锁的根源。更麻烦的是,它不会每次必现,只会在 fork 时机恰好撞上分配临界区时爆发,是典型的“概率性问题”。
第三个是 stdio 缓冲区锁。printf/fputs 这类标准 I/O 函数内部也有锁。如果是另一个线程正在写日志、写 stdout,而 fork 正好发生,子进程里再次 printf 就锁死了。我之前遇到的那个线上卡死,现象就是子进程日志打一半就不动了,gdb 拉进去看,完全卡在 _IO_flockfile 上。
2.3 fork 之后的子进程应该把自己当“信号处理上下文”
在 fork 之后的 child 路径里,程序其实处在一个很微妙的状态:它继承了整个进程的堆和全局数据,却只能安全访问非常有限的资源。最简单的安全策略,就是强制自己“只用 async-signal-safe 函数”,和信号处理程序的约束完全一致。
如果确实不能在 fork 后立刻 exec,那 child 路径里能用的东西非常有限:write、read、close、_exit、dup2、execve 这一类,都是异步信号安全的。注意,exit 都不算完全安全,因为它会触发 atexit 钩子和 stdio 刷新,正确的做法是 _exit(),直接结束子进程,不落任何清理逻辑。
所以我的铁律是:除非子进程只是简单操作几个文件描述符然后退出,否则永远不要在 fork 和 exec 之间执行复杂业务逻辑。像“在子进程里打印日志”“在子进程里调用第三方库”这种事,能规避就规避,规避不了就上 pthread_atfork 做全局锁保护,下一节细讲。
3. 安全地在多线程程序里 fork:pthread_atfork 与锁协议
3.1 pthread_atfork 解决的是什么问题
pthread_atfork 是多线程 + fork 场景下最直接的标准工具。glibc 在 fork 执行流程中,允许调用方注册三个钩子:
prepare(fork 创建子进程之前,在父进程中执行);parent(fork 创建子进程后,在父进程中执行);child(fork 创建子进程后,在唯一复制的那个线程里执行,也就是子进程里)。
利用这套机制,业界标准的做法很简单:在 prepare 阶段把所有需要保护的锁全部锁上,这样 fork 发生时,没有其他线程正处在临界区中间;然后在 parent 和 child 阶段把之前锁上的锁全部解锁。这样就能保证 fork 时整个进程处于一致的“安静状态”。
注册顺序和执行顺序有一点反直觉:prepare 钩子是逆序执行的(后注册的先执行),而 parent 和 child 钩子是正序执行的(先注册的先执行)。为什么会这样设计?因为多库场景下,每一个库通常在自己的 prepare 里“先锁自己的锁”,后注册的库锁自己的锁时,可能用到先注册库的资源,所以逆序能保证加锁顺序和依赖顺序一致。而解锁是加锁的逆序,于是 parent/child 正序正好对上。
3.2 锁顺序陷阱:atfork 不是万能药
不过,用 pthread_atfork 并不意味着万事大吉,这里有两个绕不开的坑。
第一个坑是:如果在 prepare 阶段试图锁一个已经被其他线程持有的锁,那 fork 会被卡住,直到那个锁被释放。这在设计理念上是合理的——等临界区执行完再 fork,避免复制中间状态。但如果业务代码里有一个线程持锁后执行时间很长,比如等一个网络 I/O,那么 fork 调用会一直阻塞,导致整个请求超时。所以我一般建议 prepare 里锁的粒度要尽量小,只覆盖那些 fork 瞬间不能处于中间状态的全局资源,而不是所有锁。
第二个坑更隐蔽:prepare 里的加锁顺序必须和业务代码里的加锁顺序全局一致,否则会出现“循环等待”死锁。比如线程 A 持有锁 1 等待锁 2,而另一个线程因为任务需要锁 2 已经持有了,此时 prepare 阶段又要按某个顺序锁 1 然后再锁 2,就可能撞上。解决方式只有一个——给进程里所有互斥锁定义一个全局统一的加锁顺序,比如按地址排序或按角色分层,并在所有代码里严格遵守。这属于架构设计层面的事,atfork 本身帮不了你。
我个人的经验是,在封装业务基础库时,不要零散地去调用 pthread_atfork,而是做一个统一的“全局资源快照控制”入口,里面集中列出 fork 期间需要保护的所有互斥锁、条件变量和全局状态。
3.3 一个可落地的 C++ 封装示例
下面是我在项目里使用的一种封装思路:一个 ForkProtector 类,构造时注册 atfork 钩子,业务代码里所有需要保护锁都通过它来登记。简洁起见,这里用 C++ 11 的 std::recursive_mutex 做演示,实际工程中可能直接用 pthread 原语。
cpp复制#include <pthread.h>
#include <vector>
#include <mutex>
#include <memory>
class ForkProtector {
public:
static ForkProtector& instance() {
static ForkProtector protector;
return protector;
}
void register_mutex(pthread_mutex_t* m) {
std::lock_guard<std::mutex> lock(reg_mutex_);
mutexes_.push_back(m);
}
private:
ForkProtector() {
pthread_atfork(prepare, parent, child);
}
static void prepare() {
// 按全局一致顺序加锁,这里简单用容器顺序
auto& self = instance();
std::lock_guard<std::mutex> lock(self.reg_mutex_);
for (auto* m : self.mutexes_) {
pthread_mutex_lock(m);
}
}
static void parent() {
auto& self = instance();
// 加锁顺序的严格逆序解锁
for (auto it = self.mutexes_.rbegin(); it != self.mutexes_.rend(); ++it) {
pthread_mutex_unlock(*it);
}
}
static void child() {
// 子进程里同样需要还原锁状态
parent();
}
std::mutex reg_mutex_;
std::vector<pthread_mutex_t*> mutexes_;
};
这里有个细节必须强调:prepare 里加锁时,我先锁住了 reg_mutex_,再按固定顺序锁定所有业务锁,而 parent 和 child 里的解锁顺序正好相反。这样做的好处是,注册表本身不会被并发修改,加锁/解锁的顺序也绝对对称。
但这种方法也有代价:所有注册进来的锁,在每次 fork 时都会被成批锁一遍。如果锁特别多,或者某个临界区特别长,fork 的延迟会非常明显。因此我实际只把“短期临界区但 fork 时不能处于中间态”的锁注册进来。像那种长期持有的资源锁,我宁愿在 fork 前后用另一套专门的机制处理,而不是靠 atfork 去等它。
3.4 更省心的方案:fork 之后立刻 exec
说到最后,我在真实项目里用得最多的方案其实是“不要在子进程里做复杂事情”。具体做法就是,fork 完立刻 exec 一个专用的小工具子进程,让它去独立完成剩下的工作。这样:
- 子进程的地址空间会被 exec 清空并重装成新程序镜像,那些复制过来的锁、堆、stdio 状态全部作废;
- 子进程变成单线程的独立程序,压根不存在“多线程复制”的问题;
- 程序自身可以专注于处理业务参数和文件描述符继承。
这种模式在很多服务端架构里很常见。比如 Web 服务处理请求时,主进程多线程跑业务,某些合法需求需要调用一个外部处理进程(图片缩放、PDF 生成、数据清洗),那就 fork + exec 一个独立 worker 程序,父进程只要等待它退出、检查退出码即可。
当然,exec 也不是万能药。有些场景你确实希望在子进程里复用父进程已加载的数据,比如大模型、不可序列化的缓存等,这时候 exec 会强制重新加载所有资源,反而更慢。这种场景下你就得认真设计 atfork 周期了。
4. 实战翻车记录:一次 fork 后子进程卡死在 malloc 的排查
4.1 现象描述
这个事故发生在某个内部服务上。业务模型是:主服务里有一个线程池,某个接口收到请求后,会在工作线程中调用 fork 创建一个子进程,子进程做一次图像处理,完成后退出,父进程等待子进程退出码。问题表现是:高并发压测时,偶尔会出现一个请求卡住很久,随后超时。更让人头疼的是,父进程完全正常,其他线程也没受影响,就是那个发起 fork 的线程一直在 waitpid 等子进程,而子进程卡死不动了。
起初第一怀疑是图像处理库的性能问题,或者内存不足导致子进程创建失败。但用 gdb attach 到那个卡住的子进程后,发现它卡在 malloc 内部的 __lll_lock_wait_private,也就是堆锁的等待上。也就是说,子进程根本无法再分配哪怕一块很小的内存。
4.2 排查过程
我当时的排查步骤可以记成一套固定流程,以后遇到类似问题可以直接照抄。
第一步,用 ps 查看进程状态,确认子进程处于什么状态。如果子进程长时间处于 Sl 状态而不是退出,基本可以排除正常处理时间长的可能。
第二步,用 gdb -p <child_pid> 附加到子进程,执行 thread apply all bt 看调用栈。这里要注意,子进程只有一个线程,但调用栈就能看出卡在哪。我当时看到是 malloc 内部锁等待,随后继续查看锁的持有者。
第三步,在 gdb 里查 pthread_mutex_t 的状态,看 owner 线程 ID。这里如果锁的 owner 指向一个已经消失的线程,就基本确定是“fork 时的锁继承”问题。
第四步,用 strace 拉一把系统调用,发现子进程阻塞在 futex(0x7f..., FUTEX_WAIT_PRIVATE, 2, NULL),这是锁等待的典型系统调用。
第五步,回到母进程,查当时有哪些线程正在执行 malloc/free 或者 println/printf。因为我怀疑是 fork 发生时,另一个线程正处于 printf 的临界区里面,而子进程里接下来又调用了 printf,于是被同一把 stdio 锁卡死。
最终确认:子进程卡死前执行的最后一句业务日志用了 fprintf,而它fork 下来的瞬间,另一个工作线程也正握住了全局 stdio 锁在输出日志。子进程继承了这个已经锁住的 stdio 锁,随后再用 fprintf,自然死锁。
4.3 修复方案
修复思路其实很清晰,由于问题根因是“fork 发生时全局 stdio 锁处于加锁状态”,那就保证 fork 瞬间持有 stdio 锁的临界区已经结束。但全局 stdio 锁是 glibc 内部管理的,我无法直接锁住它。所以最后综合采用了多层方案:
- 第一个调整:把日志写入从之前的直接
fprintf改成独立日志线程,其他线程通过内存队列把日志消息投递到日志线程,避免日志 I/O 在主业务线程中执行。这本身也降低了持锁时间。 - 第二个调整:在 fork 前通过一个全局“任务暂停锁”上锁,确保所有工作线程在处理完当前请求后,不会进入新请求,也不会执行任何 malloc/printf 类操作,然后再 fork。这个锁在 fork 后由父进程和子进程依次释放。
- 第三个调整:图像处理这个需求改动为
fork + exec,让独立小工具去做图像转换,而不是在子进程里直接调用图像库。这样一来,子进程里静态库初始化的风险整个移除了。
实测下来,改动后连续压测几天,没有再复现。这算是我最完整的一次针对多线程 fork 死锁的线上排查了。
5. 不同语言视角下的 fork 与多线程:C++ / Python / Java
5.1 C++:std::thread 与 fork 的兼容性问题
C++11 引入 std::thread 之后,很多人觉得多线程是语言标准能力了,就不会再踩 C 的老坑。但实际上 std::thread 底层仍是 pthread 或平台线程实现,fork 的问题不仅没消失,反而因为 C++ 标准库内部也有锁和分配器,变得更容易触发。
一个常见场景是:程序里跑着多个 std::thread,其中某个线程用 std::system 或 fork + execl 去调用外部命令。如果 std::system 内部实现用到了 fork,那同样存在锁继承问题。更隐蔽的是,C++ 的 iostream 在内部会维护锁和缓冲区,你在父进程一个线程里 cout <<,另一个线程 fork 出来子进程,子进程再用 cout,照样可能卡死。
我的建议是:C++ 多线程程序里如果要创建子进程,优先用 posix_spawn(它内部做了更精细的处理)或者 fork + exec 然后立刻跑一个独立函数,不要用 std::system,更不要在 fork 子进程里使用任何 C++ 标准库的流对象。
5.2 Python:GIL 并没能帮你躲开坑
Python 因为 GIL(全局解释器锁)的存在,很多纯 Python 代码即使在多线程下,也不会同时踩到内存分配锁的竞争。但 Python 的 os.fork() 是基于 C 的 fork 实现,GIL 和 解释器内部的锁同样会被复制。
这就意味着:如果你在一个持有 GIL 或其他 Python 内部锁的线程里调用 os.fork(),然后子进程里再跑 Python 代码,解释器内部的锁状态可能已经不一致,轻则卡死,重则直接崩溃。尤其在 Python 3.7+ 里,解释器内部有不少模块会用锁保护状态,fork 后子进程如果继续 import 模块、或者执行某些反射操作,出现问题的概率并不低。
multiprocessing 模块的设计者在考虑这个问题时,采取的策略是在 fork 后立刻关闭掉父进程带过来的所有锁和线程,只保留主线程去执行新任务的入口。但如果你在“已经启动了很多线程的进程”里再去用 multiprocessing.Process,它默认 fork 模式依然存在风险。所以用 Python 做多线程 + 多进程混合服务时,我一般建议把 multiprocessing 的启动方式设为 spawn,虽然每次启动进程开销更大,但安全性和可预期性要好很多。
5.3 Java:别在 JNI 层手写 fork
Java 里也有 fork 的身影,不过大多数情况下你感知不到。Runtime.getRuntime().exec()、ProcessBuilder 底层调用的是 fork + exec,但 JVM 在内部已经做了大量处理,包括锁清理、线程恢复、内存分配器的状态检查等,所以从 Java 应用层调用外部进程,正常不会遇到因为其他 JVM 线程持锁导致子进程卡死的问题。
真正要小心的,是在 JNI 层自己封装了 C/C++ 代码并调用了 fork。如果进程里 JVM 已经创建了大量线程,那些线程可能正持有 JVM 堆管理相关的锁或堆分配器锁,你 JNI 层的 fork 很容易复制出一个锁状态不一致的进程。更稳妥的做法是:在 JNI 层避免直接 fork,而是通过 Java 层的 ProcessBuilder 或其他外部进程机制来完成子进程创建,把精力集中在 Java 应用层处理进程间通信。
所以,无论是哪种语言,核心原则都是一致的:不要在多线程环境的深处贸然使用裸 fork,要么走语言或框架封装好的安全接口,要么保证 fork 时全局状态是可控的。
6. 高价值总结:多线程 fork 的避坑原则与速查表
6.1 三条反复验证过的避坑原则
经过这些年的实践,我总结出几条深刻教训,基本是绕不开的:
第一条,fork 之后能 exec 就 exec,没有例外。只要子进程不是只做“关闭文件描述符、设置环境、调用 exec”这种极简操作,就不应该写复杂的业务逻辑。子进程里每调用一个库函数,都在赌那个函数没有内部锁,这种赌局迟早会输。
第二条,如果非要 fork 后不 exec,那只能用 async-signal-safe 函数。把 man 7 signal-safety 里的函数清单打印出来贴屏幕旁边,这是我在给团队定规范时反复强调的。如果某个需求必须用不在清单里的函数,那就应该在 fork 之前通过锁和全局状态让其他线程释放掉相关资源。
第三条,该用锁的时候别偷懒,但锁本身也可能变成新的死锁源。pthread_atfork 提供的是“锁住所有关键锁”的机制,但它并不能自动保证你的加锁顺序一致。所以要在系统层面形成一套锁纪律,所有锁都按固定的层级或顺序获取,这样 atfork 的 prepare 才有意义。
6.2 线程安全与 fork 场景速查表
| 场景/函数 | 线程安全吗 | fork 后子进程可以用吗 | 说明 |
|---|---|---|---|
strtok |
否 | 否 | 内部静态状态,用 strtok_r |
strtok_r |
是 | 需谨慎 | 若底层无锁可使用,但 glibc 实现通常依赖分配器 |
localtime |
否 | 否 | 返回静态结构体,用 localtime_r |
printf / fprintf |
是(加锁) | 否 | fork 时若另一个线程持有 stdio 锁会死锁 |
malloc / free |
是(加锁) | 否 | fork 时若其他线程在分配会死锁 |
read / write |
是(不涉及共享锁) | 是 | 属于 async-signal-safe 范畴 |
execve |
是 | 是 | 子进程分支最推荐的下一步 |
_exit |
是 | 是 | 子进程无害退出方式,不要用 exit |
pthread_mutex_lock |
是 | 否 | 若锁状态被复制且无持有者,死锁 |
snprintf |
是(库内部实现) | 部分 | glibc 一般可安全用于没有堆锁的场景,但要小心 |
这张表是我在团队 Wiki 里长期维护的,每次新增一个需要与子进程交互的模块,我都会让所有人按这张表检查一遍。
6.3 面试高频题:如何用几分钟讲清楚多线程 fork
如果面试官问“多线程 fork 会有什么后果”,我会用“三步法”来回答:第一步,点明 fork 只复制调用线程;第二步,说明复制资源而丢失执行线程导致锁死循环,重点提 malloc 锁和 stdio 锁;第三步,给出对策——trylock 不允许,应该用 atfork 钩子或 exec。这样既体现了原理,也展示了实战经验。
另一个高频问题是“线程安全函数和可重入函数的关系”。我会举 strtok 和 strtok_r 的区别:线程安全指并发调用不冲突,可重入指中断后再进入也不冲突。再举一个加锁函数的反例:同一个线程持锁后再调用同一个函数会死锁,说明可重入性不足。
还有一个常问:“如何判断一个函数是否是线程安全的?”我建议的回答是:查 man 手册 ATTRIBUTES 字段,看它是否在内部使用静态缓冲区或全局状态,以及是否带 _r 后缀的变体。如果面试能说出“MT-Safe / MT-Unsafe”这些 POSIX 标准里的标记,基本就过关了。
最后这条既是面试题也是真实场景,很值得记录:如果你在多线程环境中写了一个会 fork 的服务,怎么保证子进程不会卡死?答案里最好提到三个关键词:pthread_atfork、exec、async-signal-safe。
6.4 个人体会:这坑只要踩一次,就再也不会忘
我自己踩坑最深的一次,就是 4. 里面提到的子进程卡在 malloc。事后复盘时最大的感受是:所有隐患都写在了文档里,但文档不会替你在高并发下保命。只有把“fork 即单线程世界”这个心智模型刻进脑子里,才不会在代码 review 时放过那个“在子进程里初始化第三方库”的写法。
这几年带团队,我定的规范其实就三条:多线程进程里创建子进程,默认走 fork + exec;子进程里只允许 _exit;复杂任务交给独立 worker 进程。如果有人非要在 fork 后不 exec 做点复杂事情,要求他先用 pthread_atfork 把所有锁声明清楚,并且写清加锁顺序。规范看着繁琐,但换来的是半夜不用爬起来看监控,我觉得很值。
