24、信号进阶:sigaction()函数、信号集操作与sigprocmask()
信号处理,说白了就是进程和内核之间的一种“软中断”通信。上一章我们聊了 signal() 这个老接口,但说实话,它在实际项目中坑不少。我个人习惯是,只要涉及信号处理,直接上 sigaction()。为什么?因为它更可控、更安全,而且能解决很多 signal() 说不清道不明的问题。
这一章我们深入三个核心点:sigaction() 的用法、信号集的操作函数,以及如何用 sigprocmask() 屏蔽信号。嗯,这些都是你写健壮服务程序时绕不开的东西。
24.1 sigaction():更可靠的信号安装接口
sigaction() 是 POSIX 标准推荐的信号安装函数。它的原型长这样:
#include <signal.h>
int sigaction(int signum,
const struct sigaction *act,
struct sigaction *oldact);
参数不多,但每个都很关键:
- signum:要捕获的信号编号,比如 SIGINT、SIGTERM。
- act:新的信号处理方式,传入一个
struct sigaction结构体。 - oldact:如果不为 NULL,内核会把之前的处理方式填进去。方便你保存和恢复。
核心是这个结构体:
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_handler 和 sa_sigaction 是共用体的,你只能用其中一个。如果你设了 SA_SIGINFO 标志,那就用 sa_sigaction,否则用 sa_handler。
重点: sa_mask 字段指定了在信号处理函数执行期间,哪些信号会被自动屏蔽。这能防止信号处理函数被重入打断。我建议你至少把当前信号加进去,避免递归调用。
来看一个实际例子:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
printf("捕获到信号 %d\n", sig);
}
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(),在某些系统上处理完一次信号后,处理函数会被重置为默认行为。而 sigaction() 不会,它会一直保持你设定的处理方式。
我的经验: 我在项目中遇到过一个问题——用 signal() 注册 SIGCHLD 处理函数,结果子进程退出时信号丢失了。后来换成 sigaction() 并设置 SA_NOCLDSTOP 标志,问题就解决了。所以,能用 sigaction() 就别用 signal()。
24.2 信号集操作:五个基础函数
信号集(sigset_t)是一个位图,用来表示一组信号。你不能直接操作它的内部位,必须用标准函数。这五个函数是基础中的基础:
| 函数 | 作用 | 返回值 |
|---|---|---|
sigemptyset() |
清空信号集,所有信号都不包含 | 成功返回0,失败返回-1 |
sigfillset() |
填满信号集,包含所有信号 | 成功返回0,失败返回-1 |
sigaddset() |
向信号集中添加一个信号 | 成功返回0,失败返回-1 |
sigdelset() |
从信号集中删除一个信号 | 成功返回0,失败返回-1 |
sigismember() |
检查某个信号是否在信号集中 | 在则返回1,不在返回0,出错返回-1 |
用法非常直观:
sigset_t set;
sigemptyset(&set); // 清空
sigaddset(&set, SIGINT); // 添加 SIGINT
sigaddset(&set, SIGTERM); // 添加 SIGTERM
if (sigismember(&set, SIGINT)) {
printf("SIGINT 在集合中\n");
}
sigdelset(&set, SIGINT); // 删除 SIGINT
这里有个细节:sigemptyset() 和 sigfillset() 必须在使用信号集之前调用。你不能假设一个未初始化的 sigset_t 是空的。我曾经见过有人忘了初始化,结果信号屏蔽行为完全不可预测——那调试起来真是头疼。
注意: 不同系统的 sigset_t 实现可能不同。有的用位数组,有的用结构体。所以一定要用标准函数操作,不要直接做位运算。否则移植性会出问题。
24.3 sigprocmask():屏蔽与解除屏蔽信号
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替换当前屏蔽集。相当于“覆盖”。
oldset 如果不为 NULL,会返回修改前的屏蔽集。方便你后续恢复。
来看一个典型场景:保护临界区代码。
sigset_t newmask, oldmask;
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);
sigaddset(&newmask, SIGTERM);
// 屏蔽 SIGINT 和 SIGTERM
if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) == -1) {
perror("sigprocmask");
return 1;
}
// 临界区代码:这里不会被 SIGINT 或 SIGTERM 打断
printf("执行关键操作...\n");
sleep(5);
// 恢复原来的屏蔽集
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) == -1) {
perror("sigprocmask");
return 1;
}
printf("信号已恢复\n");
这段代码里,我先把 SIGINT 和 SIGTERM 屏蔽掉,等关键操作做完再恢复。为什么要恢复?因为你不恢复的话,后续代码可能一直屏蔽着这些信号,导致进程无法被正常终止。
关键点: 被屏蔽的信号不会丢失。它们会被内核“挂起”,一旦解除屏蔽,就会递送给进程。你可以用 sigpending() 函数查看当前有哪些信号被挂起。
24.4 知识体系总览
下面这张图帮你理清本章的核心脉络:
24.5 避坑指南与实战建议
最后,分享几个我踩过的坑:
- 不要在多线程中随意用
sigprocmask():在多线程程序里,sigprocmask()的行为是未定义的。你应该用pthread_sigmask()。这个坑我踩过一次,调试了两天才发现是线程信号屏蔽的问题。 - 信号处理函数要尽量简单:只做最必要的事,比如设置一个全局标志。不要在里面调用
printf()或malloc(),这些函数不是异步信号安全的。我见过有人因为信号处理函数里调了printf(),导致死锁。 - 记得保存和恢复信号屏蔽字:如果你在函数里修改了屏蔽字,退出前一定要恢复。否则调用者会莫名其妙地收不到信号。这是一个很隐蔽的 bug。
- 用
sigaction()替代signal():这已经说过很多次了,但值得再强调一遍。signal()在不同系统上行为不一致,而sigaction()是 POSIX 标准,行为明确。
好了,这一章的内容就到这里。信号处理是系统编程里比较细腻的部分,多写多练,慢慢就能掌握其中的分寸。