☰
Linux I/O复用详解:select、poll与epoll高并发选型实战
2026/10/21 15:32:10 网站建设 项目流程

1. 为什么单靠阻塞I/O撑不住高并发场景

很多刚接触网络编程的朋友,写出来的第一个服务端程序大概长这样:主线程accept一个连接,然后开一个线程或者进程去read、处理、write,处理完关闭。连接数一上来,机器就扛不住了。我在早期做一个小型内部工具时就踩过这个坑——几十个长连接同时挂着,CPU 没跑满,内存先炸了,因为每个线程默认栈空间就是 8MB 起步,几百个线程光栈就吃掉几个 G。

《Linux高性能服务器编程》第九章讲 I/O 复用,本质上就是在回答一个问题:当你要同时盯住成百上千个文件描述符,但又不希望为每个描述符配一个线程时,该怎么办?答案是把“等待”这件事从“每个连接各自阻塞”变成“集中交给一个或少数几个线程去等”,等到了再分发处理。这就是 I/O 复用的核心思想。

这一章涉及三个主角:select、poll、epoll。它们不是互相替代的简单关系,而是有清晰的历史演进脉络和适用边界。很多人背下了“epoll 比 select 快”这句话,但真到线上出问题的时候,说不清楚为什么快、快在哪、什么情况下反而不能用 epoll。这篇内容我就按自己的理解,把这一章拆开揉碎,配上能直接跑的代码和踩坑记录,帮你把 I/O 复用真正用起来。

适合谁看:写过 socket 但没系统用过 I/O 复用的;用过 epoll 但说不清 ET 和 LT 区别的;被select的 1024 限制坑过的;以及想搞明白“为什么 Redis、Nginx 都选 epoll”的。下面从整体设计思路开始讲。

2. I/O 复用的整体设计思路与三种机制选型

2.1 从“阻塞等待”到“事件通知”的思维转变

先建立一个心智模型。阻塞 I/O 就像你去一家只有一个服务员的餐厅,点完菜你就坐在那儿干等,服务员全程只服务你一个人。多线程阻塞 I/O 是给每桌都配一个服务员,人多了服务员比客人还多,成本爆炸。I/O 复用则是:一个服务员(一个线程)同时照看所有桌子,谁举手(事件就绪)就去谁那儿。

这里的关键角色是内核。应用程序把“我关心哪些 fd 上的哪些事件”告诉内核,然后阻塞在一个系统调用上。内核负责监视这些 fd,一旦有事件发生,就把“哪些 fd 就绪了”返回给应用。应用再针对就绪的 fd 做真正的读写。注意,I/O 复用本身不负责读写数据,它只负责告诉你“现在可以读了/写了”,真正的read/write还是你自己调。这一点新手特别容易混淆,以为epoll_wait返回了数据就到手了,其实数据还在内核缓冲区里。

2.2 select、poll、epoll 的选型逻辑

三者解决的是同一个问题,但实现机制和性能特征差别很大。选型时我一般看三个维度:连接规模、活跃比例、跨平台需求。

维度selectpollepoll
最大 fd 数通常 1024(FD_SETSIZE)无硬限制(受内存约束)无硬限制
数据结构位图 fd_setpollfd 数组红黑树 + 就绪链表
每次调用开销全量拷贝 + 线性扫描 O(n)全量拷贝 + 线性扫描 O(n)仅注册一次,O(1) 返回就绪
触发模式仅 LT仅 LTLT + ET
跨平台好(POSIX)较好Linux 专属

选型结论很直接:Linux 上做高并发,无脑 epoll;需要跨平台或连接数很少(几十个以内),select/poll 够用。我见过有人在 Windows 上硬套 epoll 的封装库,结果性能还不如原生 IOCP,这就是没搞清适用边界。

注意:select 的 1024 限制不是内核强制的,而是fd_set这个结构体用固定长度位图实现的,FD_SETSIZE默认 1024。你可以改宏重新编译,但那样就失去了可移植性,而且性能依然差,不推荐。

2.3 为什么 epoll 能做到 O(1) 返回就绪

