信号这东西,做Linux开发的基本天天能碰到。程序崩了,先想到段错误SIGSEGV;后台进程要被杀了,kill -9丢过去;Ctrl+C想让程序优雅退出,本质就是SIGINT信号在起作用。但和很多Linux知识一样,平时用得熟不代表理解得深。我面试过不少人,能说出kill -9和kill -15区别的不少,可一旦问到"信号是怎么送到进程的""为什么有时候信号会丢""SIGCHLD到底该怎么处理"这几个层面,就开始含糊了。这篇内容就是围绕Linux进程信号做一次系统的拆解,把信号的产生、递送、处理、阻塞、等待这些环节全部过一遍。适合正在学Linux应用开发、准备面试、或者写服务端程序时被信号坑过的人阅读,我不保证每一行都让你拍大腿,但至少能帮你少踩几个信号相关的坑。
1. 信号到底是个什么东西——先建立整体认知
1.1 信号的本质:软件层面的"中断"
信号是Linux进程间通信(IPC)的一种,但它跟管道、消息队列、共享内存这些IPC机制完全不同。管道和共享内存传的是数据,讲究的是"内容",信号传的是"事件通知",本身没有数据负载,就是一个数字编号,告诉进程"发生了什么事"。
理解信号最形象的类比是硬件中断。CPU收到外部设备的中断请求时,会保存当前上下文,跳去执行中断处理程序,执行完再回来继续原来的工作。信号干的事一模一样,只不过"中断源"变成软件触发的,接收方是进程。进程收到信号后,内核会暂停该进程当前正在执行的流程,转去执行信号的处理逻辑,处理完再恢复原流程。所以信号的官方定义叫"软件中断",非常贴切。
这个机制解决什么问题呢?它解决了"进程之间如何及时告知彼此状态变化"的问题。子进程退出了、定时器到点了、外部要求终止、硬件检测到非法访问内存,这些事件都不是进程主动轮询就能高效感知的,信号提供了一套异步通知机制,让内核直接打断进程,把事件塞过去。
1.2 信号的分类:从1到31,标准信号与实时信号的区别
Linux下用kill -l可以查看全部信号列表,标准信号编号从1到31,后面还有34到64的实时信号。划分标准其实很明确:1到31号信号是UNIX系统传统信号,每个信号有固定语义,比如SIGINT是2号、SIGKILL是9号、SIGSEGV是11号。实时信号在标准信号之后,语义不固定,用途也灵活很多。
两者最本质的区别在排队机制上。标准信号是"合并式通知"——如果进程还没处理完一个信号,又连续来了好几个相同的标准信号,内核只保留一个,不排队。这就好比手机通知栏里同一个App的权限弹窗,弹一次你没点,它不会排队弹十次,等你点了再说。实时信号不同,内核会为每个实时信号排队,来一个记一个,全部递送,不丢失。
| 信号名称 | 编号 | 默认动作 | 常见触发场景 |
|---|---|---|---|
| SIGHUP | 1 | 终止进程 | 终端挂断、nohup忽略它 |
| SIGINT | 2 | 终止进程 | 按Ctrl+C |
| SIGQUIT | 3 | 终止进程并生成core文件 | 按Ctrl+\ |
| SIGKILL | 9 | 终止进程(不可捕获、不可阻塞) | kill -9强杀 |
| SIGSEGV | 11 | 终止进程并生成core文件 | 访问非法内存地址(空指针解引用) |
| SIGPIPE | 13 | 终止进程 | 向没有读端的管道写数据 |
| SIGTERM | 15 | 终止进程 | kill命令默认发送,可被捕获处理 |
| SIGCHLD | 17 | 忽略(子进程退出时父进程收到通知) | 子进程调用了exit |
为什么要把SIGKILL和SIGTERM单独拎出来说?因为这是日常运维和开发中最重要的两个信号。SIGTERM是温和的"请退出"信号,进程收到后可以自己注册处理函数,保存现场、清理资源、优雅下线;SIGKILL则是暴力拆除,内核直接帮你终止进程,不给进程任何挣扎的机会,处理函数都没机会执行。很多新手以为kill -9是清理进程的唯一手段,但在生产环境遇到业务进程没响应,正确思路是先kill -15让业务自己收尾,过几秒没退再考虑kill -9。
1.3 信号处理的三条路径
信号到达进程后,进程会怎么处理?只有三种可能:默认动作、捕获处理、忽略。
默认动作是系统预定义的,每种信号都有各自的默认行为,有的终止进程、有的终止并产生core、有的忽略。如果不想用默认动作,可以注册自己的处理函数,也就是"捕获信号",最典型的就是给SIGINT和SIGTERM注册处理函数,实现优雅退出;也可以主动告诉内核"这个信号我不关心",屏蔽掉,比如很多后台守护进程会忽略SIGPIPE,防止写管道时对端关闭导致进程被意外杀掉。
忽略和捕获有个前提:SIGKILL和SIGSTOP不能被捕获、不能被阻塞、不能被忽略。这是内核的硬性规定,因为系统必须有兜底手段能终结任何失控的进程。我见过有同事在处理函数里试图忽略SIGKILL,代码写得很复杂,最后测试发现根本没生效。这个限制不是Linux扩展出来的,是POSIX标准明确要求的,理解信号第一件事就是把这两条"红线"背下来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号从哪来——四大产生途径与实操验证
2.1 终端按键与kill命令
最熟悉的信号来源就是终端按键。按下Ctrl+C,终端驱动程序把当前前台进程组的每个进程发一个SIGINT;按下Ctrl+\,发的是SIGQUIT,这个信号默认行为是终止并产生core文件,调试时很有用。Ctrl+Z发送的是SIGTSTP,把进程暂停(挂起),前台进程变成后台停止状态,用fg或bg恢复。
终端按键产生的信号有个细节经常被忽略:信号接收对象是"前台进程组"里的所有进程,不是单个进程。我用bash执行一条管道命令cmd1 | cmd2,这时按Ctrl+C,cmd1和cmd2会同时收到SIGINT,而不是只有当前正在运行的那个进程。原因在于作业控制的设计思路:整条管道在逻辑上是一个作业,Ctrl+C是让整个作业停下来,而不是某个进程。搞明白这点,遇到"按Ctrl+C后单个进程没退,或者多个进程只退了一个"的情况,就知道往进程组方向排查。
kill命令是另一个主要来源。kill -9 12345向进程12345发送SIGKILL,kill -15 12345发送SIGTERM。注意kill命令本质上不是"杀进程"的命令,而是"发送信号"的命令,信号种类完全由参数指定。kill -l可以列出全部信号名和编号,kill -s SIGINT 12345可以指定信号名,不写默认发SIGTERM。
2.2 硬件异常与软件条件
硬件异常产生的信号,本质是CPU检测到异常后由内核兜底转化成信号。最常见的两种:空指针解引用触发SIGSEGV(段错误),整数除以0触发SIGFPE(浮点异常),还有非法指令触发SIGILL。这些信号都不是进程自己给自己发的,而是CPU执行出错、陷入内核后,内核根据异常类型选择对应的信号发给肇事进程。
开发时经常遇到的核心转储(core dump),背后就是这类信号默认行为的结果。SIGSEGV、SIGQUIT、SIGFPE等信号的默认动作都是"终止进程+生成core文件"。core文件是进程死前的内存快照,用gdb加载它可以定位崩溃时的调用栈。很多Linux发行版默认不生成core文件,需要执行ulimit -c unlimited打开。我排查线上崩溃问题时,这个开关第一时间就要确认,不然进程天天崩,连一点现场线索都留不下来。
软件条件产生的信号,指的是内核或进程主动发信号来报告特定状态。比如子进程退出时,内核自动给父进程发SIGCHLD;写管道时对端已经关闭,内核触发SIGPIPE;定时器到期触发SIGALRM。这类信号的特点是"不请自来":进程没有主动请求,内核按规则就发了,这也是信号作为异步通知机制的核心价值。
2.3 raise、abort、alarm的区分与使用场景
除了外部发信号,进程也可以给自己发信号。C标准库提供了三个常用接口:
raise(sig):给当前进程发送指定信号,等价于kill(getpid(), sig),最常用的是raise(SIGTERM)实现进程自杀。abort():向当前进程发送SIGABRT信号。它比raise特殊的地方在于,如果SIGABRT被阻塞或忽略,abort()会强制恢复默认行为并终止进程。设计意图很明确:abort是用来报"严重错误"的,不让进程有机会赖着不退出。alarm(seconds):设置一个一次性定时器,时间到了内核发SIGALRM给进程,默认动作是终止进程。想定时但不想退出,就得自己捕获SIGALRM。每调用一次alarm,之前设的闹钟就会被替换掉,没有叠加的概念。
alarm有个经典场景是处理超时。比如读取socket数据,设置了alarm(5),如果5秒内没读到数据,SIGALRM就来了,在信号处理函数里做超时处理或者设置标志位。不过这种写法有个坑,和后续要讲的"中断慢系统调用"问题直接相关,SIGALRM到达时,read调用会被中断返回EINTR错误,不处理的话程序就异常了。这块我在后面"常见问题"一节专门讲。
3. 默认行为与信号处理——捕获信号的三板斧
3.1 信号处理函数的注册:signal与sigaction怎么选
进程要捕获信号,核心就一个操作:注册处理函数。Linux下有两套API,signal()和sigaction()。
signal()是传统ANSI C接口,使用方法很直白:
c复制#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void handler(int sig) {
printf("捕获到信号 %d\n", sig);
}
int main() {
signal(SIGINT, handler);
while(1) {
sleep(1);
}
return 0;
}
编译运行后,按下Ctrl+C,进程不会退出,而是打印"捕获到信号2",然后继续循环。这套接口好写好理解,但有两个痛点:第一,它在不同UNIX系统上语义有细微差别,有的系统处理完信号后会自动把处理函数恢复成默认行为,导致第二次按Ctrl+C直接退出;第二,它不支持精细控制信号的屏蔽、标志位等行为。
sigaction()是POSIX标准推荐的现代接口,逻辑更完整:
c复制#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void handler(int sig) {
printf("捕获到信号 %d\n", sig);
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler; // 处理函数
sigemptyset(&sa.sa_mask); // 处理函数执行期间不额外阻塞信号
sa.sa_flags = 0; // 标志位
sigaction(SIGINT, &sa, NULL);
while(1) {
sleep(1);
}
return 0;
}
可以看到sigaction需要填充一个结构体,字段多,但每一步都是明确的:sa_handler指定处理函数,sa_mask指定处理函数运行期间要额外屏蔽的信号集合,sa_flags控制行为细节。最常用的标志是SA_RESTART,它让被信号打断的系统调用自动重启,省掉手动处理EINTR的麻烦。个人经验是,新代码一律用sigaction,signal仅用于写简单 demo。
3.2 处理函数里能干什么、不能干什么——可重入函数约束
信号处理函数不是在主流程里按顺序调用的,而是内核随时插入来执行的。这个特点带来一个极其重要的约束:处理函数必须"可重入"。
可重入的意思是这个函数可以被中断后再次进入,而不会破坏数据。比如printf()内部有全局缓冲区,如果主程序正在调用printf处理到一半,信号来了,处理函数里又调printf,两个调用交替修改同一个缓冲区,结果就是输出错乱,甚至缓冲区状态崩溃。同理还有malloc、free这类有锁和内部状态的函数,都不应该在信号处理函数里调用。
那处理函数里能做什么?官方推荐的做法是:设置一个volatile sig_atomic_t类型的标志变量,主程序循环里检查这个变量。sig_atomic_t是C标准里保证读写在信号处理场景下原子性的整数类型,配合volatile防止编译器优化导致的读取失效:
c复制#include <signal.h>
#include <stdio.h>
#include <unistd.h>
volatile sig_atomic_t g_flag = 0;
void handler(int sig) {
g_flag = 1; // 只做最小操作,安全
}
int main() {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
while(1) {
if (g_flag) {
printf("收到退出请求,开始清理\n");
break;
}
sleep(1);
}
return 0;
}
我早期写信号处理时就在handler里直接写日志、调接口,结果遇到线上生产环境日志乱成一团,查了半天才发现是printf在信号处理函数里调用导致缓冲区被并发访问。后来所有信号处理的逻辑全部统一成"置标志位",主循环收到标志后再做真正的复杂操作,这个模式一旦养成就再没踩过坑。
3.3 SIGCHLD与僵尸进程——为什么wait是必要的
接下来必须聊SIGCHLD,这是子进程退出时内核发给父进程的通知信号。很多新手不知道这个信号的存在,直到程序跑着跑着冒出大量僵尸进程(Zombie),才意识到"子进程结束了,但父进程一直没处理"。
僵尸进程的本质:子进程退出后,内核不能完全清除它的进程表项,因为需要保留退出状态码,供父进程调用wait()或者waitpid()取走。如果父进程既不调用wait,也不处理SIGCHLD,那么子进程的占位就一直留着。僵尸进程不占用CPU、不占内存,但它是进程表里的一条记录,积累多了,进程号被耗尽,新进程就创不出来。
正确做法是注册SIGCHLD的处理函数,在函数里调用waitpid回收子进程:
c复制#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
void child_handler(int sig) {
int status;
// WNOHANG表示没有已退出的子进程就立即返回,避免阻塞处理函数
while (waitpid(-1, &status, WNOHANG) > 0) {
// 循环回收所有已经退出的子进程
}
}
int main() {
struct sigaction sa;
sa.sa_handler = child_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
pid_t pid = fork();
if (pid == 0) {
exit(0);
}
pause(); // 等待信号
return 0;
}
注意两个细节:第一,处理函数里用WNOHANG非阻塞选项,因为信号到达时可能不止一个子进程退出,但是一个SIGCHLD信号只代表"有子进程退出了",不代表退出了几个,一定要用循环把所有退出子进程回收干净;第二,sa_flags加SA_NOCLDSTOP,这个标志的意思是"子进程暂停(SIGSTOP/SIGTSTP等)时不触发SIGCHLD",只有终止才触发,过滤掉无关干扰。
4. 屏蔽与未决——信号不是立即送达的
4.1 阻塞集合与未决集合:内核怎么管理信号状态
信号的交付时间和产生时间不一定同步。内核为每个进程维护两个关键集合:阻塞集合(block set)和未决集合(pending set)。
进程可以调用sigprocmask()把某些信号加入阻塞集合。被阻塞的信号不是消失了,而是被挂到未决集合里等待。什么意思呢?比如进程调用了sigprocmask(SIG_BLOCK, &set, NULL)阻塞了SIGINT,然后按下Ctrl+C,SIGINT不会立刻触发处理函数,而是进入未决状态。等到进程解除阻塞,信号才真正递送,处理函数才执行。
这个机制的核心价值是"临界区保护":在修改共享数据的代码段前后,临时屏蔽可能打断操作的信号,保证操作的原子性。比如程序正在写一个配置文件,写到一半如果被SIGTERM打断,处理函数一执行,配置文件可能就只写了一半,恢复主流程后又是另一种错乱。正确写法是把"屏蔽信号、写文件、解除屏蔽"打包成一个完整动作。
c复制#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void handler(int sig) {
printf("收到信号 %d\n", sig);
}
int main() {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, &oldset); // 屏蔽SIGINT
printf("SIGINT已被屏蔽,2秒内按Ctrl+C检查效果\n");
sleep(2);
sigprocmask(SIG_SETMASK, &oldset, NULL); // 解除屏蔽
printf("已解除屏蔽,信号可能此刻才递送\n");
sleep(2);
return 0;
}
4.2 屏蔽期间的信号还能查到吗——sigpending实战
被屏蔽的信号究竟存没存下来,可以用sigpending()查未决集合验证。进程在被屏蔽期间收到信号,该信号会被放入未决集合,调用sigpending可以看到它的存在。
这个组合经常用来做"安全重启"或者"延迟退出"的逻辑:服务收到SIGTERM,但当前正处理一批重要任务,不想立刻被终止,就先屏蔽SIGTERM,处理完任务后再解除屏蔽。一旦解除屏蔽,积累的信号立刻递送,触发清理逻辑,实现"优雅延迟退出"。注意如果屏蔽期间来了多个SIGTERM,标准信号只保留一个,解除后只触发一次处理,这是标准信号的合并特性,需要清楚。
4.3 信号处理函数执行期间的屏蔽行为
sigaction结构体里的sa_mask字段,描述的是"处理函数执行期间要额外阻塞的信号集"。假设正在执行SIGINT的处理函数,又来了SIGTERM,这个信号是否立刻触发处理函数?答案取决于SIGTERM是否在sa_mask里。
如果sa_mask里包含SIGTERM,那么SIGTERM会被阻塞并进入未决状态,等SIGINT处理函数执行完毕再处理;如果没包含,则SIGTERM可以打断SIGINT的处理函数,嵌套进入自己的处理函数。嵌套信号处理是很多疑难bug的来源,因为处理函数状态复杂、不可重入的问题会被放大。我的建议是除非你明确知道自己在干什么,否则保持sa_mask为空集合即可,让内核默认只阻塞"当前正在处理的这个信号",避免嵌套。
4.4 信号混叠失真的类比思考
看到有同学用"信号混叠失真"这个词组查信号处理,其实是把数字信号处理领域的采样混叠概念混进来看进程信号了。进程信号领域更关注的是"信号的合并与丢失":标准信号不排队,高频连续触发的相同信号会被合并,后到的覆盖先到的,表现出来的效果和"混叠"很像——原始信号密集触发,处理函数看到的信号却是稀疏的。这是内核刻意设计的性能取舍:信号只是通知,不是计数器。如果业务上真的需要统计信号触发的次数,不能依赖处理函数被调用的次数,得在信号产生方通过其他IPC手段(如管道、共享内存)把计数传过来。
5. 进程等待wait与信号的配合
5.1 wait和waitpid的差异与选择
wait/waitpid的作用是让父进程获取子进程的退出状态。wait()最简单,但它有两个明显限制:一是阻塞调用,如果子进程没退出,父进程就卡死在wait上;二是随便一个子进程退出就返回,没法精确指定等哪个子进程。
waitpid()补足了这两个痛点。通过参数可以指定等待的进程号,通过WNOHANG选项实现非阻塞轮询。两者返回值也有讲究:返回0表示还有子进程存活但没退出(只有WNOHANG场景下才可能出现),返回-1表示出错了,最常见错误是ECHILD:当前没有子进程可等。
c复制#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
sleep(2);
return 42; // 子进程退出码
}
int status;
pid_t ret = waitpid(pid, &status, 0); // 0表示阻塞等待
if (WIFEXITED(status)) {
printf("子进程 %d 正常退出,退出码 %d\n", ret, WEXITSTATUS(status));
}
return 0;
}
status不是简单的退出码,需要用宏解析:WIFEXITED判断是否正常退出,WEXITSTATUS取退出码,WIFSIGNALED判断是否被信号杀死,WTERMSIG取杀它的信号编号。
5.2 wait与SIGCHLD配合中的经典坑
前文已经说了SIGCHLD处理函数里用waitpid回收僵尸进程。但这里有个非常经典的坑:如果父进程只是主循环里调用了wait/waitpid,同时又把SIGCHLD捕获了,两个逻辑就会竞争回收子进程。发生的事情大致是:子进程退出,内核发SIGCHLD;SIGCHLD处理函数执行waitpid,成功回收了;主循环里的waitpid返回-1,因为已经没有子进程可等了。看起来像是"waitpid没等到子进程",程序逻辑直接出错。
解决方案是二选一:要么完全靠SIGCHLD异步回收,主循环不掺和;要么完全不捕获SIGCHLD,主循环里用waitpid配合WNOHANG轮询。混着用,就等着排查一小时。我自己的习惯是:守护进程统一用SIGCHLD+waitpid的异步方案,因为主循环通常有其他事情在忙,不能让主流程卡在等待上。
5.3 孤儿进程与托管
父进程先于子进程退出,子进程会变成孤儿进程,由内核自动收养,PPID变成1(init进程,或systemd)。init会周期性地wait回收这些孤儿,避免它们变成僵尸。这个机制是内核的兜底方案,但依赖它做业务设计不是好习惯,进程生命周期管理还是应该自己控制。
6. 常见问题与排查技巧实录
6.1 EINTR:被信号打断的系统调用
信号处理函数执行完后,被中断的系统调用怎么办?Linux下的做法是返回错误,errno设置为EINTR。最典型的就是socket accept、read、write这些阻塞操作,收到信号后会返回-1并且errno就是EINTR,而不是继续阻塞等待。
新手看到代码报EINTR,第一反应是"网络出了问题",其实根本不是。正确做法是捕获EINTR后重试这次调用。sigaction注册时加上SA_RESTART标志后,内核会自动重启被中断的可重启系统调用,省掉手动重试的代码。但注意:并不是所有系统调用都能被SA_RESTART自动重启,比如sleep、poll、epoll_wait这些,即使是SA_RESTART也可能照样返回EINTR。所以严谨的代码是两套方案并行:能加SA_RESTART的加上,关键系统调用还是要检查EINTR并手动重试。
6.2 多线程与信号:SIGSEGV到底发给谁
多线程进程的信号递送规则比单线程复杂得多。进程级信号(如kill发出的信号、SIGSEGV这种硬件异常)默认递送给任意一个不阻塞该信号的线程,具体选哪个由内核决定。线程级信号(如pthread_kill发出的信号)只能递送到指定线程。
这里有个必须注意的坑:标准信号在多线程里的默认去向是不确定的,这让"哪个线程负责处理信号"变成悬案。处理这类问题的标准做法有两种:一是进程启动时主线程用sigprocmask把所有信号屏蔽,然后用sigwait在专门的信号处理线程里同步等待信号,统一处理;另一种是每个线程各设各的sigaction,把关心的信号通过pthread_sigmask控制到自己线程来。开发网络服务时,我强烈建议用sigwait方案,逻辑清晰,不依赖内核调度线程的运气。
6.3 信号相关面试题速查表
| 问题 | 核心要点 |
|---|---|
| kill -9和kill -15的区别 | 15是SIGTERM可捕获可忽略,9是SIGKILL不可捕获不可忽略 |
| 僵尸进程产生与清理 | 子进程退出父进程没wait回收,通过wait/waitpid或SIGCHLD处理清理 |
| 信号处理函数里能调用哪些函数 | 只保证可重入函数安全,优先使用sig_atomic_t标志位方案 |
| 标准信号和实时信号的区别 | 标准信号不排队会合并,实时信号排队不丢失 |
| 阻塞信号会怎样 | 信号进入未决状态,解除阻塞后递送 |
| 被信号打断的read调用如何处理 | 返回EINTR,需要手动重试或使用SA_RESTART |
| 多个相同信号连续到达如何处理 | 标准信号合并为一次递送,不能依赖调用次数 |
6.4 排查信号问题我常用的三招
第一招是strace。strace -f -e trace=signal ./program可以实时打印进程接收和发送的所有信号,一眼看出信号到底有没有到进程、走了哪条路径。
第二招是/proc/<pid>/status。查看SigPnd、SigBlk、SigIgn三个字段,分别对应未决信号集、阻塞信号集、忽略信号集,是排查屏蔽问题最直接的手段。
第三招是gdb的signal相关命令。gdb里handle SIGSEGV stop可以设置信号到达时是否停下,调试段错误时特别好用。有一次我查一个诡异的"程序莫名退出"问题,用gdb挂了进程后发现是SIGPIPE在作祟,socket对端关闭后write触发了SIGPIPE,默认动作直接终止进程。这种情况用signal(SIGPIPE, SIG_IGN)忽略掉即可。
7. 信号在生产环境里的典型应用模式
7.1 优雅停机模式
服务端程序几乎都会实现优雅停机:收到SIGTERM后,停止接收新请求,等待正在处理的请求完成,释放连接和资源,保存必要状态,最后exit。实现骨架就是"信号置标志位,主循环检测→执行清理"。
c复制#include <signal.h>
#include <stdio.h>
#include <unistd.h>
volatile sig_atomic_t g_stop = 0;
void stop_handler(int sig) {
g_stop = 1;
}
int main() {
struct sigaction sa;
sa.sa_handler = stop_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
while (!g_stop) {
// 正常的业务循环,比如处理请求
sleep(1);
}
printf("开始清理资源...\n");
sleep(1);
printf("清理完成,进程退出\n");
return 0;
}
这个模式我用过很多次,配合systemd或脚本里的kill -15,可以做到秒级平滑下线,比kill -9直接杀掉稳太多。
7.2 SIGHUP与配置热加载
SIGHUP信号默认动作是终止进程,传统守护进程会利用它做"重新加载配置"。为什么用SIGHUP?因为1号信号通常表示终端挂断,后台守护进程没有终端,这个信号平时不会出现,用它做配置重载通知,语义冲突小。处理函数里置标志位,主循环读到标志就重新读取配置文件。
7.3 nohup与信号的缘分
nohup命令的原理就是对SIGHUP信号做忽略处理。启动进程后关掉终端,终端会发SIGHUP给进程,默认行为会杀进程。nohup让进程忽略SIGHUP,自然就不会被终端关闭带走了。理解了信号机制再看nohup,就是个包装了一层信号处理的工具而已。
个人在实际操作中的体会是:信号这套机制,真正理解透了之后调试很多诡异问题会快很多。早期遇到"程序莫名其妙挂了"的case,我第一反应是看日志、查崩溃栈,后来才发现一堆看似无关的问题根源都是信号——要么SIGPIPE没处理、要么SIGCHLD回收不及时堆积了僵尸、要么主循环被信号打断没重试EINTR。现在排查线上问题,我习惯先用strace扫一遍信号,再决定往哪个方向走,十次有五次能省下大把时间。
最后再分享一个小技巧:调试信号处理函数的嵌套问题时,可以在gdb里handle all print,这样每次信号触发都会打印帧信息,配合bt查看嵌套调用栈里到底是谁在打断谁,比自己猜快得多。信号这块的学习曲线虽然有一定坡度,但一旦把产生、传递、阻塞、处理这些环节串联起来,后续再碰多线程信号、异步IO这些进阶内容时,会轻松很多。
