news 2026/9/29 9:35:40

从阻塞到epoll:Linux非阻塞IO多路复用与高并发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从阻塞到epoll:Linux非阻塞IO多路复用与高并发实战

1. 先把概念捋直:阻塞与非阻塞到底在说什么

做 Linux 网络编程的人,几乎都踩过同一个坑:服务跑着跑着连接数一上来,CPU 占用不高,但响应时间像坐电梯一样往上蹿,日志里也没报错,就是"慢"。我第一次遇到这种情况是在一个内部数据采集服务上,单机扛了两百多个长连接就开始掉数据,排查半天内存、GC、SQL 都正常,最后用strace -p挂上去一看,进程绝大多数时间卡在read()系统调用上纹丝不动。那一刻才真正理解,阻塞与非阻塞不是书本上的两个名词,而是决定你的程序能不能同时照看多个连接的分水岭。这篇内容就围绕 Linux 网络编程里的阻塞与非阻塞展开,从内核怎么处理一次读写、到怎么把阻塞式代码改造成非阻塞加多路复用、再到实际排障时怎么定位问题,全部讲透。适合已经会写基本 socket 代码、但一上并发就卡壳的开发者,也适合需要把这些概念讲清楚的运维和面试准备者。

1.1 从一次 read 调用说起:内核里的等待队列

很多人以为read()是"从网卡拿数据",其实不是。数据到达网卡后,由内核的协议栈处理,最终放进这个 socket 对应的接收缓冲区,read()做的事情是把数据从内核缓冲区拷贝到用户缓冲区。关键在于:如果缓冲区里没有数据,阻塞式的read()就会把当前线程挂到这个 socket 的等待队列上,然后主动让出 CPU 进入睡眠状态,直到有数据到达或者被信号打断才被唤醒。这个过程对调用方是完全透明的,代码看起来就像"停在那里等"。

阻塞的本质是"线程让出 CPU,等条件满足后由内核唤醒",它并不消耗 CPU 时间片,这一点非常重要。很多人误以为阻塞就是"死等浪费性能",其实恰恰相反,阻塞本身是高效的,真正的问题是一个线程只能等一个 fd。当你有 1000 个连接时,用阻塞模型就得开 1000 个线程,线程的栈空间、上下文切换、内核调度开销才是压垮系统的元凶,而不是阻塞这个动作本身。想清楚这一点,后面所有的方案选择就顺理成章了。

而把 socket 设置成非阻塞之后,read()的行为完全变了:缓冲区有数据就拷贝并返回拷贝的字节数,没数据就立刻返回 -1,同时把errno置为EAGAIN或EWOULDBLOCK(在 Linux 上这两个值是同一个)。这里的"立刻返回"意味着调用方必须自己去轮询或者借助别的机制知道"什么时候可以读了",这就是为什么要引入 select、poll、epoll 这些 IO 多路复用工具。

1.2 阻塞非阻塞、同步异步,别再把它们搅在一起

面试里最喜欢问的一组概念就是"阻塞非阻塞和同步异步有什么区别",很多人答得含糊。用最直白的话说:阻塞与非阻塞描述的是调用方在等待结果时线程的状态,同步与异步描述的是结果是谁来通知、数据是谁来搬。一个非阻塞的read()依然是同步的,因为你还是得自己去检查返回值、自己去处理拷贝后的数据,只是你不睡觉了而已。

真正的异步 IO(Linux 上是io_uring、老一点的AIO)是另一回事:你提交一个请求,然后什么都不用管,内核把数据搬完直接通知你。这个概念差别很关键,很多所谓"异步框架"底层其实是"非阻塞 + 事件循环",本质上还是同步非阻塞,只是把"检查哪个 fd 就绪"这件事交给了 epoll 而已。理解了这个层次,你看任何网络库的源码都不会被它的宣传语带偏。

顺便提一句,线程池里的阻塞队列(ArrayBlockingQueue、LinkedBlockingQueue 这类)用的是同一套思想:生产者消费者之间通过条件变量做等待与唤醒,消费者没活干时挂起、有活干时被唤醒。这和 socket 的等待队列在哲学上是一回事,只是发生在用户态,用pthread_cond_wait或者 futex 实现。理解了内核那套,用户态这套也就一眼看穿了。

