24、信号处理:信号的分类、信号处理函数、异步信号安全函数

信号处理这个话题,说实话,是很多C语言并发程序员容易忽视的角落。我早年做嵌入式系统时,就吃过信号处理的亏——一个没处理好的SIGPIPE信号,让整个服务进程静默退出了,排查了半天才找到原因。嗯,从那以后,我对信号就格外上心。

信号本质上是一种软件中断。它通知进程某个事件发生了。你想想看,操作系统通过信号这种机制,让进程能响应外部或内部的异步事件。说白了,信号就是内核跟用户态进程之间的一种“紧急通知”。

信号的分类

信号分两大类:标准信号和实时信号。标准信号是Unix早期定义的,编号从1到31。实时信号是后来POSIX标准扩展的,编号从SIGRTMIN到SIGRTMAX,通常有31个。

标准信号有个特点:不支持排队。如果同一个标准信号连续来了多次,进程可能只收到一次。我在项目中遇到过这种情况——一个高并发网络服务,多个客户端同时断开连接,SIGPIPE信号蜂拥而至,但进程只处理了其中一次,其他连接的错误就漏掉了。

实时信号就不一样了。它们支持排队,而且可以携带附加数据(通过sigqueue函数发送)。每个实时信号都会按顺序被处理,不会丢失。

常见的标准信号我列一下:

信号 编号 默认行为 触发场景
SIGINT 2 终止进程 Ctrl+C
SIGQUIT 3 终止+core dump Ctrl+\
SIGKILL 9 终止进程 kill -9
SIGSEGV 11 终止+core dump 非法内存访问
SIGPIPE 13 终止进程 写已关闭的管道
SIGALRM 14 终止进程 定时器到期
SIGTERM 15 终止进程 kill默认信号
SIGCHLD 17 忽略 子进程状态变化
注意:SIGKILL和SIGSTOP这两个信号不能被捕获、阻塞或忽略。这是内核的硬性规定,用来保证管理员总能杀死或暂停一个失控的进程。

信号处理函数

处理信号,传统方式是用signal()函数。但我个人习惯用sigaction(),因为它更可靠、功能更强大。signal()在不同Unix系统上的行为有差异,而sigaction()是POSIX标准定义的,行为一致。

来看一个简单的例子:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sigint_handler(int signo) {
    // 注意:这里只做简单操作
    write(STDOUT_FILENO, "\nCaught SIGINT!\n", 16);
}

int main() {
    struct sigaction sa;
    sa.sa_handler = sigint_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;

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

    printf("Press Ctrl+C to trigger signal...\n");
    while (1) {
        pause();  // 等待信号
    }
    return 0;
}

这段代码里,我注册了一个SIGINT的处理函数。当用户按下Ctrl+C时,不会终止进程,而是打印一条消息。注意我用了write()而不是printf()——为什么?后面会讲。

sigaction结构体有三个关键字段:

  • sa_handler:信号处理函数指针
  • sa_mask:在处理当前信号时,要额外阻塞的信号集
  • sa_flags:控制信号行为的标志位,比如SA_RESTART可以让被信号中断的系统调用自动重启
我的建议:在sa_flags中加上SA_RESTART。否则像read()、write()这类慢速系统调用被信号中断后会返回EINTR错误,你得手动处理重试逻辑。加了SA_RESTART,内核会自动帮你重启这些调用。

异步信号安全函数

这是信号处理中最容易踩坑的地方。信号处理函数是异步执行的——它可能在程序执行的任何时刻被触发。这意味着,你在信号处理函数里调用的函数,必须是“异步信号安全”的。

什么叫异步信号安全?简单说,就是函数内部不会使用全局或静态的非重入数据结构,不会调用malloc/free这类可能修改全局状态的操作,也不会持有锁。

我曾经犯过一个错误:在信号处理函数里调用了printf()。结果程序偶尔会莫名其妙地卡死。排查了很久才发现,printf内部使用了全局锁,而信号触发时主程序正好也在调用printf,两个printf互相等待,死锁了。

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

  • write():直接系统调用,安全
  • read():直接系统调用,安全
  • _exit():立即退出,不执行清理,安全
  • sig_atomic_t类型的变量读写:原子操作,安全
  • signal() / sigaction():设置信号处理函数,安全

不安全的函数就太多了:printf、malloc、free、fprintf、sprintf、任何锁操作、任何可能修改errno的函数(除非你保存和恢复errno)。

核心原则:信号处理函数里,尽量只做两件事——设置一个volatile sig_atomic_t标志变量,或者调用write()写一个简单的消息。其他所有复杂操作,都放到主程序里去做。

来看一个正确的做法:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t got_signal = 0;

void handler(int signo) {
    // 只做一件事:设置标志
    got_signal = 1;
}

int main() {
    struct sigaction sa;
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGINT, &sa, NULL);

    printf("Waiting for signal...\n");
    while (!got_signal) {
        // 主循环做实际工作
        pause();
    }

    printf("Signal received, cleaning up...\n");
    // 在这里做真正的清理工作
    return 0;
}

这个模式很实用。信号处理函数只设置一个标志,主程序在循环中检查这个标志,然后做实际的处理。这样既响应了信号,又避免了在信号上下文里调用不安全的函数。

信号处理的核心逻辑

我把信号处理的整体流程画了一张图,方便你理解:

信号处理核心流程 信号来源 内核/硬件/其他进程 信号递送 内核向进程发送信号 是否阻塞? 挂起等待 解除阻塞后递送 信号处理 执行处理函数或默认动作 自定义处理函数 仅调用异步安全函数 默认动作 终止/暂停/忽略/core dump 返回主程序继续执行 信号来源 信号递送 判断 挂起 处理

从这张图可以看出,信号从产生到被处理,中间要经过“递送”和“阻塞判断”两个关键环节。如果信号被阻塞,它会挂起在待处理队列里,直到解除阻塞后才递送。如果没被阻塞,就直接执行处理函数或默认动作。

避坑指南:我曾经在信号处理函数里调用了一个非安全函数,结果程序在压力测试下频繁崩溃。后来我用sigaction的sa_mask字段,在处理信号时阻塞了其他可能产生冲突的信号,再配合volatile sig_atomic_t标志变量,问题就解决了。记住:信号处理函数里,越简单越好。

最后总结一下信号处理的几个要点:

  • 用sigaction()代替signal(),行为更可控
  • 信号处理函数只做最小操作,设置标志或写简单消息
  • 只调用异步信号安全函数,避免printf、malloc等
  • 使用volatile sig_atomic_t类型声明共享标志变量
  • 考虑使用SA_RESTART标志,减少EINTR错误处理
  • 实时信号支持排队和携带数据,适合需要可靠传递的场景

信号处理看起来简单,但细节很多。你想想看,一个异步执行的小函数,如果写得不小心,可能让整个系统崩溃。嗯,多写多练,慢慢就有感觉了。


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