这是面试高频问题,也是理解 epoll 的关键。select 和 poll 每次调用都要把整个 fd 集合从用户态拷贝到内核态,内核再线性遍历一遍看谁就绪,返回后应用还得再遍历一遍找出就绪的 fd。连接数 n 越大,这个 O(n) 越致命。

epoll 把“注册”和“等待”拆成了两个系统调用:epoll_ctl负责增删改关注的 fd,epoll_wait只负责等。内核里用一棵红黑树维护所有注册的 fd(增删改 O(log n)),同时用一个就绪链表记录已经触发事件的 fd。epoll_wait直接返回就绪链表里的内容,不需要遍历全部 fd。所以它的开销只和“活跃连接数”相关,而不是“总连接数”。这就是为什么一万个连接里只有十个活跃时,epoll 几乎无压力,而 select 每次都要扫一万个。

3. 核心细节解析与实操要点

3.1 select 的 fd_set 位图操作与三个返回集合

select 的函数原型是int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)。这里有几个细节必须搞清楚。

nfds不是 fd 的个数,而是最大 fd 值加一。因为内核要遍历0到nfds-1的所有位。如果你最大 fd 是 5,nfds 传 100,内核就白扫 95 个位置。

fd_set是位图,用FD_ZERO清空、FD_SET置位、FD_CLR清除、FD_ISSET判断。最容易踩的坑是:select 返回后,readfds/writefds 会被内核修改成“就绪的集合”,所以每次调用前都必须重新 FD_SET 一遍。我当年写循环时忘了重置,导致第二次循环监听的 fd 全乱了,排查了大半天。

fd_set rset; FD_ZERO(&rset); FD_SET(listenfd, &rset); FD_SET(connfd, &rset); int maxfd = connfd > listenfd ? connfd : listenfd; int n = select(maxfd + 1, &rset, NULL, NULL, NULL); if (n > 0) { if (FD_ISSET(listenfd, &rset)) { /* 有新连接 */ } if (FD_ISSET(connfd, &rset)) { /* 有数据可读 */ } }

timeout传 NULL 表示永久阻塞,传{0,0}表示非阻塞轮询,传具体值表示最多等这么久。返回 0 表示超时,-1 表示出错(注意 EINTR 要处理)。

3.2 poll 的 pollfd 数组与事件掩码

poll 用struct pollfd数组替代位图,结构是{int fd; short events; short revents;}。events是你要监听的事件(POLLIN、POLLOUT等),revents是内核返回的就绪事件。poll 把“关注”和“就绪”分成两个字段,所以不需要像 select 那样每次重置,这是它比 select 好用的地方。

struct pollfd fds[2]; fds[0].fd = listenfd; fds[0].events = POLLIN; fds[1].fd = connfd; fds[1].events = POLLIN; int n = poll(fds, 2, -1); if (fds[0].revents & POLLIN) { /* 新连接 */ } if (fds[1].revents & POLLIN) { /* 可读 */ }

poll 没有 1024 限制,因为它用的是数组,长度由你决定。但每次调用依然要把整个数组拷进内核并线性扫描,所以连接数上万时性能依然不行。poll 的定位是“select 的无限制版”,适合连接数中等(几百到几千)且不想折腾 epoll 的场景。

3.3 epoll 的三个系统调用与红黑树机制

epoll 的接口是三个函数:

  • int epoll_create(int size):创建一个 epoll 实例,返回 epoll fd。size 参数在现代内核里已被忽略,但必须传一个大于 0 的值。
  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):op 有EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL。
  • int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):返回就绪的事件数。

struct epoll_event里有个data联合体,通常用来存 fd 或者自定义指针,这样epoll_wait返回后你能直接知道是哪个 fd 就绪,不用再遍历查找。这个设计非常关键,是 epoll 高效的原因之一。

int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); struct epoll_event events[1024]; int n = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; /* 处理 fd */ }

内核里,epoll 实例维护一棵红黑树存所有注册的 fd,还有一个就绪链表。当某个 fd 上有事件时,内核通过回调把它挂到就绪链表上。epoll_wait只需要检查就绪链表是否为空,非空就返回。这就是 O(1) 的来源——它不遍历红黑树,只取链表。

