拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux信号机制详解:从内核流程到sigaction与EINTR实战

Linux信号机制详解:从内核流程到sigaction与EINTR实战

接触Linux系统编程的人,大概率都有过被信号支配的经历:终端里按一下Ctrl+C程序没了,write管道时程序莫名其妙退出,后台守护进程被killall之后变得半死不活。信号这东西在系统编程里绕不开,它说简单也简单,无非就是"内核给进程发通知",但真要把它的语义吃透,把SIGCHLD、SIGPIPE、EINTR这些坑全部踩平,非得亲手写几个进程,被折磨几轮不可。这篇笔记就是我把自己和信号较劲的过程整理出来的一份总结——不光是记API用法,更想讲清楚信号在内核里是怎么流转的,为什么标准信号会丢,哪些场景必须用sigaction而不是signal,以及处理函数里到底能不能碰printf。如果你正在啃"Linux系统编程"这条路,早晚要和信号打交道,这份笔记应该能让你少走不少弯路。

1. 信号到底是个什么玩意儿:内核的"软件中断"

1.1 信号的本质与生活化类比

信号在Linux里的准确定义是"内核用来通知进程发生了异步事件的一种软中断机制"。注意"异步"这两个字——进程自己不知道什么时候会被打断,信号来的时机完全不可预测。它和中断很像:硬件中断是CPU被外部设备打断,信号则是进程被"逻辑事件"打断,打断之后进程还得接着跑自己之前的活儿。

我每次跟新人解释信号,喜欢用这个类比:你正坐在工位上写代码,同事喊你"下午三点开会",你听到之后不会立刻放下键盘跑到会议室——你会先把手头这个if语句写完,到代码一个段落自然停下的地方,才站起来去开会。信号就是这个"同事喊话"的动作,而"写完当前段落再去响应"就是信号的处理时机。内核里也一样,信号来了之后并不会立刻插入执行,而是等进程从内核态返回用户态、或者调度到该进程的时候,才实际去跑对应的处理逻辑。

这个类比还能引申出一个重要结论:信号处理程序跑的时候,主程序是"暂停"的。处理函数返回之后,主程序从被打断的位置继续执行。如果理解不了这一点,后面信号处理函数里的各种诡异bug就完全没法排。

1.2 常见的信号到底有哪些

Linux标准信号一共就30多个,实际开发中高频碰到的不超过15个。我把最常用的整理成一张表,每个信号后面标注了默认动作,这个动作决定了你没写处理函数时进程的"死法":

信号编号触发场景默认动作
SIGHUP1终端挂断、进程的控制终端关闭终止进程
SIGINT2终端按下Ctrl+C终止进程
SIGQUIT3终端按下Ctrl+\终止进程并生成core文件
SIGKILL9kill -9强制杀进程终止进程,不可捕获
SIGSEGV11访问非法内存地址终止进程并生成core文件
SIGPIPE13写管道/ socket,但读端已关闭终止进程
SIGALRM14alarm定时器超时终止进程
SIGTERM15kill命令默认发送终止进程
SIGCHLD17子进程停止或退出忽略
SIGCONT18继续一个已停止的进程继续执行
SIGSTOP19暂停进程暂停,不可捕获
SIGTSTP20终端按下Ctrl+Z暂停进程

这个表看起来平平无奇,背后的信息量很大。第一,SIGKILL和SIGSTOP这两个是不可捕获、不可阻塞的,原因很简单:如果允许进程把SIGKILL也当作普通信号处理掉,那你拿它就真的一点办法没有了,系统里会有一堆杀不死的野进程。内核在递送这两个信号时会直接跳过查处理函数这个环节。第二,SIGPIPE是很多新手死得最惨的一个——你得记住它的默认动作是"终止进程",不是返回错误码。很多人程序里根本没注册SIGPIPE处理函数,于是对于网络编程来说很关键的一点就是:一定要提前把SIGPIPE忽略掉,然后用write的返回值判断有没有问题。

还有一个容易被忽视的信号:SIGCHLD。它的默认动作是忽略,但如果你用waitpid去回收子进程,就得清楚它有没有被捕获。很多服务端框架的常见做法是捕获SIGCHLD然后循环waitpid,或者干脆用SIG_IGN让内核自动回收子进程,后一种方式在Linux上确实有效,虽然语义上不太"正规"。

1.3 信号不是线程,也不是中断

