多线程程序里用fork,这个坑我踩了不止一次。第一次是在一个线上服务里,工作线程正在处理数据,管理线程为了拉取配置直接fork了一个子进程,结果子进程刚启动就卡死,日志也不刷了,整个服务像被按了暂停键。后来排查了半天,才发现问题出在“线程安全函数”和“fork”这两个看似不相干的概念碰撞在一起。这个组合是Linux/C++多线程开发里非常经典也很容易翻车的点,面试也爱问,生产环境也爱出问题。
这篇内容围绕“线程安全函数”和“多线程中的fork”这两个核心展开,适合正在写Linux多线程服务的C/C++开发者,也适合准备系统编程面试的工程师。我会把概念讲清楚,把坑点列出来,再给出实际可用的解决方案和排查思路,尽量让零基础的人也能看懂,让有经验的人也能有收获。
1. 线程安全函数:多线程编程的基石
1.1 什么是线程安全函数,为什么它如此重要
线程安全函数,简单说就是可以被多个线程同时调用而不会产生数据竞争、不会导致结果错乱的函数。多线程环境下,线程之间共享进程的地址空间,全局变量、静态变量、堆内存都是共享的。如果一个函数内部使用了这些共享资源,又没有加锁或做隔离,那多个线程同时调用时就会互相踩踏,轻则计算结果错误,重则直接崩溃。
举一个最常见的例子:strtok。这个函数用于分割字符串,内部用一个静态指针保存剩余字符串的位置。如果线程A还没有分割完,线程B又调用了strtok,那么A的剩余位置就会被B覆盖,导致解析结果完全错乱。这就是典型的不线程安全函数。
很多人在刚开始写多线程程序时会忽略这个问题,等到线上出现偶发性的数据错误,才意识到是线程安全函数惹的祸。而且这类问题非常难排查,因为它是时序相关的,可能跑几天才出现一次,复现率极低。
“可重入”和“线程安全”这两个概念经常被混在一起谈,但严格来说不完全一样。可重入函数要求函数执行期间即使被中断,再次进入也不会出错,它对任何全局状态都必须是只读的;而线程安全则允许使用锁等机制来保护共享状态。一个可重入函数必然是线程安全的,但一个线程安全函数不一定是可重入的(比如它内部用了非递归锁,同一个线程重入时会死锁)。理解这个区别,对后面理解fork的坑很有帮助。
1.2 如何判断函数是否线程安全,常见函数对照表
Linux下判断一个函数是否线程安全,最可靠的方法是看man手册。在手册的“ATTRIBUTES”一栏,会标注“MT-Safe”(多线程安全)、“MT-Unsafe”(多线程不安全)、“MT-Safe race: xxx”等字样。例如man strtok就会明确告诉你它是不安全的,而man strtok_r则给出线程安全版本。
常见的线程安全版本和不安全版本对照,我整理了一个表,开发时可以直接对着查:
| 不安全版本 | 线程安全版本 | 说明 |
|---|---|---|
strtok |
strtok_r |
用调用方提供的指针保存状态,替代内部静态指针 |
localtime/gmtime |
localtime_r/gmtime_r |
返回值写入调用方提供的结构体,避免共享静态结构 |
rand |
rand_r |
用调用方提供的种子状态,替代全局状态 |
getenv |
getenv_r(非标准,需自己加锁) |
返回全局环境变量区的指针,多线程下可能被setenv改动 |
ctime/asctime |
ctime_r/asctime_r |
结果写入调用方提供的缓冲区 |
strerror |
strerror_r |
错误描述字符串,不同实现行为不同 |
我自己在项目里有一条原则:凡是标准库提供了_r版本的函数,一律用_r版本;凡是man手册标注“MT-Unsafe”且没有替代方案的函数,就自己加锁保护。这样做可以省掉很多未来的麻烦。
除了这些明显有_r版本的函数,还有一类容易被忽略的“隐形不线程安全”函数,比如setenv、unsetenv,它们会修改进程环境变量表,而getenv在多线程环境下读到的可能是正在被修改的数据;还有new/`delete