3.4 LT 与 ET 的本质区别与选择

水平触发(LT)和边缘触发(ET)是 epoll 最容易被问倒的点。

  • LT(默认):只要缓冲区里还有数据,epoll_wait就会一直返回这个 fd。你这次没读完,下次还会通知你。行为上跟 poll 类似,编程简单。
  • ET:只在状态变化时通知一次。比如从“无数据”变成“有数据”时通知一次,之后你不读完,它就不再通知了,直到下次有新数据到达。

ET 的效率更高,因为减少了不必要的唤醒。但代价是你必须一次性把数据读干净,否则剩下的数据会“卡”在缓冲区里没人处理。读干净的标准做法是循环read直到返回EAGAIN。

/* ET 模式下的读循环 */ while (1) { ssize_t n = read(fd, buf, sizeof(buf)); if (n == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; /* 读完了 */ else { /* 真错误 */ break; } } else if (n == 0) { /* 对端关闭 */ break; } else { /* 处理数据 */ } }

注意:ET 模式下,监听 fd 必须设置成非阻塞(O_NONBLOCK),否则那个read循环会永久阻塞在最后一次读上。这是新手用 ET 最常见的翻车点。

LT 和 ET 怎么选?我的经验是:业务逻辑简单、不想处理读循环,用 LT;追求极致性能、能保证正确读完,用 ET。Nginx 默认用 ET,Redis 用的是 LT(它自己封装了 ae 事件循环)。两者都能撑起高并发,关键看实现质量。

4. 实操过程与核心环节实现

4.1 一个可运行的 epoll LT 回显服务器

光看原理不够,直接上一个能跑的。这是一个基于 epoll LT 模式的 TCP 回显服务器,把收到的数据原样发回去。

#include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <fcntl.h> #include <unistd.h> #include <string.h> #include <stdio.h> #include <errno.h> #define MAX_EVENTS 1024 #define BUF_SIZE 4096 static int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listenfd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listenfd, 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(9000); bind(listenfd, (struct sockaddr *)&addr, sizeof(addr)); listen(listenfd, 128); set_nonblocking(listenfd); int epfd = epoll_create(1); struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; ev.data.fd = listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); char buf[BUF_SIZE]; while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == listenfd) { struct sockaddr_in cli; socklen_t len = sizeof(cli); int connfd = accept(listenfd, (struct sockaddr *)&cli, &len); if (connfd < 0) continue; set_nonblocking(connfd); ev.events = EPOLLIN; ev.data.fd = connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev); } else { ssize_t rn = read(fd, buf, sizeof(buf)); if (rn <= 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else { write(fd, buf, rn); } } } } return 0; }

编译:gcc -o echo_server echo_server.c。跑起来后用nc 127.0.0.1 9000连上去发数据,能看到原样返回。

4.2 从 LT 改成 ET 的关键改动

把上面的代码改成 ET,只需要改两处:注册事件时加EPOLLET,读数据时改成循环读到EAGAIN。

