进程信号这块,很多教程讲完 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 等主流模型。
2. 用sigaction写出靠谱的信号处理函数
2.1 别再用signal了,真的
很多入门教材教的是 signal(SIGINT, handler),简单直观,但它有两个非常烦人的问题。
第一是行为在不同系统间不一致。在 System V 体系下,signal 注册的信号处理函数当信号触发一次之后会被重置为默认动作,你必须在 handler 里重新调用 signal 再次注册,否则第二次收到信号就是默认行为,进程直接退出或者终止。在 BSD 体系下则不会重置。Linux 的 signal 函数实际上是由 glibc 实现的,行为类似于 BSD,且底层调用了 sigaction,但依赖 glibc 的行为总归是给自己埋雷。
第二是它无法精细控制处理过程中的细节。如果你想在信号处理期间屏蔽其他信号、获取信号发送方的信息、让被信号打断的系统调用自动重启,signal 统统做不到。
sigaction 接口其实很清晰,学习成本不高,我建议你从现在开始所有新代码一律用 sigaction,兼容性和可控性都好得多。
c复制#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);
};
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
sa_handler 是旧式的信号处理函数指针;sa_sigaction 是增强版,配合 SA_SIGINFO 标志使用,能拿到更多信息。这两个字段其实是联合体,你用哪个取决于有没有设置 SA_SIGINFO。sa_mask 是信号处理函数执行期间需要额外屏蔽的信号集合,它会在进入 handler 前自动被加入进程屏蔽字,handler 结束再恢复。sa_flags 是各种行为开关。
2.2 sa_flags到底怎么选:SA_RESTART、SA_SIGINFO、SA_RESETHAND
sigaction 的灵活性主要靠 sa_flags 体现,常用的标志我整理成了一张表:
| 标志 | 作用 | 使用建议 |
|---|---|---|
| SA_RESTART | 被信号打断的慢系统调用自动重启 | 服务端程序一般都会加,省得每次 read/accept 都要处理 EINTR |
| SA_SIGINFO | 使用 sa_sigaction 而不是 sa_handler | 需要拿信号来源信息时必开 |
| SA_RESETHAND | 信号处理函数执行后重置为默认动作 | 模拟 System V 的 signal 行为,一般不建议 |
| SA_NODEFER | 信号处理期间不屏蔽当前信号 | 默认处理当前信号时,同种信号再次进来会被阻塞;开了这个标志可能递归触发,容易栈溢出 |
我在实际写服务端程序时,绝大多数场景只需要 SA_RESTART,如果需要知道信号是谁发的(比如另一进程通过 kill 发来的 SIGUSR1),才加 SA_SIGINFO。
看一个典型配置代码:
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
static void handler(int sig)
{
/* 在信号处理函数中,能调用的函数非常有限,printf 其实也不完全安全,
但示例代码为了演示方便,仍然使用。实际工程里建议只写标志位。 */
printf("捕获信号 %d\n", sig);
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask); /* handler 执行期间不额外屏蔽其他信号 */
sa.sa_flags = SA_RESTART; /* 让 read/accept 等系统调用自动重启 */
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
/* 业务主循环 */
for (;;) {
pause();
}
return 0;
}
一个常见的疑问是:sa_mask 需不需要把当前信号也加进去?答案是不需要。内核在进入 handler 之前会自动把当前正在处理的信号加入屏蔽字,所以默认情况下,同一个信号在 handler 执行期间再来一次,会被阻塞,等 handler 执行完才会处理第二次。除非你显式设置 SA_NODEFER 让同信号可以递归打断自身,否则不会自己打断自己。
2.3 用sa_sigaction读取信号来源信息
开启 SA_SIGINFO 之后,信号处理函数变成这样的签名:
c复制void handler(int sig, siginfo_t *info, void *ucontext);
info 里携带了大量信息,我实际用到的字段主要是这几个:
si_signo:信号编号,跟第一个参数一样。si_pid:发送信号的进程 PID。si_uid:发送信号的进程的实际用户 ID。si_code:信号产生的原因,比如SI_USER表示由 kill 发出,SI_KERNEL表示内核发出,SI_TIMER表示定时器超时。si_value:通过sigqueue发送的额外整数值,这是实时信号携带数据的重要途径。
写段代码验证一下信号来自哪里,排查问题时特别有用:
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
static void handler(int sig, siginfo_t *info, void *ctx)
{
printf("收到信号 %d\n", sig);
printf("发送方 PID: %d\n", info->si_pid);
printf("发送方 UID: %d\n", info->si_uid);
printf("si_code: %d\n", info->si_code);
if (sig == SIGUSR1) {
/* 通过 sigqueue 发送时,可以读取额外数据 */
printf("随信号携带的整数值: %d\n", info->si_value.sival_int);
}
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigaction(SIGUSR1, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
printf("PID=%d\n", getpid());
for (;;) {
pause();
}
return 0;
}
另一个终端执行 kill -USR1 12345,程序就能打印出发送方 PID。如果想知道 PID 对应什么进程,拿这个 PID 去 /proc/<pid>/comm 查一下,或者用 ps -fp <pid> 看一眼,很多“莫名的信号是哪里来的”问题就是这样定位的。
si_code 有几个宏定义值值得记住。SI_USER 表示信号来自普通 kill,SI_QUEUE 表示来自 sigqueue,SI_KERNEL 表示内核自身发送(如 SIGSEGV),SI_TIMER 来自 timer_create。不过这些值没有跨平台统一标准,有的系统宏是负数有的是正数,写代码时用宏名判断,不要死记数字。
3. 等待信号的正确姿势:pause、sigsuspend与sigwait
3.1 一个经典坑:pause之前的竞态窗口
很多守护进程的写法是“主循环里调用 pause(),等待信号到达”。pause() 的作用是挂起当前进程,直到捕获一个信号、信号处理函数执行完毕后才返回。看起来简单,但它有一个非常隐蔽的竞态问题:如果在调用 pause() 之前信号就已经到达了,而这个信号又没有处于未决状态,那么这个信号会被直接处理掉,pause() 依然继续挂起,永远不会返回,程序就卡死了。
假设场景是这样的:
c复制sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigprocmask(SIG_BLOCK, &set, NULL); /* 先屏蔽信号 */
/* ... 做一些准备动作 ... */
sigprocmask(SIG_UNBLOCK, &set, NULL); /* 解除屏蔽 */
pause(); /* 等待信号 */
第一步屏蔽信号确实把竞态窗口关上了,可一旦解除屏蔽,信号如果就发生在“解除屏蔽”之后、“pause”之前这几条指令之间,pause 还是会被永久挂起。因为信号在 pause 进入内核挂起之前就已经被处理了,内核调度器根本不知道接下来还有一次等待。这个窗口极短,但真实系统里信号不分时机,只要发生过一次,对服务来说就是一次永久卡死。
3.2 sigsuspend:原子地修改屏蔽字并等待
sigsuspend 就是为了解决这个竞态而生的。它会原子地完成两件事:先把进程的屏蔽字设置为参数指定的集合,然后挂起进程,直到捕获一个信号。更关键的是,信号处理函数执行完之后,sigsuspend 返回前会恢复调用它之前的屏蔽字。
c复制#include <signal.h>
int sigsuspend(const sigset_t *mask);
sigsuspend 永远不成功返回(除非被信号打断后返回 -1 并设置 errno 为 EINTR),所以它的返回值基本不用检查。正确的等待模式是这样:
c复制sigset_t set, wait_set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigprocmask(SIG_BLOCK, &set, NULL); /* 先阻塞 SIGUSR1 */
/* 此处可以安全地做准备工作,信号即使来了也被挂起 */
/* 构造 wait_set,它不包含 SIGUSR1,只屏蔽其他信号 */
sigemptyset(&wait_set);
sigaddset(&wait_set, SIGINT);
sigaddset(&wait_set, SIGTERM);
sigsuspend(&wait_set);
/* 到这里时,SIGUSR1 已经被捕获并处理了 */
sigprocmask(SIG_UNBLOCK, &set, NULL);
sigsuspend 内部先把屏蔽字切到 wait_set,因为 wait_set 里没有 SIGUSR1,所以 SIGUSR1 一旦到达就会被立即递送给处理函数;如果 wait_set 里有 SIGINT/SIGTERM,那这俩来了照样被阻塞。信号处理完返回后,内核把原屏蔽字恢复了。整个过程以原子方式完成,那扇竞态的窗户被彻底焊死。
很多人在传统 Unix 编程里会遇到这种模式:父子进程业务同步时,父进程等子进程发 SIGUSR1,子进程等父进程发 SIGUSR2。这种双向握手如果用 pause 写在临界区附近,偶发卡死你根本追不到原因,换 sigsuspend 之后,代码跑再久都是稳的。
3.3 sigwait:同步等待信号的新思路
sigwait 是完全不同的一条路线,它不注册信号处理函数,而是同步地在程序里“收”信号。这种方式对多线程程序特别友好,因为处理代码跑在线程的正常上下文里,可以使用 malloc、pthread_mutex_lock 等异步信号安全函数,完全绕开“信号处理函数中不能调用哪些函数”的限制。
c复制#include <signal.h>
int sigwait(const sigset_t *set, int *sig);
set 是需要等待的信号集,sig 是输出参数,表示实际收到的信号编号。sigwait 在信号到达前会一直阻塞,一旦 set 中的某个信号成为未决状态,它就把这个信号从未决队列里取出来返回。
使用 sigwait 前,通常要把对应的信号先阻塞掉,否则信号到达时会走默认动作或者被已注册的 handler 抢走,sigwait 就收不到了。
c复制sigset_t set;
int sig;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigaddset(&set, SIGUSR2);
sigprocmask(SIG_BLOCK, &set, NULL);
for (;;) {
sigwait(&set, &sig);
if (sig == SIGUSR1) {
/* 处理业务 */
} else if (sig == SIGUSR2) {
/* 处理另一项业务 */
}
}
这种同步写法的最大好处是:你想怎么处理就怎么处理,业务逻辑可以写得很大块,不用担心把系统调用打断。下一节我会把它跟线程结合起来,这才是 sigwait 真正的主战场。
4. 信号与多线程:pthread_sigmask、sigwait与专用信号线程
4.1 信号发给进程后,哪个线程接收?
很多人在多线程程序里对信号处理一头雾水,核心疑问是:进程收到 SIGUSR1 后,到底哪个线程执行 handler?
规则是这样的:信号可以发给进程,也可以发给线程(pthread_kill 定向发)。当一个进程级的信号(比如来自 kill(pid, sig))到达时,内核会选择这个进程里任意一个没有阻塞该信号的线程来递送。如果所有线程都阻塞了这个信号,那它就停留在未决状态。
每个线程各自有自己的信号屏蔽字。进程级屏蔽字和线程级屏蔽字的效果叠加,实际是否阻塞 = 进程屏蔽字或线程屏蔽字中有一个阻塞了,就阻塞。
这就给多线程程序带来一个问题:如果你在主线程里用 signal 注册了某个信号的处理函数,但你无法确定这个信号最终会被哪个线程接收。如果恰好被一个正在执行关键逻辑的线程收到,你的 handler 可能跑在你想不到的上下文里,操作共享数据时可能引发各种诡异问题。
主流的正道解法是:所有线程统一阻塞需要处理的信号,然后单独起一个或者多个专用线程,用 sigwait 同步接收信号,再把这些信号翻译成业务操作。这样信号处理代码完全集中,不会打断任何业务线程。
4.2 主线程屏蔽信号,专用线程收信号
完整的实现思路如下:
- 程序一开始,主线程就调用
pthread_sigmask把需要处理的信号全部阻塞。 - 创建一个普通线程作为信号接收线程,这个线程继承主线程的屏蔽字,所以它也不会被那些信号打断。
- 信号接收线程里调用
sigwait等待这些信号,收到之后抛给业务逻辑处理。 - 其他业务线程无需关心信号,它们根本不会收到这些信号。
注意一个细节:线程创建之后,新线程的屏蔽字是从创建者那里继承的,所以创建信号接收线程前,pthread_sigmask 会直接影响它。
c复制#include <stdio.h>
#include <signal.h>
#include <pthread.h>
#include <unistd.h>
static void *signal_thread(void *arg)
{
sigset_t set;
int sig;
/* 这里再重新设置一次也无妨,保证线程独立运行时不依赖创建时机 */
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
for (;;) {
if (sigwait(&set, &sig) != 0) {
continue;
}
switch (sig) {
case SIGUSR1:
/* 在正常线程上下文中,随便调用 malloc、printf 都没事 */
printf("信号线程收到 SIGUSR1\n");
break;
case SIGTERM:
case SIGINT:
printf("信号线程收到终止信号 %d,开始做清理动作\n", sig);
/* do_cleanup(); */
return NULL;
default:
break;
}
}
return NULL;
}
int main(void)
{
sigset_t set;
pthread_t tid;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
/* 主线程先把这三个信号统统阻塞,后续创建的业务线程不会收到它们 */
pthread_sigmask(SIG_BLOCK, &set, NULL);
pthread_create(&tid, NULL, signal_thread, NULL);
/* 主线程做正常的业务循环 */
printf("主线程 PID=%d, 业务循环中...\n", getpid());
for (;;) {
sleep(1);
}
return 0;
}
编译时记得加 -pthread。写完后自己开两个终端,一个进程跑起来,另一个发 kill -USR1 PID 和 kill -TERM PID,观察输出。
这种模式最大的价值是:避免在异步信号处理函数里写复杂逻辑。异步信号处理函数的限制非常严格,你甚至不能安全地调用 printf,也不能碰 malloc,因为信号可能在进程分配内存的过程中打断执行,再调用同样的函数容易死锁。而 sigwait 把“信号到达”变成一种同步事件,处理代码跑在线程的普通上下文里,想怎么调库就怎么调库,不需要担心可重入性。
4.3 常见注意事项
用 sigwait + 专用线程,我会提醒你注意下面这几条:
第一,sigwait 返回后,信号不会执行默认动作,即使该信号本来是不可捕获的,比如你可以在集合里包含 SIGTERM,收下之后进程不会死掉。但千万别把 SIGKILL 和 SIGSTOP 放进集合,内核不允许捕获这两个信号,sigwait 也不会正确处理它们。
第二,pthread_sigmask 只影响当前调用线程的屏蔽字,不是整个进程。所以在多线程程序里,如果你已经创建了多个线程,再想让新线程都不接收某个信号,必须保证在创建线程之前调用,并让各线程继承屏蔽字。
第三,代码里我用了 printf,这在 sigwait 线程里是安全的,只有“在 handler 内部”才需要担心可重入性。不要混淆这两个场景。
第四,pthread_create 之后,主线程继续跑业务,信号接收线程其实是阻塞在 sigwait 上,不占 CPU。这个线程栈要设置合理大小,毕竟它承担了所有信号事件的串行处理,如果处理逻辑太重,后续信号会排队等待。
5. 实时信号、EINTR与排障经验
5.1 标准信号丢消息?试试实时信号
前面说过标准信号不排队,同一个信号连续来三次,最后只处理一次。这在有些场景下是不能接受的。比如有一个外部监控程序,靠 SIGUSR1 的频率判断某个节点是否存活,如果监控程序连发多次 SIGUSR1 而信号被合并,统计就会失真。
实时信号(real-time signal)就是为了解决这个问题。Linux 中实时信号编号从 SIGRTMIN 到 SIGRTMAX,取值因平台而异。实时信号的核心特性是排队,每个信号都有独立的槽位,发送多少次就排队多少次,不会合并;还可以通过 sigqueue 发送附加数据。
常用方式是在信号处理或者 sigwait 时,通过 siginfo_t 拿到附带的数据:
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
static void handler(int sig, siginfo_t *info, void *ctx)
{
printf("收到实时信号 %d, 携带值: %d\n",
sig, info->si_value.sival_int);
}
int main(void)
{
struct sigaction sa;
int rt_sig = SIGRTMIN + 3;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO;
sigemptyset(&sa.sa_mask);
sigaction(rt_sig, &sa, NULL);
printf("PID=%d, 本程序监听实时信号 %d\n", getpid(), rt_sig);
for (;;) {
pause();
}
return 0;
}
发送端用 sigqueue:
c复制#include <signal.h>
#include <string.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr, "用法: %s <pid> <值>\n", argv[0]);
return 1;
}
pid_t pid = atoi(argv[1]);
union sigval val;
val.sival_int = atoi(argv[2]);
if (sigqueue(pid, SIGRTMIN + 3, val) == -1) {
perror("sigqueue");
return 1;
}
printf("已向进程 %d 发送实时信号,携带值 %d\n", pid, val.sival_int);
return 0;
}
实时信号用于进程间传递小消息非常顺手,它省掉了管道、共享内存那些基础设施。只是要注意两点:一是不要直接拿 SIGRTMIN 当固定信号,不同 Linux 发行版不同,最好在启动时用 SIGRTMIN + N 动态计算;二是实时信号的排队长队不是无限的,受内核信号队列限制,如果过度发送可能触发 SIGKILL 或 EAGAIN 错误。
5.2 EINTR:被信号打断的系统调用
写网络服务时,最常见的一个信号相关问题是:程序明明阻塞在 read 或 accept 上,信号一来,系统调用立即返回 -1,errno 设置为 EINTR,程序如果没正确处理,直接当成错误退出,服务就挂了。
这里的根源在于,很多慢系统调用(slow system call,比如读写管道、终端、网络 socket)在等待期间接收到信号时,会被中断。处理方式主要有三种:
| 方案 | 说明 | 推荐度 |
|---|---|---|
注册 sigaction 时加 SA_RESTART 标志 |
内核自动帮你重新发起系统调用,对程序员透明 | 最省事,适合大部分场景 |
| 在代码中检查 EINTR,手动重启 | 有些系统调用不受 SA_RESTART 影响(比如 epoll_wait、select、poll、nanosleep 的部分情况),只能自己循环 |
必备技能 |
| 改用 pselect/ppoll 等带信号屏蔽字的接口 | 先原子地解除阻塞并等待,规避竞态 | 适用于需要复杂信号协调的场景 |
典型的手动重启写法是:
c复制ssize_t n;
do {
n = read(fd, buf, sizeof(buf));
} while (n < 0 && errno == EINTR);
epoll_wait 这个函数在 Linux 上即使加了 SA_RESTART 也不会自动重启,你必须手动处理 EINTR。很多初学 epoll 的人满怀信心地跑起服务,一个 kill -USR1 发过去,epoll_wait 返回 -1,程序直接退出,就是这个原因。
调试这类问题有个很笨但有效的方法:strace 跟踪系统调用。strace -p <pid> 多等一会,一旦信号触发,你就能在那个时间点看到 epoll_wait 返回了 EINTR,问题一目了然。
5.3 用/proc和kill -l做现场排查
定位信号相关疑难杂症,我一般会优先看这几个地方:
第一是 /proc/<pid>/status 里跟信号有关的字段:
SigPnd和ShdPnd:线程级和进程级的未决信号位图。SigBlk:当前阻塞的信号位图。SigIgn:被忽略的信号位图。SigCgt:被捕获的信号位图。
这些值都是十六进制掩码,怎么看?把十六进制展开成二进制,每一位对应一个信号编号,从 1 号 SIGHUP 开始数。比如 SigBlk 的值是 0000000000000400,展开后第 11 位是 1,说明 SIGSEGV(11 号)被阻塞了——这显然不正常,继续排查谁屏蔽了它。
第二是用 kill -l 查看系统支持的信号列表。不同架构的信号编号有差异,写跨平台代码时不要硬编码数字。
第三是看 core dump。进程被 SIGSEGV、SIGABRT、SIGQUIT 等信号杀掉并生成 core 文件时,gdb 打开 core 文件直接 bt 能看到最后的调用栈。我曾经遇到过进程莫名退出,把 core 文件拖进 gdb 才发现是某个库内部自己发了 SIGABRT 做断言失败,而不是有人 kill 了它。
第四是 strace -e signal 更好用,它能直接跟踪进程收到的所有信号:
bash复制strace -e trace=signal -p <pid>
这个命令专门打印信号相关的系统调用和信号递送事件,比全量 strace 输出清静得多。
6. 真实排障案例与操作心得
6.1 案例一:看起来像内存越界的无规律崩溃
有一次排查一个网络服务,运行几小时后莫名退出,dmesg 里根本没有 segfault,core 文件也没有。最后用 strace 发现进程收到的是 SIGTERM,而没有任何代码应该主动发 SIGTERM。顺着 si_pid 找到了是一个监控脚本在“清理”没响应的心跳进程,而程序的心跳线程当时刚好卡在一个慢查询上超过了监控阈值。表面上是信号问题,根子其实是业务线程卡顿。这让我养成一个习惯:所有处理异常退出的进程,都优先把信号来源打出来,而不是只记录“收到 xx 信号”。在进程退出前把 siginfo_t 信息写到日志里,往往是定位问题的第一把钥匙。
6.2 案例二:标准信号丢失导致的状态不同步
另一个项目里,两个进程靠 SIGUSR1 做简单通知,发送方认为“发一次就要处理一次”,接收方实际只处理了一次,因为短时间内连发了多次,信号被合并。当时的接盘代码用的是 signal 注册 handler,无法排队。后来改成实时信号 + sigwait 专用线程,把每次通知的附加值也带上,彻底解决了丢失问题。从那以后,凡是需要“频率敏感”的进程间通知,我都不会再考虑标准信号。
6.3 最后分享几个小技巧
如果你现在正在写信号相关代码,这几个点都是从实际生产环境里提炼出来的,值得默认加入你的代码习惯:
第一,sigaction 注册时,一定把 sa_mask 初始化。很多人只设置了 sa_handler 和 sa_flags,没清 sa_mask,结果 handler 执行期间屏蔽了一堆随机信号,行为完全不可控。定义 struct sigaction 后立刻 memset 或者 sigemptyset,这是基本功。
第二,信号处理函数内部尽量“只做标记,不做业务”。经典做法是 handler 里只设置一个全局的 volatile sig_atomic_t 标志,主循环里检查到标志再执行实际逻辑。如果需要传递更复杂的信息,用 sigwait 或者管道(self-pipe trick)把信号转成事件。现代多线程程序我更推荐 sigwait 专用线程。
第三,多线程程序如果还在用 sigprocmask,趁早改成 pthread_sigmask。我见过同事在 Linux 上误用 sigprocmask 影响全部线程导致诡异问题,虽然 glibc 的实现把 sigprocmask 映射为 pthread_sigmask,但接口语义不同,可移植性很差,标准写法就是在多线程程序里只调用 pthread_sigmask。
第四,kill 命令发送信号时尽量用信号名而不是数字,比如 kill -TERM pid,不要写 kill -15 pid。数字在架构间可能有差异,信号名更安全。写脚本时用 kill -0 pid 检查进程是否存在也是个保底技巧。
进程信号这块内容非常多,光内核里信号递送的状态机就够写几本书。但对我们写应用层和系统层代码的人来说,把信号集、sigaction、sigwait、实时信号这几块吃透,配合 EINTR 处理和 /proc 排查技巧,已经能解决绝大多数生产环境里跟信号相关的问题。下次再遇到“进程神秘消失”或“服务偶发卡死”,先别急着怀疑内存泄漏,看看是不是信号在背后搞鬼。
