ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux信号处理全指南:从信号机制到优雅退出实战

Linux信号处理全指南:从信号机制到优雅退出实战 风中低语Linux 信号处理的艺术与实践如果你写过Linux下的服务端程序大概率经历过这种诡异时刻进程在线上跑得好好的没有任何报错日志突然就没了。检查磁盘、检查内存、检查逻辑代码什么都查不出来最后打开core dump一看发现进程是被某个信号送走的。这种无声无息的死亡恰恰是Linux信号处理最典型的场景——信号就像内核在进程耳边的一声低语你说没听见吧它确实来过你说听见了吧又往往来不及反应。这篇内容我打算把Linux信号处理这件事从头到尾讲透覆盖信号的本质、生命周期、注册方式、阻塞与未决以及真实项目里的实战姿势和踩坑记录。不管你是刚接触Linux的初学者还是已经写过不少服务端代码的开发者这篇文章应该都能帮你在面对signal相关问题时少走一些弯路。1. 信号到底是什么内核与进程之间的悄悄话1.1 先从kill命令说起很多人第一次认识信号就是通过kill命令。比如杀不掉这个进程了kill -9这句台词在运维圈几乎天天有人喊。但99%的人可能没想过一个问题kill为什么能杀进程kill的本质是什么实际上kill只是一个发信号的工具它本身不具备任何杀伤力。它做的事情很简单调用系统调用kill(2)告诉内核帮我把某个信号发给某个进程。至于信号到了之后进程会不会死取决于两件事这个信号是什么以及进程有没有给自己注册对应的处理逻辑。Linux下的信号本质上是一个数字编号。这个编号在内核里对应着一张事件表每种编号都代表一个特定的事件类型。比如SIGINT这个编号是2对应的事件是用户在终端按下了CtrlCSIGTERM的编号是15对应的事件是有人请求这个进程优雅地退出SIGSEGV的编号是11对应的事件是进程访问了非法内存地址。信号到达进程之后由进程决定怎么处理——当然不是所有信号都有得选。1.2 一个更贴近生活的类比把进程想象成一个大忙人信号就是发到他手机上的消息。有些消息是提醒式的忙人可以选择忽略也可以选择回复有些消息是强提醒一旦收到就必须停下手里的一切去处理还有一类消息最特殊——发件人是系统管理员内容只有两个字你被开除了不管你在干什么、愿不愿意都会立刻生效。对应到Linux信号上大多数信号都可以被进程捕获也就是注册一个处理函数在信号到达时执行自定义逻辑有一小部分信号不可捕获比如SIGKILL和SIGSTOP属于内核的强制手段还有一部分信号如果你不处理内核会按照默认行为执行比如终止进程或忽略信号。理解这层关系之后再回头看kill -9杀进程这件事就清晰多了kill -9发的是SIGKILL信号这个信号直接由内核处理进程连说等一下的机会都没有结果就是立刻消失。这也是为什么在维护重要服务时能不用-9就不要用-9的原因——它剥夺了进程清理资源、保存状态的最后机会。1.3 每个信号背后的故事Linux标准信号一共有31个编号1到31再加上编号32到64的实时信号。作为日常开发不需要全部背下来但下面这张表里的信号几乎每个写Linux程序的人都躲不开信号编号触发场景默认行为SIGHUP1终端挂断、进程退出控制终端终止进程SIGINT2用户按下CtrlC终止进程SIGQUIT3用户按下Ctrl\终止并生成core dumpSIGKILL9强制终止命令kill -9终止进程不可捕获SIGSEGV11非法内存访问段错误终止并生成core dumpSIGPIPE13写入无读端的管道或socket终止进程SIGALRM14定时器alarm/setitimer到期终止进程SIGTERM15默认的kill信号终止进程SIGCHLD17子进程终止或停止忽略SIGSTOP19停止进程停止进程不可捕获SIGCONT18继续已停止的进程继续执行SIGUSR1/210/12用户自定义事件终止进程观察一下这张表你会发现一个很有意思的事实像SIGSEGV、SIGPIPE这种致命信号从名字到默认行为都透着一种事已至此你活该的意味而SIGINT、SIGTERM这种则属于给你一个体面的机会自己收拾残局。开发服务端程序时需要重点处理的通常是SIGINT、SIGTERM、SIGHUP、SIGCHLD、SIGPIPE这几位后面我会挨个展开讲。2. 信号的生命周期从内核投递到进程处理2.1 信号从哪儿来三大来源信号不会凭空出现它的产生来源主要有三类。第一类是硬件异常。比如进程执行了非法指令、触发了除零操作、访问了越界地址CPU在硬件层面检测到问题后会向操作系统报告内核据此生成对应的信号SIGILL、SIGFPE、SIGSEGV等。这类信号通常意味着程序本身有bug而且默认行为往往直接让进程崩溃并留下core dump。第二类是终端按键。当你按下CtrlC终端驱动并不会直接杀进程它只是把用户要求中断前台进程这个信息传递给内核内核接着向前台进程组的每个进程发送SIGINT信号。类似的Ctrl\发送SIGQUITCtrlZ发送SIGTSTP挂起进程。第三类是软件事件。最典型的例子是定时器到期产生SIGALRM以及一个进程主动调用kill()、raise()、alarm()、abort()等函数去给另一个进程或自己发信号。UNIX环境编程里引入的一个经典场景就是父进程用SIGUSR1通知子进程该继续干活了子进程用SIGCHLD通知父进程我已经结束了。还有一类比较容易忽略的信号来源是内核本身。比如系统检测到进程写了一个已经关闭的管道——对端已经退出底层socket已经关闭数据根本送不出去——这时候内核会补发一个SIGPIPE给你默认结果就是进程被终止。2.2 信号的传递路径内核的悄悄话是怎么说的信号产生之后并不一定会被进程立刻听见它要经过一条比较完整的链路产生(generation) → 送达(pending) → 投递(delivery)。当信号送达进程时内核会给进程的未决信号表里打一个标记。如果进程没有阻塞这个信号它会在一个合适的时机真正进入处理逻辑这一步叫投递。但如果进程设置了信号屏蔽那么信号就会一直停留在未决状态直到进程解除屏蔽。这里有一个值得深入理解的细节信号处理动作的发生时机并不是信号产生的那一刻。内核会选择进程从内核态切换回用户态的这个边界点检查未决信号表然后安排处理。换句话说如果进程正在执行内核代码进行系统调用那么信号会被挂起直到系统调用结束、进程准备回到用户态时才会先跳转到信号处理函数里跑一趟然后再回到主程序中原本要执行的地址。这也解释了为什么某个耗时很长的系统调用比如read等待IO信号的到来会让它提前返回EINTR——因为信号处理函数已经插入进来了系统调用被打断了。2.3 不处理信号时内核替你做主如果进程没有对某个信号注册处理器它会按照内核预设的默认行为来处理信号。Linux对每个信号的默认行为可以分为五类终止进程Term进程直接被销毁比如SIGTERM终止进程并生成core dumpCore进程终止的同时把内存现场导出为core文件方便事后调试比如SIGSEGV、SIGABRT忽略信号Ign什么都不做比如SIGCHLD、SIGURG停止进程Stop把进程挂起不销毁可以等待继续比如SIGSTOP、SIGTSTP继续执行Cont让暂停的进程恢复运行比如SIGCONT。理解默认行为这一步很重要很多线上问题其实不是信号处理函数写错了而是默认行为本身把进程搞死了。举个例子一个网络服务端的socket连接如果客户端突然断开服务端那边再次write()的时候内核发现已无读端会立刻发一个SIGPIPE。而SIGPIPE的默认行为是终止进程于是你的服务端悄无声息地挂了。这种情况实战里太常见了处理方式我们在后面第五部分专门说。3. 注册信号处理器从入门API到正规军3.1 signal()新手的好朋友老手的坑POSIX最初提供的信号注册API是signal()它的函数签名长得非常劝退void (*signal(int signum, void (*handler)(int)))(int);翻译成人话就是signal()接收一个信号编号和一个函数指针返回之前设置的信号处理函数指针。实际使用的时候不用管这么复杂的声明直接这么写#include signal.h #include stdio.h void handler(int sig) { // 这里做什么先卖个关子 } int main(void) { signal(SIGINT, handler); // 主程序逻辑 return 0; }看起来挺简单对吧但signal()在实战中有一个著名的问题它在不同UNIX系统上的语义不一致。SysV版本的signal()在信号处理函数执行完毕后会把该信号的处理方式重置为默认行为也就是说下一次再收到同一个信号就会直接走默认流程你的handler就不生效了。BSD版本的signal()则不会重置处理完一次之后继续保留你的handler。两种行为各有拥趸但可移植性极差所以后来在编写严肃程序时几乎都统一改用sigaction()。3.2 sigaction()把控制权牢牢握在手里sigaction()是POSIX推荐的信号注册方式它的功能远比signal()丰富而且行为明确、可移植。#include signal.h struct sigaction { void (*sa_handler)(int); // 信号处理函数 void (*sa_sigaction)(int, siginfo_t *, void *); // 扩展版本能拿到更多信息 sigset_t sa_mask; // 处理期间额外阻塞的信号集 int sa_flags; // 行为标志位 void (*sa_restorer)(void); // 已废弃不用管 }; int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);其中sa_flags值得专门记住几个常用取值SA_RESTART被信号打断的系统调用自动重启比如慢速读如果不设置read/write等调用会返回EINTR错误SA_SIGINFO表示使用sa_sigaction而不是sa_handler这样处理器可以获取发送信号的进程PID、信号的具体原因等SA_NOCLDWAIT配合SIGCHLD使用子进程结束后内核直接回收不产生僵尸进程。我平时写信号处理注册函数时固定模板是这样一个封装#include signal.h #include stdio.h void setup_signal_handler(int signo, void (*handler)(int)) { struct sigaction act; act.sa_handler handler; sigemptyset(act.sa_mask); act.sa_flags SA_RESTART; // 让慢速系统调用尽量不被打断 if (sigaction(signo, act, NULL) 0) { perror(sigaction); // 真实项目里这里应该记录日志而不是perror } }注意我通常在sa_flags里带上SA_RESTART。这个细节有利有弊好处是read/write这类系统调用不会因为信号而返回EINTR省去很多重试代码坏处是有时候程序设计上就必须依赖EINTR来做超时唤醒这时候就得去掉SA_RESTART。如果你在维护一段老代码看到退出的循环条件是errno EINTR那多半就是当时设置了不带SA_RESTART的处理器。没有绝对的对错关键是你得知道自己在做什么。3.3 处理器里那点不能做的事信号处理函数里有一个非常严肃的约束只能调用异步信号安全async-signal-safe函数。什么意思就是你只能调用那些即使正在被主程序使用信号处理器再次调用也不会出问题的函数。最容易踩的雷区就是printf()。很多初学者在信号处理函数里写printf一收到信号就疯狂输出结果程序直接卡死。原因很简单printf内部有缓冲区加锁机制如果信号到达的时机恰好是主程序正在执行printf并且已经拿到了缓冲区锁那么处理器里的printf会去抢同一把锁从而死锁。malloc、free、fork、sprintf等常见函数同样存在类似风险。那么处理器里应该做什么最稳妥的做法是尽量少做事。实践中推荐使用一种经典模式——处理器只设置一个标志主循环检查并处理#include signal.h #include stdio.h #include unistd.h static volatile sig_atomic_t g_flag 0; void handler(int sig) { g_flag 1; // 仅设置标志安全 } int main(void) { setup_signal_handler(SIGTERM, handler); while (1) { if (g_flag) { // 清理、保存、退出 break; } // 正常工作 } return 0; }volatile的作用是告诉编译器这个变量的值可能随时被外部改变不许优化掉sig_atomic_t保证了对该变量的读写是原子操作不至于读到中间值。这一对组合是信号处理器里面最安全的通信方式。4. 信号的阻塞、未决与信号集操作4.1 阻塞与未决信号也会排不上队阻塞block和未决pending是走到进阶阶段绕不开的两个概念。阻塞可以理解为进程主动对一个信号说现在别打扰我晚点再说而未决就是那个被挂起、正在等待投递的信号。你可能会问被阻塞的信号等到解除阻塞的时候还会来一次吗答案是会但只能来一次。换句话说如果进程在阻塞期间对方连发了10个SIGUSR1解除阻塞后进程只会收到1个SIGUSR1因为标准信号不排队。这是整个信号处理中一个非常重要的特性也是很多设计失误的根源。内核为每个进程维护了两张位图一张是信号屏蔽字blocked一张是未决信号位图pending。产生一个信号时内核在pending里置位投递信号前先检查blocked里是否对应置位如果被阻塞信号就一直停留在pending里。注意一个信号可能被多次产生但pending里只有一个bit位最多只能记录有信号来过至于来了几次标准信号是记不住账的。4.2 信号集操作的完整姿势先处理信号集本身的初始化再谈屏蔽和等待。信号集类型是sigset_t不能直接用赋值需要调用专门函数#include signal.h sigset_t set; sigemptyset(set); // 清空 sigaddset(set, SIGINT); // 添加SIGINT sigdelset(set, SIGINT); // 移除SIGINT int ret sigismember(set, SIGINT); // 查询是否包含屏蔽和恢复则靠sigprocmask()#include signal.h int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);how有三个取值SIG_BLOCK把set中的信号加入当前屏蔽字SIG_UNBLOCK把set中的信号从当前屏蔽字移除SIG_SETMASK直接用set替换当前屏蔽字。oldset是一个输出参数用于获取调用前的屏蔽字方便后面恢复现场。一个典型的使用场景是有一段对共享资源读写的临界区不希望被打断先把SIGINT和SIGTERM全部屏蔽掉处理完再解除。下面是一段完整可运行的示例#include signal.h #include stdio.h #include unistd.h static volatile sig_atomic_t g_flag 0; void handler(int sig) { g_flag sig; } int main(void) { setup_signal_handler(SIGINT, handler); sigset_t block_set, old_set; sigemptyset(block_set); sigaddset(block_set, SIGINT); // 进入临界区前阻塞 sigprocmask(SIG_BLOCK, block_set, old_set); // 模拟临界区操作 printf(critical section start...\n); sleep(3); // 查询是否有未决信号 sigset_t pending_set; sigpending(pending_set); if (sigismember(pending_set, SIGINT)) { printf(SIGINT is pending while blocked\n); } // 离开临界区恢复屏蔽字 sigprocmask(SIG_SETMASK, old_set, NULL); printf(critical section end\n); // 如果刚才有信号被挂起这里就会投递 sleep(1); return 0; }这个例子完整展现了阻塞 → 信号到达成为未决 → 解除阻塞 → 信号投递的全过程。你可以在终端里运行它在sleep(3)期间按下CtrlC会发现输出里多了一行pending提示随后的SIGINT处理器被触发。这就是未决信号与投递时机最直观的体验。4.3 原子等待sigsuspend的妙处如果你需要在等待一个信号的过程中临时解除对其他信号的阻塞直接先后调用sigprocmask和pause会有一个窗口期如果两个调用之间信号恰好到达信号就丢了。sigsuspend()这个函数专为此而生它接受一个信号集作为临时屏蔽字原子地完成设置屏蔽字 → 等待信号 → 恢复原屏蔽字这一整套动作。经典用法是先解除某一信号的阻塞再挂起等待#include signal.h #include stdio.h static volatile sig_atomic_t g_received 0; void handler(int sig) { g_received 1; } int main(void) { setup_signal_handler(SIGUSR1, handler); sigset_t wait_set; sigemptyset(wait_set); sigaddset(wait_set, SIGUSR1); printf(waiting for SIGUSR1...\n); sigsuspend(wait_set); // 这里set之外被阻塞SIGUSR1不会被阻塞 // 到了这里说明SIGUSR1已经被处理过了 if (g_received) { printf(received SIGUSR1\n); } return 0; }注意这里wait_set的含义与直觉恰好相反sigsuspend会把进程屏蔽字临时替换为wait_set所以参数里包含哪个信号、哪个信号反而会被阻塞。正确的逻辑是想要等到SIGUSR1就不要把它放进wait_set。这个反直觉设计坑过不少人我自己初学的时候也被绕晕过。5. 真实场景中的信号实战优雅退出、超时控制与子进程回收5.1 优雅退出让服务体面地离开服务端程序最常见的死法是被打断就原地去世。真正稳健的服务应当能处理SIGINT和SIGTERM在清理完状态后再退出。下面是一个比较标准的优雅退出框架#include signal.h #include stdio.h #include unistd.h #include stdlib.h static volatile sig_atomic_t g_stop 0; static volatile sig_atomic_t g_reload 0; void handle_signal(int sig) { if (sig SIGINT || sig SIGTERM) { g_stop 1; } else if (sig SIGHUP) { g_reload 1; } } int main(void) { setup_signal_handler(SIGINT, handle_signal); setup_signal_handler(SIGTERM, handle_signal); setup_signal_handler(SIGHUP, handle_signal); while (!g_stop) { if (g_reload) { // 重新读取配置文件的逻辑 g_reload 0; } // 主循环accept/read/process // 注意使用带超时的IO避免无限阻塞 sleep(1); } // 善后关闭fd、同步数据、记录日志 printf(server exited gracefully\n); return 0; }关键点有两个一是主循环要用有超时的IO不能永远卡在read/accept这种不可中断的调用上否则信号到了也只能等二是善后逻辑放在主循环之外不能放在信号处理器里因为处理器里不允许多做事情。这套模式我用了很多年在真实生产环境里扛过各种各样的杀进程操作只要不是kill -9服务都能体面退出。5.2 超时控制alarm与setitimer很多程序都需要如果XX秒内没反应就当作失败的超时判断。最原始的做法是alarm()它会在指定秒数后向进程发送SIGALRM信号如果不处理进程就默认终止。这种方式的问题在于分辨率只有1秒而且一个进程只能有一个alarm定时器。更高精度的做法是setitimer()它支持微秒级别还分三种模式ITIMER_REAL真实时间到点发SIGALRM、ITIMER_VIRTUAL进程用户态运行时间发SIGVTALRM、ITIMER_PROF用户态内核态时间发SIGPROF。#include sys/time.h #include signal.h void timer_handler(int sig) { // 超时了做点该做的事 } int main(void) { setup_signal_handler(SIGALRM, timer_handler); struct itimerval t; t.it_interval.tv_sec 0; // 一次性定时器 t.it_interval.tv_usec 0; t.it_value.tv_sec 5; // 5秒后就触发 t.it_value.tv_usec 0; setitimer(ITIMER_REAL, t, NULL); // 继续执行可能超时的操作 return 0; }注意一个细节ITIMER_REAL和alarm()用的是同一个内核定时器设施所以二者不能混用后设置的会覆盖先设置的。如果你在一个库里用setitimer实现了超时又在另一个模块里调用alarm它们会互相踩。现代高性能编程中很多人转而用timer_create创建基于POSIX时钟的定时器每个定时器可以独立管理但那是另外一个话题了。5.3 SIGPIPE一路追杀网络服务的隐形杀手前面提到往一个对端已关闭的socket连接里写数据内核会补发SIGPIPE默认行为就是终止进程。对网络服务端来说这简直是灾难某个客户端网络波动了一下连接断了服务端一写数据整进程直接崩溃。更糟糕的是由于不是core dump可能连日志都没记录到。处理方案有两种第一种全局忽略SIGPIPEsignal(SIGPIPE, SIG_IGN);这是最省事的方式。忽略之后write/read函数会返回-1并设置errno为EPIPE代码里就能通过这个返回值来识别连接已关闭并做清理。第二种在使用socket发送时不依赖信号而是显式指定MSG_NOSIGNAL标志ssize_t ret send(fd, buf, len, MSG_NOSIGNAL);这样即使连接断开也不会触发SIGPIPE而是由send返回EPIPE错误。需要精确控制连接状态的程序更适合用这种方式。我个人推荐全局忽略SIGPIPE 代码中专门处理EPIPE的组合因为即使是read或write触发的管道破裂也一并覆盖了而MSG_NOSIGNAL只对send有效。5.4 SIGCHLD子进程的临终遗言父进程fork出来的子进程退出后内核会给父进程发SIGCHLD信号目的是提醒父进程该用wait/waitpid收尸了。如果父进程一直不回收子进程就会变成僵尸进程占据进程表项长时间积累能把系统的进程数上限耗尽。忽略SIGCHLD是一个省力的办法如果父进程set了SIGCHLD的handler为SIG_IGN内核会在子进程退出时直接回收连僵尸状态都不会产生。但这会带来另一个问题你也获取不到子进程的退出状态了。很多严肃的项目还是选择注册SIGCHLD处理器在处理器中用waitpid循环回收#include signal.h #include sys/wait.h #include stdio.h void chld_handler(int sig) { int status; pid_t pid; // 用WNOHANG循环一次wait只能回收一个子进程 // 如果同时多个子进程退出必须循环直到返回0 while ((pid waitpid(-1, status, WNOHANG)) 0) { printf(child %d reaped\n, pid); } }这里有一个很关键的实战细节必须循环调用waitpid直到返回0或-1而不是只调用一次。因为标准信号不排队如果多个子进程几乎同时退出内核只投递一次SIGCHLD你如果只wait一次剩下的子进程就会变成僵尸。这个坑我踩过不止一次排查僵尸进程时一度怀疑是代码逻辑问题最后发现就是只收了一次。还有一个进阶技巧如果你的服务完全不关心子进程的退出码可以用sigaction配合SA_NOCLDWAIT标志。设置了SA_NOCLDWAIT之后子进程退出时不会产生僵尸内核自动回收父进程也不需要SIGCHLD处理器一举两得。但同样地你也失去了获取子进程状态的能力需要根据业务权衡。6. 那些年我们踩过的信号坑排查链路的完整复盘6.1 信号处理器调用printf导致死锁一次真实的卡死事故有一年我接手一个消息系统线上偶现进程完全卡死的现象没有任何报错。用gdb attach上去所有线程的堆栈都停在printf相关的锁上主线程停在正常的日志输出里而一个信号处理线程停在同一个printf上。两把锁互相等待直接死锁。复盘时发现信号处理器里写了printf用于调试上线时没删干净。平时不触发信号也就没事一触发就碰到主线程正在打印日志的窗口双方抢同一把锁进程就两败俱伤了。修复方案很简单把调试printf全删了处理器里只保留标志位的赋值。但这类事故特别容易在赶进度、先调试后补的场景下被带进生产环境所以我的建议是在代码评审里把信号处理器里是否包含非异步安全函数作为一条硬性检查项。6.2 竞态检查标志与等待信号之间丢了信号另一个常见问题是出发前看了一眼结果信号偏偏在那一瞬间到了。典型的错误写法是while (1) { if (g_flag) break; pause(); // 永久等待信号 }问题出在if检查g_flag和pause()之间有一个窗口期如果信号在检查完之后、pause()之前到达信号处理完了g_flag置位但进程还在pause()里睡着不会醒来一直挂到天荒地老。虽然这个例子稍微有点刻意但实际业务中先检查条件、再等待事件这种模式真的很常见。解决方案是用sigsuspend这种原子等待或者把条件检查和等待一并放进一个原子操作里确保没有窗口期。本质上信号处理竞态和并发编程里的check-then-act竞态是一回事只不过并发是多个线程这里是信号打断主流程。6.3 多线程程序里的信号到底发给谁了很多人把单进程模型里的信号处理经验直接搬进多线程结果发现行为诡异。原因在于多线程程序的标准信号默认发送给任意一个不阻塞该信号的线程而不是固定发给主线程。如果某个线程恰好屏蔽了这个信号另一个线程收到了处理函数执行的上下文就是不确定的。正确的多线程信号处理方法有两种常用套路第一种让所有线程都阻塞信号单独用一个线程调用sigwait/sigtimedwait专门取信号。这样信号到达后不是打断某个线程而是排队给信号线程处理彻底避免了异步打断带来的所有麻烦#include signal.h #include pthread.h void *signal_thread(void *arg) { sigset_t set; sigemptyset(set); sigaddset(set, SIGINT); sigaddset(set, SIGTERM); // 这个线程只负责等待和取出信号 int sig; while (1) { sigwait(set, sig); printf(received signal %d\n, sig); // 统一处理 } return NULL; }第二种仍然使用sigaction但在每个相关线程的起始处统一调用pthread_sigmask设置屏蔽字将信号定向投递到指定线程。这种方式控制粒度更细但复杂度也更高。我个人的建议是新项目直接走sigwait专线程方案就算将来要加信号也多只需要扩展一下switch-case比在多个线程里分散处理信号要省心得多。6.4 kill(0)误伤进程组一炮打翻一船人还有一个特别容易忽略的细节kill()函数的第一个参数如果是0含义是给当前进程组的所有进程发送信号而不是给PID为0的进程。在包含多个子进程的服务里如果你误调用了kill(0, SIGTERM)等于向整个进程组的所有进程发出终止信号所有兄弟进程、后台进程全部阵亡。这个bug极其隐蔽因为错了也不会报错。判断是不是进程组误伤可以在出问题时用ps -eo pid,pgid,comm查看哪些进程处于同一个PGID。真正想给指定进程发信号务必确认pid参数是正数而且要检查返回值kill失败会返回-1并设置errno。6.5 与exec的相爱相杀如果一个进程在设置了信号处理器之后执行exec族函数比如fork后立即exec那么exec会把所有已设置为捕获的信号恢复为默认行为但继承了被忽略信号的设置保持不变。这个行为是POSIX规定的。这个规则带来一个经典问题如果子进程在exec之前收到了忽略SIGPIPE的设置exec之后SIGPIPE依然是忽略状态但如果子进程在exec之前注册过自定义handlerexec之后handler的信息就丢了。这意味着如果你想在某个外部程序执行期间屏蔽某些信号不能依赖父进程注册的处理器而要在exec之前用signal(SIGXXX, SIG_IGN)显式忽略因为忽略状态是跨exec继承的。写在最后的一点体会信号处理这个东西写起来好像就是几个API的事但真正用好的关键在于对异步打断这件事的敬畏。内核在任意一个指令边界都可能把控制权交到你的信号处理器手里而你的主程序对此一无所知。正因为这种打断是不可预期的信号处理器里才要求极度克制、只做最低限度的操作把真正的业务逻辑留在主循环里执行。我做Linux下的后台服务也有不少年头了回过头看那些线上最诡异的进程消失、卡死、僵尸堆积的问题几乎都能归结到本文讲过的几个点上要么是默认行为没搞清楚要么是处理器里干了不该干的事要么是忘记了标准信号不排队。把这几个底层逻辑刻在脑子里再遇到类似的问题排查方向基本不会跑偏。最后分享一个我从老前辈那儿学来的习惯每次写完信号处理相关代码都问自己一遍如果这个信号在每一行代码执行完之后都能到来我的代码还安全吗。想清楚了这个问题你就真正掌握了信号处理的艺术。
返回列表