1.3 五种 IO 模型的落地形态对照

把这五种模型和 Linux 上的具体实现对应起来,脑子里就有张地图了。阻塞 IO 就是默认 socket 加单线程;非阻塞 IO 是加O_NONBLOCK之后自己写while轮询(基本不用,太烧 CPU);IO 多路复用是select/poll/epoll;信号驱动 IO 用SIGIO,实际项目中极少见;异步 IO 是io_uring,近年因为高性能场景被大量讨论。

模型等待数据阶段数据拷贝阶段Linux 对应工具典型场景
阻塞 IO线程睡眠线程睡眠默认 socket低并发、简单工具
非阻塞 IO轮询不睡线程睡眠O_NONBLOCK配合多路复用
IO 多路复用阻塞在 epoll线程睡眠select/poll/epoll高并发服务器
信号驱动异步通知线程睡眠SIGIO特殊场景
异步 IO全异步全异步io_uring极致性能

这张表我建议自己动手写一遍,而不是背下来。因为当你真正去写epoll代码的时候,你会发现"多路复用只是把等待阶段从 N 个 fd 合并成了 1 个 epoll_wait",拷贝阶段依然是同步的,所以它并不神奇,只是把"等"的效率提高了 N 倍。

提示:判断一个模型属于哪一类,只看两个问题——"等数据的时候线程睡不睡"和"拷数据的时候线程睡不睡"。两个都睡是阻塞,第一个不睡第二个睡是非阻塞,两个都不睡才是异步。

2. 方案选型:什么时候该阻塞,什么时候必须非阻塞

技术选型最怕的就是"听说 epoll 性能好就无脑上"。我见过太多项目,总共就三五个连接,硬是写了一套 epoll 事件循环,代码复杂度翻了三倍,bug 还多了。所以这一章先讲清楚边界:什么场景下阻塞式代码反而更合适,什么场景下非阻塞是唯一出路,以及多路复用三个工具到底怎么选。

2.1 阻塞式 Socket 的适用边界和真实代价

阻塞模型最简单的形态是"一连接一线程",主线程accept()拿到新连接的 fd,扔给一个工作线程,线程里recv()阻塞等待数据,处理完再recv()。代码逻辑是线性的,调试极其友好,gdb挂上去栈清清楚楚。对于连接数在几十到一两百、每个连接交互频繁的场景(比如内部管理服务、数据库中间层),这种写法完全够用,甚至比事件循环更稳。

它的代价有三个。第一是线程数量,Linux 默认线程栈 8MB(虚拟内存,实际按需分配),1000 个线程光栈就要预留 8GB 虚拟地址空间,再加上内核里每个线程的 task_struct、调度实体,开销实打实。第二是上下文切换,线程多了以后 CPU 大量时间花在切换上,vmstat里的cs列会飙到几十万,有效算力反而下降。第三是内存放大,每个线程的栈、局部变量、缓冲区都会占据物理内存,同样的机器能扛的连接数远低于事件驱动模型。

我自己定的一条经验线是:并发连接稳定在 200 以内、请求处理逻辑重(有计算或磁盘 IO)、团队对异步编程不熟,就用阻塞加线程池。超过这个量级,或者连接是长连接、大部分时间空闲(比如推送、IM 场景),就必须考虑事件驱动。这个数字不是死标准,但能帮你快速做决定,不至于在选型上纠结一天。

2.2 非阻塞加多路复用:选型逻辑其实只有一句话

非阻塞的价值必须和多路复用绑定才体现得出来:单个 fd 设成非阻塞然后循环轮询,是把"线程睡觉"换成了"CPU 空转",纯亏。而 epoll 帮你解决的是"怎么在 1 个线程里知道 N 个 fd 里哪些就绪",事件驱动模型下的标准写法是:所有 fd 都设非阻塞,注册进 epoll,epoll_wait返回一批就绪事件,逐个处理,每个 fd 都读到EAGAIN为止再回到epoll_wait。