有经验的人可能会说"信号这东西我熟,就是和线程差不多嘛"。这话不对。线程是并发执行流,信号是异步通知,两者根本不是一个维度的概念。另外信号和硬件中断也完全不同:硬件中断由CPU中断控制器触发,有优先级、有嵌套,而标准信号属于"进程级事件",不会嵌套——一个进程在处理SIGINT的时候不会又被另一个SIGINT插入打断(因为标准信号本身不支持排队,而且处理函数执行期间这个信号通常会被自动阻塞)。这种"处理期间自动屏蔽同类信号"的行为,内核在把控制权转交给信号处理函数时已经帮你做掉了。

不要把信号理解成什么高深的东西,它就是一套"内核给进程递条子"的机制,上面这些表背下来,你对信号的全局观就立起来了。

2. 信号的完整生命周期:从产生到处理结束

2.1 信号的四种来源

先看信号是怎么冒出来的。归纳下来Linux里信号的产生途径有四种:

  • 硬件异常:最常见的就是除零、访问非法内存。CPU检测到异常后,内核会生成对应的信号,比如SIGFPE、SIGSEGV。这类信号通常意味着程序有bug,处理函数的首选方案是让进程赶紧退出,别硬撑着。
  • 终端按键:Ctrl+C产生SIGINT,Ctrl+\产生SIGQUIT,Ctrl+Z产生SIGTSTP。这些由终端驱动检测到按键后,向前台进程组发信号。
  • 软件条件:管道破裂触发SIGPIPE,定时器到期触发SIGALRM,子进程状态变化触发SIGCHLD,用户自定义的SIGUSR1/SIGUSR2等。
  • 显式系统调用:kill(2)、raise(3)、alarm(2)、setitimer(2),这是程序员自己主动发的信号。

其中kill这个名字很唬人,但它并不是只用来杀进程,它本质就是"给指定进程发送指定信号"。raise(sig)是给自己发信号,kill(0, sig)是给当前进程组里的所有进程发信号,kill(-1, sig)是给有权限的所有进程发信号。这几个姿势在写脚本、写控制台工具的时候非常常用。

#include <signal.h> #include <sys/types.h> int kill(pid_t pid, int sig); int raise(int sig); unsigned int alarm(unsigned int seconds);

alarm函数也容易被人低估。它安排在seconds秒后给当前进程发送SIGALRM,返回值为上次未触发的闹钟剩余秒数。如果传0就是取消闹钟。每进程同一时刻只能有一个闹钟,这是它的限制,需要多个定时器就得用setitimer或者timer_create。

2.2 内核里信号的三态:未决、阻塞、递送

信号产生之后,并不一定马上被进程处理。在进程视角里,一个信号的生命周期会经历这几个状态:

  • 未决(pending):信号已经生成,但还没被进程处理。
  • 阻塞(blocked):进程主动表示"我现在不想处理这个信号",它的mask位被置位。阻塞不等于忽略,等解除阻塞后会继续处理。
  • 递送(delivered):信号真正到达进程,执行处理函数或默认动作。

这三个状态不是互相排斥的。一个信号可以同时处于"未决+阻塞"状态,你把它解除阻塞之后,它就变成"递送"了。我见过很多新手把阻塞和忽略混为一谈,忽略是信号到达之后直接丢掉,阻塞是信号到达后先存放在pending集合里,等解除阻塞再处理——完全是两个语义,千万别搞混。

内核是怎么记录这些状态的?每个信号在内核的task_struct里都维护了两个sigset(bitmap):一个blocked集合,一个pending集合。产生信号时,内核在pending对应的bit位置1;递送信号时,这个bit清0。对于标准信号来说,同一个信号在pending期间如果又产生了一次,对不起,它不会排队,只会合并——因为bitmap里同一个bit只能表示一个"有信号",没法记次数。

2.3 什么时候真正执行处理函数

好,信号已经在pending里躺着了,进程什么时候去处理它?答案是:进程从内核态返回用户态的时候。这也是跟中断最大的区别之一。

画一个典型的执行流程:进程调用read阻塞在读磁盘上,磁盘数据到达后触发硬件中断,内核处理完中断后发现read的数据到了,于是把进程状态从睡眠改成就绪,同时检查这个进程有没有待递送的信号。如果pending & ~blocked不为空,内核就挑一个信号,先去修改用户态栈,构造一个信号处理函数的执行帧,让进程返回到用户态时先跳到handler去执行,而不是回到read的下一条指令。handler执行完之后,再回到之前被打断的地方,继续原来的流程。

这里有个重要推论:信号处理不是立刻发生的,它发生在从内核态返回到用户态的那一瞬间。如果一个进程长时间运行在用户态算东西,信号会一直挂在pending里,等它下次进内核再出来才处理。同理,高优先级的信号也没有真正的抢占优先级,就是按集合里取一个出来递送而已。

理解了这个机制,再看那些"为什么我的信号处理函数没执行"的排查问题就有思路了——要么pending里根本没有信号,要么强行阻塞了它,要么进程压根没从内核态返回到用户态。

