1. 从一次诡异崩溃说起信号是哪种“进程间通信”先讲一个我真实排查过的场景。某天凌晨线上的一台业务机突然出现服务抖动日志里没有报错进程也没有core dump文件生成但连接数一下子掉到零。运维同事第一反应是进程被OOM Killer杀了查了一圈系统日志发现内存占用并不高。最后我们用strace -p挂上去才看到进程其实是收到了SIGTERM信号然后走完了默认动作——直接终止。为什么会有SIGTERM顺着时间线往前翻发现是同机上一个定时任务脚本误调用了pkill -f把匹配到的进程全部杀了。这个案例就是典型的“进程间通信”——一个进程脚本里的pkill向另一个进程业务进程发送了一个信号。信号在这里扮演的并不是传递数据的角色而是一个“通知”你该退出了。很多初学者把IPC简单理解为“传数据”管道传字节流、共享内存传结构体、消息队列传封装好的消息这都没错。但信号这种东西它传的不是数据而是“事件”。它有自己独特的使用场景和脾气和管道、共享内存完全不是一回事。聊信号之前得先把它在整个IPC家族里的位置放对。Linux下的进程间通信方式大致可以分成几类管道/命名管道FIFO、System V消息队列、共享内存、套接字、信号量以及信号Signal。其中绝大部分IPC都是“同步”的你写我读、你发我收双方在时间轴上是有确定顺序的。而信号是唯一一种“异步”的IPC发送方发出信号之后根本不需要等接收方处理接收方也不知道什么时候会来信号它可能正在用户态执行一条普通的赋值语句也可能正卡在某个系统调用里然后内核突然把控制流切换到信号处理函数。这种“打断感”正是信号最核心的特征也是后面所有坑的源头。打个比方管道像是你来我往的微信聊天两个人按顺序对话共享内存像一块公共留言板大家看着同一块内容做事而信号更像是“电话铃响”——不管你在干嘛电话一响你就得停下手里的事去接。正因为这种强制打断的异步性信号处理函数里的代码必须非常小心否则很容易出问题。对任何玩Linux服务端的人搞懂信号不只是一个知识点它直接决定你的程序能不能优雅退出、能不能正确处理子进程、能不能在异常时保住现场。2. 信号的全家福编号、默认动作与可移植性2.1 先给常见信号排个队Linux上信号用正整数编号区分每个编号对应一个宏名。这些宏名是标准化的但具体编号在不同架构上有细微差别比如SIGBUS在x86上是7在有些架构上又是不同的值。日常写代码千万别直接写数字老老实实用宏名。我从实际使用频率排个序下面这几个你早晚会碰见信号编号x86默认动作触发场景SIGINT2终止进程终端里按CtrlCSIGQUIT3终止并产生core终端里按Ctrl\SIGKILL9强制终止不可捕获kill -9SIGSEGV11终止并产生core非法的内存访问空指针解引用SIGPIPE13终止进程向已关闭的管道/套接字写数据SIGTERM15终止进程kill默认发送的信号可被捕获SIGCHLD17忽略子进程停止或退出时发送给父进程SIGUSR1/SIGUSR210/12终止进程用户自定义用途SIGALRM14终止进程alarm()定时器到期这里有个容易踩认知偏差的点SIGKILL和SIGTERM大家天天都在用但在“优雅退出”这件事上你只应该依赖SIGTERM。SIGTERM可以被进程捕获进程收到之后可以做清理工作、保存状态、通知上下游然后自愿退出。SIGKILL则是直接由内核强制干掉没有机会执行任何用户态代码也不会有core dump。很多服务治理体系里先发SIGTERM给进程等超时了再补SIGKILL这个先后顺序很重要。再提两个“看起来是异常其实很常见”的信号。一个是SIGPIPE如果你用socket或管道做通信对端已经关闭连接你还往上写数据内核不是让你write返回错误而是直接给你进程发SIGPIPE默认动作就是终止。所以很多网络服务项目里要显式忽略SIGPIPE再靠write的EPIPE错误码做业务处理。另一个是SIGCHLD子进程退出时内核会给父进程发这个信号如果父进程不处理也不wait子进程就会变成僵尸进程躺在进程表里日积月累能让你系统进程数爆掉。2.2 可靠信号与实时信号的分水岭Linux信号还有一个重要维度可靠与不可靠。这里的历史背景是早期Unix里信号处理存在丢失问题。什么叫丢失如果进程还没来得及处理一个信号又来了两个同类型的信号内核可能只记录“有这个信号来了”这个事实不会帮你去重计数等处理完第一个第二个就没了。换句话说信号不是排队机制它是一个位图。位图里某一位被置1代表“有一个某编号的信号来了”你处理完清零如果期间又来了三次位图依然只有一位你还是只知道“来过”不知道“来了几次”。为了解决这个问题POSIX标准引入了实时信号编号从SIGRTMIN一般是34到SIGRTMAX一般是64。实时信号是有队列的能保证发送几次就接收几次并且带一个伴随的整数值可以传递一个小数据。但实时信号是稀缺资源业务代码里不要轻易占用很多运行时和中间件会用它做内部机制比如某些线程库用来做线程取消和同步。我见过不少新手搞混“信号丢失”和“信号阻塞”。丢失是因为内核不排队而阻塞是进程主动告诉内核“现在不想处理某类信号”先放在未决集合里。阻塞不等于忽略等解除阻塞之后信号还会补处理一次——但同样地如果阻塞期间同类型信号来了五次解除之后只处理一次。这个位图模型请刻在脑子里它会帮你避免后面的很多误解。还有一点必须提处理完信号之后处理函数的返回值是void它没有返回值可以告诉系统“我处理好了”。这是很多人的认知盲区但也说明信号处理本身就是“打断返回”的模型你不可能在里面做复杂事务这也是为什么信号处理函数要写得极短只做标记、只做记录、只做唤醒。3. 捕获与处理signal() 和 sigaction() 的选型对比3.1 为什么我不推荐 signal()接到信号之后进程有三种选择默认动作、忽略、自定义处理。自定义处理就是用signal()或sigaction()为某个信号注册一个处理函数。先说signal()它接口很简单void (*signal(int signum, void (*handler)(int)))(int);调用一次第二个参数传函数指针。看起来简单但它在不同Unix衍生系统上的语义差别非常大。在早期System V里信号处理完之后会把该信号恢复成默认动作意味着你得在handler里重新再设一遍signal否则第二次同信号来了就直接走默认动作。在BSD里又不同处理完会保留。Linux的signal()实现是兼容BSD的所以一般不会遇到恢复默认的坑但它依然有两个硬伤。第一个硬伤signal()在建立handler的时候默认不设置SA_RESTART标志这意味着很多慢速系统调用比如read、write、wait、accept可能会被中断返回EINTR错误。你写网络程序时如果还用signal()很可能碰到“明明什么都没做错read怎么返回-1了”的诡异问题查errno才知道是EINTR。这个问题在sigaction()里可以通过SA_RESTART优雅解决后面细说。第二个硬伤signal()不能精确控制信号处理过程中的阻塞条件。你用sigaction()可以在处理一个信号时临时屏蔽其他信号防止嵌套打断而signal()是做不到的。所以我的建议很简单所有新代码一律用sigaction()。signal()可以留在写小程序、写测试Demo时用但生产代码里最好别出现。这不是歧视这个接口而是减少不确定性的问题。3.2 sigaction() 结构体里的关键字段sigaction()的原型int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);oldact可以传NULL但如果你想知道之前是谁在处理这个信号就可以传一个结构体指针内核会把旧的处理配置填进去。这在写库函数时特别有用——保存旧handler用完再恢复不破坏调用方原本的配置。struct sigaction里几个字段struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); };sa_handler和sa_sigaction其实是一个联合体在具体实现里你用了SA_SIGINFO标志就表示使用带三个参数的函数否则使用单参数版本。三参数版本能拿到siginfo_t里面有发送者PID、uid、信号伴随值等额外信息很多监控类程序靠这个区分信号来源。sa_mask是处理这个信号期间要额外阻塞的信号集合。注意正在被处理的信号本身通常会被自动阻塞不用你重复加。sa_flags则是行为控制位常用的有SA_RESTART让慢速系统调用在信号处理完后自动重启而不是返回EINTR错误。SA_SIGINFO使用siginfo_t三参数版本。SA_NOCLDWAIT只对SIGCHLD有意义加上后子进程退出自动回收不产生僵尸但你也无法用wait拿到子进程退出状态了需要权衡。3.3 一个完整可编译的信号捕获示例写一个具有实用性的示例一个服务进程收到SIGTERM时做优雅退出收到SIGUSR1时打印当前计数。#include stdio.h #include stdlib.h #include string.h #include signal.h #include unistd.h static volatile sig_atomic_t g_running 1; static volatile sig_atomic_t g_print 0; static void handle_term(int sig) { // 只做标记不调用复杂函数 g_running 0; } static void handle_user(int sig) { g_print 1; } static void setup_signal(int signum, 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; if (sigaction(signum, sa, NULL) -1) { perror(sigaction); exit(1); } } int main(void) { setup_signal(SIGTERM, handle_term, SA_RESTART); setup_signal(SIGUSR1, handle_user, SA_RESTART); long counter 0; while (g_running) { counter; if (g_print) { g_print 0; // 主循环里做实际工作 printf(counter %ld\n, counter); fflush(stdout); } // 模拟业务忙碌 usleep(1000); } printf(graceful exit, counter %ld\n, counter); return 0; }这里我用了volatile sig_atomic_t类型的标志变量。为什么不用普通int因为handler在主循环执行的任意时刻可能被插入处理器缓存、编译器优化都可能导致主循环读到旧值。sig_atomic_t保证了对这个变量的读写是原子操作加上volatile保证编译器不从寄存器里直接取旧副本每次都从内存读。这是信号处理函数与主循环通信唯一安全又简单的方式。如果业务要求复杂一些比如信号处理函数里要通知别的线程那就需要用self-pipe技巧或者事件循环的唤醒机制这已经是进阶玩法了。4. 发送、阻塞与未决信号的生命周期管理4.1 发送手段五花八门但目标未必一样信号不是只能从命令行用kill发。用户态程序里常见的发送方式有这么几种kill(pid, sig)向指定PID的进程发送信号需要目标进程与自己同用户或自己有权限。raise(sig)向自己当前线程发送信号。注意它发送的对象是调用线程不是整个进程。alarm(seconds)设置一个秒级的定时器倒计时结束后内核给进程发送SIGALRM。它一次只能设置一个后面设置的会覆盖前面的。timer_create()/timer_settime()POSIX定时器能到纳秒级还能选择发给进程还是某个线程信号可以带siginfo比alarm强太多。killpg()/pkill向整个进程组发送信号这经常是误杀事故的元凶。假设你要向某个线程发信号pthread_kill(thread, sig)是线程级发送但信号能不能准确路由到目标线程还要看整个进程的线程信号屏蔽字配置这块到第5部分专门说。实际开发里信号发送经常被当成“控制命令”。我维护过的一个守护进程就是用SIGUSR1触发配置重载。它比某种基于网络的命令通道更简单但也更粗糙——因为它不可靠、不排队你不能用它传大数据。如果你发现自己需要靠信号传递结构化信息再传个JSON建议停下来想想是不是该换消息队列或者Unix域套接字。4.2 阻塞与忽略很多人都理解反了这是面试里最经典的高频易错点也是实际排障中最容易看走眼的地方。“忽略”是处理方式的一种代表信号过来之后什么都不做而“阻塞”是信号还没到进程手里内核先把它的对应位置在未决信号集里置位等解除阻塞后再处理。忽略一个信号内核收到同类信号直接丢掉阻塞一个信号信号只是暂时押着不丢。用sigprocmask()设置进程级的阻塞集合sigset_t set; sigemptyset(set); sigaddset(set, SIGUSR1); sigprocmask(SIG_BLOCK, set, NULL);把SIGUSR1加进阻塞集合后即使你向进程发一百次SIGUSR1进程也感知不到。等到你调用sigprocmask(SIG_UNBLOCK, set, NULL)解除内核才把未决的最后一个SIGUSR1递送给进程。一百次还是只处理一次。这个特性在工程上有个很有意思的用法临界区保护。如果你的关键代码段不能被打断那就先把可能打断它的信号都block掉尤其是SIGTERM、SIGINT这种等出了临界区再解除。我见过某些数据库中间件就是这么做的把“是否允许被CtrlC中断”当成一个业务状态来管理。但注意SIGKILL和SIGSTOP这两个信号无论如何都block不了内核根本不给机会这是系统的最后底牌。另一个容易被忽略的关联是当你用sigaction()给某个信号注册handler时如果设置sa_mask包含另一个信号那么在handler执行期间那个信号也会被临时阻塞。但等你handler返回一切恢复原样。这是在信号处理级而非进程级做局部屏蔽。4.3 sigsuspend() 的原子性普通写法模拟不来假设主程序要等到某个信号来了再继续往下走很多人第一反应是写个while循环while (flag 0) { pause(); }但这样有个窗口如果信号在while判断完flag之后、进入pause()之前到达那么这个信号会被“吞掉”进程将永远睡下去。有没有解决有用sigsuspend()。它的逻辑是原子地替换当前进程的阻塞信号集然后挂起直到收到一个信号且该信号被允许递送时返回之前恢复原来的阻塞集。用法上有个经典套路“解除某个信号的阻塞同时等待它到达”。先把目标信号阻塞起来如果信号早就来了那它必然在未决集里不会丢失然后sigsuspend(set)里set不包含这个目标信号意思是一边放开目标信号一边挂起等待一旦到达sigsuspend返回整个流程不存在竞态窗口。sigset_t oldset, waitmask; sigemptyset(waitmask); sigaddset(waitmask, SIGUSR1); sigprocmask(SIG_BLOCK, waitmask, oldset); // ... 做一些需要保护的事情 ... sigsuspend(oldset); // 原子地解除SIGUSR1的阻塞并等待很多服务器程序里的“主线程等待退出信号”就适合用这个模式而不是傻傻地sleep(1)轮询标志位。5. 真实项目里吃过的亏信号处理六大陷阱5.1 在信号处理函数里调用printf你可真敢新手最常犯的错在handler里写printf或者malloc甚至调用sprintf。要知道信号处理函数的执行是异步的它可能在主程序执行到任意位置时被插入。如果主程序正执行到malloc内部恰好持有堆内存分配的锁此时信号来了handler里的malloc再次进入同一套锁逻辑直接死锁。POSIX标准给出了一个清单异步信号安全函数async-signal-safe functions在这个清单外的函数都不能在信号处理函数里随便调。清单里包括read、write、open、close、wait、_exit、sigaction等但很不幸printf、malloc、free、pthread_*这些常见函数都不在里面。那怎么在handler里做日志最实用的办法是write到预先打开的文件描述符或者在handler里只设置标志位由主循环统一做实际处理。如果你想输出一个数字用write配合手写整数转字符串不要用printf。再补充一个细节handler里需要记录错误号时建议先保存errno处理完再恢复。因为信号可能打断主程序的任意系统调用而handler里一旦调用了可能修改errno的函数主程序拿到的错误码就乱了。5.2 EINTR问题你的read为什么总是被中断当进程处于阻塞的系统调用中比如read一个管道管道里暂时没有数据一个信号到达并处理完默认情况下这个系统调用会被中断并返回-1errno置为EINTR。很多初写网络程序的人看到read返回-1第一反应是网络断了实际上只是“被信号打扰了”。解决的思路不只一种在sigaction的flag里加SA_RESTART让内核自动帮你重启大多数被中断的系统调用。不依赖SA_RESTART循环里判断errno EINTR就重试。我自己的经验是如果程序里信号处理逻辑很清晰SA_RESTART基本能解决90%的问题。但注意SA_RESTART对某些系统调用并不生效比如select、poll、epoll_wait、nanosleep、wait等还是会返回EINTR。所以网络服务里主循环还是要对EINTR做兼容。我见过一个高可用程序epoll里不处理EINTR每次被信号打断就直接break出循环结果服务周期性退出排了一天查出来就这么个点。5.3 SIGCHLD与僵尸进程的爱恨情仇父进程没耐心等子进程但内核在子进程退出后不会立刻删除进程表项而是保留到父进程调用wait/waitpid来回收状态。如果父进程从未wait过子进程就变成僵尸。正确处理SIGCHLD的标准姿势是在handler里只调用waitpid(-1, status, WNOHANG)循环回收所有已退出的子进程。为什么用WNOHANG因为handler是异步的你不知道此刻有多少个子进程退出用非阻塞方式全部收割一遍绝不能在里面死等那是阻塞式wait会把信号处理函数永远挂住别的信号再来就全堵死了。用waitpid(-1, ...)循环的时候还有个坑如果同一时刻有多个子进程退出handler里一次waitpid只回收一个必须循环到返回0或-1为止不然僵尸收割不干净。把这个逻辑放到SIGCHLD handler里是Linux服务端程序的标准操作。5.4 多线程进程里的信号路由比想象中拧巴多线程情况下信号怎么路由是一个容易让人抓狂的问题。基本规则是进程级别发送的信号kill、raise对进程的默认行为可以被进程内任意一个未阻塞该信号的线程处理而不是一定发给主线程。如果你在主线程里设置了SIGTERM handler但另一个工作线程没有阻塞SIGTERM内核完全可能把SIGTERM投递给那个工作线程从而触发你想象不到的后果。一个常见的工程做法是所有线程都继承进程的初始信号屏蔽字然后在主线程里对感兴趣的信号设置handler同时让其他线程把信号都block掉。怎么做到在创建线程之前用pthread_sigmask()设置阻塞集合新线程会继承这个屏蔽字。主线程单独撤销对目标信号的阻塞这样信号只会被投递到主线程。sigset_t set; sigemptyset(set); sigaddset(set, SIGTERM); sigaddset(set, SIGINT); pthread_sigmask(SIG_BLOCK, set, NULL); // 在创建线程之前调用 // 创建其他工作线程... // 主线程自己解除阻塞 pthread_sigmask(SIG_UNBLOCK, set, NULL);另一个方向是用sigwait()而不是handler把所有信号都block住然后专门开一个线程循环sigwait()处理。这种方法避开了“在异步handler里做复杂操作”的所有限制代码逻辑可以普通同步写很多消息中间件和数据库服务都这么干因为信号处理从“异步打断”变成了“同步消费”心智负担一下小很多。只设handler传标志位是一个可行的入门方案但如果你真在维护一个高并发服务我会建议考虑sigwait线程。这里顺带提一下实时信号使用SIGRTMIN以上的实时信号时handler带siginfo_t可以用si_value传递一个指针或整数很多人把它当成轻量级事件队列用。但因为系统能排队的实时信号数量有限制过量发送照样会丢不能指望它来做大流量通信。5.5 core dump 不是免费午餐看到SIGSEGV就以为一定有core文件这是误会。core dump是否产生取决于进程的rlimitulimit -c如果为0SIGSEGV只会默默终止进程。排查崩溃问题时很多人第一句话就是“没有core怎么办”其实第一步应该检查ulimit -c unlimited再看/proc/sys/kernel/core_pattern指向哪里。我见过某公司统一把core_pattern指向一个带PID和时间的路径配合自动收集系统排查效率提升很多。但还有一点可能比core设计更重要进程收到SIGSEGV时真正有价值的是现场而不是处理函数。你可以在handler里用sigaction注册SIGSEGV/SIGABRT的handler利用siginfo_t的si_addr拿到出错地址再调用backtrace()打印调用栈然后_exit()。这种做法的本质是把崩溃变成可控的“临终日志”。注意这个handler里你只能调用async-signal-safe函数——backtrace函数族是否安全在不同平台上有争议团队里要测试后再落地。5.6 信号与IO多路复用的协作别用SIGALRM做精确计时我在某项目里看到过一个比较惨痛的教训用alarm(1)做周期性任务触发但因为系统负载高、调度不确定SIGALRM经常丢失非实时信号不排队导致周期任务不定期“漏跑”。后来改成timer_create加SIGEV_THREAD信号通过线程回调函数投递才算稳定。原因很简单SIGALRM是标准非实时信号背着位图模型不可靠的包袱根本不适合对可靠性敏感的定时任务。更合理的方式如果主循环已经基于epoll/select推荐使用timerfd_create创建定时器文件描述符把它加入epoll事件集定时器到期就以可读事件的形式出现在IO多路复用里完全绕开信号机制。这简直是服务端程序处理定时任务的最优解没有信号中断、没有EINTR、没有异步handler限制只当普通fd来读就行。所以我的个人倾向是能用IO事件解决的尽量不用信号能用同步sigwait处理的尽量不用异步handler实在要用异步handlerhandler里绝对只做标志位标记。6. 最后再分享一点实战心得信号是个好东西但要用得克制我自己用过一段时间信号做进程间控制积累了这么个习惯把所有信号处理逻辑收拢到一个独立的模块里不散落在业务各处。这个模块统一负责注册handler、统一管理信号屏蔽字、统一提供“设置退出标志”“请求重载配置”等语义接口。业务代码只调接口不直接接触signal。还有一个小技巧值得分享在测试信号处理流程时不要只发一次信号要连续快速发多个同类信号观察有没有丢失、有没有行为不一致。很多时序问题单次信号根本暴露不出来只有压着发才能看出handler是否可重入、标志位是否被正确处理。调试时可以用sigqueue给信号附带不同整数值然后通过siginfo_t.si_value区分不同指令这比单纯用USR1/USR2两个信号能表达的语义丰富太多。如果你维护的是长期运行的守护进程我还建议你做一个“信号处理自检”启动时打印一份当前注册了哪些信号、哪些信号被阻塞、哪些走默认动作的报告。这个报告平时不起眼真正排障的时候能省下大量猜测时间。我现在每接手一个陌生服务第一件事就是看/proc/pid/status里的SigCgt和SigBlk字段这两行能直接告诉我这个进程对哪些信号做了拦截、哪些被屏蔽了很多时候问题答案就藏在里面。信号就是这样一种存在它足够底层、足够强大但也足够粗糙。你可以在一个几十行的C程序里用它实现进程控制也可以在几千线程的大系统里靠它优雅退出。关键是理解它的异步本质和位图式语义然后规规矩矩地按它的脾气来写代码。上面的坑我基本都踩过一遍写出来供各位参考希望你们的进程都能收到愿意处理的信号。