做Linux开发这些年,我越来越觉得“信号”是被低估的一个知识点。它不像内存管理、文件系统那样天天被挂在嘴边,但只要你写过服务端程序、处理过进程崩溃、或者被“进程怎么又没了”折磨过,最后八成都要回来补信号这堂课。
Linux里的信号,说白了就是内核给进程发送的一条异步“通知”:你该停了、你出错了、你有子进程挂了、你写管道但没人读了。这通知到达的时机完全随机,可能在你正在 malloc 内部调整堆链表时插进来,也可能在你刚写完日志准备归还锁时插进来。这种“打断任意代码执行”的特性,就是信号处理难的核心。
这篇文章我会把信号从产生、未决到递达的完整链路讲清楚,然后带你写完 sigaction 的正确用法,再把优雅退出、回收子进程、超时控制、多线程信号处理这些实战场景逐个拆开,最后附上几个我真实踩过、而且线上事故级的坑。适合写 Linux 服务端的同学、做运维排查的工程师,以及刚接触 Linux 编程、想真正理解 kill 和 Ctrl+C 背后逻辑的读者。
1. 信号到底是个什么东西:先搞懂进程的生命节拍
1.1 信号不是“软件中断”这么简单
很多人喜欢用“软件中断”来解释信号,这个类比方向对,但容易让人误解成“跟硬件中断差不多,来了就执行一个回调”。实际上信号的处理远没有那么整齐。
我在工位上写代码时的场景就特别像信号机制:领导(内核)突然弹了一条钉钉消息(信号)。我有几种选择:马上去处理(执行 handler)、当做没看见(忽略)、先把手上代码写完再回(先阻塞,回头处理)、以及领导直接说“这项目你别干了收拾东西走人”(SIGKILL,不可拒绝)。这些选择里,前几种可以由我自己决定,最后一种只有内核能决定。
对应到 Linux 里,信号就是进程控制流之外的一个异步事件。进程不知道信号什么时候来,也不知道处理完信号后,刚才那行代码局部变量还变没变。正因为这种不可预测性,信号处理函数里能不能调用 printf、能不能加锁、能不能 malloc,都是新手最容易踩雷的地方。
还有一个关键点:信号不是“优先级高到立即执行”。内核把信号标记到进程的未决信号集合中,真正调用你的 handler,是在进程从内核态返回到用户态、并且调度器选中这个进程继续运行的时候。这也解释了为什么信号处理时机总有一种“明明发送了,却感觉慢半拍”的现象。
1.2 一次信号传递的完整旅程:从内核到进程
要理解和排查信号问题,最好把一次信号的完整生命周期在脑子里画出来。整个过程可以分四步:
- 产生。信号可以由硬件异常产生,比如访问非法内存触发 SIGSEGV;也可以由软件产生,比如调用
kill()、raise()、alarm(),或者你在终端按下 Ctrl+C 生成 SIGINT,Ctrl+\ 生成 SIGQUIT。 - 未决。进程如果当前屏蔽了这个信号,信号不会消失,而是挂在未决信号集合(pending set)里。未决信号可能累积,但同一编号的信号在标准 Linux 上不会排队,多个相同信号往往只保留一个。
- 递达。当进程不阻塞该信号且处于可调度状态时,信号从 pending 集合移除,开始处理。
- 处理。系统根据信号配置执行默认动作、忽略动作,或者跳到用户注册的处理函数。
内核里每个进程的 task_struct 都维护着两个重要的信号位图:阻塞集合(blocked set)和未决集合(pending set)。你调用 sigprocmask() 就是在改阻塞集合,调用 sigpending() 能看到未决集合。某个信号是否会被立即处理,取决于“这个信号是否在阻塞集合里”。如果不在,而且信号已经产生,那就递达。
在处理信号时,还有一个容易忽略的动作:如果采用了自定义 handler,内核会在用户态栈上建立一帧特殊的信号栈,保存当前执行上下文,然后调用 handler;handler 返回后,内核再恢复当时被打断的上下文。所以从用户程序视角看,信号处理函数就像在任意一行代码之间被插入执行,执行完又像没事一样继续跑。
1.3 常见信号速查:哪些信号容易被误解和处理出错
做信号处理前,先得对常用信号有个清晰的底。我这里整理了一张表,标注默认动作以及我自己的使用建议,你完全可以把它当字典用:
| 信号 | 默认动作 | 典型触发场景 | 实际使用建议 |
|---|---|---|---|
| SIGINT | 终止进程 | 终端 Ctrl+C 或 kill -INT |
用于请求进程优雅退出,建议捕获处理 |
| SIGQUIT | 终止并 core dump | Ctrl+\ | 常用来抓崩溃现场,不要轻易重新定义 |
| SIGTERM | 终止进程 | kill 默认信号,systemd/容器停止服务常用 |
捕获后做清理再退出,这是“体面”的关键 |
| SIGKILL | 强制终止,不可捕获 | kill -9 |
程序没有任何机会善后,别指望拦截 |
| SIGSEGV | 终止并 core dump | 野指针、栈溢出、访问已释放内存 | 通常交给 core dump 分析,不在业务层捕获 |
| SIGCHLD | 忽略 | 子进程停止或退出时发给父进程 | 必须用 waitpid 配合,回收子进程状态 |
| SIGHUP | 终止进程 | 终端挂断、断开 SSH、进程组长退出 | 守护进程常捕获它,用来重新载入配置 |
| SIGPIPE | 终止进程 | 写 socket/管道,但对端已关闭 | 服务端经常忽略或捕获,避免进程猝死 |
| SIGUSR1/SIGUSR2 | 终止进程 | 用户自定义事件 | 进程间简单通知或者日志切割信号 |
| SIGALRM | 终止进程 | alarm() / setitimer() 超时 |
配合阻塞 IO 做超时控制,但要小心竞态 |
这里最容易被误解的是 SIGTERM 和 SIGKILL。很多初学者以为 kill 就等同于 kill -9,其实 kill 默认发送 SIGTERM,是允许进程做清理工作的。而 SIGKILL 是最后手段,一旦来了,进程连执行 handler 的机会都没有。所以你在代码里写 signal(SIGKILL, handler) 是无效的,这个坑我见很多人踩过。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号捕捉的核心招式:signal、sigaction 与不可不防的坑
2.1 signal() 为什么简单但是埋雷
你搜 Linux 信号示例,市面上大量代码用的是 signal():
c复制signal(SIGINT, on_int);
两行就搞定了,确实简单。但简单背后藏着三个问题。
问题一:行为在不同系统上有历史差异。早期的 Unix 实现里,signal() 在 handler 执行完后会把该信号恢复成默认动作,这意味着你第一次收到 SIGINT 时 handler 生效,第二次如果信号重复到达,程序可能直接退出。现代 Linux 的 glibc 虽然通过 sysv_signal 兼容层让 signal() 保持“不重置”的新语义,但如果你跨平台移植,行为并不可靠。
问题二:不会自动恢复被信号打断的系统调用。比如程序正在阻塞在 read() 等待网络数据,这时候来一个 SIGTERM,handler 执行完后 read() 会返回 -1,errno 被设为 EINTR。如果你的代码没有判断 EINTR 并继续读,程序就会误认为连接已关闭,直接退出或报错。
问题三:handler 执行期间无法控制其他信号的屏蔽。sigaction() 的 sa_mask 可以指定 handler 运行期间额外屏蔽哪些信号,signal() 做不到这个精细控制。
所以我的建议很明确:新代码一律用 sigaction()。signal() 只适合写教学 demo 和临时脚本。
2.2 sigaction() 才是正规军:一个完整示例
sigaction() 的核心是 struct sigaction,先看一个能直接编译运行的例子:
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t keep_running = 1;
void on_term(int sig) {
keep_running = 0;
}
int main(void) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
if (sigaction(SIGTERM, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
while (keep_running) {
pause();
}
write(STDOUT_FILENO, "clean exit\n", 11);
return 0;
}
编译运行后,按 Ctrl+C,程序不会直接退出,而是把 keep_running 置 0,然后走完后续清理逻辑。
这里几个字段要认真理解:
sa_handler:信号处理函数指针,也可以填SIG_IGN忽略,或者SIG_DFL恢复默认。sa_mask:handler 执行期间要额外屏蔽的信号集合。注意,触发 handler 的那个信号默认会被屏蔽,handler 返回后再恢复,不需要你手动设置。sa_flags:重要的扩展开关。SA_RESTART让被信号打断的系统调用尽量自动重启,SA_SIGINFO表示使用sa_sigaction获取更多信息,SA_NOCLDWAIT可以自动回收子进程,SA_NOCLDSTOP让子进程停止时不触发 SIGCHLD。
我实际开发中几乎所有地方都设置 SA_RESTART,它解决了我上面说的 EINTR 问题。但注意,SA_RESTART 并不是所有系统调用都生效,比如 poll()、epoll_wait()、clock_nanosleep() 这类有时间限制的接口,即使设置了也照样可能返回 EINTR。所以代码里看到 EINTR 时,不要一句“设置 SA_RESTART 就行”糊弄过去,该循环重试还是得重试。
2.3 重入与异步信号安全函数:为什么不能随便在 handler 里 printf
这是信号处理里最容易被低估的一关。网上很多示例 handler 里直接写 printf("caught signal\n"),看起来活得好好的,那是因为测试代码太简单,主程序没有竞争同一个资源。
printf 不是异步信号安全函数。它内部有缓冲区,还有 stdout 的锁。如果主程序正在执行 printf,并且已经拿到了锁,此时信号到达,handler 再调用 printf,同一个线程里第二次尝试拿同一把锁——不出意外就是死锁。
我自己线上就出过类似的事。一个服务崩溃前收到 SIGTERM,handler 里想打一行错误日志,用了 fprintf(stderr, ...),结果进程直接卡死在日志锁上,导致重启脚本一直超时,最后被监控强制 SIGKILL,连临时状态都没保存下来。
那 handler 里到底能做什么?核心原则是:只调用异步信号安全函数。这类函数在多线程并发中被信号打断后再调用,行为也是确定的。常见的包括:
write()、read()、open()、close()kill()、getpid()、sigaction()_exit(),不是exit()sigemptyset()、sigaddset()等信号集操作函数
如果你确实需要在 handler 里做复杂工作,比如重新读配置、更新链表、记录结构化日志,最稳妥的做法是只在 handler 里设置一个 volatile sig_atomic_t 标志,主循环里检测到标志后再去执行实际逻辑。sig_atomic_t 保证了读写是原子的,但要注意它和 volatile 缺一不可,否则编译器可能优化到寄存器里,导致主循环永远看不到新值。
3. 实战场景拆解:让信号真正为你工作
3.1 优雅退出:如何用 SIGTERM/SIGINT 做好清理工作
如果你写过一个需要长时间运行的服务,一定遇到过这种需求:收到停止指令后,先把当前请求处理完、保存必要状态、关闭监听套接字,然后再退出。这就是优雅退出。
默认情况下,kill pid 发给进程 SIGTERM,进程立即终止,资源由内核回收。但你的业务状态可能没机会落盘,数据库事务可能没提交,日志缓冲可能没刷出去。所以生产环境的服务,几乎都会捕获 SIGTERM 和 SIGINT。
完整的优雅退出逻辑可以这样组织:
- 用
sigaction()注册 SIGTERM 和 SIGINT。 - handler 里不做长时间操作,只把全局标志置为 0。
- 主循环定期检查标志,发现要退出,先停止接收新请求,再等待在飞请求结束,最后清理资源退出。
我还习惯做一个“二次信号强杀”的保护。用户第一次按 Ctrl+C,进程开始清理;但清理过程如果卡住,比如数据库连接迟迟不释放,用户可能再按一次。这时候 handler 应该直接 _exit(1),放弃清理,避免清理线程挂死后进程完全失联。代码思路如下:
c复制static int signal_count = 0;
void on_quit(int sig) {
signal_count++;
if (signal_count >= 2) {
_exit(1);
}
keep_running = 0;
}
别在 handler 里做 sleep、等锁、写大文件这种耗时操作,这是我在无数事故里总结出来的铁律。
3.2 处理子进程:SIGCHLD 的正确姿势
写多进程模型的网络服务时,父进程 fork 出一堆子进程干活。子进程退出时内核会给父进程发 SIGCHLD,如果父进程不去 waitpid() 回收退出状态,子进程就会变成僵尸进程,占着进程表项不放。
有些人的第一反应是这么写:
c复制void on_child(int sig) {
int status;
wait(&status);
}
看起来没问题,但实际有一个隐蔽的坑:如果同一时刻有多个子进程退出,内核只会向父进程发送一个 SIGCHLD,因为同类型信号不排队。你 handler 执行一次,只 wait 了一个子进程,其他子进程可能还留在僵尸状态。这时候进程已经退出,但僵尸不会自动清除。
正确的做法是在 handler 里循环 waitpid(-1, &status, WNOHANG),直到返回 0 或 -1:
c复制void on_child(int sig) {
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// 可以先记录 pid 和退出码,再统一处理
}
}
注册时最好设置 sa_flags = SA_RESTART | SA_NOCLDSTOP。SA_NOCLDSTOP 的作用是子进程只是停止(比如收到 SIGSTOP)时不要触发 SIGCHLD,只有真正退出才通知,减少无谓唤醒。
如果你根本不在乎子进程退出状态,还有一个更省事的方案:注册 SIGCHLD 时设置 sa_flags |= SA_NOCLDWAIT,内核会自动回收子进程,避免僵尸。但代价是你无法通过 waitpid 获得退出码和终止信号。这个开关适合大量无状态 worker 的场景,不适合需要精确管理每个子进程结果的服务。
3.3 定时与唤醒:alarm、SIGALRM 和超时控制
信号在“定时”上很有用,尤其是给阻塞调用加超时。比如你的程序要从一个可能没有数据的 fd 上读取,不想无限阻塞,就可以用 alarm() 配合 SIGALRM 来实现。
关键在于理解 SA_RESTART 的作用。默认没有设置 SA_RESTART 时,SIGALRM 到达后,阻塞中的 read 会被打断,返回 -1 且 errno 为 EINTR。你可以借此判断超时:
c复制#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <errno.h>
void on_alarm(int sig) {
// 什么都不做,只是为了打断 read
}
int main(void) {
struct sigaction sa;
char buf[64];
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
// 不设置 SA_RESTART,故意让 read 被打断
sigaction(SIGALRM, &sa, NULL);
alarm(5);
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
if (n < 0 && errno == EINTR) {
// 5 秒没有输入,执行超时逻辑
printf("timeout\n");
} else {
// 读取成功
alarm(0);
}
return 0;
}
这里有个细节:read 返回 EINTR 后,定时器可能还有剩余时间,所以要主动调用 alarm(0) 取消。但如果你用的是 setitimer(),它还能区分剩余时间,逻辑会更复杂。
不过我得提醒一句:这种用信号做 IO 超时的方案在实验室里跑得通,在真实项目里我却不太推荐,因为 alarm() 是全进程唯一的,多个模块同时用会互相干扰;而且信号到达的时机和 IO 返回之间存在竞态窗口:如果信号在 alarm() 调用前就递达,或者刚好在阻塞进入内核前到达,都会造成错误。现代做法是直接用 select()、poll()、epoll_wait() 的超时参数,或者配合 timer_create() 做高精度定时。理解 SIGALRM 主要是为了加深对“信号打断系统调用”这个机制的理解,而不是让你在生产代码里硬上。
3.4 手动进程间信号:SIGUSR1/SIGUSR2 做简单通信
信号本质上也是一种进程间通信方式,虽然能传递的信息量非常小,但好处是开销低、绝对异步。我常用 SIGUSR1/SIGUSR2 做两件事:触发日志重新打开,或者触发进程重新读取配置。
以日志切割为例。服务长期运行,日志文件可能被 logrotate 改名,但进程还握着旧 fd 继续写。标准的做法是让服务在收到某个信号后,重新打开日志文件。很多经典守护进程约定用 SIGHUP 做这件事,因为 SIGHUP 默认是“终端挂断”,后台进程本来就要处理它。
代码里最安全的姿势依然是 handler 只置标志:
c复制static volatile sig_atomic_t reload_flag = 0;
void on_reload(int sig) {
reload_flag = 1;
}
int main(void) {
// 注册 SIGHUP
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_reload;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGHUP, &sa, NULL);
while (1) {
if (reload_flag) {
reload_flag = 0;
reopen_log_file();
}
// 正常工作
sleep(1);
}
}
如果你收到 SIGUSR1 后希望进程做一次状态统计,也可以把统计逻辑放到主循环,而不是信号处理函数里。这样既不会打断业务逻辑,也不会因为信号安全函数限制而缩手缩脚。
4. 信号捕捉的进阶话题:block、pending 与多线程
4.1 不要忽略信号屏蔽:sigprocmask / pthread_sigmask
有时候你不想让信号立刻处理,而是想等某个临界区结束后再处理,这就得用到信号屏蔽。
sigprocmask() 用来修改进程的阻塞信号集合。比如你想临时屏蔽 SIGINT:
c复制sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL);
// 这里 Ctrl+C 不会触发处理函数
// 信号会进入 pending 状态
sigprocmask(SIG_UNBLOCK, &set, NULL);
// 解除屏蔽后,挂起的 SIGINT 立即递达
这个机制在实现“关键事务不可打断”时很有用。比如在写配置文件的过程中,你不想让进程在写一半的时候响应退出信号,就可以先屏蔽信号,写完后解除屏蔽。
我刚才说“信号会进入 pending 状态”,你可以用 sigpending() 去查询:
c复制sigset_t pend;
sigprocmask(SIG_BLOCK, &set, NULL);
kill(getpid(), SIGINT); // 发给自己,此时被阻塞
sigpending(&pend);
if (sigismember(&pend, SIGINT)) {
printf("SIGINT is pending\n");
}
多线程环境里不要用 sigprocmask(),而是用 pthread_sigmask(),因为每个线程有自己的阻塞信号集合,进程级修改不同线程的上下文是件很危险的事。
顺便说一个我之前踩过的坑:在 fork() 之前屏蔽了信号,但子进程里忘记清除继承的屏蔽集合。结果子进程在所有关键信号上表现异常,排查了半天才发现是父进程对 SIGHUP 的屏蔽被带过去了。所以写多进程程序时,一定要检查子进程的信号状态。
4.2 多线程下的信号处理:哪个线程会收到信号?
多线程环境下的信号规则让很多人头疼,我来梳理一遍。
进程定向信号(比如外部 kill pid 触发的 SIGTERM)在 Linux 上会递送给进程中“任意一个没有阻塞该信号”的线程。这个“任意”是很随机的,取决于内核调度,不是你想让哪个线程处理就能指定。线程定向信号(比如 pthread_kill(tid, SIGUSR1))只发给指定线程。
这种随机性带来的问题是:如果多个线程做了不同的信号处理,信号可能落在一个你根本没想到的线程上,造成资源竞争。
我建议的解决方案是:专门用一个线程统一处理信号。操作流程如下:
- 在创建任何工作线程之前,主线程调用
pthread_sigmask()把要处理的信号全部屏蔽。 - 工作线程因为继承了主线程的屏蔽集合,也不会收到这些信号。
- 单独创建一个信号处理线程,在循环里调用
sigwait()等待信号。
sigwait() 不是 handler,它会直接返回信号编号,相当于把异步信号变回了同步事件,然后你可以在普通上下文里做任何操作,包括加锁、调 printf、读文件。
示例:
c复制#include <stdio.h>
#include <pthread.h>
#include <signal.h>
#include <unistd.h>
void *signal_thread(void *arg) {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
int sig;
while (1) {
sigwait(&set, &sig);
if (sig == SIGINT || sig == SIGTERM) {
printf("signal %d received in dedicated thread\n", sig);
break;
}
}
return NULL;
}
int main(void) {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
pthread_sigmask(SIG_BLOCK, &set, NULL);
pthread_t tid;
pthread_create(&tid, NULL, signal_thread, NULL);
pthread_join(tid, NULL);
return 0;
}
这里要注意:sigwait() 和 sigaction() 是两套思路。如果你已经用 sigaction 注册了 handler,又去 sigwait,行为会有冲突。实际项目里要么走 handler 路线,要么走 sigwait 路线,不要混用。
4.3 两个真实的竞态场景与解决思路
我把多线程里常见的信号竞态写成两个案例,都是我实际见过或者被同事拉去排查过的问题。
案例一:handler 里设置标志,主循环检查标志。这个方案本身没毛病,但要注意数据类型和编译优化。正确写法是 static volatile sig_atomic_t flag;。只声明 volatile 还不够,因为 sig_atomic_t 才保证信号处理函数与主循环之间的原子性。如果你用了 int 而恰好 32 位平台上对它的读写不是原子的,极端情况下可能读到半个新值。
案例二:handler 里尝试获取互斥锁,而主线程持锁时被信号打断。我来细说一下这个死锁过程:主线程 pthread_mutex_lock(&lock) 拿到锁,在临界区执行到一半,信号来了,handler 开始执行,handler 里也想 pthread_mutex_lock(&lock),但锁已经被主线程持有,于是 handler 在等锁,主线程又因为信号打断被挂起,等 handler 返回,两边互相等,进程就卡死了。
解决办法也很直接:handler 里绝对不加锁。如果你确实需要“通知主循环去做某件事”,经典的做法是自管道技巧(self-pipe trick):在 handler 里对某个管道 fd 执行 write(fd, "x", 1),主循环用 poll()/epoll_wait() 监听这个 fd,一旦有数据就知道有信号,再安全地处理。
在 Linux 上还有一个我觉得更优雅的做法:用 signalfd() 把信号本身变成一个 fd,然后直接加入到 epoll 监听集合里。这样信号处理和网络 IO 就统一到一套事件循环中,不需要任何 handler。不过要注意 signalfd 必须配合先屏蔽信号使用,原理和 sigwait 那套一致。
5. 常见问题排查与调试技巧
5.1 明明注册了 handler,为什么进程还是死了
遇到“进程莫名退出”时,先别急着怀疑业务代码。我通常按这个顺序排查:
- 信号编号到底是不是不可捕获信号?比如
kill -9发 SIGKILL,任何 handler 都不会执行。还有 SIGSTOP,直接暂停进程,也不给你处理机会。 - 注册是否成功?
sigaction()有返回值你不看,失败了你也不知道。注册函数写错、传错指针、没有判错,这些是低级错误但出现频率极高。 - 是不是 handler 执行后,同信号再次触发导致默认退出?尤其用
signal()写的代码,在某些环境下第二次信号就直接走了默认动作。 - 是不是
EINTR导致业务代码误判退出?这个最常见。服务线程读到read()返回-1,检查errno == EINTR后没有重新尝试,而是当作对端关闭连接,主动退出。遇到这种情况,要么设置SA_RESTART,要么代码里循环处理EINTR。
排查工具方面,我最推荐 strace,尤其是信号相关事件:
bash复制strace -f -e trace=signal -p PID
这样能看到进程接收了哪些信号、信号值是多少、处理返回结果如何。系统调用返回 EINTR 也会显示出来,配合 -e trace=read,write 更好用。
5.2 程序在终端退出时收到 SIGHUP,如何处理
很多开发者在本地终端前台起一个服务,一关终端窗口,服务也跟着退。原因是终端会话结束时,控制进程会向会话里的前台进程组发送 SIGHUP,默认动作是终止进程。
如果你希望服务不因为终端退出而终止,有三个常用方案:
- 前台启动时忽略 SIGHUP:
trap '' HUP这种 shell 手段,或者代码里signal(SIGHUP, SIG_IGN)。 - 使用
nohup启动:nohup ./server &,nohup 的本质就是让进程忽略 SIGHUP。 - 使用
setsid或写一个守护进程,让进程脱离控制终端。
但也要注意,很多服务其实把 SIGHUP 当作“重新加载配置”的约定信号。比如 Nginx、OpenSSH,收到 SIGHUP 后重新读配置并不退出。如果你前一个版本的服务没有处理 SIGHUP,升级后突然收到外部运维的 kill -HUP,在没有 handler 的情况下就会直接终止,看起来像“莫名崩溃”。排查信号问题时,建议用 kill -l 确认信号编码,同时查阅服务文档,确认它到底把哪些信号划给了哪些用途。
5.3 信号处理函数导致程序卡死或崩溃的案例
分享两个我处理过的真实事故。
第一个事故:消息队列服务在准备退出时,同事为了方便在 SIGTERM handler 里调用了 fprintf(stderr, ...) 记录日志。服务运行几个月都没事,直到某天业务高峰期,主线程正在执行 fprintf 到 stderr,SIGTERM 到达,handler 再次进入 fprintf,拿到了同一个 FILE 结构体的锁。锁不可重入,进程直接卡死。后来我们把日志改成了 write() 固定字符串,问题消失。
第二个事故更隐蔽:handler 里调用了 free() 去释放一块全局缓存。在某些时候,主线程正好在 malloc 内部的堆管理临界区,信号打断后 handler 也去操作堆,堆元数据被破坏,进程随后在完全无关的代码位置崩溃。这就是典型的“非异步信号安全函数进入 handler”造成的堆损坏。
这类问题有一个共同排查特征:崩溃位置每次都不一样,而且崩溃栈和业务逻辑毫无关联。遇到这种“玄学”问题,首先要怀疑是不是信号处理函数破坏了上下文。打开 core dump,用 gdb 看崩溃线程的栈,再检查信号相关内容,往往能找到真相。
5.4 验证信号是否正确捕捉:一段可以直接跑的测试代码
最后给一个我常用的信号自测模板,适合快速确认某段信号逻辑是否符合预期。代码非常简单,注册你指定的信号,然后进入等待循环:
c复制#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t caught = 0;
void handler(int sig) {
const char msg[] = "signal caught\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
caught = 1;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: %s <signal-number>\n", argv[0]);
return 1;
}
int sig = atoi(argv[1]);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(sig, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
printf("waiting for signal %d, pid=%d\n", sig, getpid());
while (!caught) {
pause();
}
return 0;
}
编译后先用 ./catch 10 启动,另开一个终端用 kill -10 PID 发信号,程序会打印 signal caught 并退出。如果你想测 SIGKILL,它不会给你机会输出任何内容,这本身也验证了“不可捕获”的特性。
测试时我建议你连续发几次信号,而不是发一次。很多问题比如 handler 重置、信号丢失、标志位没有重置,都是在连续多次信号后才暴露的。
说实话,信号这事儿平时不写就不觉得难,一旦写服务端代码、做进程守护、排查“进程莫名退出”,你就会发现它是绕不开的底层能力。我在项目里会把用到的每个信号都列成一张表,标注默认动作、目标动作、所在模块和测试方法,然后在代码注释里同步更新。这个习惯帮我省掉过不少半夜排查的时间。
最后再分享一个小技巧:测试信号处理逻辑时,别只盯着程序有没有退出。配合 strace 看 EINTR,配合 gdb 看 core,配合 sigpending 看未决状态,你才能真正看到信号在进程里的完整轨迹。这是让我从“会用信号”走向“敢写信号相关模块”的关键一步。