2.4 标准信号不排队,实时信号才排队

前面说过标准信号在pending集合里只占一bit,所以同一信号无法排队。这是很多隐蔽bug的根源。比如你的程序每秒收到100次SIGCHLD,但内核只会在pending里置一个bit,子进程退出的时间点不同,但信号处理函数最多只能有一次收获机会。要处理这个问题,你在handler里必须把能做的事全做完——典型场景就是循环waitpid(-1, NULL, WNOHANG),一把梭哈回收所有退出的子进程,而不是只处理了一个。

从Linux 2.2开始支持的POSIX实时信号(SIGRTMIN到SIGRTMAX)不走bitmap,而是走链表队列,每个实时信号实例都有自己的siginfo_t,可以通过queue的方式传递附带数据。所以实时信号天然支持排队、支持优先级。代价是内核开销更大,实时信号也快不到哪去,实际开发中99%的场景用标准信号就够了,实时信号大多用在需要携带用户数据或者严格计数的场合。

3. signal还是sigaction:选型背后的坑与理

3.1 signal的历史遗留问题

很多教程先讲signal(2),因为它简单:

#include <signal.h> void (*signal(int signum, void (*handler)(int)))(int);

一句话就能解释用法:给signum信号注册一个handler函数。但如果你在真实项目里用它,迟早被它坑到。历史上signal在不同Unix版本上语义不一样,System V的版本在信号处理函数执行完之后,会把对应信号的处理动作重置为默认动作(SIG_DFL)。这意味着如果你准备连续处理两次Ctrl+C,第二次可能直接就终止进程了。而BSD的版本不会重置,还自动支持阻塞同类信号。Linux的signal默认采用BSD语义,但它又不完全一致,网上"移植性和行为差异"的吐槽一搜一大把。

更麻烦的是signal无法处理带参数的信号(比如siginfo_t),也无法设置信号的阻塞掩码和restart行为。你用signal注册了一个handler,然后发现read返回EINTR、二次信号行为不一致、想拿到发送者的pid都不行——那时候再回来换sigaction,代价已经很大了。所以我的建议简单粗暴:写新代码一律用sigaction,signal只留着读旧代码用。

3.2 sigaction的结构与字段

sigaction的核心就是这个结构体和系统调用:

#include <signal.h> struct sigaction { union { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); } sa_u; sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); }; int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

三个字段分别干什么:

  • sa_handler:老式handler,接收一个int类型的信号编号。如果你把sa_flags设为SA_SIGINFO,则应该用sa_sigaction,它能拿到siginfo_t结构体,里面包含了信号的产生来源、发送进程pid、uid、信号附带数值(si_value)等等。在SIGCHLD处理里还能直接拿到子进程的pid和退出状态,非常方便。
  • sa_mask:在执行当前handler期间,额外阻塞哪些信号。注意这里说"额外"——实际上内核在处理信号时会自动把当前信号加入阻塞集合(除非设置SA_NODEFER),sa_mask是往里追加的。比如你在SIGINT的handler里不想被SIGTERM打断,就可以把SIGTERM加进sa_mask。
  • sa_flags:行为控制开关,常用这几个:SA_RESTART(被该信号打断的慢系统调用自动重启动)、SA_SIGINFO(使用sa_sigaction)、SA_NOCLDWAIT(子进程退出后自动回收,不产生僵尸进程)、SA_NOCLDSTOP(子进程停止/继续时不产生SIGCHLD)、SA_RESETHAND(执行完复位为默认动作,复刻System V旧行为)。

3.3 一个完整的sigaction注册模板

下面这个就是我在项目里反复用的注册模板,加了点防御性编程:

#include <signal.h> #include <string.h> #include <errno.h> #include <stdio.h> static void handle_signal(int sig, siginfo_t *si, void *context) { // 只在收到期望信号时执行,其他信号都记录下来方便排查 if (sig == SIGTERM || sig == SIGINT) { // 注意:这里不要调用printf,详见后面"异步安全"一节 write(1, "got termination signal\n", 23); } } static int setup_signal_handlers(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = handle_signal; sa.sa_flags = SA_SIGINFO | SA_RESTART; sigemptyset(&sa.sa_mask); sigaddset(&sa.sa_mask, SIGCHLD); // handler执行期间屏蔽SIGCHLD if (sigaction(SIGTERM, &sa, NULL) < 0) { perror("sigaction(SIGTERM)"); return -1; } if (sigaction(SIGINT, &sa, NULL) < 0) { perror("sigaction(SIGINT)"); return -1; } return 0; }

