凌晨的告警群里突然热闹起来多进程服务的核心日志里刷出了满屏的ERROR accept: Interrupted system call紧接着 worker 进程一个接一个退出重启脚本拉起来一批又挂掉一批。排查到最后问题就出在accept()的返回值判断上——返回值小于 0 就直接 exit完全没把EINTR当回事。这类问题在多进程服务器里太典型了今天把accept()被信号打断这件事彻彻底底讲透。1. EINTR 是怎么混进 accept() 的1.1 慢系统调用的“唤醒-打断”模型要理解EINTR得先理解内核里“慢系统调用”这个概念。像read()、write()、accept()、epoll_wait()、select()这类可能让进程长时间睡眠等待的系统调用统称为慢系统调用。它们的共同特点是进程会进入睡眠状态直到某个条件满足比如数据到达、新连接就绪、超时才被内核唤醒。问题出在信号这里。当进程正睡在accept()里等连接一个信号抵达了。内核会做两件事把进程唤醒然后让它在用户态先执行信号处理函数。信号处理函数执行完后再决定刚才那个系统调用怎么处理。站在进程的角度看accept()的行为就分成了两种系统调用被中断返回-1errno被设置为EINTR。处理函数执行完后进程并不会回到accept()里继续等。系统调用被自动重启。如果安装信号处理函数时设置了SA_RESTART标志且该系统调用属于可重启的那类那么处理函数执行完后内核会重新发起一次accept()过程对应用层完全透明。“被打断”和“被重启”的结果表面上只差一个errno但放到服务器代码里就是天壤之别。前者要求你在代码里显式处理后者要求你理解哪些接口会被重启、哪些不会。1.2 accept() 为什么是这个模型的常客accept()是典型的阻塞式系统调用进程会挂在已完成连接队列上等待。队列里有连接就取出来返回一个 fd队列为空就继续睡。这个“睡”的过程正是信号最容易插进来的窗口。还有个关键点POSIX并没有强制要求accept()可重启。在 Linux 上现代内核大概 2.6.35 之后如果信号处理函数设置了SA_RESTARTaccept()和accept4()是可以被自动重启的但在很多其他系统以及老版本内核上accept()被打断后就是直接返回EINTR。这种“平台相关、内核版本相关”的模糊地带恰恰是生产事故的温床。比accept()更麻烦的是另一类接口select()、pselect()、poll()、epoll_wait()在 Linux 上明确被列为“即使设置了 SA_RESTART 也绝不自动重启”的系统调用。也就是说只要你的事件循环里有用到这些多路复用接口信号一来它们必然返回EINTR躲都躲不掉。1.3 一个 5 分钟就能复现的实验光看理论容易犯迷糊我直接给一段复现代码。逻辑很简单父进程创建监听 socketfork 一个子进程让子进程阻塞在accept()上父进程 sleep 一秒后给子进程发送SIGUSR1。#include stdio.h #include signal.h #include errno.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h static void on_usr1(int signo) { /* 什么都不做只为了让信号打断系统调用 */ } int main(void) { int lfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons(9999); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(lfd, (struct sockaddr *)addr, sizeof(addr)); listen(lfd, 8); signal(SIGUSR1, on_usr1); // 默认不带 SA_RESTART pid_t pid fork(); if (pid 0) { sleep(1); kill(pid, SIGUSR1); wait(NULL); printf(parent done\n); } else { printf(child: accept start\n); int cfd accept(lfd, NULL, NULL); if (cfd 0) printf(child: accept errno%d EINTR%d (%s)\n, errno, EINTR, strerror(errno)); _exit(0); } return 0; }运行后子进程大概率打印child: accept errno4 EINTR4 (Interrupted system call)。这个实验最直观地告诉你信号处理函数执行完之后进程并没有回到accept()里继续等而是带着EINTR返回了。如果你的代码把这种情况当成致命错误处理worker 就没了。2. 多进程架构为什么把 EINTR 放大2.1 信号来源比单进程更杂单进程服务器的信号处理相对简单无非就是SIGTERM、SIGINT这类管理信号。但多进程服务器不一样为了管理子进程和实现热更新通常会安装一大批信号处理函数。信号典型用途在多进程服务器中的接收对象SIGCHLD回收子进程感知 worker 退出父进程masterSIGTERM优雅退出全部进程SIGUSR1重载配置、日志切割全部 workerSIGWINCH配合守护进程停worker全部 workerSIGALRM定时检查、超时清理某个或全部 workerSIGPIPE写已关闭连接的 socket全部进程信号来源一多每个 worker 在任何时刻都可能被任意一个信号击中。而 worker 的主要工作循环里accept()是占据时间最长的那段——连接少的服务worker 一天有四五个小时都挂在accept()上被打断的概率自然高。2.2 广播式信号一人发信号全员中招多进程服务器最常见的管理操作是向进程组里的所有 worker 发同一个信号。比如运维要重载配置执行一条kill -USR1 -PID负 PID 表示发给整个进程组。这一下所有正在accept()里等待的 worker 全都被同时唤醒然后一批一批地返回EINTR。如果只是返回EINTR还好顶多算一次空转。但如果代码里把EINTR当成了致命错误或者日志框架在信号处理函数里调用了非异步安全函数那广播信号就会变成 worker 连环退出、重启的导火索。我在实际项目里见过好几次“一次配置重载导致全集群 worker 滚动重启”的事故根因都不是信号本身而是对EINTR的态度。2.3 SIGCHLD 的连锁反应SIGCHLD是多进程服务器里另一个绕不开的信号。只要有 worker 异常退出父进程的SIGCHLD处理函数就会被触发去做waitpid()收尸并重新拉起 worker。这个信号主要影响的是父进程但父进程本身也可能参与业务 fd 的 accept比如某种“领导者-追随者”模式里master 和 worker 共享监听 socket。一旦子进程退出master 挂在accept()上也会立刻收到EINTR。看起来毫无关联的两个模块通过信号连在了一起。这也解释了为什么多进程服务器里SIGCHLD的误伤问题总是比单进程明显得多子进程退出越频繁父进程的accept()被打断的概率就越大。3. 标准解法一accept 循环重试3.1 最基础也是最该统一的写法处理EINTR最朴素、最可靠的办法就是给accept()套一个循环。被打断了就继续调直到拿到一个合法的连接 fd。static int accept_wait(int lfd, struct sockaddr *sa, socklen_t *salen) { for (;;) { int cfd accept(lfd, sa, salen); if (cfd 0) return cfd; switch (errno) { case EINTR: continue; /* 被信号打断继续 etc */ case ECONNABORTED: continue; /* 对端在 accept 前断了取下一个 */ case EAGAIN: case EWOULDBLOCK: return -2; /* 非阻塞模式暂无就绪连接 */ default: return -1; /* 真正需要上报的错误 */ } } }这段代码看着简单但有几个细节值得掰扯。首先是EINTR之后为什么要直接continue被打断的accept()并不会破坏已完成连接队列队列里的连接还在重新发起accept()就能拿到内核设计保证了这一点。其次accept()成功返回的 fd在 fork 继承场景下要注意FD_CLOEXEC标志防止子进程 exec 时泄露这一点和EINTR无关但经常被放在一起踩坑。3.2 关于 SA_RESTART 的争论与真相既然设置SA_RESTART可以让accept()自动重启那是不是就不需要循环重试了这是网上争论最多的问题。我的答案是不要完全依赖SA_RESTART道理有四条。第一可移植性。POSIX不要求accept()可重启你在 Linux 上验证过没问题不代表换个系统没风险。第二信号处理函数本身可能只想“置一个标志位”你其实希望系统调用被EINTR打断好立刻回去检查标志并响应信号。SA_RESTART一设置accept()重启了标志位的检查就被推迟到下一次连接到来行为完全背离初衷。第三select()、poll()、epoll_wait()这类接口即便设置了SA_RESTART也不会自动重启你迟早还是要面对EINTR。第四代码意图不明确后来维护的人看到SA_RESTART不知道你到底想强调什么而“遇到EINTR就重试”的循环一眼就能看懂。3.3 别忘了处理其他“类 EINTR”错误真实服务器里accept()返回的错误远不止EINTR一种。我建议在重试循环里把错误码分开处理不要笼统一刀切。errno触发原因推荐处理EINTR被信号打断直接重试ECONNABORTED对端在 accept 前已断开/RST重试这种连接本来也拿不到EAGAIN / EWOULDBLOCK非阻塞模式下队列为空回到事件循环等待EMFILE进程文件描述符耗尽让出调度等待其他 worker 接管ENFILE系统文件表满短暂退避后重试EPROTO / EPERM / ENOBUFS协议异常、内核防火墙、资源不足记录日志按频率退避重试EBADF / EFAULTfd 非法、指针非法严重错误需要停止并对齐开发告警这里特别容易忽略的是ECONNABORTED。很多代码只在EINTR分支里continue却把ECONNABORTED当成致命错误直接退出。恰恰在连接高并发场景下客户端在三次握手后、服务端accept()前主动断开的概率并不低这会导致不必要的 worker 退出。4. 标准解法二信号掩码 pselect4.1 为什么还需要比“重试”更强的控制力循环重试能解决“进程别死掉”的问题但解决不了“信号需要被及时响应”的问题。比如你希望收到SIGUSR1后 worker 立刻重载配置可 worker 一直阻塞在accept()上没有新连接进来它就永远不醒配置重载自然也就迟迟不生效。这时需要一个机制让 worker 既能在没有连接时睡着又能被信号立刻唤醒并且醒过来之后先检查信号标志位。最经典的做法是“全局标志位 带掩码的pselect()”。4.2 核心代码模式全局标志位先在信号处理函数里只做最小的事——置标志。按照 POSIX 规范volatile sig_atomic_t类型的读写可以保证原子性。static volatile sig_atomic_t g_reload 0; static volatile sig_atomic_t g_shutdown 0; static void on_usr1(int signo) { g_reload 1; } static void on_term(int signo) { g_shutdown 1; }然后进程启动时把信号都阻塞住避免它们在代码执行的任意位置插入。sigset_t set, orig; sigemptyset(set); sigaddset(set, SIGUSR1); sigaddset(set, SIGTERM); sigprocmask(SIG_BLOCK, set, orig);4.3 原子地“放开信号 等待”才是关键阻塞信号之后主循环长这样for (;;) { if (g_shutdown) graceful_exit(); if (g_reload) reload_config(); fd_set rfds; FD_ZERO(rfds); FD_SET(lfd, rfds); int r pselect(lfd 1, rfds, NULL, NULL, NULL, set); if (r 0) { if (errno EINTR) continue; /* 有信号来了回去检查标志 */ break; } if (FD_ISSET(lfd, rfds)) { int cfd accept_wait(lfd, NULL, NULL); handle_conn(cfd); } }关键在pselect()的最后一个参数。它的语义是在等待期间把信号掩码临时替换成set里描述的那个集合也就是把之前SIG_BLOCK的信号临时放行。这样信号一到pselect()立刻返回EINTR进程回到循环顶部检查g_reload或g_shutdown完成响应。为什么不能简单写成“先SIG_UNBLOCK再accept()”因为这两个操作之间存在窗口期信号可能恰好在SIG_UNBLOCK之后、accept()进入内核之前到达于是信号处理函数执行了但accept()完全没有被打断标志位也没人检查最终又回到阻塞等待连接的状态信号响应被无限期推迟。pselect()的意义就是把这个窗口期消除掉让“放开信号”和“进入睡眠等待”成为一个原子操作。同理可用的还有epoll_pwait()只是把fd_set换成epoll_event。5. 标准解法三signalfd epoll5.1 Linux 上的终极形态如果你在 Linux 上开发且主事件循环已经用了epoll那signalfd()是比“信号处理函数 标志位”更优雅的方案。思路是把信号当成一种普通的可读事件注册到epoll里由事件循环统一调度。sigset_t set; sigemptyset(set); sigaddset(set, SIGUSR1); sigaddset(set, SIGTERM); sigprocmask(SIG_BLOCK, set, NULL); /* 必须先阻塞 */ int sfd signalfd(-1, set, SFD_NONBLOCK | SFD_CLOEXEC);signalfd()创建出来的 fd读取出来的是struct signalfd_siginfo结构里面包含信号编号、发送进程 PID 等信息。关键点在于被signalfd接管的信号不会触发常规的信号处理函数而是被“消费”掉转变成 fd 上的读事件。因此这类信号并不会像普通 handler 那样去打断accept()或epoll_wait()。5.2 与事件循环合体struct epoll_event ev {0}; ev.events EPOLLIN; ev.data.fd sfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, ev); ev.events EPOLLIN; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); for (;;) { struct epoll_event evs[8]; int n epoll_wait(epfd, evs, 8, -1); if (n 0) { if (errno EINTR) continue; /* 其他信号仍然可能打断 */ break; } for (int i 0; i n; i) { if (evs[i].data.fd sfd) { struct signalfd_siginfo info; read(sfd, info, sizeof(info)); if (info.ssi_signo SIGUSR1) do_reload(); else if (info.ssi_signo SIGTERM) do_shutdown(); } else if (evs[i].data.fd lfd) { int cfd accept_wait(lfd, NULL, NULL); handle_conn(cfd); } } }这套模式把所有事件统一收口到一条epoll_wait()上信号的响应延迟极低也没有信号处理函数里那些异步安全限制。我团队里的新服务现在基本都默认走这条路。accept()侧仍然保留重试循环只是这个循环已经很少有机会真正触发。5.3 用 signalfd 不等于可以丢掉兜底重试必须强调一点signalfd只对已经阻塞并且注册进去的那组信号生效。如果进程里还有其他信号没被signalfd接管或者某个第三方库自己安装了信号处理函数epoll_wait()和accept()依然可能被打断返回EINTR。所以务实的策略是“三管齐下”signalfd解决主要信号源epoll_wait()的EINTR分支兜底accept()的EINTR分支兜底。三层都写代码才会在复杂环境下真正站得住。6. 实战里的“血泪”细节6.1 EMFILE 的隐形坑连接还在队列里EINTR是明确的信号问题但在生产环境里EMFILE进程文件描述符耗尽才是让我印象更深的那个坑。当accept()返回EMFILE时已经完成握手的那条连接并不会凭空消失它仍然排在内核的已完成连接队列里。如果进程只是简单sleep后重试连接会在队列里堆积队头不停被内部处理后面新来的连接甚至可能被丢弃。有一种进阶做法是“预留 fd”。进程启动时先打开一个占位 fd比如/dev/null当accept()返回EMFILE时先把占位 fd 关掉空出一个 fd 名额再立刻accept()。因为队列里确实有一条连接在等着这次accept()通常会成功。拿到连接后直接把它 close 掉然后重新打开占位 fd继续循环。static int spare_fd -1; /* 启动时: spare_fd open(/dev/null, O_RDONLY); */ if (errno EMFILE) { close(spare_fd); spare_fd accept(lfd, NULL, NULL); if (spare_fd 0) close(spare_fd); /* 消费掉一条积压连接 */ spare_fd open(/dev/null, O_RDONLY); continue; }这个技巧能有效降低高并发下的连接丢失率但要注意不是所有系统对accept()返回EMFILE的行为都一致实现前最好先在目标系统上压测量一下。更重要的是它治标不治本真正要做的还是排查 fd 泄漏。6.2 信号处理函数里的禁区信号处理函数里能做的事情非常有限。printf()、malloc()、free()、mutex锁这种在日常代码里随手就写出来的操作在信号处理函数里全是高危行为因为它们不是异步信号安全的。一个我印象很深的案例有人在SIGCHLD处理函数里写了日志输出刚好多进程服务里 worker 崩溃频繁日志输出在信号处理函数里触发了锁竞争死锁整个 master 进程直接卡死。而表面现象却是accept()疯狂返回EINTR。如果想在信号到来时传递更多信息安全做法是把数据写入一个预先创建的管道或 eventfd然后在事件循环里读取。这也是 self-pipe 技巧的由来。现在的项目里我基本只用两种方式处理信号要么sig_atomic_t标志位 pselect()要么signalfdepoll。信号处理函数里最多再加一个kill()把事件转发给其他进程。6.3 观察 EINTRstrace 与定量打点排查线上问题时strace是最直接的工具。比如盯住某个 worker 进程观察它的accept()是否被信号打断strace -f -e traceaccept,accept4 -p worker_pid -o /tmp/accept.log日志里如果出现accept(3, ...) -1 EINTR (Interrupted system call)那EINTR就是真实存在的活跃问题。如果想看信号来源可以加一条strace -f -e tracesignal -e signalSIGUSR1,SIGCHLD -p worker_pid -o /tmp/sig.log不过要提醒strace会显著拉低吞吐适合短时间定位问题不适合长时间挂在生产进程上。更温和的办法是在accept_wait()里做定量打点static unsigned long eintr_cnt; static unsigned long eintr_last_log; if (errno EINTR) { if (eintr_cnt - eintr_last_log 1000) { log_info(accept EINTR total%lu, eintr_cnt); eintr_last_log eintr_cnt; } continue; }打点之后可以观察一个规律如果EINTR数量非常稳定且可预期那是正常的信号协同如果某个时间点突然暴涨几百倍多半是有人向进程组广播了信号或者SIGCHLD风暴这时候排查重点就不是accept()本身了。6.4 其他语言里其实已经帮你兜了底现在写业务代码很多人已经不在 C/C 层直接面对这个问题了。Go 语言的运行时会在accept()返回EINTR时自动重试应用层通常感知不到错误。但 Go 的syscall包如果直接封装系统调用仍然可能返回EINTR所以算不上完全免疫。Python 从 3.5 开始落实了 PEP 475标准库层面遇到EINTR会自动重试大部分系统调用。不过如果你用了第三方 C 扩展或者自己封装了 ctypes 的系统调用照样需要处理。其他语言自带兜底是好事但这就意味着很多人在这些语言里彻底忘记了EINTR的存在。一旦遇到底层库暴露出来的InterruptedError往往反而不知道怎么处理。理解了这篇文章里的内核机制再看到这类错误心里就有底了重试即可前提是不影响标志位检查的及时性。最后再分享一个经验不管用哪种方案团队里都应该有一个统一的accept_wait()封装不鼓励大家在业务代码里直接碰原生accept()。封装的好处一是集中处理EINTR、ECONNABORTED、EMFILE这些错误二是后续想加打点、加日志、加连接审计都只有一个入口。我踩过“每个 worker 各写各的 accept 逻辑”的坑那次事故之后全组所有服务都收敛到了同一个网络库再也没人因为EINTR把线上搞挂了。