为什么 fd 必须非阻塞?因为epoll_wait返回的是"就绪"状态,但这个状态可能在你这批事件处理完之前就失效了——比如你处理第 1 个 fd 花了 10ms,这期间第 5 个 fd 对端把数据撤了、连接关了,等你轮到时去read()一个阻塞 fd,就会直接卡死整个事件循环。这是事件驱动模型最经典的死法,一旦发生,整个进程所有连接全部停摆。所以规矩是:只要进了 epoll 的 fd,必须是非阻塞的,而且读事件必须循环读到 EAGAIN。

选型上还有一层考虑是协议复杂度。如果你的协议是"请求-响应"短连接、每次交互边界清晰,多路复用代码写起来很舒服;如果是长连接双向流式推送,状态机管理会复杂不少,需要维护每个连接的读缓冲、写缓冲、半包处理,这时候用现成的库(libevent、libuv、muduo)比自己撸要划算得多,别为了"自己写"而重复造轮子。

2.3 select、poll、epoll 的参数与性能对比

这三个工具都能做多路复用,但实现机制完全不同,性能差距在大并发下是数量级的。

select的接口是select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout),fd_set是位图,默认FD_SETSIZE是 1024,也就是说最多只能管 1024 个 fd,改这个值需要重新编译内核,实际不可行。每次调用都要把整个位图从用户态拷到内核态,返回后还得遍历找出哪个 fd 就绪,复杂度 O(n)。另外select返回后fd_set被内核改写,下次调用必须重新设置,这也是常见的坑。

poll用struct pollfd数组替代了位图,突破了 1024 的限制,接口是poll(struct pollfd *fds, nfds_t nfds, int timeout)。但它依然需要每次把整个数组拷进内核,内核也要遍历所有 fd,复杂度还是 O(n)。连接数上万时,光是拷贝就是不小的开销。

epoll用三个系统调用配合:epoll_create1()创建实例,epoll_ctl()增删改关注的事件,epoll_wait()等待就绪。它的核心改进是把 fd 和事件的注册信息常驻在内核的红黑树里,不需要每次调用重复传递;就绪的 fd 由内核通过回调放进一个就绪链表,epoll_wait只需要返回这个链表里的元素,复杂度接近 O(1)。另外epoll支持边缘触发(ET)模式,配合非阻塞 fd 可以减少系统调用次数。

对比项selectpollepoll
fd 上限1024(FD_SETSIZE)无硬限制无硬限制
每次调用数据拷贝全量 fd_set全量 pollfd 数组仅就绪列表
内核查找方式线性遍历线性遍历红黑树 + 就绪链表
时间复杂度O(n)O(n)O(1) 近似
触发模式仅水平触发仅水平触发水平 / 边缘触发
适用连接数百级千级以内万级以上

epoll_wait的timeout参数单位是毫秒,设 -1 表示永久阻塞直到有事件,设 0 表示立即返回(纯轮询,别这么干)。返回的nfds表示本次就绪的数量,遍历events数组时只处理前nfds个元素即可,这一点和poll必须遍历全部不同,也是 epoll 高效的原因之一。

3. 实操:把一个阻塞服务器改造成非阻塞事件驱动

光说概念没意义,下面完整走一遍改造过程。我会先给一个能跑的阻塞版回声服务器,用它跑出基线数据,然后一步步改成非阻塞加 epoll,中间把每个改动的原因和坑都说清楚。这套流程我在实际项目里用过不止一次,照着做基本不会翻车。

3.1 阻塞版回声服务器的实现与压测基线

先看最朴素的版本,一次连接一个循环,逐条读、逐条回写:

// echo_blocking.c — 编译: gcc -O2 -o echo_blocking echo_blocking.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 9001 #define BUF_SIZE 4096 int main(void) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listen_fd, 128) < 0) { perror("listen"); exit(1); } char buf[BUF_SIZE]; for (;;) { int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) { perror("accept"); continue; } for (;;) { ssize_t n = recv(conn_fd, buf, sizeof(buf), 0); if (n <= 0) break; // 0 表示对端关闭 ssize_t off = 0; while (off < n) { // 循环写,防短写 ssize_t w = send(conn_fd, buf + off, n - off, 0); if (w <= 0) break; off += w; } } close(conn_fd); } return 0; }