这里有个容易被忽略的细节:sigemptyset(&sa.sa_mask)必不可少。struct sigaction如果在栈上不初始化,sa_mask的值是随机的,处理函数里可能莫名其妙屏蔽掉一堆信号。我在公司里帮人排查过一个奇怪bug:程序注册了SIGINT的handler,结果处理SIGINT的同时SIGUSR1也丢了,最后发现就是没清sa_mask导致SIGUSR1被意外加了进去。用memset(&sa, 0, sizeof(sa))也能达到类似效果,但用sigemptyset语义上更干净。

3.4 为什么要用SA_RESTART,为什么它不够

SA_RESTART的作用是让被信号打断的系统调用自动重新启动。举个例子,你的程序调用read从终端读数据,正阻塞着,这时一个SIGINT进来,handler执行完后read返回了-1且errno == EINTR。如果不处理EINTR,程序就莫名其妙读到失败。设置了SA_RESTART之后,内核会在handler返回后自动重发这次read,对应用程序来说就好像没被打断过。

这个开关很贴心,但别绝对依赖它。Linux上有几个系统调用即使设了SA_RESTART也照样返回EINTR,最典型的就是epoll_wait、poll、select、nanosleep,以及Linux的clock_nanosleep。这些调用内核没法"自动重放"它的等待语义,所以在循环里还是要检查EINTR并且继续重试。后面我单独开一节详细讲这个。

4. 阻塞、未决与信号集:让信号按你的节奏来

4.1 sigset_t和五大基础操作

信号集sigset_t本质就是一个bitmap,每一个bit代表一个信号编号。POSIX给它定义了一组操作函数,用之前必须先sigemptyset初始化,不然里面的随机值会让你后续操作直接崩掉:

#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);

sigemptyset把所有bit清0,sigfillset把所有bit置1。sigaddset把某个信号对应的比特位置1,sigdelset清0,sigismember查询某个位是不是1。这五个函数是后面所有信号集操作的基础,背熟就行,没什么复杂逻辑。

4.2 sigprocmask:进程级信号屏蔽

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如果非空,会把调用前的阻塞集合备份出来,方便后面恢复。一个经典用法是:

sigset_t set, oldset; sigemptyset(&set); sigaddset(&set, SIGINT); sigaddset(&set, SIGTERM); // 屏蔽这两个信号 if (sigprocmask(SIG_BLOCK, &set, &oldset) == -1) { perror("sigprocmask"); return -1; } // 这里做一些不允许被打断的临界操作 printf("entering critical section, signals blocked...\n"); // 恢复原来状态 if (sigprocmask(SIG_SETMASK, &oldset, NULL) == -1) { perror("sigprocmask restore"); return -1; }

这个模式在多进程协调里特别常见:先把信号屏蔽了,做一系列操作,再把屏蔽解除,确保某个逻辑段不会半路被信号插进来。但有一个极其关键的坑必须提:sigprocmask在单线程进程里是对的,在多线程进程里行为未定义,必须用pthread_sigmask。多线程模式下,每个线程有自己的信号掩码,信号会递送到任意一个没有阻塞它的线程,这个管理方式和单线程差别很大,不信邪的人大概率和信号玩出了薛定谔的猫。

4.3 用sigpending检查未决信号

有时候你想知道"刚才那个信号到底来没来过",sigpending就是干这个的:

#include <signal.h> int sigpending(sigset_t *set);

它返回当前进程的未决信号集合。结合sigprocmask可以玩一个小实验:

sigset_t pending; sigemptyset(&pending); if (sigpending(&pending) == -1) { perror("sigpending"); return -1; } if (sigismember(&pending, SIGINT)) { printf("SIGINT is pending but blocked, will be delivered after unblock\n"); }

这种"信号已经产生但被挡住还没处理"的状态,在调试信号问题时非常有价值。我排查过一次线上问题:一个服务在特定高峰期"卡死",strace看不到任何阻塞的系统调用,进程状态是R,CPU却不怎么动。后来在代码路径里用sigpending发现有一堆信号pending在阻塞集合里没处理,而代码又一直在spin-loop等后续事件,等于信号被"冻结"了。所以sigpending不只是教学玩具,它是诊断信号问题的一把利器。

4.4 阻塞和忽略,终于分清楚了

很多书上喜欢对比"阻塞和忽略"的区别,我用自己的话再掰扯一遍:忽略是"信号到达之后直接丢弃",处理流程是信号来了->看到handler为SIG_IGN->直接扔掉;阻塞是"信号到达之后先别扔,先放到pending集合里",等解除了阻塞再按正常流程处理。再打一个比方:阻塞就像你上了个厕所,把门反锁(屏蔽队列),敲门声(信号)在门外堆着,等你开门出来才一个个回应;忽略就像门卫直接告诉来访者"这个人不见任何人",你连他来过都不知道。这两个词搞混了,后面真的哪哪都是病。

