进程信号这块,很多教程讲完 signal 函数和默认处理动作就戛然而止,到了真实项目里一碰到信号量大、多线程、系统调用被打断的场景,整个人是懵的。这篇“下篇”集中聊真正能落地的部分:信号集操作、sigaction 的进阶玩法、等待信号的正确姿势、多线程下怎么收信号,还有我实际排障时踩过的几个坑。内容偏实操,示例代码全部用 C 语言,你最好已经知道信号是什么,以及 SIGINT、SIGKILL、SIGTERM 这些基础概念的大致含义。如果你正准备写服务端程序、多进程守护程序或者嵌入式常驻进程,这篇应该能帮你在信号处理上少走很多弯路。
1. 信号集操作与屏蔽机制
1.1 信号集就是一张位图
信号集(signal set)这个概念第一次接触会觉得抽象,其实你可以把它理解成一张位图:系统里每个信号对应其中一位,1 表示“这个信号在集合里”,0 表示“不在”。在 C 语言里它被封装成 sigset_t 类型,不同平台的底层结构可能不一样,所以绝对不要直接对 sigset_t 做赋值、按位与、清零这类操作,必须用标准接口。
日常用得最多的四个函数如下:
c复制#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
sigemptyset 把集合清空,sigfillset 把所有信号都塞进集合,sigaddset 和 sigdelset 分别是添加和删除单个信号,sigismember 则是查询某个信号是否在集合中。这几个函数的返回值基本不用太纠结,除了 sigismember 返回 1 表示在、0 表示不在、-1 表示出错之外,其余几个成功返回 0,失败返回 -1。
一个非常容易踩的坑是:定义 sigset_t set; 之后不初始化就直接用,这是完全错误的做法。sigset_t 不是基本类型,它是结构体或者数组,栈上不初始化就是随机值,哪怕你只是想“加一个 SIGINT 进去”,也必须先用 sigemptyset 或 sigfillset 打好底子。我见过有人直接写 sigaddset(&set, SIGINT) 然后发现屏蔽了一堆莫名其妙的信号,排查半天才发现是没初始化。
1.2 sigprocmask:修改命悬一线的屏蔽字
进程里每个线程都有自己的信号屏蔽字(signal mask),表示当前阻塞了哪些信号。修改屏蔽字的接口是 sigprocmask,注意这个函数在多线程环境下应该用 pthread_sigmask 替代,后者会在多线程章节详细说,单线程程序用 sigprocmask 没问题。
c复制#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,会把修改前的屏蔽字保存出来。这个参数在临时屏蔽信号时特别有用,典型的操作序列是这样的:
c复制sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGINT);
sigaddset(&block_set, SIGTERM);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
/* 此处代码执行期间,SIGINT 和 SIGTERM 都会被阻塞 */
/* ... 做一些不想被打断的临界操作 ... */
/* 恢复原有屏蔽字 */
sigprocmask(SIG_SETMASK, &old_set, NULL);
我特别想强调一点:修改屏蔽字的时机要尽量短。有些新手喜欢在程序启动时把所有信号都屏蔽掉,然后永远不恢复,这种设计不是不行,但你必须清楚后果。比如你屏蔽了 SIGINT,用户在终端按 Ctrl+C,进程完全无反应,只能 kill -9,如果这是一个守护进程,运维那边可能直接炸毛。
另外内核限制得很死:SIGKILL 和 SIGSTOP 这两个信号无法被阻塞、无法被忽略、无法被捕获。哪怕你 sigfillset 把全集都塞进去,这两个信号照样能立刻处理进程。这也是 Linux 给你留的最后一道强制管理手段。
1.3 用sigpending查看“排队中”的信号
如果一个信号已经被阻塞,此时又有进程向它发送这个信号,这个信号不会丢失,而是处于“未决”状态,内核会把它记录在进程的未决信号集里。用 sigpending 能查出当前有哪些信号已经到达但还没被处理。
c复制#include <signal.h>
int sigpending(sigset_t *set);
示例:屏蔽 SIGQUIT,然后给进程发一次 SIGQUIT,通过 sigpending 检查它是否处于未决状态。
c复制#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
sigset_t block_set, pending_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGQUIT);
sigprocmask(SIG_BLOCK, &block_set, NULL);
printf("进程 PID=%d, 请在 3 秒内向该进程发送 SIGQUIT(kill -QUIT %d)\n",
getpid(), getpid());
sleep(3);
if (sigpending(&pending_set) == 0) {
if (sigismember(&pending_set, SIGQUIT)) {
printf("检测到 SIGQUIT 处于未决状态\n");
} else {
printf("SIGQUIT 没有处于未决状态\n");
}
}
/* 恢复屏蔽字,此时 SIGQUIT 会被立即递达,默认动作是终止并 core dump */
sigprocmask(SIG_UNBLOCK, &block_set, NULL);
sleep(1);
printf("进程结束\n");
return 0;
}
有一个细节很容易被忽略:标准信号不排队。也就是对于同一个标准信号,如果连续发送多次,在未决期间只会记录一次,等屏蔽解除之后,进程也只处理一次。比如上面的代码运行期间,你连发三四个 SIGQUIT,最终解除屏蔽后只会处理一次。这个特性在生产环境里经常坑人,后面实时信号那节我会专门对比。
sigpending 的 set 参数是输出参数,传入的 sigset_t 会被覆盖,不需要提前初始化,直接传地址进去就行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