这个版本有两个细节值得说。一个是SO_REUSEADDR,服务器重启时如果有连接处于TIME_WAIT,不设这个选项bind会直接失败报Address already in use,开发阶段重启频繁,这个选项基本必加。另一个是send的循环写,网络库永远可能只写一部分,send返回值小于请求长度就是"短写",必须接着写剩下的,很多人写 demo 时直接忽略返回值,一到生产环境就出现数据截断。

压测基线怎么跑?用iperf3只能测带宽,测你这套应用层的并发能力还是得上并发客户端。简单做法是用wrk或者自己写个多线程 client,每个线程建一条连接循环发小包。我用 8 个客户端线程、每线程一条长连接、每秒发 1000 个小包测下来,阻塞版在 200 连接以内延迟很稳,但把客户端加到 500 条连接时,如果每条连接都保持活跃,服务端线程模型(这里是单线程串行处理)直接崩,因为它是串行 accept + 串行处理,第一个连接不关,后面的全部饿死。这就是阻塞模型最要命的地方。

3.2 设置非阻塞的三种方式与容易踩的坑

把 fd 设成非阻塞有三种途径,各有适用场景。

第一种是socket()时直接加标志,socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0),优点是少一次系统调用,缺点是只有新建 fd 时能用。第二种是accept4()带SOCK_NONBLOCK,这样接受的连接天然是非阻塞的,比accept之后再fcntl更省一次调用,也规避了"先阻塞后被设置"的窗口期。第三种是通用的fcntl:

#include <fcntl.h> int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); // 必须先取原来的标志 if (flags < 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 用或,不能直接赋值 }

这里有个特别容易错的地方:很多人写成fcntl(fd, F_SETFL, O_NONBLOCK),把原有标志位覆盖掉了。正常情况下看不出问题,但如果这个 fd 原本还有其他标志(比如O_ASYNC),就会被悄悄抹掉。正确做法永远是先F_GETFL再按位或。另外F_GETFL和F_SETFL是两个独立的系统调用,中间存在竞态窗口,多线程环境下要注意加锁或者直接用accept4。

还有一个坑是fcntl设置的行为在 fork 和dup时是共享的,因为非阻塞标志属于 file description 而不是 fd,dup出来的 fd 共享同一个 file description,改一个等于改全部。这个特性有时很有用,有时会带来意外,写多进程服务时必须心里有数。

注意:errno的判断不能只判EAGAIN。可移植的写法是if (errno == EAGAIN || errno == EWOULDBLOCK),虽然 Linux 上两者同值,但在其他系统上不同。另外EINTR必须单独处理——信号打断系统调用时应该重试,而不是当成错误退出。

3.3 epoll 版服务器:完整实现与关键参数计算

下面这个是改造后的核心,用水平触发(LT)版本,逻辑最直观,也是新手最不容易写错的:

// echo_epoll.c — 编译: gcc -O2 -o echo_epoll echo_epoll.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <fcntl.h> #include <arpa/inet.h> #include <sys/socket.h> #include <sys/epoll.h> #define PORT 9001 #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags < 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static void handle_read(int epfd, int fd) { char buf[BUF_SIZE]; for (;;) { ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { ssize_t off = 0; while (off < n) { ssize_t w = send(fd, buf + off, n - off, MSG_NOSIGNAL); if (w > 0) { off += w; continue; } if (w < 0 && (errno == EAGAIN || errno == EINTR)) continue; goto close_conn; } } else if (n == 0) { goto close_conn; // 对端正常关闭 } else { if (errno == EAGAIN || errno == EWOULDBLOCK) return; // 读干净了 if (errno == EINTR) continue; goto close_conn; } } close_conn: epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } int main(void) { int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); if (listen_fd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listen_fd, 511) < 0) { perror("listen"); exit(1); } int epfd = epoll_create1(EPOLL_CLOEXEC); if (epfd < 0) { perror("epoll_create1"); exit(1); } struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; // 监听可读 ev.data.fd = listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) { perror("epoll_ctl add listen"); exit(1); } for (;;) { int nready = epoll_wait(epfd, events, MAX_EVENTS, -1); if (nready < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < nready; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { for (;;) { // 一次性 accept 干净 int conn = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK); if (conn < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; break; } ev.events = EPOLLIN; ev.data.fd = conn; if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev) < 0) { close(conn); } } } else { handle_read(epfd, fd); } } } close(epfd); close(listen_fd); return 0; }