5. 信号处理函数里的安全区:为什么printf不能用

5.1 异步信号安全函数

信号处理函数的执行时机完全不可预期,可能发生在主程序执行到任何一条指令的时候,这就牵扯出了"可重入"和"异步信号安全"的概念。POSIX定义了一张"async-signal-safe"函数列表,在handler里只能调用这些函数。列表不算长,常见的读写函数、进程控制函数都在里面:read、write、open、close、exit、_exit、getpid、kill、sigaction、sigprocmask等等。而像malloc、printf、sprintf、fopen、free,统统不在里面。

为什么printf不能碰?因为它内部做了三件事:维护stdout的流缓冲、获取全局锁、动态分配缓冲区或者调用文件锁。如果主程序正执行printf,已经拿到了stdout的锁,写到一半信号来了,handler里再调用printf,就尝试再次拿同一把锁——锁是非重入的,于是直接死锁。就算不死锁,缓冲区的状态也可能是半成品,打印出来的内容根本不可信。这跟你抢一个共享数据没加锁是一个道理。

可能会有人抬杠:我试过在handler里printf,没什么问题啊。那我只能说,那是运气好、时机没凑上,不代表安全。等到你线上服务随机卡死一次,连gdb都attach不上去的时候,才会意识到自己当初的侥幸有多贵。

5.2 信号处理函数的正确姿势:flag大法

既然handler里啥都不能干,那正确的做法是什么?答案十二个字:攒标志、快进快出、主循环处理。也就是说,在handler里只做最微小的事情——给一个全局标志位赋个值——然后立刻返回。主程序在正常执行流程里检查这个标志位,再做真正耗时的清理逻辑。

这里引出一个概念:volatile sig_atomic_t。sig_atomic_t是C标准保证"读写都是原子操作"的整数类型,通常就是int。volatile告诉编译器这个变量的值可能在信号处理器里被修改,别把它优化进寄存器。这两者组合起来是信号通信的经典范式:

#include <signal.h> #include <stdio.h> #include <unistd.h> static volatile sig_atomic_t g_shutdown_requested = 0; static void on_signal(int signo) { // 简单赋值足够了,不要printf (void)signo; g_shutdown_requested = 1; } int main(void) { struct sigaction sa; sigemptyset(&sa.sa_mask); sa.sa_handler = on_signal; sa.sa_flags = 0; sigaction(SIGINT, &sa, NULL); sigaction(SIGTERM, &sa, NULL); while (!g_shutdown_requested) { // 正常工作... pause(); // 等待信号 } write(1, "shutting down cleanly\n", 22); return 0; }

这里pause()会让进程挂起直到收到任意信号。handler执行完返回后,pause返回-1且errno==EINTR,然后循环条件检查到g_shutdown_requested变成1,跳出循环。这个模式简单可靠,几乎可以应对所有"优雅退出"的需求。

如果你确实需要在handler里把更多信息传出去,一个可行的增强是写一个"自旋队列"或者用write往管道写一个字节,主循环用poll监听这个管道。后者其实和signalfd的思路很接近,后面我会再展开讲。

5.3 如果需要与处理函数共享计数器

不止一个标志位,你可能还想统计收到了多少次信号。比如你实现了一个上报服务,需要在收到N个SIGUSR1后触发一次批量上报。计数器的类型必须有符号(比如volatile sig_atomic_t),并且要明白一个问题:即便sig_atomic_t读写都是原子的,它也解决不了count++和read(count)之间的顺序问题。

考虑这段代码:

