多进程服务器里跑着几十个子进程,父进程一个accept()阻塞在监听套接字上,突然返回 -1,errno一看是EINTR。这种画面凡是写过 C/Socket 服务的人多少都见过:不是网络断了,不是客户端没来,而是某个信号恰好在这个节骨眼上把阻塞的系统调用打断了。处理不好,轻则多写几行不痛不痒的重试代码,重则在日志里刷出满屏假错误,甚至让整个服务在信号风暴里反复空转。
这篇东西会把EINTR的来龙去脉、多进程场景下哪些信号特别容易捣乱、以及我从实际项目里换来的处理经验和坑,一次讲透。适合正在写多进程网络服务、或者被这类偶发-1困扰过的人参考,看完你大概能少走很多弯路。
1. 先搞清楚 EINTR 在系统调用层面的位置
1.1 慢速系统调用与信号的中断机制
EINTR全称 Interrupted system call,中文常叫"系统调用被信号中断"。问题出在"慢速系统调用"这类阻塞型操作上。像accept()、read()、write()、wait()这类调用,可能在很长一段时间内都处于阻塞状态,等着某种条件满足。accept()等的是新连接到来,read()等的是对端发送数据。
当进程阻塞在这种调用里的时候,信号到达了。内核不会默默等这个信号被处理,它会立刻把当前正在阻塞的进程拉起来,切换到用户态去执行信号处理函数。信号处理函数执行完,返回时,进程需要决定刚才那个阻塞的系统调用还要不要继续等下去。内核在这里给了一个非常关键的设计选择:直接返回-1并把errno设成EINTR,让应用层知道"你刚才被信号打断了"。这就是我们看到的那个"错误"的来历。
从内核角度看,accept()被信号打断并不是说监听套接字出了什么问题。连接队列还好好地在那边,新连接也没丢,等你重新调用accept(),那个等着被接受的连接还是会立刻取出来。所以EINTR本质上不是套接字错误,而是内核强制把控制权还给了你的应用代码。理解到这一步,遇到EINTR时你至少不该慌,更不该去 close 监听套接字。
1.2 EINTR 存在的理由:内核把控制权塞回给你
有人会问:为什么内核不直接把被打断的系统调用重新启动,让进程继续阻塞就完了?那样对写代码的人来说不是更省事吗?
答案在于:如果内核直接重启了系统调用,信号处理函数里设置的一些关键标志位,你的业务代码可能永远没有机会及时响应。举个最常见的例子:你用小工具给进程发一个SIGTERM,信号处理函数里把全局变量g_stop = 1;,期望主循环跳出、程序优雅退出。如果此时主循环正阻塞在accept()上,而内核又默默把这次accept()重启了,主循环会继续阻塞在accept()里,g_stop = 1完全没人去看,程序退不出去。你必须再发一个信号,才可能把进程从阻塞里踢出来,而且此时EINTR才能让主循环跑回检查g_stop的代码。
所以EINTR其实是一次"信号事件通知":内核通过一个异常返回,把控制权强行交回给应用层,让你有机会检查信号处理函数设置的标志位、做必要的清理,或者干脆退出。这个设计意图,是理解后续所有处理方案的前提。你要是把它当成一个纯粹的"错误",那么所有的处理姿势都是错的。
2. 多进程服务器里,谁在打扰你的 accept()
2.1 SIGCHLD:子进程退出是最常见的元凶
在多进程网络模型下,父进程通常只干两件事:调accept()接收新连接,把连接分发给子进程处理,或者父进程直接accept()之后fork()出一个子进程去处理。不管是哪种,子进程在某个时间点都会退出。
子进程一退出,内核就会向父进程发送SIGCHLD信号。这个信号几乎是多进程服务里打断accept()的头号选手。假设父进程此刻正阻塞在accept()上等待新连接,SIGCHLD到达后,父进程被拉起来执行信号处理函数,处理完返回时,accept()就以EINTR收场。
我之前维护过一个老项目,父进程没有设置任何针对SIGCHLD的处理,用的是默认行为。结果线上日志里隔几分钟就蹦出来一条accept: Interrupted system call,排查了一番才确认,根因就是偶尔有子进程异常退出,SIGCHLD打断了父进程的accept()。所以如果你发现自己服务的日志里EINTR出现频率和子进程退出频率高度吻合,基本可以锁定SIGCHLD是元凶。
2.2 定时器、外部通知与 SIGPIPE 的连带反应
除了SIGCHLD,还有一帮"潜在选手"也会触发EINTR。比如用setitimer()或alarm()设置的定时信号SIGALRM,到了时间点一样会把阻塞的accept()打断。再比如运维想要实现配置热加载,给进程发SIGHUP,或者架构上用一个SIGUSR1通知父进程重新拉起一批子进程,这些信号全都能打断accept()。
SIGPIPE的情况稍微绕一点。它通常和write()到对端已关闭的 socket 相关,但一旦进程收到SIGPIPE,默认行为是直接终止进程,这样反而不会产生EINTR。如果你用了signal()或者sigaction()屏蔽了SIGPIPE,又恰好这个信号在accept()阻塞期间到达——虽然少见,但技术上同样可能触发中断。所以不要觉得"我只关心连接处理,不关心信号"。
这里有个容易忽略的点:不只是"业务信号"会打断accept(),任何一个没有被屏蔽的、到达该进程的信号,在对应时刻阻塞的系统调用都会被打断。信号越多,EINTR出现的概率就越高。设计多进程服务时,一个很实用的原则是:尽量收窄进程里活跃信号的种类,能屏蔽的屏蔽掉,能不注册处理函数的就别注册,从源头上减少被中断的机会。
2.3 多进程场景下的另一个盲点:多个进程同时 accept
父进程派生出多个子进程,所有子进程同时阻塞在同一个监听套接字的accept()上,这是经典的 pre-fork 模式。这个模式下,EINTR发生得不比单accept()模型少,因为任何一个子进程收到信号,只影响它自己,但关键是:当一个连接到来时,内核只会唤醒等待队列里的某一个进程。如果那个被唤醒的进程正在被信号打断,没及时回来accept(),内核可能会唤醒另一个进程,这其实是内核的自动补偿。
真正要注意的是:EINTR在 pre-fork 模式下很可能叠加"惊群"现象,让多个进程同时从accept()中醒来,其中某些进程拿到EINTR,另一些可能拿到连接。当信号频率较高时,accept()会频繁产生EINTR,导致部分连接接受延迟。所以很多框架后来引入了accept4()以及EPOLLEXCLUSIVE等方式,目的就是减少这种无谓的竞争。不过对大多数场景来说,只要accept()里正确做了重试,性能影响是可以接受的。
3. 处理 EINTR 的两条路:自己重试 与 让内核重来
3.1 应用层封装:循环重试的标准写法
最直接、可移植性最好的处理方式,是在应用层把accept()包一层,遇到EINTR就重试。这也是我会在几乎所有项目里采用的做法,因为我吃过不求甚解的亏:只看文档说SA_RESTART能自动重启accept(),但没意识到SA_RESTART对某些系统调用不生效,结果换个模块就踩坑。与其依赖内核的承诺,不如自己在代码里把逻辑写死。
标准写法长这样:
int accept_with_retry(int listen_fd, struct sockaddr *addr, socklen_t *addrlen) { int conn_fd; while (1) { conn_fd = accept(listen_fd, addr, addrlen); if (conn_fd >= 0) { return conn_fd; // 成功拿到新连接 } if (errno == EINTR) { // 被信号打断,重新阻塞在 accept 上 continue; } if (errno == EAGAIN || errno == EWOULDBLOCK) { // 非阻塞模式下,当前无新连接 return -1; } // 其余情况属于真正的错误,比如 EMFILE、ENFILE、EBADF return -1; } }这段代码的核心逻辑只有一句:errno == EINTR就继续循环。我特意把EAGAIN和EWOULDBLOCK单独拎出来判断,是因为很多人会用非阻塞模式加超时,此时没有连接可接受是正常情况,不能死循环重试。如果是阻塞模式,EAGAIN基本不会出现,但写上无害,还能让函数更通用。
这里有个技术细节值得注意:先检查返回值,再检查errno。errno是一个线程全局的概念,虽然单线程或多线程下每线程有独立的errno,但调用成功时它并不会清零。如果你先看了errno发现有EINTR,再去检查返回值,可能在异常逻辑里把一次成功的accept()误判成失败。标准做法永远是先判断返回值conn_fd < 0,再深入解读errno。
3.2 sigaction 的 SA_RESTART:到底能不能省心
除了应用层重试,内核也提供了自动重启的开关。在注册信号处理函数时,通过sigaction()的sa_flags参数设置SA_RESTART,告诉内核:这个信号处理完,如果中断了某些系统调用,帮我自动把它们重启。
struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handle_sigchld; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; // 关键在这里 sigaction(SIGCHLD, &sa, NULL);设置之后,被SIGCHLD打断的accept()不会返回EINTR,内核会替你重新阻塞。看起来省心很多。
但事情没那么简单。SA_RESTART的语义在 POSIX 里是"可选的",不同的系统调用、不同的操作系统上表现并不完全一致。在 Linux 上,SA_RESTART对大部分线下场景是可靠的,但有几个著名的例外:poll()、select()、epoll_wait()这些多路复用调用,即使带SA_RESTART,也照样返回EINTR。这意味着如果你的主循环用的是epoll_wait()而不是accept(),那SA_RESTART帮不上太多忙。
对accept()本身来说,Linux 上的SA_RESTART是生效的。但我会建议不要把宝全押在这一项上,因为一旦你把代码迁移到别的平台,或者将来改用epoll_wait()统一事件循环,SA_RESTART的行为会变。最稳妥的组合是:注册信号处理函数时设置SA_RESTART作为兜底,同时accept()仍然保留重试逻辑。两者并存不会出问题:如果SA_RESTART生效,EINTR根本不会出现,重试逻辑闲置;如果SA_RESTART失效,重试逻辑立刻接管。这种双保险的设计,写代码时多花十秒钟,线上能省好多事。
3.3 两种方式怎么选:一张表讲清
| 对比维度 | 应用层循环重试 | SA_RESTART 自动重启 |
|---|---|---|
| 实现成本 | 需要自己封装,逻辑透明 | 注册信号时加一个 flag 即可 |
| 适用范围 | 所有平台、所有系统调用 | 只对部分系统调用生效,可移植性一般 |
对accept()的支持 | 完全可控 | Linux 上支持,但依赖平台实现 |
| 对后续切换 epoll 的影响 | 仍然适用 | 对epoll_wait()无效 |
| 风险点 | 重试逻辑写错会导致死循环或误判 | 如果信号处理里有全局状态的更新,自动重启会掩盖某些状态变化 |
| 我的建议 | 主推,必写 | 作为补充,不要依赖 |
我个人实践下来的结论很简单:应用层重试是通解,SA_RESTART是优化项,不能反过来。如果你是在一个还没落地的项目里设计网络层,直接封装重试函数,然后在信号注册处顺手写上SA_RESTART,这就是最省心的组合。
4. 线上排查 EINTR 的实操经验与翻车现场
4.1 把 EINTR 当成错误打日志,日志直接爆炸
我见过最典型的翻车现场,是把EINTR当作"严重网络错误"打日志,而且没有做频率限制。正常情况下,EINTR在业务量大的服务器里并不罕见,尤其是使用定时器信号配合短连接时,几分钟内就能积累成百上千条。日志系统本身又会产生磁盘 I/O,I/O 又触发调度,信号再打断更多的系统调用,形成恶性循环。整个服务 CPU 和磁盘都会被日志拖垮,真正的异常信息反而被海量假错误淹没。
正确的处理思路是:EINTR不应该出现在 ERROR 级别的日志里,最多在 DEBUG 粒度下记录,而且必须带计数、限制采样频率。我实际项目里的做法是:用一个单调递增的计数器统计EINTR次数,只有在一段时间内次数异常增长时才打一条 WARN。这个计数的增长趋势本身,反而能帮你判断是不是有信号频率异常的问题。
4.2 信号处理函数里碰了不安全的库函数
信号处理函数的限制经常被忽视。它只被允许调用"异步信号安全函数"(async-signal-safe),比如write()、read()、_exit()这类。像printf()、malloc()、free()、pthread_*这类内部存在锁或者共享状态的库函数,在信号处理函数里调用是有风险的。如果信号打断的恰好是一次malloc()内部操作,那么信号处理函数里再次调用malloc(),就可能造成死锁或堆内存结构损坏。
我早年踩过一次坑:在 SIGCHLD 处理函数里,为了调试直接用了printf()去打印子进程 PID 和状态。结果服务在高并发下频繁卡死,压测怎么都过不去,后来用 gdb 挂上去才发现是卡在 printf 内部的锁上。EINTR本身不会杀进程,但你在信号处理函数里做危险操作,会让整个进程陷入更麻烦的局面。信号处理函数里最安全的动作只有两个:给一个volatile sig_atomic_t标志位赋值,或者调用write()写一小段纯文本到日志 fd。
4.3 errno 判断的经典误用:不检查返回值只看 errno
一个很普遍的错误写法是:调用accept()之后没有先判断返回值,直接去看errno == EINTR。这种逻辑在错乱时会出现两个问题。第一,errno在成功时不会清空,可能残留上一次调用的旧值,导致误判。第二,信号处理函数里如果再调用任何会改变errno的库函数(比如write()),errno可能早就被覆盖了。所以嵌在信号打断的上下文里时,errno的可靠性就更加依赖"先判返回值再查 errno"这个顺序。
我建议在封装函数里,把errno暂存到局部变量:
int conn_fd = accept_with_retry(listen_fd, &addr, &addrlen); if (conn_fd < 0) { int err = errno; // 此时 err 才是你该用来判断错误类型的值 }这里还有一个容易被忽略的细节:EINTR和部分临时性错误,如ENETDOWN、EPROTO、ENOPROTOOPT、EHOSTDOWN、ENONET、EHOSTUNREACH、EOPNOTSUPP、ENETUNREACH,在有些系统上,如果SA_RESTART没有生效,accept()返回EINTR后重试即可,其它错误种类则不该盲目重试,否则可能在同一个真实错误上死循环。封装函数里分类判断,就是这个原因。
4.4 一套可复用的 accept_with_retry 封装建议
封装不要只做"遇 EINTR 就 continue"这么简单。项目里对内存、文件描述符上限也要有预判。accept()在文件描述符耗尽时会返回EMFILE,如果这时候你用"遇 EINTR 就 continue"的思路去处理EMFILE,那就麻烦了——每次循环都会立刻返回EMFILE,忙循环空转,CPU 被打满。
所以一个工程上完整的封装里,至少要对EMFILE、ENFILE做特殊处理,通常做法是短时休眠或等待一小段时间后重试,而不是立即重试。虽然这属于文件描述符层面的问题,但它和EINTR往往同时出现在accept()的返回判断里,不处理好就会出现前面说的"每次重试都失败"的情况。下面是一个稍微完整一点的版本:
int accept_with_backoff(int listen_fd, struct sockaddr *addr, socklen_t *addrlen) { for (;;) { int conn_fd = accept(listen_fd, addr, addrlen); if (conn_fd >= 0) return conn_fd; if (errno == EINTR) { continue; } if (errno == EAGAIN || errno == EWOULDBLOCK) { return -1; // 非阻塞模式无连接可接受 } if (errno == EMFILE || errno == ENFILE) { // fd 耗尽,短暂休眠后再试 struct timespec ts = {0, 100 * 1000 * 1000}; // 100ms nanosleep(&ts, NULL); continue; } return -1; // 其他错误一律认为是致命的 } }这个封装的优点是把EINTR、EAGAIN、资源耗尽三种情况分开处理,每一类都有自己的策略。后面在实际项目里,基本上只需要一行替代原来的accept()调用,整条主循环就都安全了。
5. 更现代的思路:绕开 EINTR,把信号变成事件
5.1 经典解法:SIGCHLD + waitpid 的正确回收姿势
多进程服务里,父进程如果不回收子进程,就会积累僵尸进程。SIGCHLD处理函数里应该调用waitpid()把所有退出的子进程收干净,用循环配合WNOHANG:
void handle_sigchld(int sig) { int saved_errno = errno; while (waitpid(-1, NULL, WNOHANG) > 0) { // 循环回收直到没有可回收的子进程 } errno = saved_errno; }waitpid()在SIGCHLD处理函数里是异步信号安全的,可以放心用。这里还有个容易被忽视的心机:如果主逻辑里在同一时刻也在调用waitpid()或者其他会改变errno的函数,信号处理函数执行完可能顺手把errno改掉了,那样accept()返回错误时查到的errno就可能是假象。所以很多经典代码里,第一步int saved_errno = errno;存下来,最后再errno = saved_errno;恢复,就是这个道理。这也是信号处理函数里最需要养成的好习惯。
5.2 signalfd:让 epoll 管理信号,主循环不再被打断
到了高并发阶段,越来越多的服务采用epoll事件循环,主循环里根本不再直接阻塞在accept()上,而是用epoll_wait()同时监听监听 socket、连接 socket 以及其他 fd。前面说过epoll_wait()在SA_RESTART下照样会返回EINTR,所以如果你已经迁到事件循环,也应该在epoll_wait()那里做重试。
更彻底的办法是把信号也变成一个 fd。Linux 提供了signalfd(),你可以把感兴趣的信号(比如SIGCHLD、SIGTERM)都塞进一个 fd 里,然后用epoll统一监听。信号到达时,epoll_wait()会正常返回,read()这个signalfd拿到信号信息,完全不需要打断任何阻塞调用,也不需要处理EINTR。这种设计在多进程高并发服务里能显著降低信号对主循环的干扰。
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); // 把 sfd 加入 epoll,统一事件循环这个方案的核心逻辑是:与其被信号打断再重试,不如先屏蔽信号,让它们在信号 fd 上排队,等到事件循环主动去读取。主循环里只有事件,没有意外的EINTR,代码逻辑清晰很多。代价是需要额外处理signalfd这个 fd,而且它只在 Linux 上可用。如果你的项目要在多平台跑,self-pipe trick(自管道技巧)是更通用的方案,即用一个管道把信号处理函数变成向管道里写一个字节,事件循环监听管道读端的 fd。
5.3 方法论总结:EINTR 不可怕,可怕的是每次都临时处理
从老式的阻塞accept(),到后来的epoll事件循环,再到signalfd,EINTR的处理方式不断演变,但底层逻辑一直没变:你要回答一个问题——"信号打断后,程序下一步该怎么办?"是重试系统调用,还是先去检查标志位,还是干脆退出?
我的经验是,这个问题应该在设计网络层的第一天就给出答案,而不是等线上冒出问题再来补。方案就两种取向:一种是在每个阻塞调用点都做EINTR重试(简单粗暴、通用);另一种是把信号屏蔽掉,统一走事件循环(更现代、更可控)。两种并不互斥,封装一个带重试的accept(),再配一个事件循环,可以让整个服务面对信号时始终处于可控状态。
从最开始把EINTR当成一个讨厌的干扰项,到后来理解它是信号机制给应用层的"通知机会"——这个过程其实是网络编程经验提升的标志之一。我自己的习惯是:无论SA_RESTART是否设置,都写重试逻辑;信号处理函数只做标记,不碰任何复杂库函数;能用signalfd的地方,一定用signalfd,让信号从"打断者"变成"事件源"。这套组合拳打下来,EINTR就不再是坑,而是多进程服务器里一个可以预期、可以驾驭的常规现象。最后再分享一个小技巧:在项目里做一个统一的accept_with_retry()工具函数,全部入口都走这一个封装,以后不管是排查日志还是调整重试策略,都只需要改一个地方,比到处散落着if (errno == EINTR)舒服太多了。