多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Linux信号机制全解析:从异步中断到signalfd实战

Linux信号机制全解析:从异步中断到signalfd实战 写了好几年Linux服务端程序你可能会有这种感觉进程信号这个知识点面试必考、笔试常客但真到线上出问题时很少有人第一反应是“这会不会和信号有关”。我自己也被问过不知道多少次“信号是什么”每次回答都像在背八股——信号是软件中断是操作系统用来通知进程发生了异步事件的一种机制。直到有一次调一个诡异的服务挂死问题日志翻了一遍又一遍没结果最后用strace定位到进程卡在等待SIGCHLD才彻底想明白信号不是面试官拿来凑数的理论它是操作系统和进程打交道最基本的方式之一也是运维和开发一线每天都会踩到的东西。这篇文章不打算按教科书的顺序平铺直叙。我会沿着一条信号从产生到被处理的完整路径来拆解重点说清楚底层发生了什么、注册处理函数时有哪些隐藏的坑、开发高并发服务时怎么把信号安全地纳入事件循环。无论你是在准备Linux面试题还是在排查一个“莫名退出”的进程又或者想把手上的网络服务改得更稳这篇文章都值得从头到尾读一遍。1. 信号——被误解的进程间通信方式1.1 信号到底是什么软件中断与异步通知先放下教科书定义从一个最简单的场景开始你写了一个while(1)死循环的程序在终端里按了CtrlC进程就退了。表面上这是“终端把进程杀了”实际上发生的完整链条是内核的tty驱动收到CtrlC对应的字符把它解释成中断信号SIGINT然后给前台进程组的每个进程发送编号为2的信号。进程收到SIGINT后如果自己没有安装自定义处理函数就执行内核预设的默认动作——终止进程。这里的核心关键词是“异步”。管道、共享内存、消息队列这些IPC方式通信双方基本都需要在某个时间点主动去读、去写属于某种程度的同步协作。信号完全不一样发送方不管是谁另一个进程、终端、内核自身发出信号之后不需要等待接收方做好任何准备。内核会在接收进程“不知情”的情况下把它挂到进程的任务队列里等到进程从内核态返回用户态的前夕再检查有没有信号需要处理。这个过程像极了手机来电话你正在专心写文档执行主流程电话响了内核递送信号你可以选择接听调用自定义处理函数、按掉忽略或阻塞也可以不接但铃声还在响挂起等待。这就是为什么我说信号才是真正意义上异步的进程间通信——它是进程间通信里唯一的异步机制。1.2 信号在IPC中的特殊位置Linux下常见的进程间通信方式我稍微列一下管道、FIFO、消息队列、共享内存、信号量、套接字以及信号。你会发现前面几种要么依赖文件描述符要么依赖内核对象进程之间都是在“传输数据”。信号传输的不是数据而是一个“事件通知”。这个事件可能是一个数字、一个异常、一次键盘操作消息内容极其简单通常只是一个整数编号。但信息量小不代表不重要。恰恰因为信号传递的是“发生了什么事”这个事实它的语义非常明确。比如SIGSEGV告诉你“访问了非法内存”SIGPIPE告诉你“管道/套接字的读端已经关闭”SIGALRM告诉你“定时器到点了”。在很多场景下你不需要知道具体要传输什么数据只需要知道“什么时候发生了什么”信号就是为这种需求设计的。也正因为信号过于简单新手容易把它的分量看轻。实际上Linux内核的大部分“通知”机制底层都跟信号有关子进程退出会向父进程发SIGCHLD定时器到点会发SIGALRM终端挂断会发SIGHUP硬件异常会转化为对应信号。理解信号某种意义上是在理解操作系统怎么把底层事件往上抛给进程。这也是“linux底层原理”面试题里绕不开的一块。2. 一条信号的生命周期从按键到处理函数2.1 信号从哪来四种产生路径信号的产生路径总的来说有四类第一类就是我上面说的终端按键。CtrlC对应SIGINTCtrl\对应SIGQUITCtrlZ对应SIGTSTP。注意SIGQUIT的默认动作不只是终止进程还会生成核心转储文件这就是为什么生产环境严禁用Ctrl\去停服务。第二类是硬件异常。进程执行了非法指令、除以零、访问了未映射的内存地址CPU会在执行指令时触发异常内核的异常处理程序拿到这个硬件错误后把它翻译成对应的信号发送给当前进程。经典的SIGSEGV段错误、SIGFPE浮点异常、SIGILL非法指令都走这条路。第三类是软件条件。比如定时器到点产生SIGALRM子进程状态变化产生SIGCHLD向已关闭的管道写入产生SIGPIPE用setitimer这类接口还能自定义定时信号。这类信号不是“出错”更像是“通知”。第四类是通过系统调用主动发出。进程A想告诉进程B“有事找你”最简单的方式就是调用kill(2)系统调用指定B的PID和信号编号。自己给自己发信号可以用raise(3)abort(3)本质上是给自己发SIGABRT。这一层也是我们在开发中最常用的手段下面单独展开。#include signal.h #include sys/types.h #include unistd.h #include stdio.h int main(void) { pid_t child fork(); if (child 0) { // 子进程给自己发一个 SIGUSR1 raise(SIGUSR1); pause(); printf(child exit\n); return 0; } // 父进程给孩子发 SIGTERM sleep(1); kill(child, SIGTERM); return 0; }2.2 内核里的“挂起”与“递送”标准信号不排队信号产生之后内核会把这个信号登记在目标进程上这一步叫“生成”或者“注册”。它在进程的task_struct里有一组pending位图sigset_t第几位是1就表示哪个信号已经产生了但还没处理。接下来如果这个信号没有被进程阻塞内核会在进程从内核态返回用户态之前把它交给进程去执行对应动作这一步叫“递送”。这里有个非常关键的细节也是面试高频题标准信号1到31号不排队。什么意思如果进程在信号被阻塞期间同一个标准信号被发送了10次内核的pending位图只会留下一个bit位进程解除阻塞后也只能收到一次该信号。后发的信号会“覆盖”前面的但不会逐个递送。有一个类比我常用来解释这个问题标准信号是单格邮箱你往里面塞了十封信最终取出来的还是只有一封实时信号34到64号才是队列每一封都排队等着。所以如果需要精确统计信号发生了多少次标准信号是做不到的必须引入实时信号或者让信号处理函数里自己维护计数器。这个特性在日常开发中会直接影响设计很多人调了半天才发现程序漏掉了几次信号通知根因就在这。2.3 默认处理动作五种行为如果进程没有为某个信号安装处理函数内核就使用预设的默认动作。默认动作一共五种终止进程、终止进程并生成核心转储、忽略信号、暂停进程、继续执行暂停的进程。SIGHUP 1 终止终端挂断或控制进程终止 SIGINT 2 终止CtrlC SIGQUIT 3 终止 核心转储Ctrl\ SIGILL 4 终止 核心转储非法指令 SIGABRT 6 终止 核心转储abort SIGFPE 8 终止 核心转储浮点异常 SIGKILL 9 终止不可捕获不可忽略 SIGSEGV 11 终止 核心转储非法内存访问 SIGPIPE 13 终止向无读端的管道写数据 SIGALRM 14 终止定时器超时 SIGTERM 15 终止程序终止信号kill默认 SIGCHLD 17 忽略子进程状态改变 SIGSTOP 19 暂停不可捕获不可忽略 SIGTSTP 20 暂停CtrlZ SIGCONT 18 继续两张“王炸”信号必须刻在脑子里SIGKILL和SIGSTOP。它们既不能捕获也不能忽略也不能阻塞。内核直接强制终止/暂停进程。这是保证系统在任何时候都有最终控制手段的设计——如果一个进程把SIGKILL都忽略掉了那它就永远杀不死了。剩下的信号里SIGTERM是kill命令的默认信号它最大的意义是给进程一个“体面退场”的机会后面我会专门聊生产环境里怎么用它。3. signal()与sigaction()处理函数注册的正确姿势3.1 为什么别用signal()项目里接入信号处理第一个会碰到的系统调用是signal()。它用起来很直观signal(SIGINT, my_handler);一行搞定描述得也清楚那么问题来了为什么要用冗长的sigaction()呢原因是signal()在不同Unix系统上的语义不统一。System V里signal()设置的处理函数是一次性的——信号处理完处理器数就会被重置为默认行为下次再收到信号直接走默认动作终止进程。BSD里则相反处理函数是持久的并且被信号中断的系统调用会自动重启。这种差异让跨平台代码很容易玩出两种不同的行为。glibc在Linux上做了个折中它用sigaction()实现了signal()并且默认启用SA_RESTART标志同时还套了层BSD语义。但这不代表你用signal()就完全安全因为“glibc怎么实现的”并不在POSIX标准保证范围内换了别的libc或者交叉编译到嵌入式环境行为可能立刻变味。工程上一句话所有新代码都写sigaction()别给自己留隐患。3.2 sigaction核心参数与SA_RESTARTsigaction()的完整签名#include signal.h int sigaction(int signum, const struct sigaction *restrict act, struct sigaction *restrict oldact);结构体里的几个关键成员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_flags设成SA_SIGINFO才能生效它多出来的siginfo_t里包含了发送者的PID、UID、信号发送的具体原因甚至可以在拿到实时信号时直接读取伴随的整数值。sa_mask用来指定在调用处理函数期间哪些信号需要额外被阻塞。比如处理SIGINT的同时不希望SIGTERM打断就把SIGTERM塞进sa_mask。真正读写代码时最折磨人的是sa_flags和系统调用的交互。POSIX规定当一个系统调用比如read、write、pause、nanosleep被信号打断时它会返回-1并置errno为EINTR。如果没有处理EINTR程序的表现常常是“莫名其妙少读了一次数据”或者“sleep提前返回”很难查。sa_flags里的SA_RESTART就是来救场的设置之后大多数被信号中断的系统调用会被内核自动重启调用方根本感知不到信号的到来。但注意select、poll、epoll_wait这几个函数即使设了SA_RESTART也不会重启它们会照常返回EINTR。这算Linux一个公开的“特例”服务端开发碰到epoll_wait被信号打断再正常不过处理方式只能是while循环判断errno EINTR然后重新调用。我自己写过很多次这个循环每次都要强打精神别写漏。static void handle_signal(int sig) { write(STDOUT_FILENO, got signal\n, 11); } int main(void) { struct sigaction sa; sa.sa_handler handle_signal; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; if (sigaction(SIGINT, sa, NULL) 0) { perror(sigaction); return 1; } int fds[2]; pipe(fds); // 模拟阻塞在epoll_wait上 struct epoll_event ev; int epfd epoll_create1(0); ev.events EPOLLIN; ev.data.fd fds[0]; epoll_ctl(epfd, EPOLL_CTL_ADD, fds[0], ev); while (1) { int n epoll_wait(epfd, ev, 1, -1); if (n 0 errno EINTR) { continue; // 被信号打断重来 } if (n 0) { break; } } return 0; }3.3 信号处理函数里的安全边界这是全篇最想让你背下来的一段。信号处理函数是在进程的什么位置执行的答案是主程序的任意一条指令都有可能被中断然后跳进处理函数。这意味着处理函数和主程序是“并发”的只不过这个并发是在单线程内通过打断发生的。它和线程并发的区别是线程并发要加锁保护共享数据这里也一样要保护。问题在于——你不能在信号处理函数里随便用锁。比如主程序正在执行malloc()走进了分配器的临界区此刻信号来了处理函数里又调用了malloc()那就会把堆管理器的内部状态彻底搅乱轻则数据损坏重则死锁。同样危险的还有printf、fprintf、sprintf、getenv、locale相关函数等。POSIX明确列出了一张async-signal-safe函数的清单处理函数里能调用的系统调用和库函数是受限的。可以安心用的是read、write、open、close、_exit、sigaction、sigprocmask、sigsuspend、kill、getpid、time等等。凡是要碰全局缓冲区的库函数几乎都不安全。我总结三条实践规则第一处理函数里尽量只做两件事设置一个volatile sig_atomic_t类型的标志位或者往一个管道/事件fd里write一个字节。写标志位是最轻量的做法sig_atomic_t保证读写操作是原子的不会出现读到一半被改写的撕裂问题。static volatile sig_atomic_t g_sig_flag 0; static void handle_usr1(int sig) { g_sig_flag 1; } int main(void) { // 主循环里轮询 while (!g_sig_flag) { // do something } return 0; }第二如果非要在处理函数里做稍重的操作用write()往预先创建好的pipe fd写数据让主事件循环去读取。这就是经典的self-pipe trick下一章会展开讲。第三永远不要用longjmp从处理函数里跳走。longjmp虽然能把执行流强行跳回主程序的某个setjmp点但因为没有解锁、没有清理很容易把程序带到完全不可预测的状态这也是一个很隐蔽的坑。4. 信号集操作屏蔽、查询与原子挂起4.1 三步走初始化、增删、屏蔽进程可以临时“屏蔽”某些信号让它们一直停留在pending状态等到解除了屏蔽再递送。屏蔽用的接口是sigprocmask操作的对象是sigset_t信号集。信号集在C语言里其实就是一个位图但你不能直接拿它做位运算必须走一组标准函数sigset_t set; sigemptyset(set); // 清空 sigaddset(set, SIGINT); // 加入SIGINT sigdelset(set, SIGINT); // 移除SIGINT sigismember(set, SIGINT); // 检查是否在集合里初始化用sigemptyset比memset(set, 0, sizeof(set))更稳因为不同平台位图的填充方式不一定全是0。之后就可以用sigprocmask去修改进程当前的阻塞集合了sigset_t newmask, oldmask; sigemptyset(newmask); sigaddset(newmask, SIGINT); // 阻塞SIGINT同时拿到旧的阻塞集合 sigprocmask(SIG_BLOCK, newmask, oldmask); // ... 临界区代码 ... // 恢复旧的阻塞集合 sigprocmask(SIG_SETMASK, oldmask, NULL);SIG_BLOCK把newmask里的信号加到当前阻塞集SIG_UNBLOCK从当前阻塞集移除SIG_SETMASK直接替换整个阻塞集。多线程程序里要注意sigprocmask是针对于整个进程的线程库内部实现的C标准里的pthread_sigmask才是线程级的多线程程序请只用pthread_sigmask。4.2 sigpending看未决信号信号被阻塞了但还没递送就处于未决pending状态。想查看当前进程有哪些信号卡在未决状态用sigpendingsigset_t pending; sigpending(pending); if (sigismember(pending, SIGINT)) { // SIGINT 曾被触发但还没被处理 }实战里这个函数很适合用来做诊断。比如你在调试一个程序怀疑某个信号被反复发送但处理函数没执行写一行sigpending就能确认信号是不是真的到了进程。还可以看/proc/ /status里的SigPnd和ShdPnd字段内容异曲同工。4.3 sigsuspend的原子性如果只想做“临时阻塞某些信号然后挂起等待一个新的信号到来”很多人的第一反应是先解除阻塞再调用pause()。但这个写法有一个致命竞态解除阻塞 - 信号此刻到达 pause() 挂起等待信号可能在你解除阻塞之后、进入pause()之前刚好到达结果pause()永远睡下去信号就这么被“错过”了。解决这个问题的标准姿势是sigsuspend()。它能一次性完成两件事把进程的阻塞集替换成你指定的集合然后挂起进程直到有可递送的信号到来或者有信号触发了处理函数。sigset_t newmask, oldmask; sigemptyset(newmask); sigaddset(newmask, SIGINT); sigprocmask(SIG_BLOCK, newmask, oldmask); // 先拿到旧集合 // 下面这个调用是原子的 // 把阻塞集换成oldmask随即挂起 sigsuspend(oldmask); // 返回时阻塞集恢复为调用前的值sigsuspend的整个“换掩码挂起等待信号”的过程在原子上下文中完成不存在上面那个竞态窗口。这是Linux信号编程里少有的“原子操作”接口面试时问到如何等待信号这道题几乎是必答项。5. 实战避坑SIGCHLD、SIGPIPE与kill -95.1 SIGCHLD与僵尸进程回收父进程最常遇到的信号不是SIGINT而是SIGCHLD。子进程退出或停止时内核会给父进程发SIGCHLD。默认动作是忽略但子进程退出后不会自动从系统里清掉它会变成一个僵尸进程Z状态占着进程表中的一项直到父进程调用waitpid回收其退出状态。如果不回收僵尸进程越积越多就会占满进程表导致fork()失败这是线上运维非常典型的故障。怎么处理最稳的方式就是给SIGCHLD装处理函数在里面非阻塞地收割子进程static void sigchld_handler(int sig) { int saved_errno errno; while (waitpid(-1, NULL, WNOHANG) 0) { // 循环回收直到没有更多子进程退出 } errno saved_errno; }这里两个细节值得说循环里用WNOHANG是为了不阻塞在waitpid上信号处理函数里任何长时间阻塞都是灾难能快速返回就快速返回。保存并恢复errno是另一个容易忽略的点信号处理函数随时可能打断主程序里正在进行的系统调用破坏errno会让我们在主程序里拿到的错误码变得不可信。还有一种偷懒但合法的做法在注册SIGCHLD处理函数时设置SA_NOCLDWAIT标志内核直接把子进程自动回收父进程完全不产生僵尸进程。代价是你无法再用waitpid()拿到子进程的退出状态。项目里如果不在乎子进程的返回值这个标志非常省心。5.2 SIGPIPE导致服务静默退出这个坑在真实服务里吃的人太多了。场景通常是客户端已经断开连接服务端线程还往这个socket上write数据。内核在检测到对端已关闭时会向写进程发送SIGPIPE。SIGPIPE的默认动作是终止进程于是整个服务进程直接退出不是返回一个错误码是连日志都来不及写就消失了。几年前的线上故障排查里这种“无缘无故挂掉”的服务十有八九和SIGPIPE有关。解决方案就两行signal(SIGPIPE, SIG_IGN); // 忽略它忽略之后往断开连接的socket写数据时write()会返回-1并且errno为EPIPE业务代码可以正常处理这个错误该清资源清资源该打日志打日志。这比进程整体退出要好一百倍。所有基于TCP的网络服务启动时第一件事就应该把SIGPIPE忽略掉。5.3 kill -9与SIGTERM的正确使用顺序一线运维和开发对kill -9都有复杂情绪。它的确很有效像一把失控的原子弹进程没有任何机会抵抗。但副作用也在这儿进程无法做任何清理工作临时文件、锁文件、共享内存、数据库连接全都会原样留在那里下次启动可能因为锁冲突直接起不来。正确的停服务顺序应该是先发SIGTERM给进程一个优雅退出的机会收到SIGTERM后可以做清理工作再退出等几秒观察一下确认还没退再考虑SIGKILL。很多成熟的守护进程都会为SIGTERM写专门的清理流程比如把自己持有的锁删掉、关闭监听fd、把内存里的状态flush到磁盘。这背后的目的就是把进程的“临终关怀”做完整。判断一个进程为什么不响应SIGTERM我一般先看它的状态。如果是D状态不可中断的睡眠比如正在等磁盘IO连SIGKILL都不起作用只能等内核把IO完成。如果是正常的S状态但没有退出很可能是某个信号被阻塞住了或者进程正在处理一个长事务。不要一上来就kill -9一步步排查才是运维该有的节奏。6. 项目里的信号设计从标志位到signalfd6.1 经典模式一标志位主循环轮询小型单线程程序里最通用的信号处理设计是“处理函数只置位主循环轮询标志位”。登录服务器、命令行工具、嵌入式Linux上的daemon基本都能用这个模式。static volatile sig_atomic_t g_should_exit 0; static void handle_term(int sig) { g_should_exit 1; } int main(void) { struct sigaction sa; sa.sa_handler handle_term; sigemptyset(sa.sa_mask); sa.sa_flags 0; sigaction(SIGTERM, sa, NULL); sigaction(SIGINT, sa, NULL); while (!g_should_exit) { // 正常业务循环 } // 清理资源 return 0; }这个模式的优点忠实地继承了信号处理函数的安全边界——处理函数只碰一个原子变量不会和主程序产生任何数据竞争。缺点是主程序必须定期“醒来”检查标志位如果主循环陷入了长时间的阻塞调用比如阻塞读、sleep、epoll_wait标志位变化就不能被及时感知。这时候要么用带超时的阻塞调用poll设置timeout要么用下面要讲的self-pipe模式。6.2 经典模式二self-pipe让信号进入事件循环self-pipe trick的思路很直接在启动时创建一个管道在信号处理函数里往管道写入一个字节主程序的事件循环select/poll/epoll监控这个管道的读端。信号到达时管道可读事件循环立刻醒来然后正常地从read端读完数据再处理逻辑。它的好处是把“信号事件”转化成了“文件描述符可读事件”从此所有事件源都能用同一套select/poll/epoll机制统一管理不需要再专门为某些信号开线程去干等。static int g_sig_pipe[2]; static void signal_handler(int sig) { int saved_errno errno; char byte 1; write(g_sig_pipe[1], byte, 1); // 写入一个字节让主循环醒来 errno saved_errno; } int main(void) { pipe(g_sig_pipe); struct sigaction sa; sa.sa_handler signal_handler; sigemptyset(sa.sa_mask); sa.sa_flags 0; sigaction(SIGINT, sa, NULL); sigaction(SIGTERM, sa, NULL); int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd g_sig_pipe[0]; epoll_ctl(epfd, EPOLL_CTL_ADD, g_sig_pipe[0], ev); // 其他业务fd也注册进epoll while (1) { struct epoll_event events[16]; int n epoll_wait(epfd, events, 16, -1); if (n 0 errno EINTR) { continue; } for (int i 0; i n; i) { if (events[i].data.fd g_sig_pipe[0]) { char buf[64]; read(g_sig_pipe[0], buf, sizeof(buf)); // 收到信号做对应处理 } else { // 处理其他fd } } } return 0; }这个模式还有一层额外好处把多个信号折叠成“管道可读”这一个事件主循环只需要处理一种事件类型不会漏掉任何信号。管道缓冲是内核维护的写端永远不阻塞处理函数非常安全。6.3 signalfd把信号变成文件描述符如果你觉得self-pipe还得自己维护管道加处理函数Linux还提供了一个更现代的选择——signalfd。它直接把一组信号绑定到一个文件描述符上read这个fd读取到的是一段结构化的siginfo数据完全绕开了传统的信号处理函数机制。#include sys/signalfd.h sigset_t mask; sigemptyset(mask); sigaddset(mask, SIGINT); sigaddset(mask, SIGTERM); // 必须先阻塞这些信号signalfd才会收到它们 sigprocmask(SIG_BLOCK, mask, NULL); int sfd signalfd(-1, mask, 0); // 现在sfd就是一个普通的fd可以注册进epoll struct signalfd_siginfo info; ssize_t n read(sfd, info, sizeof(info)); if (n ! sizeof(info)) { // 处理错误 } if (info.ssi_signo SIGINT) { // 处理SIGINT }signalfd的设计哲学是“把信号事件捋成IO事件”所有信号处理逻辑都能用统一的IO模型去写不需要在回调函数里考虑异步安全的问题代码写起来像读普通文件一样干净。Linux的man手册明确说signalfd只能配合阻塞的信号使用如果信号没有被阻塞它就会直接按默认动作或已有处理函数走signalfd根本看不到它。这个顺序很多人写反先把signalfd建好了结果发现信号根本没进fd排查半天才反应过来是少了sigprocmask这一步。signalfd唯一的适用限制是它是Linux专有接口不可移植。在纯Linux环境下我自己的习惯是用它替代self-pipe代码更短结构更清晰。结尾的一段实际体会我自己的习惯做法是服务启动阶段用一个表单把SIGTERM、SIGINT统一注册进signalfd把SIGPIPE直接忽略SIGCHLD交给sigaction处理函数循环waitpid。日志里如果看到进程退出前没来得及打退出的日志第一件事就是查它的终止信号是哪个ps -o pid,stat,cmd和dmesg | tail往往能直接给答案。排查信号问题没有太多玄学重点就是掌握信号怎么产生、怎么被屏蔽、怎么递送、以及处理函数的安全边界在哪。把我上面讲的这一圈走通线上再碰到“进程没了”“进程卡死”“服务收不到退出指令”你至少能从信号的角度快速缩小范围而不是干瞪眼。
返回列表