信号进阶:sigaction、信号集与阻塞

信号这东西,说白了就是软件层面的中断。你写个程序跑得好好的,突然按个 Ctrl+C,SIGINT 就来了。但真实项目里,信号处理远没这么简单。我早年做网络服务器时,就被信号坑过好几次——不是丢信号,就是死锁。嗯,今天咱们就把这些坑填上。

一、sigaction:比 signal 更靠谱的选择

很多人刚学信号时,都用 signal() 函数。但说实话,signal() 的行为在不同 Unix 系统上是有差异的。我个人习惯直接用 sigaction(),它更底层、更可控。

#include <signal.h>

struct sigaction {
    void     (*sa_handler)(int);      // 信号处理函数
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 带更多信息的处理函数
    sigset_t   sa_mask;               // 处理期间要阻塞的信号集
    int        sa_flags;              // 控制选项
    void     (*sa_restorer)(void);    // 已废弃,别管它
};

这里有个关键点:sa_mask 字段。它指定了在信号处理函数执行期间,哪些信号应该被阻塞。我当年写一个日志守护进程时,就靠这个避免了信号嵌套导致的死锁。

核心区别:

  • signal() 行为因系统而异,有些系统会重置处理函数为默认值
  • sigaction() 行为由 POSIX 标准明确定义,跨平台一致
  • sigaction() 支持 SA_SIGINFO 标志,能获取发送者的 PID、UID 等信息
// 一个典型的 sigaction 用法
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int sig) {
    write(STDOUT_FILENO, "Got signal!\n", 12);
}

int main() {
    struct sigaction sa;
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);    // 不额外阻塞任何信号
    sa.sa_flags = 0;             // 无特殊标志

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    while(1) pause();
    return 0;
}

我的习惯:只要涉及信号处理,一律用 sigaction() 而不是 signal()。虽然多写几行代码,但心里踏实。

二、信号集与 sigprocmask:阻塞的艺术

信号集 sigset_t 本质上是一个位图,每个 bit 对应一个信号。你想想看,系统里有几十种信号,总不能一个个去操作吧?所以 POSIX 提供了一组操作函数。

sigemptyset(sigset_t *set);      // 清空信号集
sigfillset(sigset_t *set);       // 填满信号集
sigaddset(sigset_t *set, int signo);   // 添加一个信号
sigdelset(sigset_t *set, int signo);   // 删除一个信号
sigismember(const sigset_t *set, int signo); // 判断是否在集中

sigprocmask() 是阻塞信号的核心函数。它有三种操作模式:

参数 how 含义 实际场景
SIG_BLOCK 将 set 中的信号加入阻塞集 临时屏蔽某些信号,保护临界区
SIG_UNBLOCK 从阻塞集中移除 set 中的信号 恢复之前屏蔽的信号
SIG_SETMASK 用 set 完全替换当前阻塞集 彻底改变信号屏蔽策略

我曾经踩过的坑:在信号处理函数里调用 printf()。printf 内部使用了全局锁,如果信号恰好中断了另一个正在 printf 的线程,就会死锁。后来我改用 write() 系统调用,才彻底解决。

三、sigpending:看看哪些信号在排队

当一个信号被阻塞时,它不会丢失,而是变成「待决」状态。你可以用 sigpending() 查看当前进程有哪些信号在排队等待处理。

#include <signal.h>

int sigpending(sigset_t *set);  // 返回当前待决信号集

这个函数在调试时特别有用。我记得有一次排查一个诡异的信号丢失问题,就是用 sigpending 发现信号其实没丢,只是被阻塞了,一直没机会处理。

// 演示 sigpending 的用法
sigset_t pending;
sigpending(&pending);
if (sigismember(&pending, SIGINT)) {
    printf("SIGINT is pending!\n");
}

四、可重入函数与异步信号安全

这是信号处理中最容易出问题的地方。什么叫可重入?简单说,就是一个函数在被中断后,再次进入执行,不会出问题。

为什么信号处理要关心这个?因为信号处理函数是异步执行的——它可能在程序的任何位置被触发。如果信号处理函数里调用了不可重入的函数,而主程序恰好也在执行同一个函数,那数据就可能乱掉。

异步信号安全函数列表(部分):

  • 安全:write(), read(), open(), close(), exit(), _exit()
  • 不安全:printf(), malloc(), free(), pthread_* 系列

你可能会问:为什么 malloc 不安全?因为它内部维护了一个全局的堆管理链表。如果信号处理函数里调 malloc,而主程序刚好也在 malloc 里操作那个链表,链表结构就可能被破坏。嗯,这就是典型的不可重入问题。

// 安全的信号处理函数写法
void safe_handler(int sig) {
    // 用 write 代替 printf
    const char *msg = "Caught signal, exiting...\n";
    write(STDOUT_FILENO, msg, strlen(msg));
    _exit(0);  // 用 _exit 而不是 exit
}

我的原则:信号处理函数里只做最必要的事——设置一个 volatile sig_atomic_t 标志变量,然后尽快返回。真正的处理逻辑放到主循环里去做。这样既安全又清晰。

五、知识体系总览

下面这张图把本章的核心知识点串起来了。你可以看到,信号处理从注册(sigaction)到阻塞(sigprocmask),再到查询待决信号(sigpending),最后到安全处理,是一条完整链路。

信号进阶知识体系 信号处理核心 sigaction 注册 信号集操作 sigprocmask 阻塞 sigpending 查询 sa_handler / sa_sigaction sa_mask 阻塞集 sa_flags 控制选项 sigemptyset / sigfillset sigaddset / sigdelset sigismember 判断 SIG_BLOCK 添加阻塞 SIG_UNBLOCK 解除 SIG_SETMASK 替换 查看待决信号 调试信号丢失 可重入函数与异步信号安全

信号处理看起来简单,但实际项目中稍不注意就会出问题。记住几个关键点:用 sigaction 代替 signal、信号处理函数里别调 printf 和 malloc、用 sig_atomic_t 做标志位。做到这几点,大部分信号相关的坑你都能避开。

公众号:蓝海资料掘金营,微信deep3321