static volatile sig_atomic_t counter; void handler(int sig) { counter++; // 三步:read, add, write } int main(void) { while (1) { if (counter >= 5) break; ... } }

如果信号正好在主程序执行if (counter >= 5)的瞬间到达,counter可能刚读到旧值,判断完之后又自增了1,于是循环条件漏掉了一次更新,程序会多循环一轮。解决这个问题的办法要么是接受"最终一致性"(反正一个信号最多延迟一次触发),用sig_atomic_t配合while (1)检查;要么标准信号本身就存在丢信号/合并的问题,你本来就不该对它要求精确计数。如果要精确计数,实时信号是不二之选。

5.4 handler里真的一点重活都不能干吗

有人会不服气:我handler里做一些轻量操作行不行?判断标准不是"轻不轻",而是"这些操作自己内部有没有使用非重入状态"。举个例子,waitpid(-1, &status, WNOHANG)就是被强烈推荐在SIGCHLDhandler里做的事,因为它安全、无锁、无缓冲,还能恰好解决子进程回收的需求。kill(getppid(), SIGUSR1)也安全,因为getpid和kill都是async-signal-safe的。

所以原则其实是"只调用async-signal-safe函数 + 尽量短"。在这一点上我吃过挺大的亏,曾经在handler里做过sprintf拼接日志,测试环境稳如老狗,生产环境偶尔内存语义混乱,最后查了三天才发现就是sprintf惹的祸。从那以后,我的handler函数体都控制在十行以内,而且每一行都对着async-signal-safe列表核一遍。这不是教条,是血的教训。

6. 信号与慢系统调用的纠缠:EINTR与SA_RESTART

6.1 什么叫慢系统调用

"慢系统调用"不是指执行慢的调用,而是指那些"可能永远阻塞下去"的调用。比如从终端设备读数据的read、往管道写的write、等待子进程的wait、暂停执行的pause,还有poll、epoll_wait、select这些。这些调用本身会阻塞在内核里,进程进入睡眠状态。

问题来了:如果阻塞期间信号到达,内核在递送完信号处理函数后,怎么处理这个被"打断"的系统调用?有两种策略:一是重新发起这次系统调用(重启),二是返回一个错误码告诉用户"你被信号打断了"。默认大部分系统调用选择后者,返回-1并把errno置为EINTR。

我见过最经典的线上事故:一个服务用read从socket读协议数据,某天运维发了个SIGTERM给同机的其他进程,结果这个服务的read被打断返回EINTR,代码没处理,直接把这次读取当连接关闭处理,close(fd)然后退出循环,服务瞬间"优雅地"挂掉了。所以处理EINTR不是可选项,是必须写进代码里的基本素养。

6.2 两种处理EINTR的完美姿势

第一种,使用sigaction的SA_RESTART。设置了这个标志后,对于大部分自动重启的系统调用(read、write、open、wait等在能够重启的场景下),内核会在信号处理函数返回后自动重新发起。这是最省事的方式,你的代码几乎察觉不到信号的存在。

第二种,自己写循环。因为有些系统调用即使有SA_RESTART也不重启,比如epoll_wait、poll、select、nanosleep。这些必须手动处理:

int ret; do { ret = poll(&fds, nfds, timeout_ms); } while (ret == -1 && errno == EINTR);

如果poll是被信号打断的,这个循环就会自动重新poll。要注意的是不能让死循环空转,所以在重试前最好判断一下errno == EINTR,其他错误直接返回。

6.3 慢系统调用对信号编程的额外影响

处理EINTR还有一个容易忽略的点:如果信号处理函数执行了很长时间,比如在handler里不小心做了个耗时操作,那么系统调用的"阻塞中断"时间就会特别长,长到你认为程序卡死了。所以handler要"快进快出"不光是出于安全和重入考虑,同时也是为了减少对主流程系统性调用的扰动。

另一个影响是:如果SA_RESTART已设置,那么一个被信号打断的read会被自动重启,这带来一个有趣的副作用——在restart语义下,你的read可能不知道时间已经悄悄过去了一段。如果read原本是带超时保护的,超时由信号驱动(这个场景在老旧代码里出现过),重启行为反而破坏了超时语义。这也是为什么很多资深工程师对SA_RESTART又爱又恨。我的建议很明确:服务端网络代码一律关掉SA_RESTART(不设置),然后在read/poll/epoll的返回值里显式处理EINTR,这样行为可预测,也方便统一打日志。终端交互类程序倒是可以开着,省心。

7. 实战排坑:那些年我们都被信号坑过

7.1 SIGCHLD与僵尸进程的"一问一答"

写服务器的人八成都被僵尸进程教育过。子进程退出之后会变成僵尸,直到父进程调用waitpid收尸。如果父进程注册了SIGCHLD处理函数但只在其中waitpid一次,那在高并发fork的场景下依然会有僵尸堆积——因为SIGCHLD不排队,多次子进程退出事件很可能合并成一次信号,你只回收了一个子进程,另外几个还躺着。

正确的做法是在SIGCHLDhandler里循环调用waitpid(-1, NULL, WNOHANG)直到返回0或-1。逐个回收干净:

static void reap_children(int sig) { int status; pid_t pid; (void)sig; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 记录一下pid和status,对于需要知道哪个子进程退出很有用 } }

如果你用sigaction并且设置了SA_NOCLDWAIT,内核会直接把SIGCHLD的默认行为变成"自动回收",这样你甚至可以完全不写handler。但要注意,SA_NOCLDWAIT在Linux上有个微妙的行为:设置之后,子进程退出时直接就释放了,不会变成僵尸,但你也拿不到子进程的退出状态了。如果应用需要记录退出码和资源用量,还是老老实实写waitpid循环。