几个关键参数得算一下。MAX_EVENTS设为 1024 表示一次epoll_wait最多返回 1024 个就绪事件,如果同时就绪的超过这个数,剩下的会在下次epoll_wait返回,不会丢,但会增加调用次数。经验值是把MAX_EVENTS设成预期并发连接数的 1/10 到 1/4,太大浪费栈上空间(struct epoll_event在 64 位上是 12 字节,1024 个约 12KB,还好),太小则调用频繁。我一般用 1024 起步,压测发现epoll_wait调用次数过高再调。

listen的 backlog 参数容易被忽视。这个值表示已完成三次握手但还没被accept的连接队列长度上限,内核实际取的是min(backlog, /proc/sys/net/core/somaxconn)。现代内核somaxconn默认是 4096,所以 backlog 写 511 或 1024 都行。但如果你的服务启动后瞬间涌入大量连接、而accept处理慢,队列会溢出,客户端会看到连接超时或SYN被丢弃,这时候要么加大 backlog,要么加快 accept 速度(上面代码里的for(;;)循环 accept 就是干这个的)。

EPOLL_CLOEXEC和accept4的SOCK_NONBLOCK属于习惯性防御,前者避免 fork 出的子进程继承 epoll fd 导致资源泄漏,后者省一次系统调用。这两个都是小的优化,但养成习惯后代码质量会明显不同。

3.4 调试工具与现场记录

改完必须验证,我常用的组合是这几个命令。

ss -lnt看监听状态和Send-Q,Send-Q在 LISTEN 状态下就是 backlog 的实际上限,能直接看出有没有被内核截断。ss -s一行汇总当前连接数、TIME_WAIT数量。ss -tn state established | wc -l数活跃连接。排查 fd 泄漏用lsof -p <pid> | wc -l,如果这个数字持续增长而连接数没涨,基本就是某处close漏了。

strace -f -p <pid> -e trace=network挂到进程上能实时看到所有 socket 系统调用,改非阻塞之前用它确认阻塞点非常有效。测吞吐用iperf3 -s起服务端,iperf3 -c <ip> -t 30 -P 8开 8 条并行流跑 30 秒,能排除应用层逻辑,先确认网络链路本身没问题,再去优化代码。这是排查"到底是网络慢还是代码慢"的第一刀,别跳过。

4. 常见问题与排查技巧实录

这部分是踩坑合集。下面这些问题我几乎每一个都真实遇到过,有些还导致了线上事故,写出来希望能帮你少走弯路。

4.1 EAGAIN 处理不当的三种典型错误

第一种是把EAGAIN当错误处理。某次代码 review 看到一个函数,recv返回 -1 就close(fd),理由是"读失败",结果非阻塞模式下这个 fd 每次没数据就被关掉,服务表现为连接随机断开。正确的做法是:EAGAIN表示"现在没数据,稍后再来",是正常状态,直接返回就好。

第二种是第一次遇到EAGAIN就退出循环,但在边缘触发(ET)模式下这是致命错误。ET 模式下,只有当 fd 状态发生变化时才通知一次,如果你没有一次把缓冲区读干净,剩下的数据不会再有新的事件通知,数据就永久卡在内核缓冲区里,表现为客户端发出的请求服务端不响应。ET 模式下必须写while循环一直读到EAGAIN才退出。这也是为什么我一直建议新手先用 LT,等理解了触发语义再切 ET。

第三种是写事件的EAGAIN被忽略。非阻塞send在发送缓冲区满时返回 -1 且errno为EAGAIN,这时不能丢弃数据,必须把剩余数据存进用户态写缓冲,并注册EPOLLOUT事件,等可写时再继续发。这个"用户态写缓冲 + 动态注册 EPOLLOUT"是完整事件驱动服务器的必备模块,很多 demo 版 epoll 代码根本没写,只能应付小数据量,一到大文件传输就出问题。写缓冲建议用环形缓冲区实现,避免频繁memmove。

