很多时候我们写代码跑服务,只看到在终端里按一个Ctrl+C,进程就停了;但这个“停下”的背后,其实是 Linux 信号机制在起作用。信号这玩意儿,你说它简单,它确实就是“内核给进程发的消息”;可你要说它不复杂,那也未必,因为进程不会像听语音消息一样逐条播放信号,而是把信号先“保存”起来,到合适的时机再统一处理。这篇内容我打算用一个可运行的最小工程串起整条链路,把信号的生命周期、保存机制、注册处理函数的正确姿势、屏蔽与恢复的细节都过一遍,再加上几个我在实际项目中踩过的坑。适合刚接触 Linux 系统编程的朋友,也适合面试前想快速把信号这块捡起来的同学。
整个流程我会这样铺开:先讲清楚信号在内核里是怎么被“保存”的,即那个决定了信号命运的位图结构;接着讲signal()和sigaction()怎么选、信号屏蔽字怎么用;然后写一个能编译运行的小 demo,演示信号的发送、接收、挂起与恢复;最后整理一份我在线上环境中遇到的信号问题排查手册。
1. 信号在 Linux 中到底扮演什么角色:一份直白的全局总结
1.1 一个完整的信号生命周期:从按键到进程回调
你在终端按下Ctrl+C,看起来是进程收到了一个“中断指令”,但真实流程比这个要稍微绕一步。按下按键后,终端驱动会把字节发回给前台进程组,内核检测到这是一个中断字符,于是向该进程组的所有进程发送SIGINT。这个信号产生之后,并不会立刻打断进程正在执行的任何逻辑,内核只是把它“记一笔”,放进进程的待处理信号集合里。
等到进程下一次从内核态返回用户态时,内核会去查看这个集合里有没有需要处理的信号。如果有,就把进程的执行流切换到预先注册好的处理函数;如果没有注册处理函数,就执行内核预设的默认动作,比如终止进程。这个“先记账、后处理”的过程,就是信号保存机制的核心。信号本身不携带数据(实时信号可以通过sigqueue携带一个整数或指针),它本质上是内核给进程发的一个“通知”。
把这个逻辑想明白,很多现象就都能解释了。比如sleep(3)能被信号打断并提前返回,是因为nanosleep这种系统调用内部会检测到挂起的信号,系统调用被打断后返回一个EINTR错误,进程回到用户态先处理信号,然后你再决定是否继续睡眠。再比如一个进程在read()阻塞等待键盘输入时,你给进程发一个SIGTERM,它不会默默等在read里,而是会立刻被唤醒并进入信号处理流程。
1.2 为什么需要“信号保存”:进程控制块里的位图
“保存信号”这个说法听着有点抽象,我们来看内核是怎么实现的。Linux 的每个进程在内核里都有一个task_struct,里面保存了大量的进程状态。其中与信号直接相关的,首先是两个信号集合:
- pending 集合:表示当前进程有哪些信号已经到达、但还没被处理,也就是“待处理信号”。
- blocked 集合:表示当前进程主动屏蔽了哪些信号,这些信号即使到达,也只能继续保留在 pending 集合中,不能递送给进程的处理函数。
这两个集合的类型都是sigset_t,在 x86_64 平台上底层是一个 64 位的位图,每一位对应一个信号编号。所谓“保存”,本质上就是在这两个位图里做置位和复位。信号到达时,内核把对应位从 0 置为 1;信号被处理后,再把对应位从 1 清为 0。
这样做有什么好处?好处是快、省内存,而且天然解决了一个问题:同一个信号短时间内来几百次,位图里永远只有一位,进程处理一次就够了。但这同时也是一个“坑”:标准信号不排队,重复到达的信号会合并。如果你的业务逻辑里把信号个数当作计数来用,那必然出问题。我见过有同事在用户态用一个全局变量统计SIGUSR1的到达次数,结果并发测试下数值永远对不上,就是因为这个合并原理。
1.3 为什么“系统调用被中断”和信号是孪生兄弟
信号保存机制带来的一个延伸影响,就是系统调用的自动重启问题。一个进程正在执行read()、write()、ioctl()这类慢速系统调用时,如果有一个信号到达,默认情况下系统调用会返回-1,并设置errno为EINTR。这是内核的设计选择:既然进程马上要去执行用户态的信号处理函数,那内核态里这个不知道要阻塞多久的系统调用就没必要继续占着 CPU 了。
处理EINTR有两种策略。一种是在调用处显式判断errno == EINTR,然后重新发起调用;另一种是注册信号处理函数时设置SA_RESTART标志,让内核在信号处理完成后自动重启被打断的系统调用。很多人只记得第二种是“推荐做法”,但没想过它也有副作用:SA_RESTART不会帮你处理所有调用,比如select()、poll()、epoll_wait()以及sigsuspend(),这些函数即使设置了这个标志,在部分版本的内核下依然会返回EINTR。所以在写网络服务时,我更倾向于不依赖SA_RESTART,而是在每次调用结束后统一判断EINTR,这样行为更可控。
2. 核心细节解析:编号、默认行为与接口选型
2.1 信号编号与默认动作,先记住这张表
Linux 经典信号一共 32 个,编号从 1 到 31;在 32 位系统中实际可用的标准信号就这些,实时信号编号在 34 到 64 之间。平时我们打交道最频繁的几个信号,我整理成了一张表:
| 信号编号 | 信号名 | 默认动作 | 常见触发场景 |
|---|---|---|---|
| 1 | SIGHUP | 终止进程 | 终端挂断、进程退出导致控制终端关闭 |
| 2 | SIGINT | 终止进程 | 用户按下Ctrl+C |
| 3 | SIGQUIT | 终止并生成 core 文件 | 用户按下Ctrl+\ |
| 6 | SIGABRT | 终止并生成 core 文件 | abort()调用、断言失败 |
| 9 | SIGKILL | 必须终止,不可捕获/忽略/屏蔽 | kill -9直接发送 |
| 10 | SIGUSR1 | 终止进程 | 用户自定义信号 1 |
| 12 | SIGUSR2 | 终止进程 | 用户自定义信号 2 |
| 11 | SIGSEGV | 终止并生成 core 文件 | 非法内存访问、空指针解引用 |
| 13 | SIGPIPE | 终止进程 | 向已关闭的管道或 socket 写数据 |
| 15 | SIGTERM | 终止进程 | kill命令默认发送的信号 |
| 17 | SIGCHLD | 忽略 | 子进程停止或终止 |
| 18 | SIGCONT | 继续执行 | kill -CONT让停止的进程恢复 |
| 19 | SIGSTOP | 必须停止,不可捕获/忽略/屏蔽 | Ctrl+Z暂停进程 |
| 20 | SIGTSTP | 停止进程 | Ctrl+Z由终端驱动发送 |
这张表里最特殊的两个是SIGKILL和SIGSTOP:它们由内核强制接管,任何进程都无法修改它们的行为。所以你在业务代码里再怎么signal(SIGKILL, handler)都是无效的。这也是为什么kill -9永远是最后一招,因为它不给进程任何清理资源的机会。
2.2 signal() 与 sigaction():为什么我在生产代码里只用 sigaction
初学者最容易接触到的注册信号处理函数的接口是signal(),因为在 APUE 或《Unix 环境高级编程》里它是第一个被介绍的。但我在实际项目中几乎不碰它,原因主要有三个。
第一,signal()在不同系统上的语义差异很大。在 System V 上,信号处理函数执行完毕后,信号的处置方式会被重置为默认动作,也就是说你处理了一次SIGINT之后,下次再来SIGINT就直接走默认终止逻辑了。而在 BSD 上则不会重置。Linux 的glibc实现里signal()走的其实是 BSD 语义,但这依赖具体平台,换到其他 Unix 上行为可能就不一样。
第二,signal()没有提供屏蔽信号集的能力。我不能在信号处理函数执行期间顺带屏蔽指定的其他信号,这会限制我处理一些“处理器运行期间不希望被再次打断”的场景。
第三,signal()不提供SA_RESTART这样的标志位开关。没有它,一个read()系统调用就可能被一个不相关的信号频繁打断,每次都要判断EINTR,非常烦。
所以我建议,从一开始就用sigaction()。它的完整签名是这样的:
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);其中struct sigaction里最关键的几个字段:
struct sigaction { void (*sa_handler)(int); // 信号处理函数 sigset_t sa_mask; // 处理该信号期间要额外屏蔽的信号集合 int sa_flags; // 行为标志 void (*sa_sigaction)(int, siginfo_t *, void *); // 扩展信号信息处理函数 };注册一个处理函数的标准姿势是这样:
struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = my_sig_handler; // 指定处理函数 sigemptyset(&sa.sa_mask); // 先清空屏蔽集 sigaddset(&sa.sa_mask, SIGINT); // 在处理函数执行期间,再次收到 SIGINT 时先保存起来 sa.sa_flags = SA_RESTART; // 尽量自动重启受中断的系统调用 if (sigaction(SIGUSR1, &sa, NULL) != 0) { perror("sigaction failed"); }这段代码里我留了一个小细节:sa_mask里加入了SIGINT。这意味着当我的SIGUSR1处理函数正在运行时,如果又来了SIGINT,这个SIGINT会被暂时屏蔽并“保存”在 pending 位图里,等处理函数返回后再递送。这个功能在生产环境里非常实用,尤其是你在处理信号时可能依赖某些全局状态,不希望被其他信号打乱执行顺序。
2.3 信号屏蔽字 sigprocmask:主动保存,然后择机恢复
除了在sigaction中设置临时屏蔽,我们还可以在任意时刻通过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覆盖当前屏蔽字。
一个典型的使用场景是:在程序初始化阶段,你不想让某些信号干扰启动流程,于是临时屏蔽,等初始化完再恢复。注意这里有个关键点:屏蔽信号不等于丢弃信号,只要在屏蔽期间有信号到达,它们就会被“保存”到 pending 位图里。一旦你取消屏蔽,这些信号会立即被递送。
写一个简单的例子:
sigset_t new_mask, old_mask; sigemptyset(&new_mask); sigaddset(&new_mask, SIGUSR2); // 屏蔽 SIGUSR2,同时保存旧的屏蔽字 sigprocmask(SIG_BLOCK, &new_mask, &old_mask); // 在这段代码执行期间,如果收到 SIGUSR2,它不会被处理,但会保存在 pending 集合中 sleep(1); // 查询当前 pending 的信号,验证 SIGUSR2 是否已经到达 sigset_t pending_set; sigemptyset(&pending_set); sigpending(&pending_set); if (sigismember(&pending_set, SIGUSR2)) { printf("SIGUSR2 已经被保存到 pending 信号集合中\n"); } // 恢复旧的屏蔽字,此时如果刚才收到了 SIGUSR2,处理函数立刻被调用 sigprocmask(SIG_SETMASK, &old_mask, NULL);这里sigpending()是一个特别能帮助我们理解“信号保存”的接口,它直接展示当前进程的 pending 信号集合。我建议每个初学信号的人都亲手跑一遍这段代码,亲眼看见一个信号从“被屏蔽”到“出现在 pending 集合”再到“取消屏蔽后被处理”,这个认知比死记概念有用得多。
2.4 信号处理函数的安全性:为什么异步信号安全函数是硬规矩
信号处理函数里能调用什么函数,这是很多人容易忽视的重灾区。常规理解是“处理函数就是普通函数,想干嘛就干嘛”,但真相是:信号处理函数的执行时机是完全随机的,它可能在主程序任何一条指令之间被插入执行。如果主程序正好在执行一个非可重入函数,比如正在调用malloc(),信号处理函数里也调用了malloc(),那 malloc 内部维护的内存链表就可能在主程序还没完成操作时被二次操作,轻则数据错乱,重则直接崩溃。
所谓的“异步信号安全函数”,指的是那些被保证可以在信号处理函数中安全调用的函数。比如write()、read()、open()、close()、_exit()等。而printf()、malloc()、free()、fopen()、pthread_*这些大多是异步信号不安全的。
我个人的经验是:信号处理函数里尽量只做两件事,一是修改一个volatile sig_atomic_t类型的全局标志,二是调用write()往管道或已经执行好setvbuf的文件描述符里写一小段日志。volatile sig_atomic_t在 C 标准里被明确允许在信号处理函数中安全读写,它保证了一次读取或写入的原子性,同时避免编译器把读取操作优化掉。
static volatile sig_atomic_t g_terminate_flag = 0; static void handle_term(int sig) { (void)sig; g_terminate_flag = 1; }主循环里再检查这个标志:
while (!g_terminate_flag) { // 正常的业务逻辑 }这种“主循环轮询标志位、信号函数只置标志”的模式,是生产环境中最常见也最稳妥的做法。它把“信号处理的复杂度”降低到了“条件判断”的级别,避免了在信号处理函数里做高风险的资源操作。
3. 手把手实现一个最小可运行的信号保存与处理 demo
3.1 实验设计:我们要验证哪三件事
理论讲了半天,不如直接上手跑代码。我设计了一个小的 C 程序,主要验证这三件事:
- 第一,注册
SIGUSR1、SIGUSR2、SIGTERM的处理函数,验证不同信号走不同回调。 - 第二,在当前进程屏蔽
SIGUSR2后,用另一个终端给进程发送SIGUSR2,然后用sigpending()观察信号被保存到 pending 集合里,最后解除屏蔽,观察信号被递送。 - 第三,连续发送多次标准信号,验证标准信号的合并行为,让大家直观理解为什么“信号会丢”。
程序侧只做两件事:一是打印当前进程的 PID,方便从另一个终端发送信号;二是在主循环里用sleep()和标志位控制流程,打印信号到达的次数。整个代码不依赖任何第三方库,一个.c文件就能编译运行。
3.2 完整代码与逐段说明
创建一个文件,名字就叫signal_demo.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <signal.h> #include <errno.h> static volatile sig_atomic_t g_usr1_count = 0; static volatile sig_atomic_t g_usr2_count = 0; static volatile sig_atomic_t g_term_flag = 0; static void handle_usr1(int sig) { (void)sig; g_usr1_count++; } static void handle_usr2(int sig) { (void)sig; g_usr2_count++; } static void handle_term(int sig) { (void)sig; g_term_flag = 1; } static void setup_signal_handler(int signum, void (*handler)(int)) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handler; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; if (sigaction(signum, &sa, NULL) != 0) { perror("sigaction failed"); exit(1); } } int main(void) { // 1. 注册三个信号的处理函数 setup_signal_handler(SIGUSR1, handle_usr1); setup_signal_handler(SIGUSR2, handle_usr2); setup_signal_handler(SIGTERM, handle_term); printf("当前进程 PID: %d\n", getpid()); printf("用法: kill -USR1 %d 发送自定义信号1\n", getpid()); printf("用法: kill -USR2 %d 发送自定义信号2\n", getpid()); printf("用法: kill -TERM %d 退出程序\n", getpid()); // 2. 屏蔽 SIGUSR2,测试信号保存 sigset_t block_mask, old_mask; sigemptyset(&block_mask); sigaddset(&block_mask, SIGUSR2); sigprocmask(SIG_BLOCK, &block_mask, &old_mask); printf("已屏蔽 SIGUSR2,在 3 秒内发送信号进行测试...\n"); sleep(3); // 3. 查询 pending 集合,看看 SIGUSR2 是否被保存 sigset_t pending_set; sigemptyset(&pending_set); if (sigpending(&pending_set) != 0) { perror("sigpending failed"); } if (sigismember(&pending_set, SIGUSR2)) { printf("检测到 pending 集合中包含 SIGUSR2,说明信号已被保存\n"); } else { printf("pending 集合中没有 SIGUSR2,可能信号还没发,或者已经丢失\n"); } // 4. 解除屏蔽,此时若之前收到过 SIGUSR2,处理函数会被立刻调用 sigprocmask(SIG_SETMASK, &old_mask, NULL); printf("已解除 SIGUSR2 屏蔽\n"); // 5. 主循环,定期打印计数 int loops = 0; while (!g_term_flag) { sleep(1); loops++; if (loops % 2 == 0) { printf("运行 %d 秒: SIGUSR1 收到 %d 次, SIGUSR2 收到 %d 次\n", loops, (int)g_usr1_count, (int)g_usr2_count); } } printf("收到 SIGTERM,程序即将退出。SIGUSR1 总次数: %d, SIGUSR2 总次数: %d\n", (int)g_usr1_count, (int)g_usr2_count); return 0; }编译运行:
gcc -Wall -O2 -o signal_demo signal_demo.c ./signal_demo程序启动后会打印 PID,然后进入 3 秒的“屏蔽SIGUSR2”窗口。你在这个窗口期内,从另一个终端执行kill -USR2 <PID>,信号就会进入 pending 集合。3 秒之后,程序调用sigpending()检查保存结果,然后解除屏蔽。如果你在屏蔽期间发送过SIGUSR2,解除屏蔽的瞬间,handle_usr2就会被调用,计数加一。
我在自己的机器上跑出来的效果大致是这样:
当前进程 PID: 12345 用法: kill -USR1 12345 发送自定义信号1 用法: kill -USR2 12345 发送自定义信号2 用法: kill -TERM 12345 退出程序 已屏蔽 SIGUSR2,在 3 秒内发送信号进行测试... 检测到 pending 集合中包含 SIGUSR2,说明信号已被保存 已解除 SIGUSR2 屏蔽 运行 2 秒: SIGUSR1 收到 0 次, SIGUSR2 收到 1 次 运行 4 秒: SIGUSR1 收到 0 次, SIGUSR2 收到 1 次注意:在这几秒内,如果我还向进程发送了SIGUSR1,那么计数会自然增加,实验效果会更直观。我建议你同时开两个终端,一个跑程序,一个发信号,配合着看。
3.3 信号合并实验:发 50 次,真的能收 50 次吗
现在来做一个极端的验证。程序进入主循环后,我从另一个终端用脚本连发 50 次SIGUSR1:
for i in $(seq 1 50); do kill -USR1 12345; done等两秒钟再看程序输出,结果往往很尴尬:计数既不是 50,也不是 1,而可能是几到几十之间的一个数。这是因为标准信号在 pending 位图里只有一位,如果信号到达时前一个SIGUSR1还没被处理完,后面的SIGUSR1就会被合并。如果信号处理函数执行得很快,大部分信号都会被依次处理,但依然存在合并可能。
这是 Linux 信号的一个基本特性,不是 bug。如果业务需要精确计数,标准信号是做不到的,你得把信号换成实时信号。实时信号编号从SIGRTMIN(通常是 34)到SIGRTMAX,它们带有队列机制,每个未处理的实时信号都会在队列中占一个位置,内核会保证它们按顺序递送。实时信号的值还可以通过sigqueue()携带到一个siginfo_t结构里,这算是 Linux 信号机制里比较顶配的能力了。
一个简单测试实时信号是否排队的方式,是给代码里加上对SIGRTMIN的处理:
static volatile sig_atomic_t g_rt_count = 0; static void handle_rt(int sig) { (void)sig; g_rt_count++; } // 在 main 里注册: setup_signal_handler(SIGRTMIN, handle_rt);然后用kill -34 <pid>连发信号,再看g_rt_count是否等于发送次数。实测下来,在 Linux 上只要进程处理能力足够,实时信号一般不会合并。这就是面试题里常考的“标准信号与实时信号的区别”的答案来源。
3.4 父子进程之间的信号协作:一个实用的通信模型
很多服务的真实场景是父进程管理子进程的生命周期,比如拉起、检查、终止。最常用的方式是fork()子进程后,父进程在需要时向子进程发送SIGTERM或SIGUSR1。子进程里注册处理函数,收到信号后做清理再退出。
为了不把篇幅拖得太长,我把这个场景简化为一个流程模板,你可能经常会在生产代码里看到类似写法:
pid_t pid = fork(); if (pid == 0) { // 子进程 setup_signal_handler(SIGTERM, handle_child_term); // 子进程做自己的工作,例如循环执行任务 while (!g_child_term_flag) { do_something(); } _exit(0); } // 父进程 sleep(5); kill(pid, SIGTERM); // 等待子进程退出 waitpid(pid, NULL, 0);这里面有两点值得注意。第一,父进程在kill(pid, SIGTERM)之后,必须调用waitpid()回收子进程的退出状态,否则子进程会变成僵尸进程。也就是说,信号处理负责“让进程停下来”,而waitpid()负责“把停下后的尸体收走”。第二,子进程的信号处理函数里如果只是置一个标志位,那么子进程必须保证自己有进入主循环检查标志位的机会;如果你写了一个阻塞在read()或者poll()里的子进程,信号虽然能打断它,但EINTR回来之后你要不要退出循环,需要自己判断。
4. 常见问题与排查技巧实录:我在实际项目里踩过的坑
4.1 程序被 Ctrl+C 秒杀,子进程却“复活”了
有段时间我维护一个 Python 写的管理脚本,脚本会拉起一个 C 服务子进程。脚本里用signal.signal(signal.SIGINT, handler)捕获了 Ctrl+C,做了一些清理工作后退出。结果每次按 Ctrl+C,脚本是退出了,但 C 服务子进程却留在了系统里继续跑,第二次启动脚本时,新进程和旧进程同时监听同一个端口,直接冲突。
问题出在进程组上。按下 Ctrl+C 时,终端驱动会向前台进程组的所有进程发送SIGINT,包括脚本和脚本拉起的子进程。脚本捕获信号后退出,但子进程如果没有注册SIGINT处理,默认动作也是终止才对。为什么子进程反而活下来了?查了才发现,我在脚本里用subprocess.Popen启动子进程时加了一层start_new_session=True,让子进程独立成了一个新会话和进程组,所以终端 Ctrl+C 根本不会把信号发给子进程。
这提醒我两件事:一是管理进程时一定要明确进程组和会话的边界;二是排查信号问题时,先问“这个信号到底发给了谁”,用ps -ef加pgrep确认进程的 PPID、进程组 ID,再用kill -0测试信号是否可达。不要想当然地以为 Ctrl+C 一定会发给进程树里的所有进程。
4.2 信号处理函数里调用 printf,结果输出乱码还卡死
这算是我亲眼见过最多的一种新手错误。程序运行时打印日志,然后在信号处理函数里也打印日志,结果主线程打印到一半,信号来了,处理函数也往同一个 stdout 文件流里写数据,两个 printf 在内部缓冲区里互相撕扯,轻则输出错乱,重则死锁。
我自己的处理方案是:如果只是调试,可以在信号处理函数里用write(2, buf, len)直接往 stderr 的底层文件描述符写字符串,因为write()是异步信号安全的,它走的是内核系统调用,不经过用户态的 stdio 缓冲区。但注意这个办法只适合短日志。如果生产环境需要完整的信号日志,正确姿势是信号处理函数里往一个自建的日志管道写一个字节,主线程从管道那头读出来再交给正规日志库去处理。
static void handle_debug_signal(int sig) { const char msg[] = "signal received\n"; write(STDOUT_FILENO, msg, sizeof(msg) - 1); }这种写法的代价是格式固定、没有时间戳和上下文信息,但至少不会因为信号处理函数调了非安全函数导致程序直接崩溃,这对于部分线上故障的应急抓取来说是值得的。
4.3 信号被屏蔽后丢“退出”请求,用户眼睁睁看着进程“卡死”
有一次监控系统发来告警,说某个服务长时间没响应,我上去一看,进程还活着,CPU 也正常,但就是不退出。我们用kill -TERM <pid>发了终止信号,还是没动静。排查下来才发现,这个服务在某个模块里使用sigprocmask(SIG_BLOCK, &mask, ...)屏蔽了大量信号,数据到达量又大,处理线程在主循环里忙于处理业务,完全没进入会检查 pending 信号的逻辑,而屏蔽的信号里恰好包含SIGTERM,导致终止请求一直存在 pending 集合里,永远等不到处理时机。
这种情况的教训是:长驻服务应该在主循环固定的关键节点短暂解除屏蔽,或者更简单一点,用独立的控制线程来专门处理信号。实际工程中,一个常见的做法是创建一个sigwait()线程,让信号处理从异步回调模式变成同步等待模式:
sigset_t wait_mask; sigemptyset(&wait_mask); sigaddset(&wait_mask, SIGTERM); sigaddset(&wait_mask, SIGINT); sigaddset(&wait_mask, SIGUSR1); sigprocmask(SIG_BLOCK, &wait_mask, NULL); // 在专用线程中执行 int sig; while (sigwait(&wait_mask, &sig) == 0) { if (sig == SIGTERM) { // 执行清理和退出 break; } }sigwait()的好处是你不必在一个随时被打断的信号处理函数里做事情,而是像读消息队列一样同步地接收信号,这样可以用完整的线程栈、可以调用非异步信号安全的函数,只要保证真正的信号处理不在回调上下文里执行即可。这个模式我强烈建议用在线程序里,尤其适合多线程服务。
4.4 EINTR 导致 read 反复提前返回,服务一直被信号骚扰
在一个网络代理项目里,我发现只要有人向进程发送SIGUSR1做健康检查,进程的epoll_wait()就会提前返回 -1。因为我没有设置SA_RESTART,而epoll_wait()又不在自动重启的覆盖范围内。虽然单个信号导致一次epoll_wait返回不算严重错误,但如果健康检查脚本每隔几秒就来一次,epoll每次都被打断,就会导致某些长连接的处理节奏波动,进而引起客户端超时重连。
排查方法很简单,用strace挂上去看系统调用返回:
strace -e trace=epoll_wait,read,write -p <pid>日志里清清楚楚地显示epoll_wait(...) = -1 EINTR (Interrupted system call)。修复办法是给所有可能阻塞的系统调用统一套一层重试逻辑:
static int epoll_wait_retry(int epfd, struct epoll_event *events, int maxevents, int timeout_ms) { int n; do { n = epoll_wait(epfd, events, maxevents, timeout_ms); } while (n == -1 && errno == EINTR); return n; }这种写法看着有点丑,但确实可靠。我记得有句话叫“系统调用被信号打断不是错误,而是机会”,处理得好,整个程序的基本盘就稳了。
4.5 常见信号问题速查表
| 症状 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
kill信号发了,进程没反应 | 信号被屏蔽在 blocked 集合中 | 查看/proc/<pid>/status中的SigBlk字段,确认屏蔽字 | 检查代码中的sigprocmask调用段,移除不必要屏蔽 |
| 收到信号后进程直接退出,没用清理逻辑 | 信号默认动作是终止,且未注册处理函数 | 检查该信号的SigCgt字段是否有值 | 用sigaction注册自定义处理函数 |
| 程序崩溃但没生成核心转储 | core 文件大小限制为 0,或设置了SIG_IGN | 执行ulimit -c unlimited;检查信号是否被忽略 | 调整 limit 配置,检查sigaction的sa_flags |
信号处理函数中调用printf后程序卡死 | 调用了异步信号不安全函数导致死锁 | 在信号处理函数中改为write和sig_atomic_t标志位 | 统一采用“标志位 + 主循环”模式 |
| 标准信号计数缺失 | 标准信号不排队,重复信号被合并 | 多次发信号观察计数 | 改用实时信号或sigqueue |
| 子进程不响应父进程的终止信号 | 子进程可能在阻塞的系统调用中,或信号被屏蔽 | 用strace观察子进程活动,查看阻塞状态 | 设置SA_RESTART,或主动处理EINTR后退出 |
4.6 几个提高排查效率的调试手段
排查信号问题,光靠读代码是不够的,我得分享几个我常用的调试手段。第一个是查看/proc/<pid>/status里的信号相关字段:
grep -i sig /proc/<pid>/status输出里有一行SigBlk、SigCgt、SigIgn、SigPnd,都是位图表示的十六进制数。如果你觉得自己算位图太麻烦,可以写一个 30 行的小脚本,把位图翻译成信号名。比如SigCgt的位图里某一位为 1,表示该信号注册了自定义处理函数;SigBlk的位图对应着当前屏蔽字。
第二个是strace追踪信号相关的系统调用:
strace -e trace=signal,sigprocmask,sigaction,kill -p <pid>这样可以清楚地看到进程在何时注册了哪个处理函数、何时屏蔽了哪些信号、哪个系统调用被EINTR打断。尤其在定位“为什么没反应”时,strace的输出是最直接的证据。
第三个是gdb里直接给进程发信号,并且观察信号处理函数的执行。启动 gdb 后,handle SIGUSR1 stop print nopass,再调用signal SIGUSR1模拟发送,调试器会在信号处理入口停住,你可以看调用栈、参数和全局变量。这个方式比在外部黑盒观察要高效得多。
5. 从一批热搜词里看信号学习的边界在哪里
动笔之前我习惯性地扫了下与 Linux、信号相关的热门搜索,发现大家关注的点其实分了几层。第一层是命令层,比如“linux常用命令”“linux命令大全”,这类内容倾向于教你怎么用kill、ps、jobs、fg、bg、nohup管理进程和终端任务,绕不开信号。第二层是系统编程层,比如“linux面试题”“嵌入式linux项目”“qt信号槽多线程传参数实例”,这些场景里信号已不只是终端命令,而是进程间通信和异步逻辑的一部分。第三层则是更硬核的工程层,比如“信号完整性”“高速信号接口”“fpga ep4ce10制作fft ip核的信号频谱仪”,这就完全是另一个领域了,这里的“信号”指的是电磁波、电压波形和时序约束,和 Linux 信号机制除了共享一个中文译名,几乎没有交集。
所以我要特别提醒一句:你在搜“Linux 信号”时,可能会被大量“信号完整性”“顶底信号指标”之类的内容打断,这些都和操作系统里那个kill -9的“信号”不是一回事。做技术检索时把关键词限定成“Linux signal handling”“信号屏蔽字”“未决信号”,能省很多筛选的时间。
6. 最后分享一点个人经验:信号处理是工程习惯问题
我接触信号也有不少年头了,做过的项目从嵌入式到后端服务都有。个人的总体感受是:信号机制本身并不难,难在你能不能坚持一套安全的工程习惯。我在新代码里的信号处理,几乎都遵守这几条铁律:信号处理函数只置标志位;标志位用volatile sig_atomic_t;主循环统一检查标志位;能用sigwait同步处理的场景就用sigwait;系统调用被EINTR打断时,一律显式重试而不是依赖SA_RESTART。
另外一个小技巧是,如果某个服务需要对外暴露“优雅退出”的能力,我会保留一个额外的信号做“状态导出”。比如用SIGUSR1把当前的任务数、内存峰值的状态写到指定的文件里,这个状态文件在排查生产问题时非常有用。反正项目里可用的信号就那么几个,SIGUSR1、SIGUSR2、SIGTERM、SIGINT,用起来不心疼,但也别乱用。信号处理不是教科书上一个孤立的知识点,它是伴随着进程生命周期、系统调用、并发模型一起出现的系统工程问题。把这个链路里的每个环节都亲手跑一遍,比死记一百道面试题要有用得多。