7.2 SIGPIPE:磨人的小妖精

网络服务里最常见的意外死亡原因之一:对端关闭连接之后,你这边还在write,内核发现socket的写端已经读不通了,触发SIGPIPE,默认动作是终止进程。很多时候你的服务根本没有逻辑性错误,就是客户端断线了而已,结果整个服务进程直接没了。

解决方案有两个,我建议两个都用:

  1. 启动时忽略SIGPIPE:signal(SIGPIPE, SIG_IGN);或者用sigaction设置handler为SIG_IGN。
  2. 写数据的地方检查write返回值,出现EPIPE就主动关闭fd、上报错误、退出当前连接逻辑。

忽略SIGPIPE之后,写已关闭的socket会返回-1且errno为EPIPE,然后在业务层面处理。这远比被信号猝死要可控得多。这条规则适用于所有网络框架,包括你用libevent、libuv包装过的连接——底层照样会触发SIGPIPE,只是这些库内部也默认忽略了它,但你自己写沙盒时一定要记得。

7.3 fork后信号处理函数的继承

fork出来子进程会继承父进程的信号处理设置,包括handler函数指针。但这里有个大坑:如果你很快exec一个新的程序,那在exec之后,之前父进程捕获过的信号会全部重置为默认动作。原因很好理解——handler函数指针指向的代码是旧程序的代码段,exec之后那段代码已经不存在了,内核不可能继续跳进去执行。所以exec后的子进程如果还需要处理信号,必须在新程序里重新注册。

这个特性在写守护进程和重启器的时候要记得:父进程持有SIGTERM清理函数,子进程exec后如果不重新注册SIGTERM,直接在子进程里发kill(pid, SIGTERM),子进程会因为默认动作直接退出。有些场景是好事(比如隔离环境里直接杀掉子进程),有些场景是坏事(比如希望子进程也优雅退出)。搞清楚这个行为能帮你省半天的排查时间。

还有一个相关的坑:fork之后子进程的待处理信号(pending集合)会被清空,因为那是父进程特定的信号事件。所以如果父进程在fork前收到了信号但还没处理,子进程不会带上这笔"旧账"。

7.4 用/proc和工具定位信号问题

信号问题定位起来确实费劲,但Linux提供了一堆趁手的工具。先看/proc/<pid>/status里这几个字段:

  • SigQ:当前待处理信号数 / 队列上限。
  • SigCgt:当前捕获(注册了handler)的信号集合,bitmap形式。
  • SigBlk:阻塞的信号集合。
  • SigIgn:忽略的信号集合。

举个例子,你用cat /proc/1234/status | grep Sig就能看出这个进程当前对哪些信号敏感。SigBlk如果显示了SIGINT,那就解释了为什么你kill -INT 1234打进去毫无反应。

strace -p <pid>可以看到进程当前被哪个系统调用阻塞,同时能看到信号递送的情况:如果看到--- SIGTERM ---的日志,说明信号已经到达,接下来看handler是否被执行,或者被默认动作处理。gdb里则可以用handle SIGSEGV stop让段错误发生时停在信号处理现场而不是直接终止,再配合bt看堆栈,基本能定位到具体哪一行代码触发了非法内存访问。

7.5 一张速查表

我在实际项目中排查信号问题,靠的就是下面这张速查表,基于它可以快速缩小问题范围:

问题现象可能原因排查/解决思路
程序在Ctrl+C后没反应注册了handler,但信号被阻塞检查SigBlk,用sigprocmask解除阻塞
进程莫名退出且无core收到SIGPIPE/SIGTERMstrace跟踪,注册处理函数收集信号
子进程大量僵尸SIGCHLD handler只waitpid一次改为循环waitpid(-1,NULL,WNOHANG)
read返回EINTR信号打断慢系统调用设置SA_RESTART或循环重试
程序卡死,CPU却不高handler长时间阻塞或pending被阻塞用gdb attach看堆栈,查看pending集合
exec后带handler的新程序被信号杀死handler在exec后失效新程序里重新注册信号处理
同组进程被Ctrl+C全部终止终端向前台进程组发信号fork后改用setsid脱离终端会话

这些坑没有一个是"书上没写过"的,但几乎每个都让现场的人抓狂过。我觉得做系统编程就是这样,理论知识是地图,实战踩坑才是真的越野。

8. 更省心的玩法:把信号包装成事件

8.1 signalfd的引入

传统信号处理函数的别扭之处在于它"插队"——信号处理在进程任何一个指令边界都可能发生,这让代码很难结构化。signalfd提供了一条新路:把信号变成一个文件描述符,你像读普通fd一样去读信号事件。这样信号的产生就变成了事件队列里的一条记录,你可以用read、select、poll、epoll来统一管理,不再有信号处理函数和主流程"互相打断"的问题。

