1. signal到底是什么从一次线上崩溃说起先别急着看函数原型我给你讲一个我自己踩过的坑。有次负责一个消息推送服务上线后跑了两三天突然收到告警进程没了。登录服务器查日志最后一行停在某个第三方库的输出上没有任何异常堆栈。当时第一反应是内存溢出被OOM killer干了结果查dmesg也没发现痕迹。后来翻core文件用gdb一加载才发现根本不是崩溃而是进程收到了SIGTERM信号后被系统默认动作直接终止了。那是我第一次意识到在Linux下写程序光会调API、写业务逻辑远远不够你必须理解信号signal这套机制。很多看似“莫名其妙”的进程退出、僵尸进程堆积、网络连接异常断开根源都出在信号处理上。signal函数就是Linux系统编程里最基础、也最容易用错的一个接口。这篇博文不打算给你念man手册我用实际项目里的经历把signal函数背后的运行机制、使用姿势、以及各种让你头疼的坑尽我所能讲透。这篇文章适合谁如果你写过Linux下的C/C服务被“进程突然没了”“子进程变僵尸”“socket写数据时Broken pipe”这类问题折磨过那你来对地方了。就算你目前只用Java、Go这类语言底层一样逃不开信号这套机制理解了它排查问题会有一种“开天眼”的感觉。先说一句题外话标题里“singal”其实是笔误正确拼写是signalLinux下没有singal这个函数。别笑我刚入行时也拼错过查了半天man手册没结果后来才发现是少写了一个l。2. 信号机制的底层逻辑内核帮你记着的一张“待办清单”2.1 信号不是函数调用而是“异步通知”刚接触信号时我老把它和函数调用搞混觉得“发个信号给进程进程就去执行信号处理函数”这听起来和调用一个函数差不多。但实际上信号是操作系统级别的一种异步事件通知机制。它的底层逻辑是内核帮你记着一笔账——哪个进程收到了什么信号至于这个信号什么时候被处理得等进程从内核态回到用户态的那一瞬间由内核检查待处理信号列表然后触发对应的处理动作。这个“异步”性质决定了信号处理函数的运行环境非常特殊。它可能在主流程的任意一行代码处被插入执行主流程里哪怕刚执行到一半信号一来当前指令就被打断先去跑信号处理函数。跑完再回来继续之前的代码。这就引出了后面要讲的一个大坑在信号处理函数里很多日常操作不能做因为在任意时刻插入的代码很容易和主流程产生资源竞争。我打个比方你正在厨房一心一意地炒菜突然有人按门铃信号来了你总不能不理会只能关火保护现场去开门。但开门的这段时间你没法保证锅铲不会掉地上、油烟机不会出问题。信号处理函数里调用不安全的函数就相当于端着锅去开门看着方便其实随时可能出事。2.2 信号的完整生命周期产生、注册、递送、处理理解信号建议按“一生”来看一个信号从产生到落地总共经历四个阶段产生Generation信号来源很多比如键盘按CtrlC产生SIGINT、非法访问内存触发SIGSEGV、外部进程用kill命令发送信号、定时器超时产生SIGALRM、子进程退出触发SIGCHLD等等。注册Pending准确说是“挂起”。内核收到信号后会在目标进程的task_struct里对应的位图上打个标记。注意信号此时并没有被真正处理只是“记账”了。如果进程当前正在内核态忙碌比如正在做系统调用信号就得排队等。递送Delivery进程从内核态返回用户态时内核会检查挂起信号位图发现有待处理的信号就触发相应处理。这就是为什么你没法在纯CPU运算的循环里“中断”它——必须等系统调用或中断发生、陷入内核再返回用户态时信号才有机会被递送。对于死循环纯计算程序信号要等到时间片轮转发生调度切换才能被处理这也解释了某些“按CtrlC没反应”的现象其实是进程长时间占着CPU不切回内核态。处理Handling有三种默认处理方式终止进程、忽略信号、停止/继续进程当然你也可以通过signal或sigaction安装自定义处理函数让信号来临时执行你的逻辑。2.3 那些你必须记住的常见信号Linux下信号种类很多但日常写服务真正天天打交道的其实就那么几个。我整理了一份高优先级的清单信号默认动作常见触发场景我们常用的做法SIGINT终止进程终端按CtrlC可以捕捉用于优雅退出SIGTERM终止进程kill命令默认信号必须捕捉用于优雅关闭服务SIGKILL终止进程不可捕捉、不可忽略kill -9无解避免对关键服务使用SIGHUP终止进程终端关闭、挂断常用来让守护进程重新加载配置SIGSEGV终止进程并core dump访问非法内存一般是bug捕捉它意义不大SIGPIPE终止进程向已关闭的socket/pts写数据必须忽略或处理否则进程会莫名退出SIGCHLD忽略子进程停止或结束子进程退出必须处理否则僵尸进程泛滥SIGALRM终止进程alarm或setitimer超时常用于超时控制SIGUSR1/SIGUSR2终止进程用户自定义常作为业务信号用以前端时间排查的一个Java服务为例后台日志里经常刷“Connection reset by peer”Java进程没退出但底层C库早就收到了SIGPIPE。Java的NIO库内部把SIGPIPE屏蔽了所以Java程序员感觉不到这个信号。但如果你直接用C/C写网络服务对SIGPIPE不管不问对方一断开连接进程分分钟给你表演“毫无征兆地消失”。3. signal函数使用说明书老接口的准确用法3.1 函数原型和返回值一次说清signal函数声明很简单#include signal.h typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);signum要设置处理方式的信号编号就是上表里的SIGINT、SIGTERM这些。handler处理函数指针接收一个int参数就是信号编号。除了函数指针还有两个特殊值需要记牢SIG_IGN忽略这个信号注意SIGKILL和SIGSTOP不能忽略内核会强制限制。SIG_DFL恢复默认处理行为比如把之前自定义的处理恢复成“终止进程”。返回值返回之前对同一个信号设置的处理函数指针。设置失败返回SIG_ERR也就是宏定义的一个特殊指针值俗称“负一指针”判读时用if (signal(...) SIG_ERR)。这里要特别强调一点——很多人没注意的——signal函数第一次调用成功后返回的是该信号之前的处理函数。所以你可以在第一次调用时用返回值保存旧的handler处理完临时逻辑再恢复。这种模式在写临时屏蔽信号的工具程序里很常用。3.2 最小可运行示例3秒后优雅退出光说不练假把式给你一个我实际项目里用过的、最短的优雅退出模板#include stdio.h #include signal.h #include unistd.h #include string.h static volatile sig_atomic_t g_running 1; static void handle_sigterm(int sig) { // 注意这里绝对不能调用printf g_running 0; } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handle_sigterm; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; if (sigaction(SIGTERM, sa, NULL) -1) { perror(sigaction); return 1; } while (g_running) { // 模拟业务处理比如轮询任务队列、等待网络事件 printf(service is running...\n); sleep(1); } printf(received SIGTERM, cleaning up...\n); // 这里做资源清理关闭连接、刷盘、反注册等 return 0; }为什么这个示例里我用了sigaction而不是标题里的signal先卖个关子后面专门讲。但signal和sigaction在绝大多数Linux平台上是等价的这段逻辑换成signal也是一样。关键点在于标志变量g_running它被声明为volatile sig_atomic_t。这个类型修饰很重要volatile告诉编译器不要优化这个变量的读取因为信号处理函数和主循环都可能修改它编译器如果把它优化到寄存器里主循环可能永远读不到最新值sig_atomic_t则是保证读写这个变量是原子操作不会读到写了一半的脏数据。两者缺一不可。4. signal的弯弯绕不可重入函数、系统调用中断和平台差异4.1 为什么在信号处理函数里不能用printf这是新手最容易踩的隐形地雷。信号处理函数是异步插入执行的你在里面调用了printf而主流程恰好也在调用printf两个执行流就会同时进入printf的库函数内部。如果printf内部有非原子的全局状态变更比如缓冲区指针的移动两次执行就会相互干扰轻则输出乱序重则死锁、缓冲区损坏、程序崩溃。这类不能在信号处理函数中安全调用的函数统称不可重入函数。判断一个函数能不能在信号处理函数里调用有一个简单标准只用局部变量、不修改全局状态、不申请锁的函数是安全的。我整理了一份信号处理函数里“能碰”和“不能碰”的清单都是我踩过或者看人踩过的安全可重入/异步信号安全不安全不可重入read、write文件描述符级别的读写printf、sprintf、fprintfopen、closemalloc、freesigaction、signal任何加锁操作包括pthread_mutex_lock_exitexit会刷新stdio缓冲区可能死锁getpid、getuidsyslogsleep大部分涉及全局缓冲区的库函数最讽刺的是不少前辈写的信号处理函数里第一行就是printf跑起来好像也没事。我只能说没出事是运气出事是必然。在高并发、频繁触发信号的服务里这种问题迟早爆炸。那真的有日志或状态需要记录怎么办标准做法是信号处理函数里只做最基本的标记——置位一个volatile sig_atomic_t标志变量然后通过write写一个字节到管道self-pipe或者直接return让主流程在安全位置发现标志位变化后再去执行IO、日志等重操作。后面第6节我会给一个完整的self-pipe例子。4.2 系统调用被信号打断EINTR这个坑另一个非常隐蔽的问题是慢系统调用被信号中断。举个例子你的服务主进程阻塞在read(fd, buf, sizeof(buf))上等待网络数据这时候来了一个SIGTERM信号处理函数执行完后read系统调用怎么继续在旧版System V语义下read会被信号打断直接返回-1并设置errno为EINTRInterrupted system call。你的代码如果没有对EINTR做处理就以为read失败关闭了连接甚至退出进程。这是无数C/C网络服务“莫名其妙”断开连接的元凶之一。经典的处理方案是循环重试ssize_t n; do { n read(fd, buf, sizeof(buf)); } while (n -1 errno EINTR);但这样写起来太烦了对吧所以glibc和Linux提供了更优雅的解法——在安装信号处理函数时设置SA_RESTART标志。设置了它被信号打断的系统调用会在信号处理函数返回后自动重启内核帮你省掉了EINTR的判断。sa.sa_flags SA_RESTART;我强烈建议除非你明确需要EINTR机制来实现超时控制比如用alarm打断阻塞read否则一律加SA_RESTART。不加的酸爽只有排查线上问题时才体会得到。4.3 glibc的signal和System V时代的signal不是一回事这是个极其隐蔽的历史遗留问题。早期System V的signal函数语义是信号处理函数执行前先将该信号重置为SIG_DFL再执行handler。这意味着如果是同一种信号连续触发两次第二次就可能直接用默认动作通常是终止进程处理你写的handler根本等不到执行。BSD则把这个语义改成了信号处理函数执行期间暂时屏蔽同种信号handler返回后再恢复。而且系统调用被打断后自动重启。Linuxglibc的signal实现遵循的是BSD语义。所以你在Linux上写代码用signal调用处理同一种信号短时间内重复触发一般是不会“第二次就死掉”的。但这里有个大坑如果你把代码移植到其他遵循System V语义的Unix系统比如某些老Unix或嵌入式系统同样的代码行为完全不一样信号处理函数执行完信号被重置成默认动作第二次信号直接杀进程。所以现代Linux系统编程的共识是不用signal用sigaction。sigaction由POSIX标准定义语义清晰行为一致跨平台有保证。我自己的项目里除了简单的测试脚本一律用sigaction。下面这段是我固定的注册模板static int install_signal_handler(int signo, void (*handler)(int), int flags) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sigemptyset(sa.sa_mask); sa.sa_flags flags; return sigaction(signo, sa, NULL); }5. 生产环境里最常见的四种信号处理模式5.1 优雅退出SIGTERM/SIGINT的统一处理正规的Linux服务进程退出必须优雅先停止接收新请求然后处理完正在处理的存量请求最后释放资源、写退出日志、反注册服务。这个过程离不开对SIGTERM和SIGINT的捕捉。我之前维护过一个推送网关启动脚本里stop动作发的是SIGTERM。一开始代码没处理kill命令一发进程直接没了客户端大量断连。后来加了信号处理在handler里置位停止标志主循环消费完队列里的任务再退出客户端侧感知到的就不是“连接被重置”而是正常的TCP挥手关闭。实现要点信号处理函数只做标志位变更真正退出逻辑全部放在主循环检查。如果主循环阻塞在epoll_wait上可以用signalfd或self-pipe唤醒它这块技术后面详细说。5.2 忽略SIGPIPE网络服务的保命符写socket服务的人几乎都会碰到SIGPIPE。当对端关闭连接后你继续向这个socket写数据底层TCP会发送RST发送方进程收到SIGPIPE默认动作是终止进程。一个客户端断线居然能导致整个服务进程挂掉听着像段子但现实中这事太常见了。最省事的做法是进程一启动就忽略它signal(SIGPIPE, SIG_IGN);或者在每次send时加上MSG_NOSIGNAL标志ssize_t n send(fd, buf, len, MSG_NOSIGNAL);两者选一即可。我通常两个都用双保险。忽略SIGPIPE之后再向坏连接写数据send只返回-1、errno为EPIPE你就能在业务逻辑里处理这种失败连接而不是让进程稀里糊涂死掉。5.3 回收僵尸进程SIGCHLD与waitpid的配合另一个高频场景是父进程fork出一堆子进程干活结果子进程退出后变成了僵尸进程。原因很简单父进程没有调用wait/waitpid回收子进程的退出状态。拿着ps -ef一看满屏defunct。解决办法是在父进程里安装SIGCHLD处理函数子进程退出时内核给你发这个信号你在handler里调用waitpid把所有已退出子进程的残骸收回来static void handle_sigchld(int sig) { int saved_errno errno; // 保存errno防止被waitpid改动 while (waitpid(-1, NULL, WNOHANG) 0) { // 循环回收直到没有已退出的子进程 } errno saved_errno; // 恢复errno避免干扰主流程 }注意细节循环调用waitpid直到返回0或-1而不是只调一次因为信号可能合并多个子进程退出只发出一声SIGCHLD用WNOHANG避免阻塞handler返回前恢复errno因为errno是线程全局的不能污染主流程的错误判断。这三个细节缺一个都会在生产环境出幺蛾子。5.4 定时器信号与超时控制SIGALRM有时候你需要给一个阻塞调用加上超时。比如从一个设备fd读取数据最多等200毫秒超时就放弃。最传统的方式是用SIGALRM配合alarm函数static volatile sig_atomic_t timeout_flag 0; static void handle_alarm(int sig) { timeout_flag 1; } // 设置超时前安装handler signal(SIGALRM, handle_alarm); // 启动2秒定时器 alarm(2); int n read(fd, buf, sizeof(buf)); if (n -1 errno EINTR timeout_flag) { // 超时了按超时逻辑处理 printf(read timeout\n); } else { // 正常读取 alarm(0); // 取消定时器 }这套逻辑的精髓是利用信号打断阻塞read后返回EINTR的机制。但前提是你安装SIGALRM处理函数时不能加SA_RESTART否则read会被自动重启直接读死你。如果你用signal函数glibc默认不带自动重启所以这也是signal在这里反而好用的场景。当然更现代的替代方案是poll/select/epoll的timeout参数性能更好不需要信号不过在一些嵌入式移植场景里alarm方案依然简洁高效。6. 从signal到sigaction一个完整的实战案例6.1 为什么我建议你用sigaction替代signal前面反复出现这两个函数现在正面做一次对比。signal的好处是简单、代码短适合快速测试缺点也明显语义跨平台差异大System V vs BSD可移植性差。无法精细控制处理过程中的信号屏蔽集比如你想在处理SIGTERM时顺便屏蔽SIGINTsignal做不到。无法获取信号的详细上下文信息比如siginfo_t里携带的发送者PID、信号触发地址。sigaction则把这些能力全部补齐。它的原型#include signal.h int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);核心是struct sigaction结构体三个关键字段字段作用sa_handler处理函数指针或SIG_IGN/SIG_DFLsa_mask执行handler期间要额外屏蔽的信号集用sigemptyset/sigaddset操作sa_flags控制行为如SA_RESTART、SA_SIGINFO、SA_NOCLDWAIT等用sa_mask可以实现“处理A信号时屏蔽B信号”避免打扰。用SA_SIGINFO标志位配合sa_sigaction字段还能拿到更详细的信号来源信息。6.2 self-pipe技术让信号处理函数和主循环优雅协作有了sigaction我还要介绍一个真正在生产环境里解决信号处理痛点的技术——self-pipe自管道。核心思路信号处理函数里不做任何复杂操作只往一个管道写入一个字节。主循环比如epoll_wait监听这个管道的读端一旦有字节就说明有信号发生再从管子里读出来在安全的上下文里处理真正的业务逻辑。#include stdio.h #include stdlib.h #include string.h #include signal.h #include unistd.h #include errno.h static int pipe_fds[2]; static void signal_handler(int sig) { // 只做一件事往管道写一个字节 int saved_errno errno; char byte (char)sig; // 非阻塞写避免在信号处理函数里阻塞 ssize_t n write(pipe_fds[1], byte, 1); (void)n; errno saved_errno; } static void setup_signal(int sig) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler signal_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; if (sigaction(sig, sa, NULL) -1) { perror(sigaction); exit(1); } } int main(void) { if (pipe(pipe_fds) -1) { perror(pipe); exit(1); } // 读端和写端都设为非阻塞 fcntl(pipe_fds[0], F_SETFL, O_NONBLOCK); fcntl(pipe_fds[1], F_SETFL, O_NONBLOCK); setup_signal(SIGTERM); setup_signal(SIGINT); printf(event loop started, waiting for signals...\n); while (1) { char buf[16]; fd_set rfds; FD_ZERO(rfds); FD_SET(pipe_fds[0], rfds); int ret select(pipe_fds[0] 1, rfds, NULL, NULL, NULL); if (ret -1) { if (errno EINTR) { continue; } perror(select); break; } if (FD_ISSET(pipe_fds[0], rfds)) { ssize_t n read(pipe_fds[0], buf, sizeof(buf)); for (ssize_t i 0; i n; i) { printf(receive signal: %d\n, buf[i]); if (buf[i] SIGTERM || buf[i] SIGINT) { printf(do cleanup logic here...\n); // 真正的清理逻辑关连接、刷日志、收尾 close(pipe_fds[0]); close(pipe_fds[1]); return 0; } } } } return 0; }这个模式的精华在于所有“重活”都搬回了主循环里做信号处理函数轻如鸿毛天然避开了可重入函数的问题。你可以在收到信号后安全地调用printf、malloc、甚至发起网络请求因为此刻你在正常的执行流里。6.3 守护进程里SIGHUP实现配置热加载SIGHUP在终端断开时会给进程发信号但守护进程daemon往往借助这个信号实现配置热加载。做法和self-pipe一脉相承收到SIGHUP主循环醒来重新读取配置文件更新全局配置不需要重启进程。我在一个网关服务里就这么干过。运维改了路由配置执行kill -HUP pid服务自动reload。这个模式比“配置变更后手动重启”体验好太多也不需要在进程里开一个管理端口去监听控制指令。关键点是SIGHUP handler里同样只做标志位或写管道reload动作放主循环。千万千万不能在handler里直接解析配置文件原因还是那可重入性——解析过程动了malloc和文件IO随时可能和主流程死磕。7. 信号调试与排查技巧从现象倒推信号来源7.1 用gdb和strace定位信号相关的疑难杂症信号问题都有点“薛定谔”的感觉不好复现但也不是完全没招。我的排查三板斧先用strace跟踪系统调用和信号strace -f -e tracesignal -p 12345-f跟踪子进程-e tracesignal只过滤信号相关的系统调用-p指定目标进程。跑一小会儿就能看到进程收到了哪些信号、来自哪里、当前处理方式是什么。有一次我们服务频繁退出strace一挂上去就发现每隔几秒收到一个SIGTERM顺藤摸瓜查到了运维脚本的stop逻辑spec文件里写了kill没带进程号误杀了好几个实例。用gdb调试core文件重点看信号触发的现场gdb ./your_app core (gdb) bt (gdb) info signalsinfo signals会列出进程对每种信号的处理设置一眼能看出哪里忽略了SIGPIPE、哪里handler配错了。还要学会用kill -l查信号编号表避免在代码里硬编码数字。比如kill -l显示15是SIGTERM9是SIGKILL但代码里写signal(15, handler)是可读性极差的写法直接写SIGTERM才是正道。7.2 别用kill -9处理业务服务这个习惯很重要。SIGKILL不可被捕捉、不可被忽略是终极强制手段但代价是进程没有任何清理机会。文件没落盘、连接没释放、临时文件没删除全都来不及。生产环境里停服务正确的流程是先发SIGTERM给进程优雅退出的时间比如等30秒实在没退再考虑SIGKILL。很多自动化部署工具比如systemd的默认stop行为就是这么做的也是这个原因——给进程留一条体面的退路就是给你自己的数据留一条活路。7.3 观察信号处理的三种方式类比方式类比适用场景signal手写便签贴门上快速验证、学习、小工具sigaction正式的制度规范生产环境、跨平台、对可靠性要求高的服务self-pipe 主循环前台员工接待后台经理决策复杂业务逻辑、事件驱动架构8. 避坑总结十年信号处理经验浓缩成的五条铁律最后把我这些年和信号“相爱相杀”攒下来的经验整理成几条每一条都是真金白银换来的第一信号处理函数里越短越好。只设置volatile sig_atomic_t标志位最多write一字节到管道其余全放主循环。这一条能规避95%的信号相关崩溃。第二生产代码默认用sigaction不用signal。不是因为signal不能用而是跨平台语义差异太大。sigaction语义明确、功能全面SA_RESTART和sa_mask都是生产环境刚需。第三网络服务进程启动时立刻忽略SIGPIPE。这行代码加上你就不会再被“客户端断开导致进程退出”的问题半夜叫醒。第四别吝啬SA_RESTART。除非你想用EINTR做超时控制否则一律加上。不然每个阻塞系统调用都要写EINTR重试的循环代码丑不说漏一处就是线上事故。第五waitpid回收子进程时使用while循环WNOHANG。一条SIGCHLD可能对应多个子进程退出必须循环回收干净。保存和恢复errno同样是必须的细节。再补充一句很多人觉得信号是老掉牙的技术现代编程用不上。但实际上容器编排、进程管理、日志轮转、热更新配置底层全是信号在驱动。systemd管理服务、Docker停止容器、Kubernetes的preStop钩子本质都是在和信号打交道。把signal这套机制玩明白不光是写C代码的事对整个后端技术体系的理解都会上一个台阶。最后留一个可以自己动手验证的小实验写一个循环打印的程序运行后先按CtrlC感受默认行为再用kill -TERM pid感受进程终止然后给程序加上signal(SIGINT, handler)的处理再按CtrlC看看输出变化。这个实验做完你对信号的体感就完全不一样了。
