新手阶段我啃《APUE》信号那一章,啃了三遍才敢说自己入门了。但真正让我对信号机制“开窍”的,不是书本上的定义,而是线上一次诡异的服务“假死”事故——进程还在,CPU 占用为 0,就是什么活都不干。后来 strace 一挂,发现进程阻塞在一个被信号打断后没有重试的系统调用上,就这么安安静静地“躺”了大半天。那一刻我才明白,信号这东西,日常里安静得像风,但处理不好,真的会让整个服务在风中凌乱。这篇文章,聊的就是 Linux 信号处理的门道——从原理到实操到填坑,一次性讲透。
1. 信号是什么:先搞清楚这个“异步通知”到底在通知什么
1.1 从一个“Ctrl+C”讲起:信号的日常体验
你在终端里跑着一个耗时的命令,比如tar解压一个大文件,等得不耐烦了,按下Ctrl+C,命令立刻终止,提示符回来了。这个过程中发生了什么?很多人只知道“按 Ctrl+C 能中断程序”,但背后的机制就是信号:终端驱动把按键解释为一个中断信号(SIGINT),然后把这个信号发送给前台进程组里的每一个进程。进程收到 SIGINT 后,默认行为是终止运行。
这件稀松平常的小事,把信号的几个核心特征全暴露出来了:
- 信号是异步的:你不知道进程正在执行哪条指令,按下按键的那一刻,进程可能在做解压、可能在写日志、可能在等待网络数据包,信号随时可能到达。
- 信号是通知,不是指令:内核只是告诉进程“你该中断了”,至于进程是立刻终止、还是先保存现场再退出、甚至完全忽略这个消息,取决于进程自己设置的“态度”。
- 信号有默认行为:如果进程没有专门处理 SIGINT,内核就按默认规则办——终止进程。
理解这一点,信号就不再神秘了。它本质上是操作系统给进程递小纸条:“注意,发生了一件事,你自己看着办。”
1.2 信号在内核里的出生和送达:从按键到进程的完整链路
既然要讲“艺术和实践”,就不能只停留在应用层。我试着把信号从产生到被处理的完整链路捋一遍,你会发现它其实是一个很精巧的状态机。
一个信号的生命周期大致分为四个阶段:
- 产生(Generation):事件发生的瞬间。引发信号的事件很多:键盘输入(SIGINT、SIGQUIT)、硬件异常(段错误产生 SIGSEGV、非法指令产生 SIGILL)、软件条件(
kill()系统调用、alarm()定时器到期、子进程状态变化产生 SIGCHLD)、终端挂断(SIGHUP)等。 - 未决(Pending):信号已经生成,但还没有被目标进程接收。如果信号被进程阻塞(block),它会一直停留在未决队列里,直到解除阻塞。
- 递送(Delivery):信号被进程接收的时刻。对于未被阻塞的信号,递送几乎是即时的。
- 处理(Handling):进程对信号作出反应。三种方式:执行默认动作(通常是终止或暂停)、忽略、执行自定义的信号处理函数。
这里有个关键点:普通信号(非实时信号)是不排队的。意思是,如果进程还没来得及处理一个信号,同样的信号又来了几个,内核只记录“有一个这样的信号待处理”,不会为每个信号建立一个队列条目。这就是很多新手踩的第一个坑——以为发了 N 次信号,处理函数就会被调用 N 次,实际上可能只调用一次。
这个不排队的特性,放在后面的“常见问题”里细说,这里先记住结论。
1.3 信号与门铃的类比:为什么异步是个好东西
理解异步这件事,最有用的一个类比是门铃。
想象你在房间里专心写代码,快递员到了楼下按门铃。门铃响了(信号产生),你听到声音(递送),放下手头工作去开门(处理)。在这个模型里:
- 你不会每写一行代码就去楼下看一眼快递到了没有(那是轮询,polling)。
- 门铃响的时机你无法预测,可能在你写第一行时就响,也可能写完整整一章才响(异步)。
- 你可以选择不理会门铃(忽略信号),也可以选择贴个纸条“放门口就好”(改变默认处理方式),但你不能阻止快递员按门铃(除非你拔掉门铃线——那就是阻塞信号,让信号无法递送)。
对比一下轮询模式:如果没有信号这种异步通知机制,进程想知道“有没有新数据到了”“子进程退出了没有”“定时器到期了没有”,就只能不停地主动去查。查得频繁,浪费 CPU;查得不频繁,响应延迟。信号机制把“何时去查”的判断交给内核,进程只在信号到达时才做出反应,CPU 利用率和响应及时性都得到保障。
这就是信号的底层价值:它是对“主动查询”的一种替代方案,让进程在等待外部事件时不必空转。你可以回忆一下日常排查问题时top里看到的那些 sleep 状态进程——它们中的很多,就是在等待某个信号来唤醒,而不是在忙等。
2. 认识信号家族:常用信号逐个拆解
2.1 先看看清单:那些天天打照面的信号
Linux 上执行kill -l就能列出全部信号。传统 Unix 信号编号 1~31,POSIX 实时信号编号 32~64,日常打交道最多的是前 31 个。我把最常用的整理成一张表,默认行为列也一并标注:
| 信号 | 编号 | 触发场景 | 默认行为 |
|---|---|---|---|
| SIGHUP | 1 | 终端挂断或控制进程退出 | 终止进程 |
| SIGINT | 2 | 键盘中断(Ctrl+C) | 终止进程 |
| SIGQUIT | 3 | 键盘退出(Ctrl+\) | 终止并生成 core 转储 |
| SIGKILL | 9 | kill -9强制杀死 | 终止,不可捕获、不可阻塞 |
| SIGSEGV | 11 | 段错误(非法内存访问) | 终止并生成 core 转储 |
| SIGPIPE | 13 | 写管道但读端已关闭 | 终止进程 |
| SIGALRM | 14 | alarm()或setitimer()定时器到期 | 终止进程 |
| SIGTERM | 15 | kill默认发送的终止信号 | 终止进程 |
| SIGCHLD | 17 | 子进程停止或退出 | 忽略 |
| SIGCONT | 18 | 让暂停的进程继续运行 | 继续运行 |
| SIGSTOP | 19 | 暂停进程(Ctrl+Z 的底层机制) | 暂停,不可捕获、不可阻塞 |
| SIGTSTP | 20 | 终端挂起(Ctrl+Z) | 暂停进程 |
这张表里我标了两个“不可捕获、不可阻塞”的异类:SIGKILL 和 SIGSTOP。这是内核的刻意设计——如果所有信号都能被进程屏蔽,那系统管理员就永远没办法强制终止一个失控进程了,这相当于给操作系统留了一把“物理钥匙”。
2.2 几个容易混淆的信号:SIGTERM vs SIGKILL、SIGHUP 的经典用途
新手最常问的一个问题是:“kill和kill -9有什么区别?”这里的区别,本质上就是 SIGTERM 和 SIGKILL 的区别。
kill命令默认发送 SIGTERM(编号 15)。SIGTERM 是“有礼貌的请求”:内核通知进程“请你退出”,进程可以捕获这个信号,在退出前完成清理工作——释放数据库连接、把缓冲区的日志刷到磁盘、删除临时文件、通知上游服务“我要下线了”。这也是为什么大多数服务管理工具(systemd 的Stop、Docker 的docker stop)默认发送的都是 SIGTERM。
而 SIGKILL 是“物理消灭”:内核直接终止进程,不给任何处理的机会。进程连“临终遗言”都来不及说,所有未刷盘的缓冲区数据直接丢失,任何自定义清理代码都不执行。所以一个成熟的运维流程里,kill -9永远应该是最后手段——先用 SIGTERM 给进程一个优雅退出的机会,等几秒钟,还赖着不走再上 SIGKILL。
再说 SIGHUP,这是一个很有故事的信号。它诞生于终端时代:用户挂在终端上的进程,终端断线后,内核发送 SIGHUP(Hang Up,挂断)给该终端下的进程组。默认行为是终止——想想也是合理的,用户都走了,你还跑给谁看?后来守护进程(daemon)发扬了 SIGHUP 的另一个用途:重新读取配置文件。因为守护进程没有终端,系统管理员要让它重新加载配置,总不能重启进程(重启有服务中断的风险),于是约定俗成:给守护进程发 SIGHUP,让它重新读配置。Nginx、sshd 都有这个习惯——kill -HUP $(cat /var/run/nginx.pid)就是经典的平滑重载配置操作。
2.3 信号的“生命周期”:产生、排队、递送、处理
前面我把生命周期分成四个阶段,这里再展开说一下“排队”这个细节,因为它和实时/非实时信号的差异直接相关。
对于标准信号(编号 1~31),内核在每个进程里维护一个pending 位图:每个信号占一位,这一位一旦置 1,就表示“这个信号处于未决状态”。如果进程正在处理 SIGINT,此时又来了一个 SIGINT,内核发现 pending 位图上 SIGINT 这一位本来已经是 1 了,就直接丢弃这个新信号——这就是“不排队”的底层原因。位图只有一位,无法记录“来了多少次”,只能记录“有没有来过”。
POSIX 实时信号(编号 32~64)则不同,内核为它们维护的是队列,每个信号都可以排队,可以附带数据(借助sigqueue()发送),到达的顺序也有保证。这就是为什么实时信号在一些对可靠性要求极高的场景(比如实时工业控制)中会用到——但在日常业务开发里,99% 的场合标准信号就够用了。
这个机制直接导出一个实战经验:不能依赖发送信号的次数来传递计数信息。比如某个脚本连发 5 次 SIGUSR1,希望触发 5 次处理逻辑——这完全是错误预期。正确的做法是:收到一次信号后,去读取文件、共享内存、消息队列里的“任务清单”,有多少处理多少。
3. 两种处理方式:signal() 与 sigaction() 的选型解析
3.1 signal():老接口的便利与陷阱
写 C 语言的信号处理,第一反应往往是signal()函数:
#include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { write(STDOUT_FILENO, "catch SIGINT\n", 13); } int main() { signal(SIGINT, handler); for (;;) { sleep(1); } return 0; }这段代码能跑,也很简洁。但我要泼一盆冷水:在实际项目里,我几乎不用signal(),而是用sigaction()。原因有几个。
第一,signal()在不同 Unix 系统上的语义有历史差异。早期的 System V 版本里,信号处理函数执行完一次后,信号处置方式会被重置回默认值(SIG_DFL),这意味着处理函数执行期间再次到达的同种信号,可能会按默认行为终止进程。虽然 Linux 的signal()实现是 BSD 语义(不会被重置),但如果你写跨平台代码,这个差异就是个雷。
第二,signal()无法精细控制信号处理的行为。它不能指定“在执行处理函数期间阻塞哪些其他信号”,也不能获取当前的信号处置方式。
第三,signal()不支持实时信号的排队和带参传递。想用sigqueue()传数据?signal()无能为力,必须上sigaction()。
因此,结论很直接:新写的代码一律用
sigaction()。signal()适合在快速写个小测试程序、验证信号机制时用。
3.2 sigaction():结构体里的门道
sigaction()的函数签名如下:
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);核心是struct sigaction这个结构体:
struct sigaction { void (*sa_handler)(int); // 信号处理函数 sigset_t sa_mask; // 处理期间要阻塞的信号集 int sa_flags; // 行为标志 void (*sa_sigaction)(int, siginfo_t *, void *); // 配合 SA_SIGINFO 使用 };注意这是一个联合体(我为了直观省略了 union 的写法),sa_handler和sa_sigaction共用一块内存。如果你想用带siginfo_t的处理函数(能拿到发送者 PID、信号编号、附加数据等),需要设置sa_flags里的SA_SIGINFO标志位,并把函数指针赋给sa_sigaction字段。
sa_mask是容易被忽略却很重要的一个字段:它指定了一个信号集,当当前信号的处理函数正在执行时,这个信号集里的信号会被自动阻塞。举个例子,你在处理 SIGINT 时,不希望又收到 SIGTERM 把事情搞乱,就把 SIGTERM 放进sa_mask,这样 SIGTERM 会等到 SIGINT 处理函数返回后才被递送。注意,被处理的信号本身也会在它自己的处理函数执行期间被阻塞,防止同一信号递归打断,这个行为是内核自动保证的,不需要手动往sa_mask里加自己。
sa_flags里还有几个常用的:
SA_RESTART:让被信号打断的系统调用自动重启,这个太关键了,后面专门讲 EINTR。SA_SIGINFO:使用sa_sigaction作为处理函数,可以获得更多细节。SA_RESETHAND:处理完一次后恢复默认行为(System V 语义)。SA_NOCLDWAIT:作用于 SIGCHLD 时,子进程退出后不变成僵尸进程(但是慎用,会丢失子进程的退出状态)。
3.3 实操代码:完整实现一个优雅退出程序
理论讲完,上实操。这里我给出一个“优雅退出”的完整示例,它做了这么几件事:
- 捕获 SIGINT 和 SIGTERM,收到后设置一个全局标志位,让主循环自然退出。
- 捕获 SIGUSR1,用于触发一次配置重载(这里用日志打印模拟)。
- 在信号处理函数里只做异步安全的最简操作(后面会讲为什么)。
#include <stdio.h> #include <signal.h> #include <string.h> #include <unistd.h> #include <stdlib.h> static volatile sig_atomic_t g_running = 1; static volatile sig_atomic_t g_reload = 0; static void handle_signal(int sig) { if (sig == SIGINT || sig == SIGTERM) { g_running = 0; } else if (sig == SIGUSR1) { g_reload = 1; } } static void setup_signal(int sig) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handle_signal; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; if (sigaction(sig, &sa, NULL) == -1) { perror("sigaction"); exit(1); } } int main() { setup_signal(SIGINT); setup_signal(SIGTERM); setup_signal(SIGUSR1); printf("process started, pid=%d\n", getpid()); while (g_running) { if (g_reload) { g_reload = 0; printf("reload config...\n"); // 这里做真正的配置读取逻辑 } // 模拟业务逻辑 sleep(1); } printf("process exiting gracefully\n"); return 0; }几个细节你可能注意到了:
g_running和g_reload声明成volatile sig_atomic_t而不是普通的int。sig_atomic_t是保证对信号处理函数中的读写是原子的整数类型,避免读到半更新状态。volatile是防止编译器把变量优化到寄存器里——信号处理函数和主循环之间共享这个变量,如果编译器自作聪明地缓存了副本,主循环可能永远看不到更新。- 信号处理函数里我没有调用
printf,而是直接把标志位置位,由主循环去响应。这是一个原则性问题,下一节详细解释。
编译运行一下:
gcc -o graceful graceful.c ./graceful另开一个终端,执行kill -USR1 <pid>,程序输出 reload config;执行kill -TERM <pid>,程序打印 exiting 信息后退出。对比一下直接kill -9,退出时没有清理日志——这就是优雅退出和强杀的区别。
4. 进阶实践:定时器信号、子进程协作与 Shell 层级的信号处理
4.1 用 SIGALRM 实现定时任务
alarm()是每个 C 程序员最早接触的定时器接口之一:
#include <stdio.h> #include <signal.h> #include <unistd.h> void on_alarm(int sig) { printf("tick\n"); alarm(1); // 再次设定期时器,实现周期性 } int main() { signal(SIGALRM, on_alarm); alarm(1); for (;;) { pause(); // 挂起直到收到信号 } return 0; }这个程序每秒打印一次 “tick”。原理是:alarm()让内核在指定秒数后向当前进程发送 SIGALRM,默认行为是终止进程,我们把它捕获并重新设定下一次闹钟。
alarm()的粒度是秒级,只支持一个定时器。更精细的场景用setitimer()或timer_create():
setitimer()支持微秒级精度,且支持三种计时模式(ITIMER_REAL、ITIMER_VIRTUAL、ITIMER_PROF)。ITIMER_REAL 到期发送 SIGALRM;ITIMER_VIRTUAL 只在进程用户态执行时计时,到期发送 SIGVTALRM;ITIMER_PROF 统计用户态+内核态耗时,到期发送 SIGPROF。timer_create()是 POSIX 定时器,支持高精度、多个独立定时器,甚至可以用实时信号携带超时值。
不过在 Linux 上实现高精度的定时任务调度,我的个人建议是优先考虑timerfd,这个话题稍微超纲,这里留个引子:timerfd_create()把定时器事件绑定到一个文件描述符上,配合epoll,比信号定时器干净得多,因为你完全绕开了信号处理函数里的种种限制。
4.2 SIGCHLD 与子进程回收
fork()之后,子进程退出时,内核会向父进程发送 SIGCHLD 信号。如果父进程不对这个信号做任何处理,子进程退出后就会变成僵尸进程(zombie),占用一个进程表项,直到父进程调用wait()/waitpid()来回收。
我见过不少生产事故:父进程跑成了守护进程,fork()出的子进程意外退出,但父进程没wait(),于是每个失败的子进程都变成僵尸。僵尸越积越多,进程号枯竭,最终整个服务不可用。
正确的做法之一,就是在父进程里捕获 SIGCHLD,并在处理函数里调用waitpid():
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <sys/wait.h> #include <unistd.h> #include <string.h> static void on_sigchld(int sig) { int status; pid_t pid; // WNOHANG 保证如果没有子进程退出就立即返回,不会阻塞 while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { printf("child %d exit, status=%d\n", pid, WEXITSTATUS(status)); } } int main() { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = on_sigchld; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // 只在子进程退出时通知,停止不通知 sigaction(SIGCHLD, &sa, NULL); pid_t pid = fork(); if (pid == 0) { // 子进程 exit(42); } // 父进程持续运行 for (;;) { sleep(1); } return 0; }注意这里用了while循环配合WNOHANG反复调用waitpid(),原因仍然是信号不排队的问题:如果瞬间多个子进程退出,可能只触发一次 SIGCHLD,如果处理函数里只waitpid()一次,就可能漏收僵尸。循环回收直到返回 0(没有更多退出的子进程)才是稳妥做法。
SA_NOCLDSTOP标志的意义是:只关心子进程退出,不关心子进程因信号被暂停(SIGSTOP/SIGTSTP)的事件,避免无谓的唤醒。
4.3 Shell 脚本中的 trap 陷阱命令
信号处理不只是 C 语言的专利。写 Shell 脚本时,trap命令是你最好的朋友。它的基本用法是:
trap 'echo "catch signal"' INT TERM意思是:当脚本收到 SIGINT 或 SIGTERM 时,执行echo。更常用的场景是清理临时文件:
#!/bin/bash tmpfile=$(mktemp) trap 'rm -f "$tmpfile"; echo "cleanup done"; exit 1' INT TERM EXIT echo "tmpfile: $tmpfile" while true; do sleep 1 done这里我把EXIT也放进了 trap 列表。EXIT是 Bash 扩展的伪信号,脚本无论正常结束还是异常退出,都会触发该 trap。这样就能保证临时文件一定会被清理,不会残留在 /tmp 里。
有几个 trap 的细节值得强调:
- 如果脚本里启动了后台子进程,
kill脚本主进程不会自动传递信号给子进程。要妥善处理,可以在 trap 里显式kill后台任务的 PID。 - 如果希望忽略某个信号,trap 目标可以写空字符串:
trap '' INT,此时进程收到 SIGINT 不做任何事。这在写一些“不可中断”的安装脚本时很有用。 - trap 字符串中的变量展开时机很微妙。用单引号包裹时,变量在 trap 执行时才展开;用双引号包裹时,变量在 trap 设置时就展开。初学者在这个细节上容易踩坑,建议统一用单引号。
5. 常见问题与排查技巧实录
5.1 信号丢失:非实时信号的排队问题
这是最经典的“幽灵问题”。现象是:程序明明收到多次信号,处理函数只执行了一两次。原因我在信号生命周期里说过了——标准信号用位图维护 pending 状态,相同信号不排队。
但这背后还有一个进阶场景:处理函数还没执行完,新信号又来了。此时如果新信号被阻塞(比如正在处理该信号),它会停留在 pending 位图上;处理函数返回后,这个信号就会被递送。如果期间又来了好几个同种信号,位图还是只记录“有一个”,处理函数只会再执行一次。
所以,凡是想通过“信号次数”来驱动逻辑的设计,都要重新审视。我的经验是:信号只当“敲门砖”用,真正的信息装在文件、管道或共享内存里。收到信号后去读数据,数据有多少就处理多少,不依赖信号本身的次数。
5.2 EINTR:被信号打断的系统调用
这是一个让很多人挠头的错误。你写了一个阻塞式read(),等待客户端发送数据,然后程序收到一个信号,处理函数执行完后,read()返回了 -1,errno被设置为EINTR。
为什么?因为系统调用在等待期间被信号中断,内核会先切换到信号处理函数,等处理函数返回后,系统调用是否继续由 SA_RESTART 标志决定。如果设置了这个标志,内核会自动重新启动系统调用,对应用无感知;如果没有设置,系统调用返回EINTR,必须由应用自己决定要不要重试。
这种错误在 Java 里体现为InterruptedException,在 C 里就是EINTR。解决方式:
- 在
sigaction()里设置sa_flags |= SA_RESTART。大多数情况下,这能让read()、write()、wait()这类系统调用自动重启。 - 对于某些不支持自动重启的系统调用(如
poll()、epoll_wait()、nanosleep()),即便设置了 SA_RESTART 也可能返回 EINTR。这时必须手动重试:
do { ret = epoll_wait(epfd, events, maxevents, timeout); } while (ret < 0 && errno == EINTR);我在生产环境里排查过一起“偶发请求超时”,最后定位就是epoll_wait()被周期性信号中断,返回 EINTR 后代码直接 break 出循环,导致这一轮的连接没有被及时处理。修复方式就是加了一个 EINTR 重试循环,超时问题彻底消失。
5.3 死锁与重入:在信号处理函数里别乱调函数
这条经验,我愿称之为“信号处理的铁律”。信号处理函数执行时,主流程可能正处于一个随机的位置——可能刚给某个互斥锁加了锁,可能正在malloc()内部操作堆的内存,可能正在调用某个库函数的中间步骤。如果处理函数里也调用了printf()(内部可能加锁)、malloc()、free()、pthread_mutex_lock()这类非异步安全函数,就可能发生:
- 死锁:处理函数想拿主流程已经拿住的锁,而主流程被信号打断,永远不释放。
- 堆损坏:
malloc()是线程不安全的(且有内部锁),处理函数里再调malloc(),极可能破坏堆元数据,产生随机崩溃。
POSIX 明确规定,在信号处理函数里可以安全调用的函数是有限的,统称“异步安全函数”(async-signal-safe)。常见的有:
write()、read()、open()、close()_exit()(注意不是exit())kill()、getpid()、sigaction()waitpid()(配合 WNOHANG)
而不安全的包括:printf()、malloc()、free()、pthread_*、syslog()等。
结合我自己的实践,最稳妥的模式是:处理函数里只设置标志位(sig_atomic_t),主循环里再执行真正的业务逻辑。 如果非要立即响应,也只用
write()往管道里写一个字节,主循环用poll()监听管道可读,再去做重活。
5.4 排查信号问题的实用工具
最后给出一套我常用的信号问题排查工具链,遇到“疑似信号问题”时按顺序排查:
kill -l:列出系统支持的全部信号及编号。记不住编号时的查表神器。/proc/<pid>/status:查看进程的信号相关信息。关键字段SigPnd(挂起的信号)、ShdPnd(进程级挂起)、SigBlk(阻塞的信号)、SigIgn(忽略的信号)、SigCgt(捕获的信号),以十六进制位图显示。我曾经用这个确认过一个进程“是不是漏了 SIGTERM 的捕获”——一看SigCgt位图就真相大白。strace -p <pid>:跟踪系统调用,能直观看到信号打断系统调用的现场。-e trace=signal可以只追踪信号相关调用。gdb调试时,用handle SIGSEGV stop设置断点,排查段错误信号。
一次排查“进程收不到 kill 信号”的过程,我很推荐新手复现一下:起一个 sleep 进程,手动编辑/proc/<pid>/status是做不到的(权限不允许),但如果进程阻塞了信号,SigBlk对应位会是 1。你会发现,kill一个阻塞了 SIGINT 的进程,进程确实不会终止——不是信号丢了,而是它被阻塞在 pending 队列里了。
写在最后的一点个人体会
开头提到的那个线上“假死”事故,最后修复其实只改了三行代码:给sigaction加上SA_RESTART,给epoll_wait加上 EINTR 重试。但这三行代码背后的理解成本,是好几本 Linux 编程书和无数次踩坑攒下来的。信号处理就是这样——平时静默无声,像风一样让人感觉不到存在,可一旦你在不合适的地方做了不合适的事,它就能让整个系统在“风中凌乱”。
我个人最大的心得是:对信号保持敬畏,但不必恐惧。把“信号只做通知、不做业务”这个原则焊死在脑子里,再熟练运用sigaction的几个关键标志位,绝大多数信号相关的问题都可以在设计阶段就规避掉。如果你正在做一些网络服务、守护进程或者容器相关的工作,强烈建议亲手写一遍这篇文章里的小例子——不是背接口,而是把信号在你脑子里的“运行模型”搭起来。模型通了,代码就通了。