用法分三步走:

#include <sys/signalfd.h> #include <signal.h> #include <unistd.h> sigset_t mask; int sfd; sigemptyset(&mask); sigaddset(&mask, SIGINT); sigaddset(&mask, SIGTERM); // 关键:先阻塞这些信号,否则signalfd收不到 sigprocmask(SIG_BLOCK, &mask, NULL); // 创建signalfd sfd = signalfd(-1, &mask, 0); if (sfd == -1) { perror("signalfd"); return -1; } struct signalfd_siginfo fdsi; ssize_t n = read(sfd, &fdsi, sizeof(fdsi)); if (n == sizeof(fdsi)) { if (fdsi.ssi_signo == SIGINT) { write(1, "received SIGINT via signalfd\n", 30); } }

注意先后顺序:先sigprocmask阻塞信号,再signalfd。因为signalfd内部会记住创建时传入的信号集合,如果创建fd的时候对应信号还没被阻塞,信号会走传统的handler路线(默认动作),signalfd根本收不到。经验上,我都是把需要转为事件的信号全部先block掉,然后立刻建signalfd。

8.2 在事件循环里优雅集成

signalfd真正的价值在于让信号处理和网络事件共用一个事件循环。想象一个基于epoll的高并发服务:accept到连接、读写socket、处理定时器,现在还要处理SIGTERM优雅退出。用传统方式,你得注册一个分离的signal handler,和主循环的epoll_wait抢CPU时间;用signalfd,你只需要把sfd加入epoll:

struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev); while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); if (n == -1 && errno == EINTR) { continue; } for (int i = 0; i < n; i++) { if (events[i].data.fd == sfd) { // 读取signalfd,处理信号,设置退出标志 } else { // 正常socket/计时器事件 } } }

这样做的好处是信号处理逻辑和网络IO逻辑完全同构,不用再去思考"handler跑到一半epoll_wait会不会被EINTR打断"这种问题。signalfd的事件是排队存储的,每个信号实例都有独立的siginfo数据,比标准信号不排队的坑也舒服很多。代价是只能在Linux上用,不可移植。但在嵌入式、服务器这种纯Linux环境里,我觉得它是信号处理的一个更好的默认选择。

8.3 signalfd与传统handler,怎么选

我自己的经验法则是这样的:如果写的是单进程、短生命周期的工具程序,比如命令行工具、小型监控脚本,直接用sigaction+ flag大法就够了,简单直接。如果写的是长驻服务端程序,有epoll事件循环,有多个socket要管理,那优先考虑signalfd,把信号揉进事件循环里。如果写的是多线程任务型服务,信号处理就应该和线程设计一起考虑——大多数情况下每个线程单独用sigwait而不是注册handler,或者用一个专门线程pthread_sigmask统一管理信号。这套取舍里没有绝对正确,只有"哪一种更适合当前架构"。

8.4 信号在嵌入式/国产Linux环境下的落地

顺带提一句,现在很多项目跑在嵌入式Linux或者国产化系统上,内核仍然是Linux内核,所以这套信号机制完全通用。但嵌入式环境下有两点尤其要注意:第一,SIGXCPU、SIGSTKFLT这类信号可能和硬件看门狗或者RT补丁的行为有交互,有些内核版本对实时信号的队列长度有显式限制(默认1024),队列满了会丢实时信号,而且不报错——这个问题在工业控制场景里是被真实踩过的。第二,嵌入式Linux常常裁剪了/proc文件系统,如果你依赖/proc/<pid>/status里的SigQ定位问题,在没有这个文件的环境里就得提前设计好替代方案,比如在自己的代码里暴露信号计数器。信号这东西在哪种Linux上都一样,真正的差别在于你的调试工具有没有跟上。

写在最后的一点体会

折腾完信号这一整套,我自己最大的感受是:信号不是那种"背熟API就能用对"的东西,它特别讲究对执行时机、内核行为和系统调用交互的理解。你写的每一行信号处理代码,都要在脑子里过一遍整个链路——信号从哪里来、进程当下处于什么状态、handler执行完系统调用该怎么办。刚开始的时候我被EINTR、SIGCHLD、SIGPIPE这三个坑轮番教做人,后来慢慢养成了一套习惯:新代码一律sigaction、网络服务先忽略SIGPIPE、凡是阻塞调用必查EINTR、handler里永远不碰不安全函数。这四条规矩看起来保守,但对项目的稳定性帮助极大。信号处理的学问很深,但把这几个基本功打牢,绝大多数场景你都能从容应对。

返回列表