4.2 短读短写、粘包、事件丢失的处理

短读是指recv一次只返回了部分数据,虽然 TCP 是字节流,但内核缓冲区多大就返回多少,跟你的应用层消息边界无关。所以任何一次recv之后,你拿到的是"若干字节",可能是半条消息,也可能是一条半。解决办法是每个连接维护一个读缓冲,先把数据追加进去,然后按协议格式解析:定长头、分隔符、或者长度前缀。我一般用"4 字节长度前缀 + 变长体",解析逻辑简单,压缩和加密也容易加。

粘包其实是短读的另一面,本质是同一件事:TCP 不保留消息边界。学会用"读缓冲 + 状态机"这一套,粘包和半包就都不是问题了。状态机至少要有"读头部"和"读体"两个状态,每次recv之后在状态机里推进,直到数据不足时停在当前状态等待下次数据。

事件丢失一般有三个来源。一是上面说的 ET 模式没读干净;二是epoll_ctl(EPOLL_CTL_ADD)时用了错误的data(比如没初始化ev的没用到字段,虽然不影响,但加多个 fd 时忘记重新赋值ev.data.fd会导致 fd 错乱);三是epoll_wait返回EINTR时直接break退出循环,导致进程意外终止。这几个都是熟能生巧的活,写完代码自己strace跑一遍就都明白了。

4.3 连接数与内核参数的现场记录

事件驱动模型能扛多少连接,除了代码,内核参数的限制也很关键。ulimit -n决定单进程能打开的 fd 数,默认往往只有 1024,做高并发必须提到 65535 甚至更高,改的时候要同时改/etc/security/limits.conf里的nofile、systemd 服务的LimitNOFILE、以及fs.file-max。这三处漏一处都可能出现"文件描述符耗尽"的报错,而且报错信息通常很隐晦,比如accept返回EMFILE,此时不要关闭监听 fd,正确做法是先关掉一个已建立的空闲连接或者暂停 accept 一小会儿,因为EMFILE时新连接还在内核队列里,不是没有。

TIME_WAIT堆积也常被误认为泄漏。主动关闭的一方会进入TIME_WAIT并保持 2MSL(Linux 上默认 60 秒),高并发短连接场景下ss -s里能看到成千上万个,这是正常状态,不是 bug。可以通过net.ipv4.tcp_tw_reuse允许复用来缓解,但不要迷信网传的调参,先确认是不是短连接设计本身导致了大量主动关闭,能用长连接就用长连接,比调参有效得多。

还有一个不太引人注意的参数是TCP_NODELAY。Nagle 算法会把小包攒起来一起发,配合对端的延迟确认(delayed ACK)可能引入几十毫秒的额外延迟,对于请求-响应型的低延迟服务,建议在accept之后立刻设置:

int on = 1; setsockopt(conn_fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));

这个在交互式服务里效果立竿见影,我调过一个 RPC 框架,加了这个选项后 P99 延迟从 40ms 左右降到 3ms 以内,改动只有一行。

4.4 常见问题速查表

现象可能原因排查手段处理方式
进程卡死无响应fd 未设非阻塞就进了 epollstrace -p看卡在哪个调用所有 epoll fd 设O_NONBLOCK,读循环到EAGAIN
连接随机断开把EAGAIN当致命错误加日志打印errnoEAGAIN/EWOULDBLOCK/EINTR单独处理
数据不响应ET 模式没读干净客户端发大包复现循环读到EAGAIN,或先改用 LT
accept报 EMFILEfd 数超限lsof -p pid | wc -l提高nofile,预留空闲 fd 应急
大文件传输卡住未处理写阻塞看是否遗漏EPOLLOUT加用户态写缓冲,动态注册写事件
延迟偏高Nagle 与延迟确认互相等待tcpdump看包间隔设置TCP_NODELAY
重启 bind 失败存在TIME_WAITss -tan state time-wait加SO_REUSEADDR
监听到但没接收backlog 溢出ss -lnt看Send-Q加大 backlog,循环 accept

这张表我建议贴在工位上,出问题的时候按行对,比漫无目的地翻日志快得多。

4.5 几个反直觉的实操心得

