Linux信号处理核心函数实战:从sigaction到自管道唤醒

发布时间:2026/10/10 12:33:16
Linux信号处理核心函数实战:从sigaction到自管道唤醒 做Linux服务端开发这几年信号处理一直是我眼里最刁钻的基础模块——平时不声不响一旦出问题就是诡异bug加线上事故。SIGPIPE击垮整个服务、SIGCHLD回收不当攒出一堆僵尸进程、sigaction和signal混用导致行为不可预期这些问题我全都在真实项目里撞过。所以当有人让我整理一份Linux信号核心函数速查表时我意识到真正值得写的不是man手册的翻译而是把这些函数背后的内核机制、选型逻辑和实战踩坑串起来。这篇文章就是给那些写过signal(SIGINT, handler)但还没踩遍全部坑的人准备的——无论你是正在学APUE的在校生还是维护着高并发网关的从业者读完都应该能更稳地驾驭信号这门异步通知艺术。1. 信号机制先搞懂内核是怎么通知你的1.1 信号的本质与生命周期很多人把信号理解成软中断这个类比够形象但不够精确。真正的区别在于中断是硬件或内核主动打断CPU执行流而信号是内核向进程发送的异步事件通知进程什么时候处理、在哪条指令上处理是由内核的调度策略决定的。换句话说信号不是立即执行而是找个合适时机执行。一个信号从产生到被处理会经历三个状态生成generation、未决pending、递送delivery。进程调用kill()或者内核检测到异常比如除零、段错误时信号生成如果信号被进程阻塞它就停留在未决状态对应内核里的sigpending位图当阻塞被解除信号才会真正递送给进程触发默认动作或调用注册的处理函数。这个阻塞概念是理解后面所有函数的核心也是大多数人出错的重灾区。有个细节我反复强调标准信号不排队。同一个信号在未决期间如果多次产生内核只会记录一次。比如你连续发了10次SIGUSR1进程最终最多收到一次。这不是内核偷懒而是设计如此——标准信号本身就是事件通知不是消息队列。实时信号编号32~64才支持排队和携带额外数据但那是另一套上下文了。1.2 常见信号一览虽然完整列表可以查kill -l但真正高频使用的就那几个。整理一张速查表如下信号编号默认动作常见触发场景SIGHUP1终止进程终端挂断、守护进程重载配置SIGINT2终止进程终端CtrlCSIGQUIT3终止并产生core dump终端Ctrl\SIGKILL9强制终止不可捕获kill -9SIGSEGV11终止并产生core dump非法内存访问SIGPIPE13终止进程写一个读端已关闭的管道/ socketSIGALRM14终止进程alarm()定时器到期SIGTERM15终止进程kill默认发送优雅终止SIGCHLD17忽略子进程停止或终止SIGUSR1/210/12终止进程用户自定义事件SIGWINCH28忽略终端窗口大小变化实话说你不需要背下全部编号但必须记住几个原则SIGKILL和SIGSTOP不可捕获、不可阻塞、不可忽略这是内核的最后手段SIGCHLD默认忽略但如果不显式设置处理函数子进程结束后会变僵尸进程SIGPIPE是服务端程序的隐形杀手后面我会专门讲。1.3 信号的三种处理方式一个进程对信号的处理只有三种选择执行默认动作、忽略信号、安装自定义处理函数。SIGKILL和SIGSTOP除外剩下的信号几乎都支持全部三种方式。忽略和自定义处理函数里什么都不做看起来等价但其实有细微差别。比如SIGCHLD如果设置为SIG_IGN内核会自动回收子进程资源不会产生僵尸进程但如果你安装了自定义函数却忘了调用waitpid()僵尸进程照样累积。这个区别在Napier之类的老书上讲过但实际工程里真的有人踩。另一个容易被忽略的点是忽略信号不等于屏蔽信号信号还是会生成、会进入pending状态只是没有动作而已。如果是用sigaction设置SA_IGNORE信号在递送时才会被丢弃。2. 核心函数速查每一个都是高频考点2.1 安装处理函数signal() 还是 sigaction()先看两个函数原型这也是每次面试几乎必问的对比题。#include signal.h typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler); int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);signal()的接口极简两行代码就能用但它有两个致命短板第一不同Unix系统对signal()的语义不一致有的系统在处理函数执行完自动把信号重置为默认动作有的不会这段历史遗产非常坑第二它无法精细控制信号的阻塞行为和处理标志。我个人的原则是新代码一律用sigaction()signal()只配出现在快速验证的小demo里。struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler my_handler; sigemptyset(sa.sa_mask); // 处理函数执行期间不额外阻塞其他信号 sa.sa_flags SA_RESTART; // 让被中断的系统调用自动重启 sigaction(SIGTERM, sa, NULL);sa_mask是处理函数执行期间要额外阻塞的信号集。注意不是进程的全局阻塞集而是当这个处理函数被调用时内核会自动把这组信号加入阻塞状态等函数返回再恢复。如果你在sa_mask里放了自己的信号那同一个处理函数执行期间再来一个同样的信号就只能排队等着直到当前调用结束。sa_flags里最常用的是SA_RESTART——它让被信号中断的慢速系统调用如read()自动重新发起而不是返回EINTR错误。SA_NOCLDWAIT用于自动回收子进程SA_SIGINFO则表明处理函数接收三个参数信号编号、siginfo_t结构、上下文指针这是使用sigqueue()配合的必备条件。2.2 发送信号kill()、raise()、alarm()#include signal.h #include unistd.h int kill(pid_t pid, int sig); int raise(int sig); unsigned int alarm(unsigned int seconds);kill()的名字很有迷惑性它不是杀死进程而是向进程发送信号。pid参数的语义很微妙pid 0发送给指定进程pid 0发送给当前进程组的所有进程pid -1发送给进程组ID等于|pid|的所有进程pid -1发送给当前进程有权限发送的所有进程SIGKILL除外服务端代码里最常用的其实是pid -1比如给整个进程组广播SIGTERM做优雅下线。需要权限检查发送者必须与目标进程同属一个用户或者拥有超级用户权限。实际开发中还有一种常见操作kill(getppid(), SIGUSR1)让父进程知道子进程状态有变属于父子进程协作的基本手段。raise()等价于kill(getpid(), sig)但它还有个隐藏语义如果信号被阻塞它仍然会把信号加入pending集不会出错。alarm()则是在seconds秒后向自己发送SIGALRM这是一个真实可用的定时器。注意alarm()的返回值是上一个未到期的闹钟剩余的秒数——如果你连续调用两次alarm(10)第二次会直接覆盖第一次的闹钟返回第一次还剩下的秒数。这算是个容易误用的点。2.3 阻塞信号集sigprocmask() 与 sigpending()#include signal.h int sigemptyset(sigset_t *set); int sigfillset(sigset_t *set); int sigaddset(sigset_t *set, int signum); int sigdelset(sigset_t *set, int signum); int sigismember(const sigset_t *set, int signum); int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); int sigpending(sigset_t *set);sigset_t是一组信号编号的集合操作它必须用上面那组sig开头的函数不能直接按位操作——这在不同架构上实现可能不同。sigprocmask的how有三个取值SIG_BLOCK把set里的信号加入当前阻塞集相当于追加SIG_UNBLOCK把set里的信号从当前阻塞集移除SIG_SETMASK直接用法set替换当前阻塞集这个函数我经常用在临界区保护上比如某个共享数据结构正在被修改时我不希望SIG_INT打断逻辑就先用sigprocmask(SIG_BLOCK, sigint_set)把信号挡住等临界区代码执行完再解除阻塞。sigpending()用来查当前进程有哪些信号处于未决状态。注意被阻塞的信号不会消失只是暂时待命。所以如果你看到程序卡住不动怀疑是信号被阻塞了就可以sigpending()把这组位图打出来检查一下是不是有些信号一直排着队没人处理。2.4 原子等待sigsuspend() 与 sigwait() 的取舍在讲这两个函数之前必须提那个经典到不能再经典的竞态场景/* 有问题的写法先解除阻塞然后pause中间有窗口 */ sigprocmask(SIG_UNBLOCK, set, NULL); pause();这段代码最大的问题在于如果信号在UNBLOCK之后、pause()之前到达信号就彻底丢了而pause()后面可能永远等不到下一个信号。修复方式是把解除阻塞和睡眠等待合并成一次原子操作这正是sigsuspend()存在的意义。#include signal.h int sigsuspend(const sigset_t *mask);sigsuspend()的逻辑是临时将进程阻塞集替换为mask然后挂起进程直到收到一个未被阻塞的信号信号处理函数执行完毕后阻塞集恢复为调用前的值sigsuspend()返回-1并设置errno为EINTR。这个临时替换是原子完成的彻底消除了窗口期。但注意sigsuspend()是面向信号驱动风格的它没有返回值永远返回-1不适合用来做等信号超时这种复杂逻辑。相比之下sigwait()更像一个同步接口int sigwait(const sigset_t *set, int *sig);它要求set中的信号必须预先被sigprocmask阻塞然后sigwait()会同步等待其中一个信号把它从pending集里取出来放入sig。整个过程不执行信号处理函数而是把信号当作消息来收件。这在多线程程序里特别好用——可以开一个专用线程用sigwait承接所有信号其他线程完全不受信号处理函数打断的困扰。这是我在多线程服务里最推荐的方式之一。2.5 实时信号与信号队列sigqueue()#include signal.h #include sys/types.h int sigqueue(pid_t pid, int sig, const union sigval value);sigqueue()是对kill()的增强版支持实时信号并且可以在发送时携带一个整数或指针union sigval。它要求接收方注册处理函数时必须带SA_SIGINFO标志否则sig参数和携带的数据无法通过siginfo_t结构传给处理函数。实时信号的优势是排队每个实时信号都有独立队列发N次就收N次不会像标准信号那样去重。但它也有代价队列有限超出上限/proc/sys/kernel/rtsig-max相关老参数现在一般是RLIMIT_SIGPENDING时sigqueue()会返回EAGAIN。实时信号优先级比标准信号高并且编号越小的实时信号优先级越高。工程上实时信号常被用来做微秒里的限流控制或者事件管道但在普通业务代码里用到的机会不多更多出现在中间件的底层实现中。3. 实操环节写一个可靠的处理函数3.1 信号处理函数只能做有限安全操作这是全篇最重要的纪律。信号处理函数会在任意指令处被异步插入所以它无法安全地调用大部分库函数——比如printf()、malloc()、pthread_mutex_lock()这些都不是异步信号安全的。什么叫异步信号安全就是该函数在信号处理函数中被调用时保证不会破坏进程状态。POSIX标准定义了一个可重入且异步信号安全的函数清单包括read()、write()、open()、close()、waitpid()、kill()、sigaction()、_exit()等。而不是这个清单里的函数你在处理函数里调用就是拿程序的生命开玩笑。我自己踩过最痛的坑是在SIGTERM处理函数里调用printf()做日志输出结果程序在某个线程持有stdio锁时被打断处理函数里再次尝试获取printf内部锁直接死锁。后来学乖了处理函数里只用write()往预置的fd里写字节流日志记录丢给主循环去做。还有两个必须牢记的点。第一处理函数里访问的全局变量要声明为volatile sig_atomic_t保证读写是原子的sig_atomic_t是int类型能保证单次存取不被中断。第二如果要在处理函数里修改全局标志让主循环轮询就必须用volatile sig_atomic_t不能图省事用一个普通int。3.2 自管道唤醒把信号安全地搬到主循环如果我的程序是基于epoll事件循环的信号处理函数却可能在任何线程的任意时刻触发那怎么把信号事件平滑地融入现有的事件循环业界最经典的做法是自管道唤醒self-pipe trick。原理很简单初始化时创建一对管道fd信号处理函数只做一件事——往管道写端写入一个字节主循环的epoll监听管道读端一旦可读就循环读出并解析。这样信号处理函数中的代码量极小只调write()完全符合异步信号安全约束而真正的业务逻辑全在主循环里正常执行。static int signal_pipe[2]; static void signal_handler(int sig) { int saved_errno errno; char b (char)sig; /* 写管道是非阻塞的管道满了也只能丢不能阻塞 */ ssize_t rc write(signal_pipe[1], b, 1); errno saved_errno; } void init_signal_pipe(void) { pipe(signal_pipe); /* 设置非阻塞防止管道写满时阻塞处理函数 */ fcntl(signal_pipe[0], F_SETFL, O_NONBLOCK); fcntl(signal_pipe[1], F_SETFL, O_NONBLOCK); /* 安装 handlers... */ }注意我在处理函数里保存并恢复了errno。因为信号处理函数可能打断主程序的任意位置如果不恢复errno主程序后续判断错误码时会拿到莫名其妙的值。这是很多教科书不会写、但工程里必须养成的习惯。3.3 正确处理SIGCHLD坚决不养僵尸进程每次子进程退出内核会向父进程发送SIGCHLD。如果父进程不调用wait/waitpid子进程的进程描述符就会留在内核里变成僵尸进程。僵尸进程无法被kill杀死只能等父进程结束由init进程接管回收。很多人会写一个简单的SIGCHLD处理函数static void sigchld_handler(int sig) { waitpid(-1, NULL, WNOHANG); }这个写法在简单场景下够了但有个隐患如果多个子进程几乎同时退出信号可能合并成一次递送而waitpid(-1)一次只回收一个子进程剩下的仍然变僵尸。正确做法是循环回收直到返回0或-1static void sigchld_handler(int sig) { int saved_errno errno; pid_t pid; while ((pid waitpid(-1, NULL, WNOHANG)) 0) { /* 回收成功 */ } errno saved_errno; }WNOHANG是为了防止没有子进程退出时阻塞。这只是处理SIGCHLD的第一层基础更稳的方案还是配合epoll的signalfd这属于另一个工程话题了。3.4 一个完整示例优雅退出与子进程回收把上面的知识拼起来我给出一个略完整的可参考骨架。这个程序会启动两个子进程在收到SIGTERM后优雅地结束。#include sys/wait.h #include unistd.h #include signal.h #include stdio.h #include string.h #include errno.h static volatile sig_atomic_t g_stop 0; static void term_handler(int sig) { g_stop 1; } static void chld_handler(int sig) { int saved_errno errno; while (waitpid(-1, NULL, WNOHANG) 0) { } errno saved_errno; } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler term_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGTERM, sa, NULL); sa.sa_handler chld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGCHLD, sa, NULL); for (int i 0; i 2; i) { pid_t pid fork(); if (pid 0) { pause(); _exit(0); } } while (!g_stop) { sleep(1); } printf(main: shutting down\n); return 0; }这里的sa_flags我用了SA_RESTART。这意味着如果主循环阻塞在sleep(1)上SIGTERM到达后sleep会被重启不至于返回EINTR导致循环出问题。不过要提醒SA_RESTART并不保证所有系统调用都能自动重启有些socket调用依然可能返回EINTR所以代码里还是要做好EINTR的兜底。4. 常见问题与排查技巧4.1 EINTR被信号打断的系统调用怎么办这是新手最容易看懵的错误。一个read()明明阻塞着等数据信号一来它返回了-1errno设为EINTR。很多人的第一反应是出错了其实只是信号打断了阻塞期间的等待。处理方式取决于你有没有设置SA_RESTART。设置了绝大多数慢速系统调用会自动重启没设置或者调用本身不在自动重启的范围内就需要手动重试。通用的写法是循环包裹ssize_t n; do { n read(fd, buf, sizeof(buf)); } while (n 0 errno EINTR);我见过最坑的服务端事故是某项目没有处理EINTR某个连接空闲超时后close()被信号打断fd被误判为异常直接触发了一堆资源清理逻辑半分钟的时间窗口里雪崩了整条请求链路。所以无论是Socket还是文件IO只要你有信号处理的可能务必加上EINTR重试或者依赖SA_RESTART。4.2 信号丢失与排队陷阱标准信号不排队这个特性在实际开发里真的会咬人。举个例子你的进程同时会收到多个SIGUSR1来标记不同的业务事件结果因为某些事件发生得过于密集中间一部分信号被合并丢失导致主循环只处理了一次事件。这不是bug是机制但很多人都没提前意识到。想精确无误地传递每次事件就是要用实时信号加sigqueue()并且处理函数要带SA_SIGINFO。但实时信号也不能无限发送它受进程RLIMIT_SIGPENDING限制默认通常是32768左右。所以实时信号适合低频关键消息不适合高频业务数据。另一个容易被忽视的点是signal()在老系统上可能有处理完一次自动恢复默认动作的语义如果你不重新注册第二次信号直接按默认动作执行进程直接退出。这就是为什么我坚持用sigaction()它的语义在所有平台上都一致。4.3 竞态条件sigsuspend 到底在解决什么很多文章说sigsuspend用于等待信号但没讲清楚它和前文提到的unblockpause竞态的关系。我再补一个具体例子。假设主流程先阻塞了SIGUSR1然后你希望要么收到SIGUSR1要么永远睡着sigset_t set; sigemptyset(set); sigaddset(set, SIGUSR1); sigprocmask(SIG_BLOCK, set, NULL); // 先阻塞 /* 关键点这时如果信号已经到来它只是pending没被处理 */ sigsuspend(set); // 原子地解除阻塞并挂起 /* 信号到达并且处理完成后sigsuspend返回 */如果在sigprocmask(SIG_BLOCK)之前信号已经到达后面的sigsuspend不会阻塞因为信号已经是pending如果信号在BLOCK之后到达它依然会被sigsuspend立即接住如果信号在sigsuspend执行期间到达那正好被处理。整个过程没有窗口。这就是它不可替代的原因。工程上的替代方案是sigwaitinfo或signalfd但在标准C/POSIX环境中sigsuspend依然是等待信号的兜底方案。4.4 信号相关典型问题速查现象可能原因解决方向进程一启动就退没走业务代码忘了处理SIGPIPE写socket导致进程被默认动作杀死忽略SIGPIPE或者用MSG_NOSIGNAL僵尸进程刷屏收到SIGCHLD但没调用waitpid循环回收见3.3节程序偶尔卡死敲CtrlC也没反应信号被sigprocmask永久阻塞一直处于pending排查阻塞集确认是否误用了SIG_SETMASK处理函数里调用printf导致死锁违反了异步信号安全约束用write()或自管道read()总是返回EINTR信号打断且未设置SA_RESTART设置SA_RESTART或循环重试明明发了信号处理函数没执行信号在pending状态被后续信号合并改用实时信号sigqueue4.5 排查信号相关问题的工具箱除了裸眼读代码我推荐几个实用的排查手段。第一/proc/pid/status里的SigBlk、SigCgt、SigIgn字段可以用十六进制查看进程当前阻塞、捕获、忽略的信号位图这是定位信号被谁挡了的最快方式。第二gdb里用info signals查看调试器对每个信号的捕获策略用handle SIGXXX nostop noprint pass调整信号是否中断调试。第三strace -e tracesignal可以直接跟踪进程收到和发出的所有信号对于排查信号来源不明的场景几乎是一击必中。有一次线上服务频繁重启我看日志什么都看不出来最后用strace -e signal -p pid挂上立刻发现每30分钟就有个外部进程向我的服务发SIGHUP而我默认没处理直接退出了。加上SIGHUP处理函数后问题秒解。5. 我的几点总结与经验信号处理这门手艺说到底是异步这两个字在系统编程里的缩影。我做了几年服务端开发最大的感触是能不用信号尽量不用一旦用了就必须把它当成异步侵入者来对待主动设计安全边界。几个亲测有效的习惯整理给大家。第一新代码一律用sigaction()而不用signal()别给自己留历史坑。第二信号处理函数越短越好最好只是写管道或设标志位业务逻辑全部搬回主循环。第三多线程服务里优先考虑sigwait或signalfd用专用线程统一收信号比到处装handler优雅得多。第四每次写完信号相关代码都要问自己三个问题会不会EINTR会不会丢信号会不会在handler里调用了不安全函数最后分享一个小技巧如果你在调试时不确定某个信号到底有没有被阻塞可以直接在gdb下敲call sigprocmask(SIG_SETMASK, 0, old)再print old看位图。这比翻代码猜快很多。信号这块的知识点很碎但理清机制后其实也就那几板斧——阻塞集、处理函数、等待信号。把这三个核心概念吃透绝大多数信号相关的坑都能避开。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询