搞多进程服务器的人,应该都有过这样的经历:压测一切正常,上了线之后某个节点突然刷出一堆accept: Interrupted system call,紧接着服务就开始掉链子——连接要么连不上,要么连上了立刻被断开,日志里还夹杂着EINTR这个让人摸不着头脑的缩写。很多人第一反应是端口资源耗尽,或者防火墙抽风,查一圈发现啥都没问题,最后才恍然:自己被信号中断这个老古董坑了。
这篇文章要聊的就是accept()在EINTR下的处理机制。我会从内核到底做了什么、信号从哪来、以及多进程模型下为什么格外容易踩坑这几个角度拆开讲,最后给出四种实测可行的处理方案和一份避坑手册。无论你是正在写网络框架、维护常驻服务,还是单纯想搞清楚EINTR的本质,这篇都值得看完。
1. 先认清EINTR:系统调用为何会被“打断”
1.1 现场还原:一次典型的“假死”
先说一个我印象很深的案例。某次给一家公司排查线上服务,架构很简单:一个父进程监听端口,每来一个连接就fork()一个子进程去处理,子进程处理完就退出。压测环境下一点问题没有,但一上真实流量,问题立刻冒出来——服务端日志每隔几分钟就出现一条accept: Interrupted system call,而且伴随的现象是客户端连接超时、重试次数的瞬时飙升。
当时团队同事的第一反应都是去看CPU、内存、句柄数、连接队列,结果全正常。后来用strace抓了一下服务进程的系统调用,发现罪魁祸首根本不是网络层面的问题,而是父进程一直阻塞在accept()上,只要有子进程退出,内核就给父进程发一个SIGCHLD信号,父进程的accept()就被迫中断返回。返回结果是-1,errno被置成EINTR。
这个案例里最要命的地方不是“中断”本身,而是代码里没有任何EINTR的处理逻辑:程序一看accept()返回了负数,直接认为监听失败,要么打了日志就继续循环,要么干脆跳出了主循环,导致服务彻底不接收新连接。表面上看起来是“假死”,本质上是信号中断没有被妥善处理。
1.2 内核视角:阻塞、唤醒与中断
要搞懂EINTR,必须先把一个前提理清楚:当你调用accept()而且连接队列里没有已经完成三次握手的连接时,进程会进入睡眠状态。这个睡眠不是空转,而是把自己挂到等待队列上,然后让出CPU,等待某个条件被满足后由内核唤醒。
这时候如果有一个信号到达,比如SIGCHLD、SIGTERM等,内核必须在处理信号之前把进程从睡眠状态拉出来。因为信号处理函数要在这个进程的用户态上下文里执行,进程不能一直睡着没反应。
问题是,accept()这个系统调用还没完成它该做的事,进程就被“强行”唤醒了。内核在处理完信号处理函数之后,面临一个选择:是重新把进程塞回睡眠让accept()继续等下去,还是直接告诉用户态“你的系统调用被打断了,要不要重试你来定”。
这里就是EINTR的根源:内核选择了后者,把errno设为EINTR返回给用户态。你可以把这个过程类比成你在排队办事,排到一半有人紧急联系你,你被叫走去处理突发事件,等你回来之后,排队的状态已经被打断了,柜台不会自动帮你续上,而是让你重新决定要不要再排一次。
1.3 为什么多进程服务器是EINTR高发区
单线程、单进程的服务器里,EINTR不是那么常见,因为信号本来就少。但多进程服务器完全不同,最典型的触发源就是SIGCHLD。父进程负责accept(),每收到一个连接就fork()一个子进程。子进程处理完业务退出时,内核会给父进程发送SIGCHLD信号。
你想想,一个高并发的服务每秒可能fork()几十上百个子进程,每个子进程退出都来一下SIGCHLD,几乎必然有一个信号会落在父进程阻塞于accept()的时间窗口里。只要SIGCHLD有对应的信号处理函数被注册,且没用SA_RESTART标志,accept()就会频繁地被EINTR打断。
另外,多进程服务器还有一种经典骚操作:让父进程和多个子进程都同时accept()同一监听套接字。进程数越多,任何一个进程收到信号后造成的“连锁唤醒”也越明显。虽然这不是严格意义上的惊群,但表现上非常相似:本来只有一个连接到达,结果好几个进程被同时唤醒,其中一个成功accept(),其余则收到EINTR回到循环重试。进程数量越大,这种无效唤醒带来的CPU消耗越明显。
2. 信号、自动重启与accept()的历史纠葛
2.1 signal()与sigaction()的语义差异
很多教材里讲信号处理,会直接用signal()注册处理函数,然后加一句“在Linux上,被信号中断的系统调用会自动重启”。这个说法一半对一半错,坑了大量新手。
signal()的原生语义在不同系统上并不一致,历史上分成了两大派系:System V 和 BSD。System V 语义下,signal()注册的处理函数不会自动重启被中断的系统调用;BSD 语义下则会。早期的Linux实现,signal()走的是System V那一套,如果真的全裸用signal()去处理SIGCHLD,accept()被EINTR打断几乎是必然的。
而sigaction()则是POSIX标准的接口,它的sa_flags字段允许你显式指定SA_RESTART,告诉内核:处理完这个信号之后,如果系统调用是被它打断的,请自动帮我重启。这才是“自动重启”的正确打开方式。
你可能在别人代码里见过这样写:
signal(SIGCHLD, sigchld_handler);然后在accept()外面加一个EINTR判断。这不叫错,但属于“防御式编程”,因为Linux下的signal()是否自动重启并没有一个稳定的跨平台保证。真正稳妥的写法一定是用sigaction()并显式设置SA_RESTART,或者在代码里显式处理EINTR。
2.2 SA_RESTART真能救accept()吗
先说结论:对于accept()这个调用,在Linux上SA_RESTART基本能救。但这不是放之四海皆准的,因为SA_RESTART的“重启”能力并不会覆盖所有系统调用。
以select()、poll()、epoll_wait()这几个为例,即使你设置了SA_RESTART,它们在Linux上依然会返回EINTR。原因在于这几个系统调用的超时语义跟“自动重启”是冲突的:如果内核帮你重启,那超时时间从头算起还是接着上次算?语义不好定,所以直接从可重启系统调用清单里剔除。
这就延伸出一个特别实际的坑:你以为只要注册了SA_RESTART就能高枕无忧,一旦后续代码从accept()换成select()+accept()的组合,或者直接上epoll_wait(),EINTR会重新冒出来,而且这时候通常不在accept()上,而是在等待函数上。
所以SA_RESTART并不是万能银弹,它只是把问题从“每次都要处理”变成“特定系统调用要处理”。更合理的思路是:无论SA_RESTART开不开,核心循环里对EINTR都要有一套统一的、明确的处理规则。
2.3 进程模型直接影响中断频率:fork-on-accept vs 常驻worker
多进程服务器可以分成两大类,一类是“每连接一fork”的玩具模型,另一类是“预fork常驻worker”的生产模型。两者的EINTR发生频率差了好几个数量级。
在每连接一fork模型里,父进程唯一职责就是accept()和fork(),子进程处理完连接就exit()。每次子进程退出都会给父进程发SIGCHLD,父进程如果此刻正在accept()睡觉,就会被中断。这种模型下EINTR是常态,不是异常。
常驻worker模型则会提前fork()N个子进程,每个子进程自己调用accept()去抢连接,父子进程都常驻。这种模型里,worker处理完连接并不会退出,而是继续循环accept(),所以SIGCHLD的频率会低很多,但EINTR依然可能来自外部信号,比如管理员发送的SIGTERM、SIGHUP,或者自己代码里用alarm()设置的定时信号。
我自己的经验是:进程模型决定了你对EINTR的处理策略。如果是每连接一fork,必须把SIGCHLD处理函数写得非常轻量,只做waitpid()回收,并且确保accept()能无限重试;如果是常驻worker,则要特别注意不要让EINTR导致worker误判为自己被要求退出,否则连接还没处理完就白白死掉一个worker,会让整个服务的容量突然缩水。
3. 方案选型:四种主流EINTR处理思路对比
3.1 暴力循环重试:最简单但最容易漏细节
最直白的方案,就是在accept()返回-1时检查errno是否等于EINTR,是的话就continue,重新调用accept()。
while (1) { int connfd = accept(listenfd, NULL, NULL); if (connfd < 0) { if (errno == EINTR) { continue; } perror("accept"); break; } handle_connection(connfd); }这段代码看起来没什么问题,但实际操作中有几个细节容易被忽略。
第一,不是所有负数返回值都等于EINTR,必须同时判errno。你直接在perror里看到Interrupted system call,不代表程序打印完这一句就完事,而是要决定break还是continue。第二,如果accept()被EINTR打断时,连接队列里可能已经有连接了,你重试accept()时它大概率能立刻返回一个新连接,所以continue是正确的。第三,这种方案并不能解决“伪惊群”问题,多个子进程同时被信号唤醒,然后一起重试accept(),表面上不丢连接,但白白消耗了CPU。
暴力循环适合作为“兜底策略”,但因为缺少对信号类型的区分,一旦有周期性信号(比如定时器信号),主循环就会被无效唤醒拖累,CPU占用居高不下。
3.2 sigaction注册SA_RESTART:一行代码解决大部分问题
如果你用sigaction()注册信号处理函数,并把sa_flags加上SA_RESTART,那么对于accept()这类支持自动重启的系统调用,内核会在信号处理函数执行完之后,帮你重新发起一次系统调用。从用户态看起来,accept()就好像压根没被中断过。
struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = sigchld_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; if (sigaction(SIGCHLD, &sa, NULL) < 0) { perror("sigaction"); exit(1); }这个方案最大的优点是代码侵入性最小,你甚至不需要在accept()外面加EINTR重试逻辑。但要注意两点:第一,SA_RESTART只对支持重启的系统调用有效,select/poll/epoll_wait这些依然会返回EINTR;第二,有些场景下你反而希望系统调用被中断,以便快速响应信号,比如收到SIGTERM后希望阻塞中的accept()立刻返回,好让服务器优雅退出。如果你给所有信号都无脑加SA_RESTART,可能会导致退出流程拖很久。
这里我踩过一次坑:给SIGTERM加了SA_RESTART,结果服务收到停止指令后,父进程还傻乎乎地继续accept(),子进程也迟迟不退出,最后只能靠kill -9强杀。后来才反应过来,SA_RESTART要酌情使用,对于“告知型”信号(比如退出信号),不加SA_RESTART反而更合适。
3.3 self-pipe trick:把信号变成IO事件
第三种方案在事件驱动模型里非常经典,叫 self-pipe trick,翻译过来就是“自管道技巧”。核心思想是:在信号处理函数里向一个管道写一个字节,然后主循环用select()或poll()同时监听监听套接字和管道读端。信号到达时,信号处理函数往管道写数据,select()读端就变成可读,主循环就知道“有信号来了”,从而以IO事件的方式处理信号。
int sigpipe[2]; pipe(sigpipe); void sig_handler(int signo) { write(sigpipe[1], "x", 1); } int main() { struct sigaction sa; sa.sa_handler = sig_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; sigaction(SIGCHLD, &sa, NULL); fd_set rset; while (1) { FD_ZERO(&rset); FD_SET(listenfd, &rset); FD_SET(sigpipe[0], &rset); int nfd = select(maxfd + 1, &rset, NULL, NULL, NULL); if (nfd < 0) { if (errno == EINTR) { continue; } perror("select"); break; } if (FD_ISSET(sigpipe[0], &rset)) { char buf[64]; read(sigpipe[0], buf, sizeof(buf)); } if (FD_ISSET(listenfd, &rset)) { int connfd = accept(listenfd, NULL, NULL); if (connfd < 0) { if (errno == EINTR) continue; perror("accept"); break; } handle_connection(connfd); } } }为什么信号处理函数里要向管道写数据而不是直接长跑?因为信号处理函数的执行环境非常受限,它可能发生在主线程的任意一条指令中间,很多函数都不是异步信号安全的。你在这个上下文里调用printf()、malloc()都可能引发死锁或内存损坏。而write()是异步信号安全的,可以放心使用。
使用 self-pipe trick,本质上把你从“被信号打断系统调用”的被动局面,变成了“主动感知信号事件”的主动局面。即使select()被信号中断返回EINTR,循环重试也能正常工作,因为信号已经被管道事件捕获了。代价是需要额外维护一个管道文件描述符,而且在select()循环中要小心处理EINTR,不能因为SA_RESTART就麻痹大意。
3.4 ppoll/pselect:在等待前先屏蔽信号
第四种方案更像是“从源头避免打扰”:在调用accept()或select()之前,先把相关信号屏蔽掉,让信号排入等待队列;然后通过pselect()或ppoll()在同一时刻恢复屏蔽并阻塞等待。这样信号只能在等待期间到达,且必然导致等待函数返回EINTR。与普通select()不同的地方在于,pselect()提供了一个sigmask参数,内核保证“替换信号掩码+阻塞等待”是一个原子操作,避免信号在掩码恢复之前到达而丢失。
sigset_t newmask, oldmask; sigemptyset(&newmask); sigaddset(&newmask, SIGCHLD); sigprocmask(SIG_BLOCK, &newmask, &oldmask); while (1) { fd_set rset; FD_ZERO(&rset); FD_SET(listenfd, &rset); sigset_t zeromask; sigemptyset(&zeromask); nfd = pselect(listenfd + 1, &rset, NULL, NULL, NULL, &zeromask); if (nfd < 0) { if (errno == EINTR) { handle_signals(); continue; } perror("pselect"); break; } if (FD_ISSET(listenfd, &rset)) { int connfd = accept(listenfd, NULL, NULL); handle_connection(connfd); } }注意,pselect()里传入的zeromask是一个空信号集,意思是调用期间不屏蔽任何信号。也就是说,原本被屏蔽的SIGCHLD在pselect()内部会自动解除屏蔽,信号可以到达并中断pselect(),处理完之后pselect()返回EINTR,外层循环再continue。这样你既没有错过信号,也把EINTR变成了事件轮询的一部分。
这个方案的优点是逻辑很干净,不需要维护额外的管道,还能准确捕获等待窗口内的所有信号。缺点是pselect()和ppoll()在部分旧内核上的行为有细节差异,跨平台兼容性不如 plainselect()好。而且它对“信号处理函数本身”有要求:如果处理函数里没做多少事,pselect()返回EINTR后你怎么知道是哪个信号、该执行什么逻辑?通常还需要配合全局标志位来判断。
下面我把四种方案放在一起做个对比:
| 方案 | 侵入性 | 对accept有效 | 对select/epoll有效 | 适用场景 |
|---|---|---|---|---|
循环重试EINTR | 最低 | 有效 | 有效,但需自行处理 | 小规模、快速上线兜底 |
sigaction+SA_RESTART | 低 | 有效 | 无效 | 简单多进程模型,非事件驱动 |
| self-pipe trick | 中 | 有效,配合事件循环 | 有效 | 事件驱动型服务器 |
pselect/ppoll | 中 | 有效 | 有效,替代select/poll | 需要避免信号错失的阻塞等待 |
4. 实操笔记:代码演进的三个关键版本
4.1 V1:裸accept + continue重试
很多工程第一版都是这个写法,关键在于要把“正常错误”和“需要重试的错误”区分开。不要看到errno == EINTR就continue,其他错误一棍子打死直接break,那样只会让程序在真正的致命错误出现后逐渐失去响应。
int accept_loop(int listenfd) { for (;;) { int connfd = accept(listenfd, NULL, NULL); if (connfd < 0) { if (errno == EINTR) { continue; } // 对 EPROTO, ECONNABORTED, ENFILE, EMFILE 等错误分类处理 if (errno == ENFILE || errno == EMFILE) { sleep(1); continue; } return -1; } spawn_child(connfd); } }这个版本的实战意义在于稳定,但它隐藏了一个性能问题:假设有50个子进程,每个fork()出来的子进程退出都发SIGCHLD,同时父进程和剩余的几十个 worker 都阻塞在accept()上,那么一次信号中断可能唤醒所有 worker,每个 worker 都返回一次EINTR,然后全部再重新进入accept()。连接只有一个,但accept()被无意义地调用了几十次。极端情况下,如果各方一直踩在信号窗口上,可能出现“连接总是掉队”的用户可见问题。
4.2 V2:sigaction统一注册,回收SIGCHLD
第二版通常会把信号处理梳理清楚。多进程服务器里,SIGCHLD的信号处理函数除了要“记录有人退出”,还有一件至关重要的事:回收子进程资源,避免僵尸进程堆积。回收一定要用waitpid(-1, NULL, WNOHANG)循环调用,而不是wait()。
为什么?因为多个子进程可能在极短时间内同时退出,父进程只收到一个SIGCHLD信号(信号会合并且可能丢失),如果你只调用一次waitpid(),剩下几个未回收的子进程就会变成僵尸。必须循环回收,直到返回0或-1。
void sigchld_handler(int signo) { while (waitpid(-1, NULL, WNOHANG) > 0) { // 回收一个子进程 } }配合sigaction注册时,我通常在sa_flags里加上SA_RESTART | SA_NOCLDSTOP。SA_NOCLDSTOP的意思是子进程暂停(SIGSTOP)时不触发SIGCHLD,只有真正退出才通知,能减少无效信号数量。
这里有一个很容易被忽略的坑:信号处理函数里调用了waitpid(),但它可能在accept()还没返回时就把子进程回收了。如果主流程在accept()的EINTR分支里也尝试去waitpid(),就会出现“waitpid 已经回收了,你又去回收”的竞态。解决方案是让主流程不要重复回收,只靠信号处理函数回收,或者反过来:信号处理函数只置标志位,主循环统一回收。我推荐后者,逻辑更清晰,也不容易踩到异步信号安全的边界。
4.3 V3:self-pipe + poll统一事件模型
当服务从“一连接一进程”进化到“常驻worker + 事件驱动”之后,accept()往往不再是唯一阻塞点,poll()可能会同时监听监听套接字、信号管道的读端、以及一批已建立连接的读写事件。这时候 self-pipe trick 的价值就非常大了。
#define MAX_EVENTS 64 void signal_write(int sig) { char byte = 1; write(sigpipe[1], &byte, 1); } void event_loop(int listenfd) { struct pollfd fds[3]; fds[0].fd = listenfd; fds[0].events = POLLIN; fds[1].fd = sigpipe[0]; fds[1].events = POLLIN; for (;;) { int ret = poll(fds, 2, -1); if (ret < 0) { if (errno == EINTR) { // 即便被信号中断,循环重试即可 continue; } return; } if (fds[1].revents & POLLIN) { char buf[64]; while (read(sigpipe[0], buf, sizeof(buf)) > 0); handle_signal_flag(); } if (fds[0].revents & POLLIN) { int connfd = accept(listenfd, NULL, NULL); if (connfd >= 0) { handle_connection(connfd); } else if (errno != EINTR && errno != ECONNABORTED) { perror("accept"); } } } }在这个模型里,poll()不再依赖SA_RESTART,因为EINTR已经是循环的一部分。真正的事件来源统一为“可读条件”:监听套接字有连接、信号管道有数据。你既能在poll()等待过程中感知信号,也能把多个连接的处理整合到一个线程的循环里,减少进程切换开销。
我自己的实践体会是:一旦进入这个阶段,就不应该再纠结“accept()会不会被EINTR打断”了,因为所有系统调用都遵循同一套规则——返回EINTR就重新检查事件源。这才是事件驱动最自然的状态。
5. 避坑手册:常见的EINTR误判与排查方法
5.1 你以为重启了,其实select也EINTR
这是一条极其常见的误区。很多人给信号处理函数设置了SA_RESTART后,觉得万事大吉,结果代码里用select()等事件时依然返回EINTR,于是开始怀疑是不是哪里没写对。事实是select()、poll()、epoll_wait()在Linux上就是不会被SA_RESTART重启的系统调用。
如果你还在用select(),必须在它的-1分支处理errno == EINTR;如果你已经切换到epoll,同样要在epoll_wait()返回-1时处理EINTR。而且epoll_wait()的做法更严格,因为边缘触发模式下,如果因为EINTR提前退出循环,有可能漏掉某些文件描述符上的事件,导致连接一直等不到处理。
我的建议是写一个统一的宏:
#define HANDLE_EINTR(x) ((x) < 0 && errno == EINTR ? -1 : (x))但这只是让代码更清晰,真正要紧的是记住一个原则:凡是会有超时时间、可能会在阻塞期间被信号打断的系统调用,都要把EINTR当作“重新开始这一轮等待”的信号,而不是当作错误。
5.2 strace里的经典现场
排查EINTR最简单粗暴的方法就是挂strace看系统调用序列。但strace默认只输出系统调用名和返回结果,你要加上-e trace=network或者-e signal之类的过滤,才能把accept、wait4、rt_sigreturn和信号之间因果关系看清楚。
常见现场是这样:
accept(3, NULL, NULL) = ? ERESTARTSYS (To be restarted) --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED} --- rt_sigreturn(0) = -1 EINTR (Interrupted system call) accept(3, NULL, NULL) = 4第一行ERESTARTSYS是内核内部返回值,表示“系统调用会被重新启动”。如果注册时没有SA_RESTART,rt_sigreturn之后就会直接返回EINTR;如果有SA_RESTART,内核会帮你重新调用accept(),所以第三次accept()出现得毫无缝隙。
看到ERESTARTSYS这个关键词要特别留意,它说明内核其实支持自动重启,只是当前信号处理函数的sa_flags里没开SA_RESTART。这是排查时最有效的一个信号。
5.3 处理SIGCHLD时最容易埋的雷
最后一个坑,百分之百值得单独说:SIGCHLD的处理函数里绝不能做耗时操作,也不要在里面调用printf()或加锁。信号处理函数会打断主流程的任意一个位置,如果你在里面访问非异步安全函数,轻则数据错乱,重则死锁。
正确的做法是把信号处理函数压到最小:
static volatile sig_atomic_t g_child_exited = 0; void sigchld_handler(int signo) { g_child_exited = 1; }然后在主循环里检查这个标志位,再调用waitpid()回收。这样accept()被EINTR打断后,主循环立刻知道发生了子进程退出事件,再去做回收,逻辑清晰且安全。
另外,有个小细节:volatile sig_atomic_t类型在不同平台上宽度可能不同,但至少能保证它是原子读写的,不会出现“读到半个值”的问题。虽然现代的int也基本够用,但sig_atomic_t是标准明确支持的,建议别省。
说到排查工具,还有一个相对隐蔽的点:如果服务使用了systemd或类似进程管理器,它可能默认会给所有子进程发SIGCHLD之外的信号,比如SIGTERM、SIGINT。你在本地跑的时候没信号,一上了进程管理器,accept()莫名其妙被EINTR打断的次数直线上升。解决办法不是改SIGCHLD,而是检查进程管理器的停止信号、超时时间、以及是否开启了某种监视机制。我在某个项目里就因为进程管理器每隔一段时间发一次探活信号,导致线上所有worker都在空转重试accept(),CPU 使用率诡异飙升,排查了很久才定位到是信号来源问题。
回到开头的那个案例,我们最终的修复方法其实很简单:把父进程的SIGCHLD处理函数改成只置位一个原子标志,主循环在accept()返回EINTR时检查标志位、调用waitpid()循环回收,并给sigaction加上SA_RESTART。线上跑了几天,错误日志彻底消失。
我个人在实际操作中的体会是,处理EINTR没有一劳永逸的万能方案,关键是要建立一套“信号是常态,不是异常”的认知:所有阻塞调用都可能被信号打扰,所有EINTR都值得被当作一次重新等待的机会。多进程服务器尤其要尽早统一信号处理入口,不要每个模块各写各的while (accept() >= 0),否则后续每加一个信号源,你就得重新排查一遍所有阻塞点。把这套机制理清楚,比临时修修补补要省心得多。