你有没有遇到过这种情况:服务正常运行,突然被你自己的kill -9干掉;或者一个定时任务莫名其妙地提前触发了;又或者限流逻辑在高峰期突然失灵,放进来一堆请求。排查到最后,发现全跟信号处理有关。
我最近在做一个轻量级的数据转发服务,需要在用户态做限流和超时管理。一开始靠sleep轮询,CPU占用高得离谱,后来换成信号 + 计时器组合,才把问题真正解决。过程中把信号集、信号屏蔽字、pending状态机、SIGALRM多任务计时、令牌桶算法全串了一遍。这篇就按我实际踩坑的顺序,把整套东西梳理清楚,已经跑通的代码会直接贴出来,你可以照着改。
1. 信号的执行模型:为什么说它是“进程被强插的异步回调”
1.1 信号到底是怎么被“插入”进程的
很多同学学信号,只知道“信号是用来杀进程的”,然后就知道有个SIGKILL、SIGTERM。其实信号最本质的特征是异步通知:内核在某个时刻收到事件,把信号挂到进程上,然后在进程从内核态返回用户态的瞬间,检查有没有待处理的信号。如果有,就强制进程暂停当前的执行流,跳到预设的处理函数里跑一圈,再回来继续刚才的代码。
这个“强制暂停”很有意思。你无法预测它发生在哪一条指令之后,它可能在你算到一半的表达式中间打断你,也可能在你调read()刚阻塞的时候打断。所以信号处理必须遵守一个铁律:处理函数里不能做复杂操作。你可以把它理解成程序员赶工时突然有人拍你肩膀让你去开个短会,你只能简单处理,不能顺手把整周的工作全干完再回来。
1.2 哪些场景会产生信号
信号不是只有kill命令才能发。我整理了一份最常碰到的触发来源:
| 信号来源 | 典型信号 | 说明 |
|---|---|---|
| 硬件异常 | SIGSEGV、SIGFPE、SIGBUS |
段错误、除零、总线错误,进程内出现非法操作 |
| 终端按键 | SIGINT、SIGQUIT |
Ctrl+C 产生 SIGINT,Ctrl+\ 产生 SIGQUIT |
| 软件条件 | SIGALRM、SIGCHLD、SIGPIPE |
计时器到期、子进程退出、管道对端关闭 |
| 显式发送 | kill()、raise() |
进程间通信的原始手段之一,同一个进程也能给自己发 |
SIGPIPE 值得单独提一下。我在转发服务里遇到过:下游连接被对端关了,write() 写一半,内核直接给进程发一个 SIGPIPE,默认动作是终止进程。如果不处理或者不忽略,服务就会莫名其妙地“自杀”。真实的线上服务里,几乎所有人都会对它忽略处理,然后自己检查 write 的返回值。
1.3 默认处理、自定义处理和“不可干预”信号
信号有三种归宿:默认处理、捕获处理(自定义 handler)、忽略。默认处理对大多数信号来说是终止进程,少数是停止进程(SIGSTOP)或者忽略(SIGCHLD、SIGURG)。
但 SIGKILL(9号)和 SIGSTOP(19号)这两个是例外,它们既不能被捕获,也不能被忽略,更不能被屏蔽。内核直接执行默认动作。所以设计守护进程时的保底方案通常是 kill -9,就是因为它一定能杀掉进程。而 SIGTERM(15号)可以被捕获,很多服务收到它的第一反应是优雅地保存数据、关闭连接、退出,这也是为什么生产环境停服务优先发 SIGTERM 而不是直接 SIGKILL。
注册信号处理函数,旧接口 signal() 在不同系统上语义有细微差别,我建议直接上 sigaction():
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
static void on_int(int sig)
{
// 注意:handler 里不要调用 printf,后面会专门讲为什么
char msg[] = "catch SIGINT\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_int;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; // 不设置 SA_RESTART,后面讲 EINTR 时要考
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
while (1) {
pause(); // 把进程挂起,等信号来
}
return 0;
}
sigaction 的优势是你可以精确地控制信号处理期间的屏蔽字(通过 sa_mask)和行为标记(通过 sa_flags),这是老接口给不了的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号生命周期的三态流转:pending、屏蔽与递达的边界
2.1 产生、未决、递达:一个信号的一生
一个信号从出来到处理完,状态机是这样的:事件发生,信号被内核产生;如果当前进程没有屏蔽它,那它很快会被递达(执行默认动作或进入 handler);如果进程把信号屏蔽了,那这个信号就停在未决(pending)状态,待在进程的未决信号集里排队。
所以你记住一句话:屏蔽不是丢弃,只是延迟递达。信号被屏蔽期间怎么发都行,它老老实实待在那里,等屏蔽解除的瞬间,如果没有同类型信号抢先,它就会被处理掉。
这里有个非常重要的细节:标准信号是不排队的。什么意思?屏蔽期间你给它发了 5 次 SIGALRM,解除屏蔽后,它只会被递达一次,因为同一个未决信号在 pending 集合里只是一个比特位,内核不会维护“总共来了几次”的计数。这跟你收快递不一样,快递柜能存 5 个货,每个货对应一个格子;标准信号就像一个布尔开关,只有“有”和“没有”两种状态。
2.2 为什么“屏蔽”不等于“忽略”
忽略是信号来了以后,我明确表态“我不想处理它”,内核直接把它丢掉。屏蔽是信号来了以后,我先不处理,把它压进 pending 队列,等我说“解禁”了,它再从 pending 里出来执行。
这两者的区别在实战中非常关键。比如做令牌桶限流时,我需
