22、多线程与信号处理:信号在进程与线程中的行为、pthread_sigmask的使用、异步信号安全函数、信号与线程的交互陷阱
信号处理这个话题,说实话,在多线程编程里一直是个容易让人栽跟头的地方。我记得刚入行那会儿,总觉得信号这玩意儿跟线程没啥关系——信号嘛,发给进程的,线程不都是进程的一部分吗?后来线上一个诡异的内存泄漏问题,追了整整两天,最后发现是信号处理函数里调了 malloc,而主线程正拿着堆锁……嗯,从那以后我再也不敢小看信号与线程的交互了。
信号在进程与线程中的行为差异
先搞清楚一个基本问题:信号到底是发给进程的,还是发给线程的?
答案是——看情况。
- 同步信号(比如
SIGSEGV、SIGFPE、SIGILL):由线程自身执行指令触发,只发给触发该信号的线程。你想想看,A线程访问了非法地址,总不能把B线程拉过来处理吧? - 异步信号(比如
SIGINT、SIGTERM、SIGHUP):由外部事件(用户按Ctrl+C、kill命令)触发,发给进程。但进程收到后,由哪个线程处理?这就有讲究了。
对于异步信号,POSIX标准说:进程中的任意一个未阻塞该信号的线程都可以接收并处理它。具体是哪个?不确定。这就像一群人开会,外面有人敲门,谁离门近谁去开——但你不能提前知道是谁。
关键点:异步信号在多线程环境下,处理线程是不确定的。如果你依赖某个特定线程来处理信号,必须显式控制信号掩码。
pthread_sigmask:控制线程的信号掩码
既然信号可能被任意线程接收,那怎么让指定线程来处理?答案就是 pthread_sigmask。
这个函数的作用,说白了就是给每个线程单独设置一个「信号黑名单」。被屏蔽的信号,该线程收不到——但进程里其他没屏蔽的线程可以收到。
#include <signal.h>
#include <pthread.h>
// 在主线程中阻塞 SIGINT 和 SIGTERM
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
// 注意:这里设置的是调用线程的信号掩码
pthread_sigmask(SIG_BLOCK, &set, NULL);
// 创建专门处理信号的线程
pthread_t sig_thread;
pthread_create(&sig_thread, NULL, signal_handler_thread, NULL);
// 其他工作线程正常创建,它们继承主线程的信号掩码
// 但可以在创建后单独修改
我个人习惯的做法是:在主线程中先阻塞所有需要处理的信号,然后创建专门的信号处理线程,在该线程中调用 sigwait 来同步接收信号。这样信号处理就变成了一个可控的、线程安全的流程。
小技巧:新创建的线程会继承创建者线程的信号掩码。所以如果你在主线程中设置了阻塞,子线程默认也是阻塞的。但子线程可以随时调用 pthread_sigmask 修改自己的掩码。
异步信号安全函数(Async-Signal-Safe Functions)
这是信号处理里最要命的问题。信号处理函数(无论是用 signal() 还是 sigaction() 注册的)是在中断上下文中执行的。什么意思?就是程序正在执行某个函数,信号来了,啪,打断它,跑去执行信号处理函数。等处理完再回来继续。
问题来了:如果信号处理函数里调用了 printf、malloc、free、pthread_mutex_lock 这些非异步信号安全的函数,而主程序刚好也在调用它们——那就可能造成死锁、数据损坏、内存泄漏。
我在项目中遇到过最典型的一个坑:信号处理函数里调了 free,而主线程正拿着堆锁在 malloc。信号一来,free 也想拿堆锁——好了,死锁。程序卡死,连日志都来不及打。
POSIX 标准明确规定了哪些函数是异步信号安全的。这里列出最常用的几个:
| 类别 | 安全函数举例 | 说明 |
|---|---|---|
| 文件I/O | read(), write(), open(), close() |
注意:printf、fprintf 不安全 |
| 进程控制 | _exit(), getpid(), kill() |
exit() 不安全,因为它会调用清理函数 |
| 信号操作 | sigaction(), sigprocmask(), sigpending() |
信号处理函数内可以安全操作信号 |
| 内存操作 | memset(), memcpy() |
不涉及堆分配的函数基本安全 |
| 其他 | sleep(), usleep() |
但 nanosleep() 不安全 |
警告:千万不要在信号处理函数中调用 malloc、free、printf、pthread_mutex_lock、pthread_cond_wait 等非安全函数。如果你需要记录日志,可以用 write() 直接写到文件描述符,或者用自旋锁+原子变量实现一个无锁队列。
信号与线程的交互陷阱
这部分我踩过的坑最多,挑几个典型的说说。
陷阱一:sigprocmask 在多线程中不可用
sigprocmask 是进程级的信号掩码操作函数。但在多线程程序中,它的行为是未定义的。你必须用 pthread_sigmask 来操作每个线程的信号掩码。我曾经看到过老代码里混用这两个函数,结果信号掩码莫名其妙地丢失,排查起来非常痛苦。
陷阱二:信号处理函数与线程锁的冲突
假设你有一个全局状态,信号处理函数需要修改它,而工作线程也需要读取它。你可能会想:加个互斥锁不就行了?
不行。因为信号处理函数可能在任何时刻被调用,包括工作线程正持有锁的时候。如果信号处理函数也去拿同一把锁——死锁。
解决方案:
- 使用
sig_atomic_t类型的全局变量(保证原子读写) - 使用自旋锁 + 原子操作实现无锁数据结构
- 或者干脆用
sigwait在专用线程中同步处理信号,彻底避免中断上下文的问题
陷阱三:信号与条件变量的虚假唤醒
我记得有一次,一个同事发现 pthread_cond_wait 莫名其妙地返回了,但并没有人调用 pthread_cond_signal。查了半天,发现是信号导致的——当线程在 pthread_cond_wait 中阻塞时,如果收到一个未被屏蔽的信号,pthread_cond_wait 会返回 EINTR。这不是虚假唤醒,而是信号中断。
正确的做法是:
while (!condition) {
int ret = pthread_cond_wait(&cond, &mutex);
if (ret == EINTR) {
// 被信号中断,重新等待
continue;
}
// 处理条件满足的情况
}
陷阱四:fork 与信号掩码的继承
多线程程序中调用 fork(),子进程只保留调用 fork 的那个线程。但子进程会继承父进程中所有线程的信号掩码的并集。这可能导致子进程中某些信号被意外阻塞,而子进程又没有线程去解除阻塞——信号就永远收不到了。
建议:在多线程程序中尽量避免使用 fork。如果必须用,在 fork 之后立即在子进程中重置信号掩码,并调用 exec 系列函数来替换进程映像。
知识体系图:信号与线程的核心交互
下面这张图梳理了信号在多线程环境下的传递路径和关键控制点:
从图中可以看到,信号从外部到达进程后,会遍历所有线程,找到第一个未阻塞该信号的线程进行投递。如果你希望某个线程专门处理信号,就让其他线程阻塞该信号,然后让专用线程调用 sigwait 来同步接收。
最佳实践总结
说了这么多,最后给几条我自己的经验总结:
- 能用 sigwait 就别用 signal/sigaction。同步处理信号比异步处理安全得多,不会出现中断上下文的问题。
- 信号处理函数里只做最轻量的事。设置一个
volatile sig_atomic_t标志位,然后让主循环去检查。千万别在里面调复杂函数。 - 多线程中永远用 pthread_sigmask,别用 sigprocmask。前者是线程级的,后者在多线程中行为未定义。
- 小心 fork 后的信号掩码继承。如果必须 fork,记得在子进程中重置掩码。
- 检查系统调用的 EINTR 返回值。很多阻塞系统调用(如
read、write、pthread_cond_wait)都可能被信号中断,需要重新调用。
最后提醒一句:信号处理是系统编程里最容易出「偶现 bug」的地方。很多问题在开发环境跑几天都复现不了,一上线就出问题。所以,设计阶段就把信号处理方案想清楚,比事后排查要省心得多。