在写多进程 TCP 服务器的时候,accept()返回EINTR恐怕是新手和老手都绕不过去的一个老熟人。很多人第一次遇到它时,一脸茫然:明明监听 socket 一切正常,客户端也确实发来了连接,为什么accept()就是不肯把连接交给我,反而甩了一个 "Interrupted system call" 的错误?有些人在循环里简单加了句continue就以为万事大吉,结果服务器在高并发或频繁收到 SIGCHLD 信号时表现诡异——要么进程卡死不退出,要么 CPU 占用飙升。
这篇文章我想把自己的理解完整梳理一遍,重点聊聊多进程服务器中accept()被信号中断(EINTR)的处理机制:从信号中断系统调用的底层原理,到常见错误写法为什么不对,再到生产环境里可以直接抄作业的处理模板,最后分享几个我用 strace 和 gdb 排查这类问题的实战经验。如果你是刚接触 Linux 网络编程,或者正在维护一个线上多进程服务,这篇文章应该能帮你省不少踩坑的时间。
1. EINTR 为什么会出现在 accept() 上
1.1 信号中断系统调用的底层机制
在 Unix/Linux 系统里,系统调用并不是“铁板一块”地原子执行完。当进程阻塞在内核态等待某个条件时,比如accept()等待接入的连接、read()等待数据到达,外部信号一旦送达,内核会立刻把进程从中断或睡眠状态唤醒,强制返回用户态去执行对应的信号处理函数。
这里有个关键点:信号处理函数执行完之后,进程可以选择“回到刚才中断的地方继续执行”,也可以选择“让系统调用直接返回错误”。这种选择取决于信号处理函数的属性,更准确地说是取决于安装信号时是否携带了SA_RESTART标志。如果没有这个标志,内核会让被中断的系统调用直接返回-1,并将errno设置为EINTR。于是你的accept()就成了那个倒霉蛋,明明连接已经在内核的完成队列里了,但它还是只能带着错误回到用户态。
从层次上理解,可以把accept()看成一次“等待-交接”操作:内核先等待连接队列非空,再取出一个连接并生成新的 socket 文件描述符。这个过程不是瞬间的,所以它属于慢系统调用。慢系统调用在阻塞期间被打断,就会产生 EINTR。不只是accept(),read()、write()、select()、poll()、epoll_wait()、connect()等对 I/O 阻塞等待的系统调用都可能返回 EINTR。
1.2 多进程服务器的特殊信号环境
单进程服务器当然也会遇到 EINTR,但多进程服务器遇到它的概率和复杂度要明显高出一截,原因在于信号源更多了。
最常见的信号是SIGCHLD。父进程在accept()上阻塞等待时,如果某个子进程恰好退出,内核会向父进程发送SIGCHLD。如果父进程没有忽略这个信号,它会立刻打断accept()。而多进程服务器里子进程随机退出几乎是家常便饭,尤其是长连接服务中客户端断开频繁的时候,SIGCHLD可能在一个很短的时间窗口内连续到达,accept()被反复打断。
其次是运维信号。比如通过kill -USR1 pid通知进程重新加载配置、kill -HUP pid要求重新打开日志文件、kill -TERM pid要求优雅退出。这些信号都会在你完全没有防备的时候拍进来。只要信号处理函数没有携带SA_RESTART,正在阻塞的accept()就会立刻“举手投降”,返回 EINTR。
所以,在多进程服务器里处理 EINTR 并不是一个独立的逻辑,它和信号处理策略、子进程回收、进程生命周期管理强耦合。这也是很多教程只讲“加个continue重试”远远不够的原因。
2. 处理 EINTR 的两种经典方案
2.1 手动循环重试:最简单但最容易写错
最直观的做法是,发现accept()返回 EINTR 后重新调用一次。绝大多数入门代码会写成这样:
while (1) { int connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen); if (connfd == -1) { if (errno == EINTR) { continue; // 被信号打断,重试 } perror("accept"); break; } handle_connection(connfd); }从“系统调用被信号打断,重试一次”的角度看,这段代码没错。但放到真实的生产环境里,它至少有两个隐患。
第一个隐患是:如果服务器本来打算在收到退出信号后优雅终止,而accept()恰好也被这个信号打断了,那么continue会让你永远停在这里重试,退出信号被白白消耗掉。比如在信号处理函数里设置了一个g_stop_flag = 1,准备让主循环退出,结果accept()因为SIGTERM返回 EINTR,而你无脑continue,那么g_stop_flag永远不会被检查到,进程就陷入“想退退不了”的状态。
第二个隐患是:continue把“系统调用被打断”和“业务状态需要变更”两件事完全剥离开。信号处理函数里通常不只是设置标志位,还可能回收了子进程、切换了日志 fd、重新加载了配置。这些副作用发生之后,你其实应该在重新accept()之前先检查一下全局状态,而不是直接continue回到原点。
所以,手动循环重试不是不行,而是要带着全局状态去重试:
while (!g_stop_flag) { int connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen); if (connfd == -1) { if (errno == EINTR) { continue; // 注意:每次 continue 前都会检查 g_stop_flag } perror("accept"); break; } handle_connection(connfd); }这个改进看起来很微小,但它确保了一个关键语义:信号处理函数先执行,然后accept()返回 EINTR,最后主循环在下一次迭代时立刻看到由信号处理函数修改的状态。如果你把g_stop_flag的判断放在continue之前或者放在循环条件里,这个语义才真正闭环。这也是后面我梳理“信号语义、重试边界与进程生命周期”时要重点强调的内容。
2.2 借助 sigaction 的 SA_RESTART 标志:系统帮你重启
第二种方案是把信号处理函数注册为可自动重启系统调用,也就是在sigaction中使用SA_RESTART:
struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = sig_chld_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; sigaction(SIGCHLD, &sa, NULL);加了SA_RESTART之后,内核会在信号处理函数返回后,自动重新启动被中断的accept(),也就是说accept()不会返回 EINTR,而是继续阻塞等待连接。对于 SIGCHLD 这类“不需要改变主流程”的信号,SA_RESTART确实是一种偷懒且有效的方式。
但这里有两个坑。第一个坑是SA_RESTART只对部分系统调用有效。accept()、read()、write()、recv()这些经典的慢系统调用通常可以自动重启,但select()、poll()、epoll_wait()、sem_wait()等系统调用即使加了SA_RESTART也不会自动重启,它们照样会返回 EINTR。如果你在服务器里用的是epoll_wait()而不是accept()阻塞,那这个标志根本救不了你。
第二个坑是SA_RESTART会掩盖信号处理中对业务语义的需求。举个例子,你用SA_RESTART处理SIGTERM,信号处理函数里设置了g_stop_flag = 1,然后内核自动帮你重启了accept(),那这个标志位什么时候才会被检查到?答案是只有当一个新的连接真正到达,accept()返回之后,循环体才有机会看到g_stop_flag。在低并发或者空闲时段,这个“延迟退出”可能就是无限延迟,服务器就像吊着一口气迟迟不咽。这种场景下,SA_RESTART就不是省事,而是帮倒忙。
所以我的建议是:SIGCHLD 这类纯通知型信号,考虑使用SA_RESTART;SIGTERM/SIGINT 这类需要改变进程生命周期的信号,要么不设SA_RESTART,要么在主循环里把g_stop_flag的检查放到最显眼的位置,绝不能让自动重启把退出路径堵死。
3. 深入解析信号语义、重试边界与进程生命周期
3.1 “重试一次”的语义在哪里结束
很多人把 EINTR 的处理简化为“被打断就重来”,但没有思考一个更本质的问题:什么情况下应该重来?什么情况下应该放弃重来,去做其他事情?
我习惯把 EINTR 理解成一次“强制上下文切换的通知”。内核告诉你:刚才有个信号来了,我已经执行了信号处理函数,现在我把控制权交还给你。至于你怎么做,取决于这次信号处理函数改了什么。
最经典的例子是结合 SIGCHLD 回收子进程。父进程阻塞在accept()上时,一个子进程退出了,内核送来 SIGCHLD。如果你的信号处理函数里只写了一句waitpid(-1, &status, WNOHANG),那没问题,回收完僵尸进程后继续accept()即可。但如果你的处理函数里不仅回收了子进程,还统计了当前子进程数量、清理了相关资源,那么重新accept()之前你应该重新审视整个服务器的状态,而不是机械地重试。
举个例子,一个按需 fork 子进程的服务器,信号处理函数可能做了这样的逻辑:回收子进程后,如果发现当前空闲进程数不够,就立即 fork 一批补上。那这个新的进程信息、状态字段都在信号处理函数里发生了变化。此时你直接continue回到accept(),看起来也没问题,但假如服务器设计是“一旦收到 SIGCHLD,就需要重新计算负载、调整监听策略”,那么重试前至少应该检查一下这些状态是否还满足继续accept()的条件。
用生活化的类比解释:你正在柜台排队办业务,突然有个紧急电话打进来(信号),你接了电话,处理完电话内容,发现队伍前面的人又多了几个。你是直接回到队伍里继续排,还是先抬头看看当前窗口的营业状态?EINTR 重试也一样,continue只是“回到队伍里”,但它没有回答“营业状态是否仍然正常、你是否还需要继续排”的问题。
3.2 中断时 pending 状态的细节:handler 先执行还是 errno 先设置
在 POSIX 语义里,信号触发后,系统调用被中断,流程是这样的:进程从内核态返回用户态,先执行信号处理函数,然后系统调用返回 EINTR。换句话说,当你在用户态看到accept()返回 -1 且errno == EINTR时,信号处理函数已经把该做的事情都做完了。
这个顺序常常被忽略,但它其实是一个强大的设计:你可以在信号处理函数里设置一些变量(比如g_stop_flag、g_child_exited、g_reload_cnt),然后在主线程看到 EINTR 之后,第一时间检查这些变量,决定是重试、退出还是执行其他逻辑。这是“信号处理函数 + 主循环状态协作”的基础。
还需要注意信号 mask 的细节。在执行信号处理函数期间,当前信号通常会被自动加入进程的信号屏蔽集合(取决于sigaction的sa_mask设置),防止嵌套触发同一信号。如果另一个信号在第一个信号处理函数尚未结束时到达,那么这个信号会变成 pending 状态,待当前信号处理函数返回后再递送。这意味着accept()可能连续两次返回 EINTR——一次是给第一个信号,一次是给第二个信号。如果你的循环只处理一次就完事,可能漏掉第二次。
3.3 accept() 之外:还有哪些慢系统调用同样会被 EINTR 影响
处理 EINTR 时,只盯着accept()还不够。多进程服务器里还会遇到大量其他系统调用,它们的 EINTR 语义各不相同。我列一个常用的表格:
| 系统调用 | 是否支持 SA_RESTART 自动重启 | 重试注意事项 |
|---|---|---|
accept() | 支持 | 重试前检查全局状态,注意连接队列可能已经变化 |
read()/write() | 支持 | 对非阻塞 fd 不存在 EINTR;对阻塞 fd,注意部分读写需要结合返回值处理 |
select() | 不支持 | 重试只能重新设置 fd 集合,耗时且容易遗漏事件 |
poll() | 不支持 | 同 select,且超时时间会被重置,实际等待时长可能小于预期 |
epoll_wait() | 不支持 | 重试时注意maxevents、epoll fd 的事件状态,不建议死循环重试 |
connect() | 支持但特殊 | 不能简单重启,非阻塞 connect 返回 EINTR 后需要检查SO_ERROR,否则状态机容易错乱 |
sem_wait() | 不支持 | 需要根据业务决定是重试还是退出 |
这里面最坑的是connect()。它和accept()不同,accept()重试是无状态的,连接还在完成队列里等着呢,重新accept()一次就好。但connect()一旦返回 EINTR,内核可能已经开始建立连接了,如果你粗暴地重试,可能导致两次connect()同时操作同一个 socket,状态完全不可控。正确的做法是对非阻塞 socket 使用poll()/select()等待可写事件,然后通过getsockopt(fd, SOL_SOCKET, SO_ERROR, ...)确认连接是否真正建立成功。
再说epoll_wait()。现在很多多进程服务器并不直接用accept()阻塞,而是在epoll_wait()之后调用非阻塞accept()。这种情况下,epoll_wait()返回 EINTR 的可能性依然存在,而且由于它以事件驱动为核心,单纯continue很容易导致忙等。我会在后面的实践模板里给一个更贴近epoll场景的方案。
4. 多进程服务器中的实践案例:从代码层面彻底搞定 EINTR
4.1 一个典型的单次 accept 循环的错误示范
先给一个标准错误模板,我见过不少线上事故都长这个样:
static void on_sigchld(int signo) { int saved_errno = errno; while (waitpid(-1, NULL, WNOHANG) > 0) { // 回收子进程 } errno = saved_errno; } int main() { signal(SIGCHLD, on_sigchld); // 错误示范:使用 signal() 而非 sigaction() // 省略 socket 创建 while (1) { int connfd = accept(listenfd, NULL, NULL); if (connfd == -1) { if (errno == EINTR) { continue; } perror("accept"); break; } // 处理连接... } }这个模板看起来把 EINTR 处理了,但实际有两个隐患。
隐患一:signal()的语义在不同 Unix 系统中并不一致。在 SysV 派生的系统上,signal()调用后,信号处理函数被重置为默认行为,而且不保证自动重启系统调用。如果你在 Linux 上跑还好,但换到其他 Unix 环境,同样的代码可能直接导致accept()被中断后信号处理函数被重置,下一次网络请求就变成默认终止进程了。
隐患二:continue前没有检查任何与进程生命周期相关的标志位。如果测试人员执行kill -TERM 进程号,并且 SIGTERM 默认是终止进程,那么这个模板只会被 SIGTERM 直接干掉,谈不上优雅退出。真正要注意的是当 SIGTERM 被捕获后,服务器想退出,但continue让主循环根本停不下来。
4.2 一套适合放入生产环境的处理模板
在生产环境里,我更倾向于写一个显式状态机风格的循环,把所有信号处理函数设置到主循环读取的全局变量上,再集中处理 EINTR:
#include <signal.h> #include <errno.h> #include <sys/socket.h> #include <sys/wait.h> #include <stdbool.h> static volatile sig_atomic_t g_stop_flag = 0; static volatile sig_atomic_t g_reap_child = 0; static void on_term(int signo) { g_stop_flag = 1; } static void on_chld(int signo) { g_reap_child = 1; } static void reap_children(void) { int status; while (waitpid(-1, &status, WNOHANG) > 0) { // 回收一个子进程 } g_reap_child = 0; } static int create_listen_socket(void) { // 创建、绑定、监听,返回 listenfd } int main(void) { struct sigaction sa_term; memset(&sa_term, 0, sizeof(sa_term)); sa_term.sa_handler = on_term; sigemptyset(&sa_term.sa_mask); sa_term.sa_flags = 0; // 故意不设 SA_RESTART,让 accept 能被 SIGTERM 打停 sigaction(SIGTERM, &sa_term, NULL); sigaction(SIGINT, &sa_term, NULL); struct sigaction sa_chld; memset(&sa_chld, 0, sizeof(sa_chld)); sa_chld.sa_handler = on_chld; sigemptyset(&sa_chld.sa_mask); sa_chld.sa_flags = SA_RESTART; // SIGCHLD 只做标记,可以自动重启 sigaction(SIGCHLD, &sa_chld, NULL); int listenfd = create_listen_socket(); while (!g_stop_flag) { if (g_reap_child) { reap_children(); } struct sockaddr_storage cliaddr; socklen_t clilen = sizeof(cliaddr); int connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen); if (connfd == -1) { if (errno == EINTR) { // 被信号打断:先处理因为信号而产生的待办项 // 然后回到循环顶部,重新检查退出标志和子进程回收标志 continue; } if (errno == EMFILE || errno == ENFILE) { // 进程 fd 耗尽,这种情况要特殊处理 // 不能简单重试,否则可能忙等 sleep(1); continue; } if (errno == ECONNABORTED) { // 客户端连接被意外终止,直接重试 continue; } perror("accept"); break; } // 这里把 connfd 交给 worker // 有两种分支:进程 fork 或者线程池 pid_t pid = fork(); if (pid == 0) { // 子进程里关闭监听 fd,处理连接 close(listenfd); handle_connection(connfd); _exit(0); } close(connfd); } // 退出前统一回收子进程 reap_children(); close(listenfd); return 0; }这个模板的关键设计有三个。
第一,SIGTERM/SIGINT 故意不设SA_RESTART,目的是让信号可以直接打断accept(),从而让主循环能快速感知到退出标志。如果在循环里再写一个sleep(1)辅助等待,退出时也不会等太久。
第二,SIGCHLD 使用SA_RESTART并设置g_reap_child标记。因为SA_RESTART让accept()不再返回 EINTR,信号处理函数本身只做标记,真正回收子进程的操作放在主循环里做。这种模式避免了在信号处理函数里调用waitpid可能带来的复杂性和重入问题,也让主循环有机会统一管理状态。
第三,在重试前统一处理g_reap_child。每轮循环先检查是否设置了回收标记,然后一次性把所有僵尸子进程回收干净。这样即便某个信号没有触发SA_RESTART而是返回了 EINTR,主循环也能在下一次迭代中执行回收逻辑。
你可能注意到这里同样用了continue,但它与错误示范里最大的区别是continue回到了一个能被信号感知的循环顶部,而不是简单地回到accept()调用处。这个循环顶部承载了所有信号处理函数产生的待办项。
4.3 结合 SIGCHLD 的僵尸进程回收
在 fork 型多进程服务器里,SIGCHLD 和 accept() 更像一对冤家:子进程退出越频繁,SIGCHLD 信号越多,accept() 被 EINTR 打断的次数也越多。如果你不处理 SIGCHLD,那么僵尸进程会以肉眼可见的速度堆积;如果处理了但没处理好,waitpid回收逻辑可能和accept()的重试互相干扰。
一个经常被忽略的问题是在信号处理函数里直接调用waitpid的时机。虽然 POSIX 保证waitpid是异步信号安全函数,但如果你在信号处理函数里做太多事情,比如同时回收多个子进程并更新统计信息,那么主流程看到 EINTR 之后再做自己的状态计算时,很容易出现数据不一致。解决办法就是上面模板的做法:信号处理函数只设置g_reap_child,所有回收动作全部在主循环的上下文里执行。这样一个信号处理函数总共只做两件事:保存旧errno、赋值一个sig_atomic_t变量,简单到不可能出错。
此外,waitpid(-1, &status, WNOHANG)的循环条件也值得打磨。有些代码写的是:
waitpid(-1, NULL, WNOHANG);只调用一次waitpid,这在子进程批量退出时会漏掉一部分僵尸进程,导致下次 SIGCHLD 到来之前它们一直留在进程表里。正确的写法是上面模板里的 while 循环,WNOHANG保证没有子进程退出时立即返回 0,循环自然结束。一次信号处理周期内,把所有已退出子进程全部收干净。
4.4 如何通过 strace 和 gdb 验证 EINTR 处理逻辑
代码写完,怎么验证 EINTR 的处理逻辑是有效的?总不能靠运气等着信号随机砸下来。我常用的方法有两个:strace跟踪系统调用,以及gdb主动注入信号。
用strace可以观察accept()在被信号打断时的真实行为。假设服务器启动后监听在8888端口,PID 是 1234,那么执行:
strace -p 1234 -e trace=accept,read,write,wait4 -e signal=SIGCHLD,SIGTERM,SIGUSR1-e signal=可以只跟踪关心的信号,避免输出刷屏。当你从另一个终端向服务器发送kill -USR1 1234或kill -CHLD 1234时,strace会直接显示系统调用的返回值。配合-e trace=accept,你能清楚看到accept(3, ...) = -1 EINTR (Interrupted system call)或者= 4这样的真实结果。如果你用的是SA_RESTART方案,则不会出现 EINTR 行,而是直接返回新的连接 fd,说明自动重启生效了。
用gdb则可以更主动地模拟中断。先让服务器运行起来,然后在 gdb 里给指定线程发送信号:
gdb -p 1234 (gdb) handle SIGUSR1 stop print (gdb) call kill(1234, SIGUSR1)或者更直接一点,在accept函数上打一个断点,然后手动修改errno并让系统调用返回 -1,模拟内核被信号打断的效果。不过这种方法对信号语义的模拟不够真实,我更推荐直接在另一台终端里kill -USR1,然后在 gdb 里设置条件断点,比如break accept if errno == EINTR,观察程序走到哪里。
还有一种更符合多进程场景的验证方法:让服务器处理大量短连接,客户端连上后立刻断开。短连接会频繁 fork 子进程、触发 SIGCHLD,你可以观察服务器的accept()日志中是否有 EINTR 被正确重试的记录,同时检查/proc/pid/status里的SigCgt和僵尸进程数量。如果僵尸进程数量持续上涨,说明回收逻辑有问题,很可能和 EINTR 处理顺序有关。
5. 常见问题与排查技巧实录
5.1 为什么加了一层循环之后 CPU 占用率还是很高
典型的症状是服务器在空闲状态下,单核 CPU 占用持续飚到 100%。先别急着怀疑accept()循环本身,大多时候是 EINTR 发生时重试太“着急”了。
当信号处理函数把g_stop_flag设为 1,主循环本应立即退出,但因为某种原因你写成了 while 死循环直到g_stop_flag为 0 才退出,结果就是退不出去。另一种情况是accept()被 EINTR 打断后,连接队列是空的,你立刻continue重新调用accept(),此时内核很快又让你阻塞,按理说不会忙等。但如果你在处理 EINTR 的分支里做了其他 I/O,或者使用了poll()、epoll_wait()且因为 EINTR 反复重置timeout,那么每一轮循环都可能秒返回,CPU 自然就满了。
我排查这类问题时的步骤是:先看strace -c统计系统调用高频项,如果发现gettimeofday()、clock_gettime()等调用次数异常多,基本可以断定某个循环在忙等;然后看 EINTR 发生频率,如果每秒几十次,说明信号源太多了,可以考虑用signalfd将信号处理从“中断模型”换成“事件模型”。
说到signalfd,这其实也是一个值得尝试的方案:把 SIGCHLD 等信号统一收进一个 fd,用epoll或poll来读取信号事件,彻底绕开“信号打断慢系统调用”的问题。多进程服务器里,把信号处理收编进事件循环后,EINTR 就不再是常态了。不过signalfd并不能完全消除 EINTR,比如accept()本身仍可能被其他未捕获的信号打断,所以该做的重试检查还是不能少。
5.2 为什么加了 SA_RESTART 之后依然会看到 EINTR 错误
这个问题不少人都踩过。明明SIGCHLD信号处理函数已经加了SA_RESTART,accept()还是偶发返回 EINTR。原因通常有两个。
第一个原因是SA_RESTART并没有覆盖所有被中断的系统调用。正如前面表格里列的,poll、select、epoll_wait、sem_wait都不支持自动重启,哪怕信号处理函数装了SA_RESTART,这些调用照样返回 EINTR。如果你的服务器主循环其实不是accept()阻塞,而是epoll_wait()阻塞,只是epoll_wait()返回后立即调用了非阻塞accept(),那你看到的 EINTR 其实是epoll_wait()返回来的,而不是accept()本身。这种情况下,给SIGCHLD加SA_RESTART毫无作用。
第二个原因是信号处理函数可能在accept()返回 EINTR 之前又触发了另一个信号。比如某个信号处理函数执行过程中,一个子进程刚好退出,内核在信号处理函数返回后再次递送 SIGCHLD,于是accept()又被打断。这种“连续信号”情况下,SA_RESTART只能保证内核自动重启一次,第二次中断依然会返回 EINTR。所以哪怕你全用了SA_RESTART,主循环里也必须留一个 EINTR 的分支,只不过这个分支通常只需要continue,不需要太多额外处理。
这里我建议把accept()的 EINTR 处理和信号处理函数严格分开来看:SA_RESTART是减少 EINTR 的手段,而不是消灭 EINTR 的手段。生产代码里,永远保留对 EINTR 的检查,这是系统调用层面的“防御性编程”。
5.3 信号处理函数中的 write 和锁的操作风险
在信号处理函数里做太多事情是新手常犯的错误。比如为了调试,在信号处理函数里直接printf,或者在处理函数里对共享线程池加锁。这些操作在同步信号的情况下风险极大,因为信号处理函数会打断主线程的任何代码路径,如果主线程正在持有同一个锁,信号处理函数又去抢锁,就死锁了。
对于多进程服务器,我的原则是:信号处理函数只做一类事——修改volatile sig_atomic_t全局标志,或者直接write()一个已经打开的自管道 fd。在 fork 型服务器中,我更倾向于只设置标志位,所有复杂操作都放回主循环做。这样 EINTR 多起来也不会和信号处理函数内部逻辑纠缠在一起。
另一点是关于errno的保护。信号处理函数内部如果调用了write()、waitpid()等函数,它们自身也可能会改变errno,而主流程看到 EINTR 后通常要检查errno,所以一个标准做法是进入信号处理函数时先保存旧errno,退出前恢复。这个细节我在前面的模板中已经体现了,但值得单独拿出来强调一遍,因为它太容易被忽略,一旦忘了,你会在主循环里看到极其诡异的问题:errno凭空从 EINTR 变成了 ECHILD 或 ENOMEM。
5.4 测试工具的搭配建议
最后分享一个小组合。我通常在验证 EINTR 处理逻辑时,用 strace 记录线上故障现场,用 gdb 验证本地复现,用perf统计忙等热点,再用一个简单的客户端压测工具发起大量的短连接。这样一套下来,信号中断相关的 bug 基本都能定位。
另外,自己在本地测试时,可以用kill -USR1 pid模拟信号打断,也可以在代码里临时加一个定时器信号setitimer或者timer_create,比如每 10 毫秒触发一次 SIGALRM,强制制造 EINTR 风暴,观察主循环是否能稳如泰山。我测试下来,挨过 EINTR 风暴之后的accept()重试和回收逻辑,才敢放到线上。
6. 从 EINTR 到信号驱动的事件循环演进
依赖accept()阻塞 + EINTR 重试,在很多场景下依然是好用的方案,但它的缺陷在于:每次 EINTR 都意味着一次内核态/用户态切换,而且信号处理函数和主循环的状态同步完全靠手工维护。当你的服务器业务规模变大、信号种类变多时,这套“被信号打断—重试—检查标志”的模式会越来越别扭。
我个人在维护一个高并发网关时,最终把信号处理从“打断模型”换成了“事件模型”:用signalfd把所有关心的信号统一收集为普通事件,交给epoll_wait()去驱动。具体做法是:
sigset_t mask; sigemptyset(&mask); sigaddset(&mask, SIGCHLD); sigaddset(&mask, SIGTERM); sigprocmask(SIG_BLOCK, &mask, NULL); int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);然后epoll_wait()同时监听 listenfd 和 sfd。当信号到达,epoll_wait()返回,你从 sfd 里读到signalfd_siginfo结构体,就能明确知道是哪个信号、由谁触发、当前状态如何。这比在信号处理函数里猜状态要清晰得多。而且signalfd之后,SIGCHLD 等信号不会再打断epoll_wait(), EINTR 的发生频率直线下降。
但要注意,signalfd方案并不会取代 EINTR 重试,因为进程可能仍会收到其他你不想 block 的信号,比如调试器附加时的SIGTRAP,或者kill -USR1这类你愿意让它打断主循环的信号。处理 EINTR 分支依然是必要的兜底逻辑。
从整个演进过程看,处理 EINTR 的深层价值并不在于那一个错误码,而在于你对“慢系统调用与异步事件”之间关系的理解水平。只要你选择了阻塞式系统调用,就必须面对信号中断的边界;只要选择了信号驱动或事件驱动,就必须把所有中断源统一抽象。我在实际项目中最常踩到的坑,恰恰不是代码逻辑本身写错,而是对信号和 EINTR 的边界语义理解浅了一寸,导致 corner case 里的行为差出千里。
最后再说一个很实用的经验:无论你采用哪种方案,一定要在代码注释或设计文档里写明“本进程如何处理 EINTR”,尤其是确认了哪些信号设置了SA_RESTART,哪些没有,以及为什么。这个看似小到不值得写进文档的细节,恰恰是后来人排查难以复现的偶发问题时最重要的线索。我自己就经历过一次:几个月后另一个同事接手,看到SIGCHLD用了SA_RESTART,顺手把SIGTERM也加上了SA_RESTART,结果服务器优雅退出的功能直接失效,查了半天才想起当初故意让 SIGTERM 打断accept()来快速感知退出标志的设计意图。
回过头来看,accept()遇上 EINTR 从来都不是一个“加个 continue 就好”的问题。它背后是信号、系统调用、多进程生命周期三者之间如何协作的设计问题。把这一小块吃透,你写出来的多进程服务器,才算真正经得住生产环境的捶打。