聊Linux进程管理的时候,信号(signal)是无论如何都绕不过去的一块。很多人最初接触它的契机是kill -9 pid杀进程,但kill背后的信号机制远不止“杀进程”这一个用处。信号本质上是一种异步事件通知机制,它告诉进程“有事情发生了”,而这个事情既可能来自用户按下 Ctrl+C,也可能来自内核发现你访问了非法内存,还可能来自另一个进程专门给你发了一条消息。这篇文章我就结合平时写服务、排查线上异常的经验,把 signal、kill、raise、abort、alarm、Core Dump 机制,以及它们和硬件中断之间的关联,一次讲清楚。无论你是刚学进程管理,还是已经被信号处理函数坑过几次,都值得往下看。
1. 先弄清楚信号这东西到底是什么
1.1 信号是一种“让进程来不及准备”的通知
我们可以把信号理解成现实里的“紧急电话”:内核是调度员,它有电话可以直接打到某个进程那里。这个电话什么时候响,进程自己没法预测,所以信号是异步的。进程收到电话之后,可以选择自己接(注册自定义处理函数)、直接当没听见(忽略信号),也可以保持默认反应(通常是终止进程)。
信号既然叫“信号”,本质上就是发给进程的一个整数代码。Linux 里每个信号都有名字和编号,比如SIGINT是 2,SIGTERM是 15。这些编号在不同的系统调用、命令行工具、日志信息里都会冒出来,认识它们是很底层但也很有用的基本功。
拿常见的几个信号来看:
| 信号 | 编号 | 默认动作 | 典型触发场景 |
|---|---|---|---|
| SIGHUP | 1 | 终止进程 | 终端挂断,很多守护进程用它触发配置重载 |
| SIGINT | 2 | 终止进程 | Ctrl+C |
| SIGQUIT | 3 | 终止并生成 Core | Ctrl+\,手动触发核心转储 |
| SIGKILL | 9 | 无条件终止 | kill -9,不可捕获也不可忽略 |
| SIGSEGV | 11 | 终止并生成 Core | 非法内存访问,典型的段错误 |
| SIGPIPE | 13 | 终止进程 | 向已关闭读端的管道写数据 |
| SIGALRM | 14 | 终止进程 | alarm 定时器到期 |
| SIGTERM | 15 | 终止进程 | kill 默认发送的终止信号 |
| SIGCHLD | 17 | 忽略 | 子进程退出或停止,父进程靠它知道“孩子状态变了” |
| SIGSTOP | 19 | 暂停进程 | 无条件暂停,不可捕获也不可忽略 |
| SIGCONT | 18 | 继续运行 | 让被暂停的进程继续跑 |
注意SIGKILL和SIGSTOP很特殊,它们不能被进程捕获,也不能被忽略。这是内核故意保留的最后手段:一个进程就算是把信号处理函数玩出花来,也没办法永远躲掉这两条系统级的约束。
1.2 信号从哪来,又要到哪去
一个完整信号的“一生”可以分成三步:
第一步是产生。产生信号的来源很多:
- 用户在终端按下 Ctrl+C、Ctrl+\,内核会向前台进程组发送 SIGINT 或 SIGQUIT。
- 程序执行中触发了 CPU 异常,比如除零、访问非法地址、执行非法指令,内核会把异常转换信号发给出错的进程。
- 某个进程主动调用
kill()、raise()、abort()等接口,指定把某个信号发给另一个进程或自己。 - 内核的定时器到期,比如
alarm()设置的时间到了,代写 SIGALRM 发送。
第二步是登记。信号产生之后,并不是立刻跑到目标进程的代码里“打断执行”,而是先被内核记录在该进程的信号等待队列中。现代内核用位图或者链表去维护这些 pending 信号,位图的每一位对应一个标准信号。
第三步是投递。进程从内核态返回用户态之前,内核会检查有没有 pending 信号需要处理。如果有,就让进程在用户态执行对应的信号处理函数。这里有个关键点:信号处理函数运行在用户态,不是在内核态跑,所以我们可以放心地在这个函数里写业务逻辑,最多受一些可重入规则约束。处理完之后,进程会从上一次被中断的系统调用或指令处继续执行,具体怎么继续,还取决于系统调用是否被设置成自动重启。
1.3 进程收到信号后的默认行为
默认行为不是只有“终止”这一种,粗略分五种:
- 终止:直接杀掉进程。
- 忽略:内核把这个信号丢掉,进程根本不需要知道。
- 终止加核心转储:进程先写一个 Core 文件,把当前内存快照、寄存器状态等现场保存下来,然后再退出。
- 停止:把进程暂停,不是杀掉,之后还能用 SIGCONT 继续。
- 继续:把之前被暂停的进程恢复执行。
这五种默认行为对应了不同场景。比如SIGCHLD的默认行为是忽略,因为父进程通常想知道子进程变退出,但不一定需要立刻处理;SIGSEGV、SIGABRT这类信号默认就要生成 Core,因为崩溃现场太珍贵了,丢掉就真白崩了。
2. 发信号的手段:kill、raise、abort 的差异与使用场景
2.1 kill() 系统调用:不只是杀进程
很多开发者容易把kill命令和“杀掉进程”画等号,但实际上kill系统调用只是“发送任何信号”的通用入口,名字沿用历史习惯而已。
函数原型:
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);pid参数的变化会决定信号发给谁:
pid > 0:发给指定 pid 的进程。pid == 0:发给调用进程所属进程组里的所有进程。pid == -1:发给调用进程有权限发送的所有进程,注意这里包含系统里的很多进程,用的时候要非常小心。pid < -1:发给进程组 id 等于|pid|的所有进程。
sig参数传 0 有特殊用法,叫“探活检查”:kill(pid, 0)不真正发送信号,只检查指定进程是否存在,以及当前用户是否有权限向它发送信号。如果返回 -1 且 errno 是 ESRCH,说明进程不存在;如果是 EPERM,说明进程存在但你没权限。这个技巧在服务健康检查脚本里很常用,比/proc/pid判断要方便。
使用示例:
#include <stdio.h> #include <signal.h> #include <errno.h> #include <string.h> #include <unistd.h> int main(void) { pid_t target = 12345; // 假设这是目标进程 if (kill(target, SIGTERM) == -1) { fprintf(stderr, "kill failed: %s\n", strerror(errno)); if (errno == ESRCH) { fprintf(stderr, "进程不存在\n"); } else if (errno == EPERM) { fprintf(stderr, "没有权限发送信号\n"); } return 1; } return 0; }实际工程里,kill(pid, SIGTERM)通常用来请求一个服务优雅退出,程序在收到 SIGTERM 后可以清理资源、保存状态、关闭连接,然后自己退出。只有在一段时间后没退,才考虑SIGKILL强制结束。
2.2 raise() 函数:进程给自己发信号
raise的语义更直接:给当前进程自己发送指定信号。
函数原型:
#include <signal.h> int raise(int sig);在单线程程序里,raise(sig)相当于kill(getpid(), sig)。但在多线程程序里,两者就有区别了:raise会把信号发送给调用线程,而kill(getpid(), sig)虽然也是发给整个进程,但最终哪个线程拿到这个信号,由内核的线程调度和信号分发规则决定,一般会送到主线程或某个可用线程。
所以如果你想在某个工作线程里触发当前线程的信号处理逻辑,用raise更符合直觉;如果你想让进程层面统一处理,可以考虑用pthread_kill(pthread_self(), sig)或者kill(getpid(), sig)。
实际场景中,raise常用于业务自检失败时主动报警。比如程序读到一个明显不合理的数据状态,希望在崩溃前把现场留下来,就可以用raise(SIGABRT)触发 abort 逻辑,等会儿会讲。
2.3 abort() 函数:让进程带着“遗书”离开
abort()的职责非常明确:异常终止当前进程,并且尽量把崩溃现场记录下来。它的内部实现大致是先调用raise(SIGABRT),如果之后函数又返回了——也就是 SIGABRT 被进程捕获或忽略——那么它会重置 SIGABRT 的默认处理方式,再次触发,确保进程一定会被终止。
#include <stdlib.h> void abort(void);它和exit()的区别在于:exit()是“体面退场”,会执行atexit注册的钩子函数、刷新 stdio 缓冲区;而abort()的目标是“就地暴毙”,尽可能保留原始出错现场。glibc 实现里会尝试刷新 stdio 流,但这并不保证所有 C 库实现都一样,所以别把资源清理逻辑放到 abort 之后执行。
在库函数里处理不可恢复错误时,abort()比exit()更合适。因为库代码根本不该替调用方决定“要不要继续跑”;直接 abort 并产生 Core,等开发者用调试器看到底是哪一步出了问题,反而是最清晰的错误信号。
3. signal 函数与信号处理函数的正确姿势
3.1 注册一个信号处理函数
最基础的方式就是signal()函数:
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);handler 有几种取值:SIG_IGN表示忽略信号,SIG_DFL表示恢复默认行为,也可以是自己定义的函数指针。函数返回值是旧的处理函数,通常用在切换前后保存现场,但实际项目里更多直接忽略了,只有出错时返回SIG_ERR值得关注。
一个常见的优雅退出实现:
#include <stdio.h> #include <signal.h> #include <unistd.h> static volatile sig_atomic_t g_running = 1; static void handle_term(int sig) { (void)sig; g_running = 0; // 只改标志,不做复杂操作 } int main(void) { signal(SIGINT, handle_term); signal(SIGTERM, handle_term); while (g_running) { // 业务逻辑 sleep(1); } printf("clean exit\n"); return 0; }这里的标志位必须用volatile sig_atomic_t。sig_atomic_t保证了在主程序正在读取的同时,信号处理函数写入时不会导致数据撕裂;volatile是通知编译器不要把这个变量优化到寄存器里缓存,否则主循环可能永远读不到更新后的值。
3.2 处理函数里能干啥?这才是重点
信号处理函数最坑的地方在于:它在任意时刻都可能运行,也就是说它会打乱主程序的正常执行流程。所以这个函数不能随便调用普通库函数。
不可重入函数的意思,简单说就是:如果一个函数在执行过程中再次被调用,结果会出错或产生竞态,那它就是不可重入的。printf、malloc、free、getenv这类函数在信号处理函数里都非常危险。
printf内部有 stdio 缓冲区,还可能持有锁。如果主程序在 printf 内部走到一半,信号来了,处理函数里又调 printf,就会发生:
- 缓冲区状态错乱,输出重复或丢失;
- 锁未释放,处理函数里的 printf 被自己锁死,程序卡住。
malloc更严重。主程序可能在堆分配中途被信号打断,处理函数又调用 malloc,堆管理数据结构处于中间状态,轻则内存泄漏,重则直接崩掉。
处理函数里安全可以做的事比较有限,最稳妥的是:
- 只设置一个
volatile sig_atomic_t标志位; - 用
write()写几个字节到标准错误; - 调用
_exit()直接退出,而不是exit()。
static void alarm_handler(int sig) { (void)sig; write(STDERR_FILENO, "timeout!\n", 9); _exit(1); }如果确实需要在信号发生时做更多工作,推荐采用“信号处理函数只记录标志,主循环轮询标志并执行具体逻辑”的模式。这样实际的风险都控制在主循环里,行为可预测,调试也方便。
3.3 用 sigaction 替代 signal,别在现代代码里用历史包袱
signal()的历史实现有两大派系:System V 和 BSD,两者最大的分歧在于信号处理函数执行完后,这个信号的处理方式是否重置为默认行为。System V 的做法是重置,导致处理函数只能生效一次,第二次同样的信号来临时,进程可能直接按默认动作终止;BSD 的做法是保持注册。不同平台的signal()行为不同,这种不确定性在跨平台项目里很容易引发莫名其妙的边界问题。
现代 Linux 编程规范里,首选sigaction():
#include <signal.h> struct sigaction { void (*sa_handler)(int); sigset_t sa_mask; int sa_flags; void (*sa_sigaction)(int, siginfo_t *, void *); };struct sigaction sa = {0}; sa.sa_handler = handle_term; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; sigaction(SIGTERM, &sa, NULL);sa_mask指定在处理该信号期间额外阻塞哪些信号,防止嵌套打扰。sa_flags常用几个标志:
SA_RESTART:被信号打断的系统调用自动重启,比如read、write遇到信号后不会返回 EINTR,而是等信号处理完继续执行。SA_SIGINFO:使用sa_sigaction回调,可以拿到信号来源、附加数据等详细信息。SA_RESETHAND:处理完一次后重置为默认行为,恢复 System V 风格。
需要获取信号更多信息时,必须用sa_sigaction+SA_SIGINFO。比如想判断SIGCHLD到底是因为子进程退出、停止还是继续,只有siginfo_t的si_code能区分。
4. alarm 与定时信号:别让超时控制写成一团乱麻
4.1 alarm() 很简单,但背后的默认行为要记住
#include <unistd.h> unsigned int alarm(unsigned int seconds);alarm()让内核在 seconds 秒之后向当前进程发送SIGALRM。返回值是上一次 alarm 设置后剩余的秒数。如果之前设置过定时器且还没到期,再次调用 alarm 会直接覆盖旧的,并返回旧定时器还剩的秒数。
通常我们把这个串联成一个定时机制:
#include <stdio.h> #include <signal.h> #include <unistd.h> static volatile sig_atomic_t g_flag = 0; static void on_alarm(int sig) { (void)sig; g_flag = 1; } int main(void) { struct sigaction sa = {0}; sa.sa_handler = on_alarm; sigaction(SIGALRM, &sa, NULL); alarm(3); while (!g_flag) { // 等待 3 秒或处理其他事情 } printf("alarm fired\n"); return 0; }注意SIGALRM的默认动作是终止进程。如果你只调用alarm(3)却没注册处理函数,那 3 秒后进程就直接被杀了,这常常是初学者的疑惑点。
4.2 用 alarm 做 IO 超时控制,经典的 EINTR 坑
一个很常见的场景:我们需要给read()设置一个超时,避免网络客户端一直不发送数据导致服务卡住。很多人的第一反应是:
alarm(5); n = read(fd, buf, sizeof(buf)); alarm(0); // 读完了,取消闹钟这个写法有个竞态问题:如果read()还没调用,alarm 已经到点,信号直接送达,而你还没进入阻塞读,超时信号就白白浪费了。更麻烦的是,即使信号在read()阻塞期间到达,read()可能被中断,返回-1且 errno 为EINTR,你需要判断到底是“真超时”还是“被信号打断”。
规范的写法是先设置信号处理函数,再用sigaction的SA_RESTART让读重新开始,但这样又没办法用 EINTR 来感知超时了。所以更多人直接用poll或带超时的 epoll,这套机制在 IO 超时控制上比信号靠谱得多。alarm的定位更多是简单的定时任务,或者配合 sleep 类操作。
还有一个经典的点:alarm()只能保证到点发一个信号,信号不排队。多次设置只能覆盖,不会累加。如果需要周期性触发定时的“心跳”,可以在处理函数里再次调用alarm(),自己续周期,但断续之间如果有抖动,需要评估精度。
4.3 比 alarm 更现代的定时器选项
alarm精度只有秒级,因为它的粒度就是秒。要做更高精度的定时,Linux 提供了几个递进方案:
| 接口 | 精度 | 重复触发 | 通知方式 | 适合场景 |
|---|---|---|---|---|
| alarm() | 秒级 | 不自动重复 | SIGALRM | 简单超时控制、定时唤醒 |
| setitimer() | 微秒级 | 可自动重复 | SIGALRM 等信号 | 轻量周期任务 |
| timer_create() | 纳秒级 | 可自动重复 | 信号或线程回调 | 高精度 POSIX 定时器 |
| timerfd_create() | 纳秒级 | 可自动重复 | 文件描述符可读 | 高并发事件循环,配合 epoll |
对于高并发服务,我最推荐timerfd_create()。它把定时器变成了一个文件描述符,可以直接丢给 epoll 监听。到时候 fd 可读,主循环统一处理,根本没有信号中断的烦恼,也不会在信号处理函数里做危险操作。这个设计思路其实是“把异步信号的复杂性转化为事件循环里的普通事件”,对稳定性要求高的服务非常合适。
5. Core Dump 核心转储机制:程序崩溃后的一手现场
5.1 为什么叫 Core Dump
这个名字最早来自老式内存技术“磁芯存储器”,当程序异常终止时,内核把进程当时的“核心”内存映像保存到磁盘文件,所以叫 Core Dump。这个文件保存的不只是内存数据,还包括寄存器、栈、打开的文件描述符、信号信息等,可以说是一次完整的“案发现场”。
默认情况下,很多 Linux 环境是不生成 Core 文件的。因为虚拟内存空间很大,如果程序使用了几 GB 内存,直接落盘可能会瞬间写满磁盘。操作系统提供了限制开关:
# 查看当前限制 ulimit -c # 关闭 ulimit -c 0 # 不限制大小 ulimit -c unlimited很多同学写了段带 bug 的 C 代码,跑出Segmentation fault (core dumped)提示,但当前目录却没找到 core 文件,多半就是 ulimit 限制的问题。先执行ulimit -c unlimited再复现崩溃。
5.2 Core 文件放哪里,怎么控制
Core 文件的命名和路径由内核参数kernel.core_pattern控制:
cat /proc/sys/kernel/core_pattern不同发行版差别很大。早期系统简单输出core,意思是直接写到当前工作目录下名为 core 的文件;现在很多系统会通过管道交给 systemd-coredump,比如:
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h这种情况下普通用户看不到 core 文件,需要用coredumpctl list、coredumpctl info PID之类的工具去查。
如果需要自定义路径,可以修改 core_pattern,比如:
echo "/var/core/core_%e_%p_%t" > /proc/sys/kernel/core_pattern%e是程序名,%p是 PID,%t是时间戳。这些占位符在排查多个崩溃实例时非常有用。
生产环境里,我的建议是:
ulimit -c设置一个合理上限,比如 200MB,防止 Core 文件把磁盘打满;- core_pattern 指向独立分区或专用目录;
- 用 coredumpctl 或自定义监控脚本定期清理和归档核心转储;
- Core 文件可能包含敏感内存数据(比如密码明文),权限和保存周期都要谨慎。
5.3 用 Core 文件还原崩溃现场
假设我们写了一个肯定会崩的程序:
#include <stdio.h> #include <string.h> int main(void) { int *p = NULL; *p = 42; return 0; }编译运行:
gcc -g -o demo demo.c ulimit -c unlimited ./demo输出Segmentation fault (core dumped),目录下出现 core 文件。接着用 gdb 调试:
gdb ./demo core进入 gdb 后,第一步一定是bt(backtrace)看调用栈:
(gdb) bt #0 0x00000000004004f5 in main () at demo.c:6 6 *p = 42;这一下就定位到第 6 行的空指针赋值。如果代码复杂,还可以:
info locals查看当前局部变量;frame 1、frame 2切换调用栈帧;info reg查看寄存器里的现场;list显示对应源码行,前提是编译时带-g调试信息。
释放一个很实用的经验:生产环境排查崩溃,不要依赖用户描述和日志,直接把 Core 文件拿回来 gdb 一把梭定栈是最高效的手段。很多“神秘崩溃”看了 Core 文件之后发现是栈溢出、double free 或者内存被踩坏,几乎不用猜。
6. 硬件中断与信号:信号源头中的硬骨头
6.1 硬件中断是信号的前置触发点
很多人以为信号只是软件层的东西,实际上很多关键信号来自 CPU 内部的异常响应。
比如访问非法内存。当用户态程序执行一条访问地址的指令,CPU 的 MMU 会做地址转换,发现这个地址没有映射或权限不对,CPU 触发一个页故障异常,这个异常被操作系统捕获。内核查了一下,发现这个地址确实不在进程地址空间里,就决定给当前进程发SIGSEGV。
比如整数除零。CPU 在执行除法指令时发现除数是 0,会触发一个“除法错误”异常,内核把这个异常对应到 SIGFPE,发给当前进程。
再比如执行非法指令。CPU 遇到无法识别的操作码,会触发非法指令异常,内核转成 SIGILL 发给进程。
这一整条链路是“硬件中断/异常 -> 内核处理 -> 进程收到信号”,而不是信号真的从硬件里直接飞出来。信号本身仍然是软件层面的机制,但它的触发源带上了硬件异常的味道。这也是为什么段错误、总线错误这类信号发生时,默认动作一般都会生成 Core:因为内核知道这是进程“搞出了严重问题”,现场对排查价值极高。
6.2 外部硬件中断和信号的差别
电脑上的网卡收到数据包时,会通过中断通知 CPU,这个过程是硬中断。内核中断处理程序把数据包放进协议栈队列,后续处理完成后唤醒正在等待的进程,这个过程可能对进程表现为“socket 可读了”,但通常不直接发一个信号给进程。
所以外部设备中断和“进程信号”是两种不同层面的事件:
- 外部中断是给内核的,告诉它“硬件有事情”,内核需要决定怎么处理。
- 信号是内核给进程的,告诉它“有事情涉及你”,进程需要决定怎么处理。
只有当硬件状态变化转化为进程必须关注的条件时,内核才会发明信号。比如网卡入队的数据超过 socket 缓冲区阈值,可能触发 SIGURG 通知进程有紧急数据。
6.3 另一个被忽略的软件信号:SIGPIPE
除了硬件异常,很多常见信号来自软件条件,比如 SIGPIPE。如果你向一个已经关闭读端的管道或 socket 写数据,内核会发送 SIGPIPE 给写进程。默认动作是终止进程,这就导致很多服务在客户端断开连接后,服务端在往 socket 写数据时突然“莫名其妙”死掉。
要处理这个问题,一般做法是:
signal(SIGPIPE, SIG_IGN);忽略 SIGPIPE 后,内核不会再用“杀进程”来表达管道断开,而是让write()调用返回-1,并设置 errno 为 EPIPE。这样程序就能通过检查 write 返回值来感知连接异常,做出优雅处理。这个处理在编写网络服务时几乎是必做的。
7. 信号实战中的常见坑与排查技巧
7.1 标准信号不排队,别被“信号丢失”骗了
这是信号机制里最让人郁闷的特性之一。Linux 的标准信号(1 到 31,不含实时信号)是不排队的。进程在执行信号处理函数的过程中,如果又收到一个相同的信号,这个新信号会被标记为 pending,但不会累积。也就是说,发 10 次同一个标准信号给进程,进程可能只处理一两次。
比如你用信号给守护进程发送“重新加载配置”的通知,结果连续来了 5 次,处理函数只执行了一次,有时候这符合预期(重复通知合并处理反而更高效),有时候却是 bug。
解决方案有两个方向:
- 使用实时信号
SIGRTMIN以上的信号,这部分信号支持排队; - 用
signalfd读取信号,把信号变成事件流,由事件循环统一计数处理。
signalfd的高明之处在于:它把信号当作普通事件描述符看待,可以用 read 把信号信息读出来,还可以配合 epoll 做完整的事件驱动模型。它尤其适合写高性能网络服务时,既不想用不可靠的标准信号,又不想开一堆线程处理信号的场景。
7.2 信号处理函数里的野操作,偶发崩溃的典型来源
我见过不止一个线上服务,平时好好的,一到高峰期就偶发崩溃,查了很久才发现是在信号处理函数里写了日志。原理上面说过:处理函数里调 printf 一类函数不安全,但开发初期可能没人注意这些,毕竟测试时很难稳定重现。
解决办法很简单:
- 处理函数只设置标志变量;
- 日志改到主循环检测到标志之后,由主逻辑打印;
- 实在要在处理函数里输出,用
write,别用printf。
如果是 C++ 程序,还要额外注意:处理函数里不能调用任何可能分配内存或执行非线程安全操作的标准库函数,连new、delete都不应该出现。
7.3 忽略 SIGCHLD 引起的僵尸进程
子进程退出后不会立刻从系统里消失,而是变成僵尸进程(Zombie),直到父进程调用wait()/waitpid()回收它的退出状态。如果父进程一直不回收,系统进程表里的僵尸条目就越积越多,pid 资源被白白占用。
正确处理方式有两种:
如果你不关心子进程的退出状态,最简单的是:
signal(SIGCHLD, SIG_IGN);这是 Linux 的一个特殊约定:父进程显式忽略 SIGCHLD,那么子进程退出时会被内核自动回收,不产生僵尸进程。
如果你需要知道子进程的退出码、终止原因,可以注册处理函数,在里面调用 waitpid:
static void on_sigchld(int sig) { (void)sig; int status; pid_t pid; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 处理 pid 的退出状态 } }这里用WNOHANG是避免阻塞父进程,并且用循环把所有已退出子进程都收一遍。
7.4 排查信号问题的小工具
生产环境里排查信号问题,三个工具很实用:
第一是strace。跟踪进程的系统调用时,信号相关事件也会被记录下来。比如:
strace -e trace=signal -p 12345可以看到进程收到了哪些信号,以及系统调用被信号打断后返回了EINTR。这能很直接地告诉我们“信号来的时候程序正在干什么”。
第二是gdb的信号处理。调试时你可以让 gdb 在特定信号发生时停下来:
(gdb) handle SIGSEGV stop (gdb) run这样崩掉时先停在信号的投递点,再用bt看调用栈,比直接看 Core 文件还早一步。
第三是/proc/pid/status。查看进程的信号相关信息,比如:
grep -E "Sig(Pnd|Blk|Ign|Cgt)" /proc/12345/statusSigPnd是 pending 的信号位图,SigBlk是阻塞的信号位图,SigIgn、SigCgt分别记录忽略和捕获的信号。它反映的是当前进程的信号配置快照,排查“为什么这个进程没有被 SIGTERM 杀掉”时非常有用。
7.5 服务端程序到底该用 handler 还是 sigwait
处理信号有两种主流模型,选择取舍很重要。
一种是传统的 handler + 全局标志。优点是小,轻量,适合少量信号、逻辑简单的小进程。缺点是:信号处理函数在任意线程中随机执行,容易和主线程产生竞态;对多线程复杂状态的一致性很难保证。
另一种是sigwait模型。把感兴趣的信号先阻塞掉,再开一个专用线程调用sigwait()等待信号到来,信号会作为普通的整数值返回,然后在线程里慢慢处理。这个模型相当于把信号变成了一个普通的业务队列事件,完全没有“中断主程序”的副作用。
参考代码思路:
sigset_t set; sigemptyset(&set); sigaddset(&set, SIGTERM); sigaddset(&set, SIGINT); sigprocmask(SIG_BLOCK, &set, NULL); // 在某个线程里 int sig; while (sigwait(&set, &sig) == 0) { if (sig == SIGTERM || sig == SIGINT) { break; } }对于高并发的服务端程序,我更推荐 sigwait 模型。它的行为可预测,不需要在信号处理函数里做任何受限操作,线程化的处理也可以直接加日志或者做复杂的释放逻辑。代价是多开一个线程和稍微复杂一点的初始化顺序,但这点成本换来的稳定性非常划算。
最后的一点个人经验
我自己的项目里曾经被SIGALRM坑过一次。当时图省事,用alarm()给某个阻塞 IO 做超时,为了判断超时,信号处理函数里写了几条日志,结果压测时服务偶尔卡死。后来用 gdb 一查,发现printf在信号处理函数和主逻辑之间发生了锁竞争,一个持有锁没释放,另一个进去等锁,整个线程全卡住。改成在信号处理函数里只设置标志位、主循环里检查标志之后再打日志,问题立刻消失。后来所有需要定时、超时的服务,我都优先选择 timerfd 加 epoll 的方式,彻底绕开了信号处理函数的各种限制。信号这套机制看着简单,真正用起来,边界条件比一般代码多得多,每一次踩坑都值得把原因弄透,否则下次还会在同样的地方栽跟头。