☰
Linux信号处理核心函数实战:从sigaction到自管道唤醒
2026/10/10 12:33:12 网站建设 项目流程

做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终止进程终端Ctrl+C
SIGQUIT3终止并产生core dump终端Ctrl+\
SIGKILL9强制终止,不可捕获kill -9
SIGSEGV11终止并产生core dump非法内存访问
SIGPIPE13终止进程写一个读端已关闭的管道/ socket
SIGALRM14终止进程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或-1:

static 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()明明阻塞着等数据,信号一来,它返回了-1,errno设为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用于"等待信号",但没讲清楚它和前文提到的"unblock+pause"竞态的关系。我再补一个具体例子。假设主流程先阻塞了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节
程序偶尔卡死,敲Ctrl+C也没反应信号被sigprocmask永久阻塞,一直处于pending排查阻塞集,确认是否误用了SIG_SETMASK
处理函数里调用printf导致死锁违反了异步信号安全约束用write()或自管道
read()总是返回EINTR信号打断且未设置SA_RESTART设置SA_RESTART或循环重试
明明发了信号,处理函数没执行信号在pending状态被后续信号合并改用实时信号+sigqueue

4.5 排查信号相关问题的工具箱

除了裸眼读代码,我推荐几个实用的排查手段。第一,/proc/<pid>/status里的SigBlk、SigCgt、SigIgn字段,可以用十六进制查看进程当前阻塞、捕获、忽略的信号位图,这是定位"信号被谁挡了"的最快方式。第二,gdb里用info signals查看调试器对每个信号的捕获策略,用handle SIGXXX nostop noprint pass调整信号是否中断调试。第三,strace -e trace=signal可以直接跟踪进程收到和发出的所有信号,对于排查信号来源不明的场景几乎是一击必中。

有一次,线上服务频繁重启,我看日志什么都看不出来,最后用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看位图。这比翻代码猜快很多。信号这块的知识点很碎,但理清机制后其实也就那几板斧——阻塞集、处理函数、等待信号。把这三个核心概念吃透,绝大多数信号相关的坑都能避开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询