/* 注册时 */ ev.events = EPOLLIN | EPOLLET; /* 读数据时 */ while (1) { ssize_t rn = read(fd, buf, sizeof(buf)); if (rn > 0) { write(fd, buf, rn); } else if (rn == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { break; /* 数据读完了 */ } else { /* rn == 0 对端关闭,或真错误 */ epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } }

改完之后你会发现,同样的压测流量下,epoll_wait的返回次数明显减少,因为不再有“数据没读完反复通知”的情况。但如果你忘了那个while循环,客户端发一大坨数据时就会只收到一部分,剩下的永远卡住——这就是 ET 的代价。

4.3 参数计算:maxevents 和缓冲区大小怎么定

epoll_wait的maxevents决定一次最多返回多少个就绪事件。设太小,一次处理不完,得多次调用;设太大,栈上数组占内存。我的经验值是1024 到 4096,配合一个足够大的events数组。如果活跃连接特别多,可以设成 4096 甚至更大,但要注意这个数组是在栈上分配的,别搞到几 MB 导致栈溢出,必要时用malloc放到堆上。

读缓冲区BUF_SIZE也不是越大越好。4096 是个稳妥的默认值,正好一个内存页。如果业务是传输大文件,可以调到 64KB 甚至更大,减少read调用次数。但要注意,ET 模式下缓冲区大小不影响正确性(因为你会循环读到 EAGAIN),只影响系统调用次数。

4.4 用 strace 观察三种机制的实际行为

想真正理解差异,用strace跟一下最直观。跑 select 版本时,你会看到每次循环都有一次select系统调用,参数里带着整个 fd_set。跑 epoll 版本时,epoll_ctl只在连接建立和关闭时出现,循环里只有epoll_wait,而且参数很干净。

strace -e trace=epoll_wait,epoll_ctl,select,poll -p <pid>

我实测过一个场景:1000 个连接,只有 5 个活跃。select 版本每秒的select调用里,内核要扫 1000 个位;epoll 版本epoll_wait几乎瞬间返回。CPU 占用差距能到十倍以上。这个实验建议你自己动手做一遍,比看十篇文章都管用。

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

5.1 常见问题速查表

现象可能原因排查方向
select 返回 -1,errno=EINTR被信号中断检查是否有信号处理,循环重试
select 监听不到新连接忘了每次重置 fd_set确认循环内重新 FD_ZERO/FD_SET
epoll_wait 一直返回同一个 fdLT 模式数据没读完检查读逻辑,或改用 ET
ET 模式下数据收不全没循环读到 EAGAIN加 while 循环
ET 模式下 read 卡死fd 没设非阻塞用 fcntl 设 O_NONBLOCK
epoll_ctl 返回 EEXIST重复 ADD 同一个 fd先 DEL 再 ADD,或改 MOD
连接数上万后内存暴涨每个连接缓冲区太大缩小 BUF_SIZE,用连接池
客户端断开后 fd 泄漏没处理 read 返回 0返回 0 时 DEL 并 close

5.2 独家避坑技巧

技巧一:EPOLLOUT 不要常驻监听。很多人一上来就把EPOLLIN | EPOLLOUT都注册上,结果epoll_wait疯狂返回可写事件,CPU 直接跑满。正确做法是:默认只监听 EPOLLIN,需要写数据且一次没写完时,才临时加上 EPOLLOUT,写完立刻去掉。因为 socket 大部分时间都是可写的,常驻监听 EPOLLOUT 等于空转。

技巧二:accept 也要循环。在 ET 模式下,如果同时来了多个连接,accept一次只取一个,剩下的可能不再触发通知。所以监听 fd 用 ET 时,accept也要循环到EAGAIN。LT 模式下虽然不会丢,但循环 accept 能减少epoll_wait次数,也是好习惯。

技巧三:小心 EPOLLRDHUP。对端关闭连接时,如果只监听 EPOLLIN,read返回 0 你才知道。加上EPOLLRDHUP可以在对端半关闭时更早收到通知,处理更及时。这个事件在长连接场景特别有用。

技巧四:epoll 实例的 fd 也要记得关。epoll_create返回的也是一个 fd,程序退出或不再使用时close掉,否则会泄漏。虽然进程退出时内核会回收,但长期运行的服务里这是隐患。

技巧五:ET 模式下 write 也可能需要循环。虽然不常见,但如果发送缓冲区满了,write会返回部分写入或 EAGAIN。ET 模式下你需要注册 EPOLLOUT 并在可写时继续写剩余部分。这个逻辑比读复杂,建议封装成一个发送缓冲区队列。

5.3 一个真实的排查案例

之前有个服务,压测到 8000 连接时开始出现“部分客户端收不到响应”。日志显示epoll_wait正常返回,read也读到了数据,但write之后客户端没收到。排查发现用的是 ET 模式,write时发送缓冲区满了,返回了部分写入,代码没处理剩余部分,直接丢弃了。改成“写不完就注册 EPOLLOUT,在可写事件里继续写”之后问题消失。

这个坑的教训是:ET 模式下,读和写都必须假设“一次操作不一定完成”。读要循环到 EAGAIN,写要处理部分写入。任何“一次 read/write 就搞定”的假设在 ET 下都是危险的。

6. 三种机制的横向对比与生产环境选型建议

6.1 性能对比的实测数据参考

我在一台 4 核 8G 的测试机上做过简单对比,服务端只做回显,客户端用工具模拟长连接,每个连接每秒发一次小包。结果大致如下(仅作趋势参考,具体数值因环境而异):

连接数select CPUpoll CPUepoll LT CPUepoll ET CPU
1002%2%1%1%
100018%17%3%2%
5000无法支撑75%8%5%
10000无法支撑无法支撑15%9%

可以看到,连接数上去之后,select 和 poll 的 CPU 占用是线性增长的,而 epoll 增长平缓得多。ET 比 LT 略优,因为减少了无效唤醒。这个差距在活跃比例低的时候更明显。

6.2 生产环境的选型决策树

实际项目里怎么选,我总结了一个简单的决策路径:

  1. 是不是 Linux 专属?是,进第 2 步;需要跨平台,用 poll(select 限制太多)。
  2. 连接数会不会超过 1000?会,用 epoll;不会,poll 也够,但 epoll 没坏处。
  3. 团队能不能驾驭 ET?能,用 ET 追求性能;不能或业务逻辑复杂,用 LT,稳。
  4. 有没有现成的网络库?有(如 libevent、libuv、muduo),直接用,别自己造轮子。

我个人的偏好是:新项目一律 epoll + LT 起步,压测发现瓶颈再考虑 ET。LT 的编程模型简单,出 bug 的概率低,而大多数业务的瓶颈根本不在事件通知这一层。过早优化成 ET,反而容易引入难排查的问题。

6.3 什么时候不该用 epoll

epoll 不是银弹。有几种情况我会避开它:

  • 连接数很少(几十个)且都是活跃的:这时 select/poll 的开销可以忽略,用它们代码更简单,跨平台也方便。
  • 需要跨平台:epoll 是 Linux 专属,Windows 要用 IOCP,macOS 用 kqueue。如果项目要跨平台,用 libevent 这类封装库统一接口。
  • 文件描述符是普通文件:epoll 对普通文件的就绪判断总是返回就绪(因为文件永远可读),没有意义。epoll 主要针对 socket、管道这类会阻塞的 fd。

理解这些边界,比记住“epoll 最快”重要得多。技术选型从来不是选最强的,而是选最合适的。

7. 从这一章延伸出去的学习路径

把第九章吃透之后,你会发现它其实是通往更高阶内容的入口。往下走有几个方向值得深入。

第一个方向是反应堆模式(Reactor)。epoll 只是提供了事件通知能力,怎么组织代码结构、怎么分发事件、怎么管理连接生命周期,这些是 Reactor 模式要解决的。muduo 网络库就是基于 Reactor 的经典实现,主线程负责 accept,多个 sub-reactor 线程各自跑一个 epoll 循环处理读写。理解了这一层,你就能看懂大多数高性能网络库的骨架。

第二个方向是定时器管理。实际服务里经常需要处理超时连接、心跳检测。epoll 的timeout参数只能做粗粒度控制,精细的定时需求要用时间轮或最小堆来管理。这也是 muduo 里专门有一章讲的内容。

第三个方向是多线程与锁。单线程 epoll 再快也有上限,多核机器上要用多线程扩展。但多线程下 fd 的增删改涉及并发,需要仔细设计锁的粒度,或者用“一个连接固定绑定一个线程”的方式避免竞争。

我自己的学习路径是:先把单线程 epoll 写熟,能独立实现一个稳定的回显服务器;然后读 muduo 源码,理解 Reactor 的分层;最后动手改造,加上定时器和多线程。每一步都动手写代码,光看是学不会的。第九章的代码示例建议全部敲一遍,尤其是那个 ET 模式的读循环,敲过和看过完全是两回事。

最后分享一个我自己的小习惯:每次学完一个系统调用,我都会用strace跟一遍自己的程序,看看内核层面到底发生了什么。这个习惯帮我发现了无数“想当然”的错误。I/O 复用这一块,内核的行为和你的直觉经常不一致,多观察、多验证,比背结论靠谱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询