
我做了这么多年Linux开发经常看到有人被信号折磨得死去活来。进程突然死了、程序卡住不动、服务莫名其妙退出很多时候都是信号机制没搞明白。信号这个东西说简单是一回事真要玩透又是另一回事——它是操作系统通知进程有事发生的异步机制也是进程间通信的最原始手段。这篇东西我会从信号的本质、核心API、阻塞与未决、并发陷阱到实战封装一条线完整过一遍适合刚接触Linux编程的初学者也适合写了几年代码但对信号细节一直模棱两可的人。1. 先弄明白信号到底是什么以及它凭什么能打断进程很多教材把信号解释成软件中断这个说法其实非常精准。中断的本质是CPU暂停当前任务、跳去执行中断处理程序、再回来继续原来的任务信号干的也是类似的事只不过它不是一个硬件管脚拉低了电平而是内核在软件层面给进程制造的一次打断。1.1 信号从产生到被处理的完整路程我习惯把一条信号的完整生命周期拆成三个阶段信号产生、信号挂起、信号递达。信号产生就是某个事件触发了信号比如你按了CtrlC终端驱动会向前台进程组发送SIGINT编号2比如你执行了kill命令实际上是调用kill()系统调用向目标进程发送信号再比如进程访问了非法内存内核检测到段错误后产生SIGSEGV编号11。信号产生之后并不会马上执行处理函数而是先进入挂起状态放在进程的pending信号队列里。进程运行在用户态还是内核态在这个阶段区别很大——如果进程正在内核态执行系统调用信号通常要等系统调用返回、进程切换回用户态之前才有机会被递达处理。你有时候会感觉到信号处理好像有延迟往往就是这个原因。信号真正被递达处理有三种选择默认动作、忽略、捕获。默认动作对大多数信号就是终止进程所以很多面试题问哪些信号可以被捕获实际上是在问你有没有背过那两张表不可捕获的SIGKILL和SIGSTOP是例外这俩直接由内核处理你注册任何handler都拦不住。忽略也好理解就是告诉内核这个信号我不在乎。而捕获就是我们这篇文章的重头戏——用户注册一个回调函数信号到来时内核会打断进程当前的执行流跳到回调函数里跑跑完再回到原来的断点继续执行。1.2 一个最容易忽略的底层规则信号处理跑在用户态栈上很多人不理解为什么信号处理函数里不能随便调用非异步信号安全的函数原因就在于信号处理函数被调用时进程并没有切换到另一个线程它就是在当前进程的同一个执行流里、同一个栈上、甚至同一个函数调用链的中间位置被硬生生插了一段代码。内核在递达信号时会在用户态栈帧上压入一个特殊帧把当前上下文寄存器、PC指针等保存好然后修改用户态PC指向你的handler入口。handler返回时执行特殊的sigreturn系统调用内核恢复之前保存的上下文进程接着原来的断点继续跑。整个过程就像你在写一篇文章时手机响了你接电话说两句挂掉回来接着写——但你根本不知道电话会在哪句话中间响所以你在电话里说出的任何话都不能依赖当前写到第几个字这种状态。被信号打断的这段代码如果是不可重入的比如malloc内部的链表还没操作完、printf的缓冲区锁还攥在手里handler里再去调用malloc或printf就会造成双重加锁、链表错乱甚至死锁。这就是为什么信号处理函数里最安全的做法是只设置标志变量、或者使用write()这类异步信号安全的系统调用一切复杂操作统统扔给主循环去做。2. signal()和sigaction()一个是被时代淘汰的坑一个是正经该用的API初学信号时大家基本都是从signal()函数入手的代码短、看着简单。但我要说的是如果你的新代码还在用signal()注册handler趁早改掉。它至少有两大硬伤行为在不同Unix版本间不一致以及信号处理期间会重置handler且无法阻塞其他信号。2.1 signal()到底哪里不靠谱先看两种历史语义。在System V里signal()注册的handler执行完一次之后会被重置为默认行为这意味着如果同一个信号连续来两次第二次进程就直接按默认动作退出好多线上程序的诡异崩溃就是这么来的。而在BSD里signal()的语义又改成了持久注册、执行期间阻塞当前信号。Linux的signal()实现的是BSD语义但GLIBC的man手册里明确建议不要用它实现可移植的信号语义。再就是这个执行期间自动阻塞当前信号的特性signal()要么不支持要么不可控。设想你的handler正在执行这时同一个信号又来了一次第二次信号如果没被阻塞就会嵌套打断当前handler造成栈上递归。这是非常危险的行为光凭signal()你是没法精细控制的。2.2 sigaction()的关键参数逐个拆解sigaction()是POSIX标准推荐的信号注册接口它的核心结构体长这样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是增强版回调能拿到siginfo_t结构体里面有发送者PID、信号发送时的用户态寄存器上下文、导致信号的内存地址等丰富信息。这俩是union同一个时刻只能设置一个用sa_flags里的SA_SIGINFO标志来区分。sa_mask是信号处理期间的额外阻塞集。比如handler执行时你想屏蔽SIGUSR1和SIGALRM在handler执行期间这两个信号就会被挂起等handler返回后再递达。注意当前正在处理的信号本身默认是会被阻塞的除非你显式设置SA_NODEFER标志。sa_flags里几个实用标志我列一下SA_RESTART让被信号打断的慢速系统调用read、write、wait等自动重启而不是返回EINTR错误。SA_SIGINFO启用sa_sigaction回调需要搭配siginfo_t使用。SA_NOCLDWAIT配合SIGCHLD使用子进程退出时自动回收不产生僵尸进程。SA_ONSTACK使用sigaltstack设置的备用信号栈适合handler需要较大栈空间的场景。2.3 完整演示用sigaction接收SIGUSR1并打印发送方信息看一个实际可编译的例子。这个程序注册了SIGUSR1的handler通过siginfo_t拿到发送者和触发时的系统调用上下文同时测试信号不会因为handler执行期间被再次触发而丢失#include stdio.h #include signal.h #include string.h #include unistd.h #include stdlib.h static void handler(int sig, siginfo_t *si, void *ctx) { // 这里只能用异步信号安全函数 char msg[128]; int len snprintf(msg, sizeof(msg), received signal %d from pid %d, uid %d\n, sig, si-si_pid, si-si_uid); write(STDOUT_FILENO, msg, len); } int main(int argc, char *argv[]) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_sigaction handler; sa.sa_flags SA_SIGINFO; sigemptyset(sa.sa_mask); if (sigaction(SIGUSR1, sa, NULL) -1) { perror(sigaction); exit(EXIT_FAILURE); } printf(pid%d, waiting for SIGUSR1...\n, getpid()); for (;;) { pause(); // 挂起进程直到任意信号递达 } return 0; }用一个测试进程发信号过去kill -USR1 12345输出会是received signal 10 from pid 4567, uid 1000这里我用snprintf拼字符串然后write输出而不是直接printf就是因为printf内部有锁、缓冲和更复杂的调用链在handler里禁用。snprintf本身是否全路径绝对安全我不敢打包票但在glibc下常用场景足够稳如果想完全严格可以用手写数字转换。3. 信号的发送、阻塞和未决处理信号风暴的正确姿势信号机制真正容易出问题的地方在量的控制上——单个信号好处理但几十上百个信号同时涌过来标准信号会直接丢给你看。这里面有一个绝大多数教程讲不透的关键点标准信号不支持排队。如果你连续给一个进程发送10个SIGUSR1进程可能只会收到一次其余9次会合并成一次递达这就是所谓的信号丢失。3.1 阻塞集与未决集信号不会消失只是排队等待进程里有两张关键的位图blocked阻塞集和pending未决集。你用sigprocmask()把某个信号加进阻塞集之后这个信号来了不会消失而是被置于pending状态当你想通了把这个信号从阻塞集里去掉它立刻就会递达。这里有一个经典陷阱需要特别注意如果一个信号被阻塞期间多次产生pending集里只保留一位。也就是说阻塞期间来了3次SIGINT解除阻塞后进程只会收到1次SIGINT。对于标准信号内核不会帮你排队计数。上代码我用阻塞未决演示一个信号合并的现象#include stdio.h #include signal.h #include unistd.h #include stdlib.h static volatile sig_atomic_t count 0; static void handler(int sig) { count; } int main(void) { struct sigaction sa { .sa_handler handler }; sigemptyset(sa.sa_mask); sigaction(SIGINT, sa, NULL); sigset_t block_set, old_set; sigemptyset(block_set); sigaddset(block_set, SIGINT); sigprocmask(SIG_BLOCK, block_set, old_set); printf(SIGINT blocked, send 5 SIGINTs…\n); raise(SIGINT); raise(SIGINT); raise(SIGINT); raise(SIGINT); raise(SIGINT); sigset_t pending_set; sigpending(pending_set); printf(pending SIGINT%d\n, sigismember(pending_set, SIGINT)); sigprocmask(SIG_SETMASK, old_set, NULL); printf(unblocked, sleep 1s...\n); sleep(1); printf(handler executed count %d\n, (int)count); return 0; }运行结果会让你印象深刻pending标志位为1解除阻塞后handler只执行了1次count1。5个信号被压缩成了1个。这就是为什么在做优雅退出这类场景时千万不要依赖收到了几次SIGTERM来统计业务量。3.2 想排队用实时信号标准信号不排队但Linux还提供了一组实时信号范围是SIGRTMIN到SIGRTMAX默认是34到64它们支持排队每个实时信号都附带一个整数数据由sigqueue()发送。实时信号在pending队列里不是位图而是链表每个信号都有独立节点发送100次就会递达100次且按编号从小到大递达。需要精确计数或者传递数据时实时信号是标准信号的补位选手。3.3 kill、raise、sigqueue几个发送接口的区分发信号的接口各有各的适用场景kill(pid, sig)最常用向任意进程或进程组发信号。pid为0表示发给同组所有进程pid为-1表示发给有权限发送的所有进程pid为负值表示发给对应进程组。raise(sig)进程向自己发信号等价于kill(getpid(), sig)在多线程程序里等价于pthread_kill(pthread_self(), sig)。sigqueue(pid, sig, value)发送实时信号并附带union sigval里的整数值或指针值。这里提醒一个权限问题普通用户只能向uid相同的进程发信号向root进程或别的用户进程发送会被EPERM拒绝。所以脚本里kill不到某个进程时先看一眼uid对不对。3.4 sigprocmask使用要点sigprocmask有SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK三种操作最推荐的前置姿势是先sigemptyset初始化一个空集合再sigaddset逐个添加信号。多线程环境下sigprocmask是进程级的但要谨慎使用——信号递达的线程是不确定的。如果某个线程专门负责处理信号其他线程就要各自调用pthread_sigmask把信号屏蔽掉只留处理线程不屏蔽。这块展开是一整篇独立话题这里先记住一个原则多线程程序不要在主线程里随意sigprocmask除非你清楚所有线程的信号屏蔽关系。4. 从SIGCHLD到优雅退出进程生命周期里的三个高频实战场景光会调API还不够真正考验功力的是信号在真实进程管理场景下的运用。下面这三个场景是我在服务端程序中反复用到的代码量不大但每一个都藏着不少细节。4.1 子进程退出与SIGCHLD处理僵尸进程的终极方案父进程fork出子进程后子进程退出时内核并不会立刻清除它的task_struct而是把它变成僵尸进程等待父进程调用wait/waitpid来收尸。如果父进程一直不等待子进程的PID就永远占着坑积累多了系统能创建的进程数会耗尽进程号上限一般可以通过ulimit -u查到。最优雅的收尸方式就是利用SIGCHLD信号。关键代码框架static void child_handler(int sig) { int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { // 回收一个子进程 } } int main() { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler child_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART | SA_NOCLDWAIT; // 注意SA_NOCLDWAIT sigaction(SIGCHLD, sa, NULL); ... }这里必须解释两个关键点。一是while循环而不是if是因为信号会合并一次SIGCHLD递达可能对应多个子进程退出只wait一次会漏掉其余僵尸。二是WNOHANG标志保证当前没有子进程可回收时waitpid立即返回0不让handler阻塞。至于SA_NOCLDWAIT加了这个标志后子进程退出时内核直接自动回收不会产生僵尸进程你的waitpid会返回-1且errno为ECHILD——也就是说用了SA_NOCLDWAIT之后其实不需要再写waitpid了整个收尸逻辑都省了。不过要注意如果你还有其他逻辑依赖子进程退出后获得退出码SA_NOCLDWAIT会让你拿不到status需要根据业务做权衡。4.2 优雅退出用SIGTERM做热清理而不是被kill -9猝死线上发布新版本时我们总希望服务进程收到请退出信号后有缓冲时间做清理——关掉监听、刷盘、释放锁、通知注册中心摘除节点然后再真正退出。这个机制几乎是所有服务端框架的标配。经典写法是设置一个全局的volatile sig_atomic_t标志handler里只置位主循环里检查。注意这个类型sig_atomic_t保证读写是原子的不会出现在handler写完主线程还没看到旧值这种半更新状态——当然还要配合编译器不要过度优化这个变量所以前面加volatile。为什么不用更宽的类型C标准只保证sig_atomic_t在信号上下文中的原子访问别自作聪明用int64或结构体。static volatile sig_atomic_t g_running 1; static volatile sig_atomic_t g_reload 0; static void term_handler(int sig) { g_running 0; } static void hup_handler(int sig) { g_reload 1; } int main(void) { signal(SIGTERM, term_handler); signal(SIGHUP, hup_handler); while (g_running) { if (g_reload) { g_reload 0; reload_config(); // 主线程中执行 } do_work(); } cleanup_and_exit(); return 0; }SIGTERM是kill命令不带-9时发出的信号也是systemd等服务管理器通知服务退出的标准信号所以业务程序一般只捕获SIGTERM和SIGINT让SIGKILL留在最后兜底。SIGINT就是CtrlC开发调试时打断程序也会走这条路所以测试优雅退出可以直接CtrlC验证。4.3 定时器信号SIGALRMsetitimer与alarm的机制差异定时器信号在异步I/O、超时控制、看门狗等场景出场频率很高。alarm()只能定秒级的一次性定时器setitimer()可以定到微秒级还支持ITIMER_REAL真实时间、ITIMER_VIRTUAL进程用户态CPU时间、ITIMER_PROF用户态加内核态CPU时间三种计时器到期分别发SIGALRM、SIGVTALRM、SIGPROF。典型用法是配合sigaction设置SIGALRM的handler做超时中断struct itimerval timer; timer.it_value.tv_sec 1; timer.it_value.tv_usec 0; timer.it_interval.tv_sec 1; timer.it_interval.tv_usec 0; setitimer(ITIMER_REAL, timer, NULL);这套配置实现每秒触发一次SIGALRM。我在看门狗程序里常用这个机制主线程每收到一次SIGALRM就检查一次某个业务进程的心跳文件更新时间超过阈值就判定异常并拉起新的工作进程。注意handler里只是置标志或write一个字节到管道通知select/poll醒过来不要在handler里直接做逻辑。5. 信号处理与并发EINTR、重入和数据竞争的三座大山这一节是区分会写信号和玩明白信号的分水岭。表面看信号处理函数就像普通函数一样跑但它在多线程、多信号并发环境下对全局状态的访问有一系列暗坑。5.1 EINTR被信号打断的系统调用当进程阻塞在read()等慢速系统调用上时一个信号递达并执行完handler后如果sa_flags没设SA_RESTARTread()会返回-1且errno被置为EINTR。很多人第一次在这个errno上翻车以为是网络错误其实是信号打断。处理EINTR的通用代码范式ssize_t n; do { n read(fd, buf, sizeof(buf)); } while (n 0 errno EINTR);这种做法本质上是重新发起一次调用。另一个做法就是开头提到的SA_RESTART标志让内核帮你自动重启大部分系统调用省掉手动重试。但注意SA_RESTART并非对所有系统调用生效比如nanosleep、poll、epoll_wait、sigwait等在某些内核版本上仍然会返回EINTR所以写成底层循环重试是更稳妥的通用保障。5.2 什么是异步信号安全函数什么是绝对不能在handler里调的理解重入要先弄明白函数被重入是什么概念。简单说就是同一个函数在执行到一半时又被另一条执行路径再次调进来。如果这个函数内部用了静态变量、全局锁、或者malloc的堆管理结构等共享可变状态两次调用就会互相踩踏。信号处理函数插入的位置是完全随机的所以它只能调用那些保证可以重入的函数。POSIX列了一个async-signal-safe函数清单里面有write、read、open、close、fcntl、wait、waitpid、sigaction、sigprocmask、_exit等。常见但绝对不安全的有malloc/free绝大多数真实实现都加锁、printf/fprintf/sprintf内部缓冲和锁、syslog、getpwnam等库函数。还有一个容易踩的坑就是handler里调用了gethostbyname这类看似无害的库函数实际上内部用了静态缓冲区整个都不安全。所以我在生产代码里约定了一条硬规则信号处理函数里只做一件事——向一个pipe或者eventfd写入一个字节write本身是异步安全函数通知主事件循环有信号来了去处理。所有读取状态、修改全局数据、清理资源的逻辑全放到主循环里执行。这套模式业界叫self-pipe trick是最稳妥的实用方案。5.3 volatile sig_atomic_t是下限不是万能药全局标志位用volatile sig_atomic_t只是保证读写原子性和编译器不缓存寄存器副本但它不能保证多个信号处理函数之间的复合操作是安全的。比如先判断、再修改两个不同信号并发递达时仍有可能出现交错。需要复合操作时应该用sigprocmask先阻塞相关信号或者干脆把复杂操作移到主循环。另外提一下C11的原子类型_Atomic int这类变量在信号场景下是否能完全替代volatile sig_atomic_t标准里并没有把信号递达和C11原子模型完全对齐常规实践中我用得更保守——直接遵循POSIX的guidance不折腾。6. 搭建一个可复用的信号处理框架self-pipe与统一事件循环理论到这儿已经够多了这一部分我给出一套完整的、可以直接抄走的信号处理框架。很多服务端框架里都能看到它的影子信号处理函数里什么逻辑都不做只往管道里丢一个字节主事件循环select/poll/epoll监听这个管道read端读到字节后解码信号编号再分发到对应用户回调。这样信号处理和事件循环共用一套模型彻底规避了并发和重入问题。6.1 框架的骨架代码#include stdio.h #include stdlib.h #include unistd.h #include string.h #include signal.h #include errno.h #include fcntl.h #include poll.h static int sig_pipe[2]; static void sig_handler(int sig) { // 在handler内write是异步信号安全函数 if (write(sig_pipe[1], sig, sizeof(sig)) ! sizeof(sig)) { // 忽略写失败一般不可能发生 } } int setup_signal_handlers(void) { if (pipe(sig_pipe) -1) { perror(pipe); return -1; } // 把读端设为非阻塞防止管道里残留字节时无限阻塞 int flags fcntl(sig_pipe[0], F_GETFL, 0); fcntl(sig_pipe[0], F_SETFL, flags | O_NONBLOCK); fcntl(sig_pipe[1], F_SETFL, fcntl(sig_pipe[1], F_GETFL, 0) | O_NONBLOCK); struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler sig_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; int signals[] { SIGINT, SIGTERM, SIGHUP, SIGUSR1, SIGCHLD }; for (size_t i 0; i sizeof(signals)/sizeof(signals[0]); i) { if (sigaction(signals[i], sa, NULL) -1) { perror(sigaction); return -1; } } return 0; } void process_signal_byte(void) { int sig; while (read(sig_pipe[0], sig, sizeof(sig)) sizeof(sig)) { switch (sig) { case SIGHUP: reload_config(); break; case SIGINT: case SIGTERM: g_running 0; break; case SIGCHLD: reap_children(); break; case SIGUSR1: dump_status(); break; default: break; } } } int main(void) { if (setup_signal_handlers() -1) { exit(EXIT_FAILURE); } while (g_running) { struct pollfd fds[2]; fds[0].fd sig_pipe[0]; fds[0].events POLLIN; // fds[1]可以放你的监听socket等 int ret poll(fds, 1, 1000); if (ret 0 (fds[0].revents POLLIN)) { process_signal_byte(); } do_work(); } cleanup_and_exit(); return 0; }这个框架最大的优势是所有业务逻辑都在单线程主循环里顺序执行信号处理函数和主循环不再共享可变状态自然就没有锁竞争和数据竞争。配置文件重载也好、优雅退出也好、子进程回收也好统统变成事件循环里的一个case分支排查问题时思路特别清晰。6.2 EINTR在这个框架里的处理原则虽然上面sigaction设置了SA_RESTARTpoll/select这类调用依然有可能被信号打断返回EINTR。在这个框架里poll被信号打断根本无所谓——因为信号已经写进管道下一次poll立刻又能读到。所以我在这里不会对EINTR做重试反而会让poll超时短一点比如800ms或1000ms用轮询做兜底逻辑更简单。这里也体现出框架的统一性你不必为某个信号单独熬夜排查系统调用是谁打断的因为所有信号都汇聚到了同一个入口。6.3 调试信号问题常用的套路最后聊点实际的排错手段。进程收不到信号第一步用strace挂上去跑一段看有没有sigreturn、rt_sigaction这些系统调用第二步检查进程是不是变成僵尸了状态列是Z表示还没被父进程收尸信号根本递达不了第三步检查是不是被另一个进程先吞了信号看全局的handler是不是被谁篡改过。还有一个实用工具/proc/ /status里的SigBlk、SigCgt、SigIgn三行分别显示该进程阻塞了哪些信号、捕获了哪些信号、忽略了哪些信号直接cat出来一看就知道配置对不对。比如SigCgt里没有对应信号的掩码位说明handler压根没注册上别再查业务代码了。grep -E ^Sig(Blk|Cgt|Ign) /proc/12345/status输出会是一个十六进制掩码用python算一下第几号信号有没有置位就行比靠猜快得多。另外如果要给一个已经跑起来的进程动态调整信号策略可以用gdb attach上去调用sigaction但这需要ptrace权限线上一般不建议这么搞本地调试时很管用。我从第一次被SIGPIPE搞到进程静默退出、到后来把self-pipe框架沉淀成团队共用组件中间踩的坑基本都写在上面了。信号这块的学习路径我的建议是先跑小实验验证信号合并EINTR这些底层行为再把框架代码自己敲一遍最后到线上服务里把默认的暴力kill换成优雅退出趟完这几个阶段你基本就能跟人聊明白进程是怎么被信号干掉的这一整件事了。