第一条,不要过早优化。我在一个日活几千的服务上先上了 epoll,后来发现连接数峰值不到 100,用阻塞加线程池代码量能少一半,可维护性还更好。性能优化的前提是先有压测数据证明瓶颈在哪,perf top、vmstat、pidstat三件套先跑一遍,确认瓶颈真的是 IO 等待而不是锁竞争或内存分配,再动手改架构。

第二条,epoll_wait之后的处理不要做重活。事件循环是单线程的,你在这批事件里做一次耗时 100ms 的数据库查询,所有其他连接就集体卡 100ms。重活要么扔给线程池,要么用异步接口,这是事件驱动架构的铁律。我自己踩过一次坑:在事件回调里读了本地配置文件(以为很快),结果文件所在磁盘刚好在做快照,一次读花了 2 秒,整个服务两秒无响应。

第三条,务必做慢连接攻击的防御。非阻塞服务器虽然不会因为单个慢连接卡死,但慢连接会持续占用内存和 fd,配合读超时是必须的:给每个连接记录最后活跃时间,在事件循环里用epoll_wait的timeout参数做周期性扫描(比如返回0表示超时),把空闲超过阈值的连接关掉。这一步不做,攻击者几条命令就能把你的连接池占满。

第四条,日志要能定位到 fd。事件驱动模型下调用栈是扁平的,出问题时只有 fd 这个线索,所以每条关键日志都带上 fd、事件类型、errno。我做项目时习惯定义一个连接上下文结构体,把 fd、对端地址、状态、最后活跃时间打包在一起,注册 epoll 时用ev.data.ptr指向它而不是用ev.data.fd,这样回调里直接拿到完整上下文,比到处查表方便得多,也少一次映射查找。这个改动很小,但对后期排障效率提升非常明显。

最后分享一个小技巧:写 epoll 代码的时候,先写 LT 版本跑通全部功能,再在epoll_ctl处加EPOLLET切换成 ET 做压测对比,如果 ET 版本的行为和 LT 完全一致(功能、错误日志都一样),说明你的读循环写对了;一旦出现数据丢失或不响应,就回去检查读循环。这个对照测试我做过好几次,比自己盯着代码找漏读要靠谱得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 9:35:38

JDBC 三大 SQL 执行方法区别与避坑

线上排查问题时&#xff0c;我见过太多次类似的场景&#xff1a;一段跑了大半年的定时任务突然开始报Can not issue data manipulation statements with executeQuery()&#xff0c;值班同学第一反应是数据库出问题了&#xff0c;翻日志翻监控折腾两小时&#xff0c;最后发现是…

作者头像 李华
网站建设 2026/9/29 9:31:46

OTN技术体系详解:ODUk映射、光层设计与排障实战

简介&#xff1a;面向光传输网络工程师、通信专业学生及运维人员的OTN技术体系专题PDF&#xff0c;系统梳理ITU-T从G.872网络架构到G.709接口标准、G.798设备功能模块、G.808/873保护机制等核心标准&#xff0c;并结合标准体系图与分层结构示意&#xff0c;从演进背景到架构细节…

作者头像 李华
网站建设 2026/9/29 9:28:52

机器视觉产线相机到PLC链路部署与接线实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 9:27:58

白盒测试与黑盒测试如何分工:从测试金字塔到团队人员分配实践

“白盒测试&#xff0c;黑盒测试&#xff0c;项目团队人员分配”&#xff0c;这三个词放在一起&#xff0c;基本就是中小型研发团队在搭建测试体系时绕不开的三座大山。我见过太多项目组&#xff0c;要么全员扑在黑盒功能验证上&#xff0c;上线前白盒用例覆盖率惨不忍睹&#…

作者头像 李华
网站建设 2026/9/29 9:26:58

strace与dtruss实战:从系统调用定位卡死、崩溃与性能瓶颈

strace和dtruss这两个命令&#xff0c;对不少开发者来说可能听过名字&#xff0c;但真正用顺手的并不多。我刚开始接触系统调用跟踪时&#xff0c;也只觉得它们是"高级版的黑盒调试器"&#xff0c;直到有一次线上服务莫名卡死、日志里什么都没留下&#xff0c;靠着st…

作者头像 李华