22、多线程与信号处理:信号在进程与线程中的行为、pthread_sigmask的使用、异步信号安全函数、信号与线程的交互陷阱

信号处理这个话题,说实话,在多线程编程里一直是个容易让人栽跟头的地方。我记得刚入行那会儿,总觉得信号这玩意儿跟线程没啥关系——信号嘛,发给进程的,线程不都是进程的一部分吗?后来线上一个诡异的内存泄漏问题,追了整整两天,最后发现是信号处理函数里调了 malloc,而主线程正拿着堆锁……嗯,从那以后我再也不敢小看信号与线程的交互了。

信号在进程与线程中的行为差异

先搞清楚一个基本问题:信号到底是发给进程的,还是发给线程的?

答案是——看情况

  • 同步信号(比如 SIGSEGVSIGFPESIGILL):由线程自身执行指令触发,只发给触发该信号的线程。你想想看,A线程访问了非法地址,总不能把B线程拉过来处理吧?
  • 异步信号(比如 SIGINTSIGTERMSIGHUP):由外部事件(用户按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() 注册的)是在中断上下文中执行的。什么意思?就是程序正在执行某个函数,信号来了,啪,打断它,跑去执行信号处理函数。等处理完再回来继续。

问题来了:如果信号处理函数里调用了 printfmallocfreepthread_mutex_lock 这些非异步信号安全的函数,而主程序刚好也在调用它们——那就可能造成死锁、数据损坏、内存泄漏。

我在项目中遇到过最典型的一个坑:信号处理函数里调了 free,而主线程正拿着堆锁在 malloc。信号一来,free 也想拿堆锁——好了,死锁。程序卡死,连日志都来不及打。

POSIX 标准明确规定了哪些函数是异步信号安全的。这里列出最常用的几个:

类别 安全函数举例 说明
文件I/O read(), write(), open(), close() 注意:printffprintf 不安全
进程控制 _exit(), getpid(), kill() exit() 不安全,因为它会调用清理函数
信号操作 sigaction(), sigprocmask(), sigpending() 信号处理函数内可以安全操作信号
内存操作 memset(), memcpy() 不涉及堆分配的函数基本安全
其他 sleep(), usleep() nanosleep() 不安全

警告:千万不要在信号处理函数中调用 mallocfreeprintfpthread_mutex_lockpthread_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 系列函数来替换进程映像。

知识体系图:信号与线程的核心交互

下面这张图梳理了信号在多线程环境下的传递路径和关键控制点:

信号与线程交互流程 外部信号源 (SIGINT/SIGTERM等) 发送 进程 (PID) 信号到达进程级 分发 选择未阻塞线程 pthread_sigmask控制 线程A 信号掩码: 无阻塞 → 可能接收信号 线程B (专用) 信号掩码: 无阻塞 → sigwait接收 线程C 信号掩码: 阻塞 → 不接收信号 信号处理方式 默认处理 终止/忽略/停止 自定义处理 sigaction注册 同步处理 sigwait等待

从图中可以看到,信号从外部到达进程后,会遍历所有线程,找到第一个未阻塞该信号的线程进行投递。如果你希望某个线程专门处理信号,就让其他线程阻塞该信号,然后让专用线程调用 sigwait 来同步接收。

最佳实践总结

说了这么多,最后给几条我自己的经验总结:

  1. 能用 sigwait 就别用 signal/sigaction。同步处理信号比异步处理安全得多,不会出现中断上下文的问题。
  2. 信号处理函数里只做最轻量的事。设置一个 volatile sig_atomic_t 标志位,然后让主循环去检查。千万别在里面调复杂函数。
  3. 多线程中永远用 pthread_sigmask,别用 sigprocmask。前者是线程级的,后者在多线程中行为未定义。
  4. 小心 fork 后的信号掩码继承。如果必须 fork,记得在子进程中重置掩码。
  5. 检查系统调用的 EINTR 返回值。很多阻塞系统调用(如 readwritepthread_cond_wait)都可能被信号中断,需要重新调用。

最后提醒一句:信号处理是系统编程里最容易出「偶现 bug」的地方。很多问题在开发环境跑几天都复现不了,一上线就出问题。所以,设计阶段就把信号处理方案想清楚,比事后排查要省心得多。