3. 信号处理方式:默认处理、忽略信号、自定义捕捉(signal函数)、信号集与信号屏蔽字

信号处理,说白了就是进程收到信号后怎么办。这个问题我早年踩过不少坑,今天跟你好好聊聊。

3.1 三种基本处理方式

每个信号都有三种处理方式可选。嗯,咱们一个一个看。

处理方式 说明 典型场景
默认处理 执行系统预定义的动作 大多数信号的默认行为
忽略信号 内核直接丢弃该信号 SIGCHLD、SIGURG 等
自定义捕捉 调用用户注册的处理函数 优雅退出、资源清理

默认处理又分几种:终止进程、终止+core dump、忽略、暂停、继续。比如 SIGINT 默认终止进程,SIGQUIT 默认终止+core dump。我刚开始学的时候老记混,后来发现用 man 7 signal 查一下最靠谱。

忽略信号要注意——SIGKILL 和 SIGSTOP 你不能忽略,这是内核的底线。我曾经在项目里想屏蔽 SIGKILL 保护守护进程,结果编译都过不了,哈哈。

警告:SIGKILL(9)和 SIGSTOP(19)不可被忽略、不可被捕捉、不可被阻塞。这是内核留给系统管理员的最后手段。

3.2 signal 函数——最朴素的信号捕捉

signal 函数是 POSIX 标准最早提供的接口。用法很简单:

#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

第二个参数有三种取值:

  • SIG_DFL:恢复默认处理
  • SIG_IGN:忽略信号
  • 函数指针:自定义处理函数

来个实际例子:

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

void sigint_handler(int sig) {
    // 注意:信号处理函数中只能调用异步信号安全函数
    write(STDOUT_FILENO, "Caught SIGINT!\n", 15);
}

int main() {
    // 捕捉 Ctrl+C
    signal(SIGINT, sigint_handler);

    while(1) {
        pause();  // 等待信号
    }
    return 0;
}
个人经验:signal 函数在不同 Unix 系统上行为有差异。我建议新项目直接用 sigaction,它更可靠。signal 适合快速原型验证。

3.3 信号集——管理信号的集合

信号集说白了就是一个位图,每个信号占一位。内核用 sigset_t 类型表示。我习惯把它想象成一个「信号清单」,你可以往里面加信号、删信号、查信号。

操作信号集的函数:

#include <signal.h>

int sigemptyset(sigset_t *set);      // 清空集合
int sigfillset(sigset_t *set);       // 填满所有信号
int sigaddset(sigset_t *set, int sig);   // 添加信号
int sigdelset(sigset_t *set, int sig);   // 删除信号
int sigismember(const sigset_t *set, int sig); // 检查是否存在

使用前一定要先初始化,要么 sigemptyset 要么 sigfillset。我见过有人直接定义 sigset_t set; 就用,结果里面是随机值,排查了半天。

3.4 信号屏蔽字——进程的「信号黑名单」

信号屏蔽字,就是告诉内核:这些信号先别递送给进程,给我挂着。每个进程都有一个信号屏蔽字,子进程会继承父进程的屏蔽字。

核心函数是 sigprocmask:

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

how 参数有三种:

宏 含义
SIG_BLOCK 将 set 中的信号加入屏蔽字(追加阻塞)
SIG_UNBLOCK 将 set 中的信号从屏蔽字移除(解除阻塞)
SIG_SETMASK 用 set 完全替换当前屏蔽字

来看一个经典用法——保护临界区:

sigset_t newmask, oldmask;

// 初始化信号集,加入 SIGINT
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);

// 阻塞 SIGINT,保存旧屏蔽字
sigprocmask(SIG_BLOCK, &newmask, &oldmask);

// 临界区代码——此时 SIGINT 被挂起
// ... 操作共享数据 ...

// 恢复旧屏蔽字,挂起的 SIGINT 会递送
sigprocmask(SIG_SETMASK, &oldmask, NULL);
核心要点:被屏蔽的信号不会丢失,它们会变成「未决信号」。当屏蔽解除后,内核会立即递送这些信号。但注意,同一个信号多次产生,在未决状态下只记录一次。

3.5 知识体系总览

下面这张图帮你理清信号处理的整体脉络:

信号处理方式知识体系 默认处理 终止进程 终止 + core dump 忽略 / 暂停 / 继续 忽略信号 SIG_IGN 设置 SIGKILL/SIGSTOP 不可忽略 自定义捕捉 signal() 函数 sigaction() 函数 处理函数注册 信号集与信号屏蔽字 sigset_t 位图结构 sigemptyset / sigfillset sigaddset / sigdelset sigprocmask() SIG_BLOCK / UNBLOCK SIG_SETMASK 未决信号(Pending) sigpending() 查询 屏蔽解除后递送

3.6 避坑指南

我这些年做信号处理,踩过的坑不少。挑几个典型的说说:

  • 信号处理函数要尽量简单——只能调用异步信号安全函数(async-signal-safe),比如 write 可以,printf 不行。我曾经在信号处理里调 malloc,结果死锁了,查了一整天。
  • signal 函数在不同系统上行为不一致——System V 上 signal 会重置处理函数为 SIG_DFL,BSD 上不会。跨平台项目建议用 sigaction。
  • 屏蔽字要记得恢复——用 SIG_SETMASK 恢复旧屏蔽字时,挂起的信号会立即递送。如果你不想在临界区里处理信号,可以用 sigsuspend 原子地等待。
  • 子进程继承屏蔽字——fork 之后子进程的屏蔽字和父进程一样。如果你在父进程里屏蔽了 SIGINT,子进程里也是屏蔽的,别忘了在子进程里重新设置。
我的习惯:生产代码里我从来不用 signal,只用 sigaction。signal 太「随性」了,不同平台实现差异大。sigaction 虽然参数多,但行为明确,可移植性好。

信号处理这块,说白了就是「什么时候做什么事」。默认处理省心,忽略信号省事,自定义捕捉灵活但要注意安全。信号集和屏蔽字是精细控制信号递送的工具,多线程编程里尤其重要。嗯,先聊到这儿,你先把这些基础打牢,后面讲 sigaction 和实时信号时咱们再深入。

可可精品资料库