多路IO复用详解:select、poll与epoll实战对比

发布时间:2026/10/10 3:11:13
多路IO复用详解:select、poll与epoll实战对比 1. 写在前面从“头疼的并发”说起刚开始写网络程序那会儿最烦的就是处理多个连接。早期我做了一个小的聊天室Demo服务端逻辑很简单accept 一个客户端然后 recv 数据、再 send 回去。单连接跑起来一切正常一旦同时接入三五个客户端程序就卡得死死的——第一个客户端的 recv 阻塞在那里后面排队的连接根本没机会被 accept。用多线程倒是能解决但每来一个连接就创建一个线程线程一多上下文切换开销大得吓人CPU 都被浪费在调度上了。后来接触到 Linux 系统编程里的多路 IO 复用IO Multiplexing才算是把这个问题从根上解开了。简单说多路 IO 复用就是让一个进程/线程同时监视多个文件描述符fd当其中任何一个 fd 就绪可读、可写、出错时再去执行对应的 IO 操作。睡觉的功夫从“一个人只能守一个门口”变成了“一个保安坐镇大厅盯着所有门口的对讲机”谁按铃了就处理谁。本文就从实际使用角度出发依次聊透三种主流多路复用方案select、poll、epoll并用 c 代码做一个基于 epoll 的高并发 echo 服务端实战最后整理一些我在实际项目中踩过的坑。适合正在学习 Linux 网络编程、想彻底搞懂高并发 IO 模型的开发者也适合那些已经被阻塞 IO 和多线程搞到头大的朋友。2. 为什么需要多路 IO 复用2.1 阻塞 IO 的天然缺陷在理解多路复用之前先明确一下我们到底在优化什么。默认情况下Linux 的套接字是阻塞模式的一切 IO 操作都“不达目的不罢休”。阻塞在 accept()如果没有新连接到达调用线程就一直卡住。阻塞在 recv()如果客户端不发数据服务端线程就一直挂起等待。阻塞在 send()如果发送缓冲区满了线程也会卡住。这种设计对开发者很友好——代码逻辑跟“流水账”一样逐行执行不出幺蛾子。但它对系统资源极度浪费一个线程只能服务一个连接为了服务一百个连接就得开一百个线程。线程多了以后CPU 时间片疯狂切换每次切换还要保存和恢复线程上下文真正常干活的时间不到一半。2.2 非阻塞 IO 轮询的长处与短处最朴素的改进是给 fd 设置非阻塞模式fcntl 加 O_NONBLOCK然后循环调用 recv。没有数据就返回 -1errno 设为 EAGAIN于是线程可以接着去检查下一个连接。这个思路通但效率上有个致命伤轮询是“空转”的。大多数时候没有任何 fd 就绪可程序还得一遍一遍地全量检查。连接数一多大量 CPU 时间都花在无意义的系统调用上同样撑不住。2.3 多路复用的核心价值多路 IO 复用的优势在于把“主动询问”变成了“通知机制”。调用 select / poll / epoll_wait 时把一批 fd 交给内核然后睡觉。内核一旦发现有 fd 就绪了就把线程唤醒线程再针对就绪的那些 fd 做实际 IO。这种方式一方面大大减少了无效系统调用另一方面让单线程就能管理成千上万个连接完美契合高并发场景。像 Nginx、Redis、Netty 底层用的就是这套思路只不过在 Linux 上最终落地到了 epoll。注意多路复用本身是同步 IO它只是帮你“察觉”谁就绪了实际的 read/write 仍然由你自己完成。3. 三种主流方案细节对比3.1 select老当益壮但限制明显select 是历史最悠久的多路复用接口几乎所有类 Unix 系统都支持。原型如下#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);使用套路是把关心的 fd 放进 fd_set调用 select内核会阻塞直到有 fd 就绪或超时。select 内部会修改 fd_set因此每次调用前都需要重新填充。select 最遭人诟病的三个点fd 数量上限FD_SETSIZE 通常为 1024无法高效管理海量连接。每次都要全量拷贝用户态的 fd_set 每次调用都要整体拷贝到内核态内核检测完后还要把整个集合拷回用户态数据量一大拷贝开销就很可观。只能模糊感知就绪select 返回后你只知道“有 fd 就绪了”但不知道具体是哪一个必须线性遍历所有 fd 去检查 FD_ISSET。3.2 poll解决了上限问题但线性扫描依旧poll 的接口比 select 稍微现代一点#include poll.h int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; // 文件描述符 short events; // 期望的事件 short revents; // 实际发生的事件由内核填充 };poll 不再使用 fd_set而是用一个 pollfd 数组所以理论上 fd 数量没有上限。另外 poll 把“期望事件”和“发生事件”分开放在 events 和 revents 上每次调用不用重新初始化整个数组。但 poll 还是没治好两个老毛病全量拷贝问题依旧fds 数组仍需在用户态和内核态之间来回拷贝。返回后仍需要线性遍历内核不会告诉你哪个 fd 就绪你仍要遍历 fds 数组检查 revents 是否非零。当连接数达到十万级别时每次调用 poll 先在内核里线性扫描所有 fd再回用户态线性扫描一遍妥妥的 O(n) 复杂度性能曲线非常难看。3.3 epollLinux 下的性能担当epoll 是 Linux 2.6 以后引入的事件驱动模型也是目前 Linux 高并发服务器的标准答案。它通过三个系统调用协同工作#include sys/epoll.h int epoll_create(int size); // 创建 epoll 实例 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册 / 修改 / 删除 fd int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件与 select/poll 最大的不同epoll 在内核里维护了一个事件表底层用红黑树管理注册的 fd并借助回调机制把就绪的 fd 链入就绪队列。epoll_wait 返回时直接把就绪事件写入用户提供的 events 数组应用层只需遍历就绪列表即可不需要扫描全部 fd。关键点总结O(1) 复杂度注册、删除、等待就绪事件都和 fd 数量没有线性关系。零拷贝优化epoll_wait 可以配合 EPOLLET 和 mmap 等手段减少数据拷贝。水平触发与边缘触发默认是水平触发LT数据没读完会持续通知边缘触发ET只在状态变化时通知一次配合非阻塞 IO 使用可以大幅减少系统调用次数。3.4 三方对比速查表维度selectpollepoll底层结构fd_set 位图pollfd 数组红黑树 就绪链表最大连接数受 FD_SETSIZE 限制无上限受内存限制无上限受内存限制拷贝开销每次调用全量拷贝每次调用全量拷贝使用 mmap 减少拷贝就绪检测方式线性遍历全部 fd线性遍历全部 fd直接返回就绪列表无需遍历全部事件触发模式水平触发水平触发水平触发 边缘触发可移植性极高有 Unix 的地方就有较高仅 Linux性能瓶颈fd 多时很吃力fd 多时依旧吃力支撑百万并发的主流之选3.5 选型建议需要兼容老旧系统和嵌入式环境用 select。没有历史包袱且目标平台锁定 Linux直接 epoll。想写跨平台网络库包括 macOS、Windows建议封装一层接口底层按平台分别用 epoll / kqueue / IOCP。至于 poll其实处境比较尴尬它既没有 select 的便携性优势又没有 epoll 的性能优势新项目里我基本不会主动选它。4. 实战基于 epoll 的高并发 echo 服务端接下来写一个可以直接运行的 echo 服务器客户端连上来后发什么服务端就原样回什么。为了更贴近真实使用场景我会在代码中加入非阻塞模式、边缘触发、以及错误处理逻辑。4.1 环境与设计思路操作系统Linux内核版本建议 4.0。编译器gcc代码标准使用 c11。核心流程socket - bind - listen - epoll_create - epoll_ctl 注册监听 fd - 循环 epoll_wait - 就绪处理accept 新连接 / 收发数据。设计上只使用一个线程、一个 epoll 实例就能同时管理监听套接字和所有客户端连接。这正是高并发服务端最基础的骨架。4.2 初始化监听套接字#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include fcntl.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/epoll.h #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 static int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) { perror(fcntl F_GETFL); return -1; } if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) -1) { perror(fcntl F_SETFL); return -1; } return 0; } static int create_listen_socket(int port) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd -1) { perror(socket); return -1; } int reuse 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); 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)) -1) { perror(bind); close(listen_fd); return -1; } if (listen(listen_fd, 128) -1) { perror(listen); close(listen_fd); return -1; } set_nonblock(listen_fd); printf(listening on port %d\n, port); return listen_fd; }这里有一点值得解释监听套接字我直接设置成了非阻塞。实际场景下accept 之前通过 epoll_wait 已经知道有连接来了所以 accept 大概率不会阻塞但为了防御极端情况比如连接在 epoll_wait 返回后、accept 之前被客户端断开用非阻塞模式加上错误处理才是最稳妥的写法。4.3 创建并初始化 epoll 实例int main(int argc, char *argv[]) { int port 9000; if (argc 1) port atoi(argv[1]); int listen_fd create_listen_socket(port); if (listen_fd -1) return 1; int epfd epoll_create(1); if (epfd -1) { perror(epoll_create); return 1; } struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); struct epoll_event events[MAX_EVENTS]; for (;;) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n -1) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // 接受新连接 handle_accept(epfd, listen_fd); } else { // 处理客户端数据 handle_client(epfd, events[i]); } } } close(epfd); close(listen_fd); return 0; }epoll_create 的 size 参数在新内核里其实已经没用了但传 0 或 1 并不会报错这里传 1 只是保持兼容习惯。真正的重点在于 epoll_wait 的 timeout 参数-1 表示永久阻塞0 表示立即返回正整数表示最多等待多少毫秒。4.4 处理新连接static void handle_accept(int epfd, int listen_fd) { struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); while (1) { int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, addr_len); if (conn_fd -1) { if (errno EAGAIN || errno EWOULDBLOCK) { // 已经没有等待处理的连接了 break; } else if (errno EINTR) { continue; } else { perror(accept); break; } } set_nonblock(conn_fd); struct epoll_event ev; ev.events EPOLLIN | EPOLLET; // 边缘触发 可读 ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); printf(new client connected: fd%d, addr%s:%d\n, conn_fd, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); } }这里用了 while 循环疯狂 accept直到 EAGAIN 才停。因为在边缘触发模式下必须一次把当前所有就绪连接都处理完否则下一次可能不会再有 EPOLLIN 通知了。提示ET 模式下最稳妥的写法是循环 IO 直到返回 EAGAIN一次性把事情干完。这也是判断是否用对了 ET 的硬指标。4.5 收发数据与心跳检测static void handle_client(int epfd, struct epoll_event ev) { int fd ev.data.fd; char buffer[BUFFER_SIZE]; ssize_t n; while (1) { n recv(fd, buffer, sizeof(buffer) - 1, 0); if (n 0) { buffer[n] \0; // echo 回显 send(fd, buffer, n, 0); printf(echo: %s, buffer); } else if (n 0) { // 对端关闭连接 printf(client closed: fd%d\n, fd); close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 数据读完了退出当前循环 break; } else if (errno EINTR) { continue; } else { perror(recv); close(fd); break; } } } // 如果 fd 仍然处于打开状态且之前没处理 EPOLLERR / EPOLLHUP int is_closed 0; if (ev.events (EPOLLERR | EPOLLHUP)) { // 出错或挂断主动关闭 is_closed 1; printf(error or hangup: fd%d\n, fd); close(fd); } (void)epfd; (void)is_closed; }这段代码里有个细节值得强调边缘触发模式下recv 必须一直读到 EAGAIN才能保证没有残留数据。千万不要写“只 recv 一次就完事”的逻辑那在 LT 模式下能跑换到 ET 模式下分分钟丢数据。4.6 完整的 Makefile 与运行测试CC gcc CFLAGS -Wall -O2 -stdc11 TARGET echo_server all: $(TARGET) $(TARGET): main.c $(CC) $(CFLAGS) -o $(TARGET) main.c clean: rm -f $(TARGET)编译运行make ./echo_server 9000终端开一个连接测试nc localhost 9000 hello, epoll hello, epoll再用 ab 或者写个多线程客户端压测会发现单进程 epoll 可以轻松扛住上万连接而同等条件下 select/poll 已经明显吃力了。5. 常见问题与排查技巧实录5.1 读不到数据、丢包严重先查 ET/LT很多人第一次用 EPOLLET 都会遇到“明明有数据却不通知”的诡异现象。最常见的原因就是读数据时没有循环读或者读了一部分就退出导致还有数据残留在内核缓冲区而 ET 模式又不会再次通知。排查步骤如下确认 fd 设置了非阻塞。确认 recv 循环处理到 EAGAIN 才退出。临时把 EPOLLET 去掉改成默认 LT如果问题消失基本就是 ET 使用姿势不对。5.2 epoll_wait 频繁返回但程序 CPU 占用居高不下这个大概率是电平触发的“忙轮询”效应。套接字一直可读比如客户端一直在发心跳包LT 模式就会频繁唤醒每次 epoll_wait 都返回同一个 fd。优化方式处理完数据后如果暂时不关心额外事件可以用 EPOLLONESHOT 避免重复通知。收发频繁的业务考虑 ET 模式。一定要配合非阻塞 IO避免在同一个 fd 上卡死事件循环。5.3 有没有必要纠结 ET 和 LT从我实践来看LT 模式代码简单、不容易出错适合绝大多数业务ET 模式性能上限更高但要求开发者对 IO 循环有透彻理解。我自己在网关项目里用 ET 多一些在业务服务里经常用 LT并没有哪种绝对更好只有哪种更适合你的团队和场景。5.4 惊群问题多线程/多进程同时 epoll_wait 同一个 epoll 实例时一个就绪事件可能会唤醒多个等待者但最终只有一个能处理成功其他线程白白被唤醒这就是“惊群”thundering herd问题。解决办法有几种使用 EPOLLEXCLUSIVE 标志Linux 4.5让内核只唤醒其中一个等待者。自行设计事件分发只有一个线程负责 epoll_wait拿到就绪 fd 后分发给其他工作线程处理。by SO_REUSEPORT 配合多进程监听同一个端口把 accept 分散到多个进程上。我在项目里用得最多的是“单 epoll 线程 工作线程池”的模型这样既避免了惊群又能利用多核 CPU 的处理能力。5.5 调试利器与经验strace跟踪系统调用确认 epoll_ctl 和 epoll_wait 的实际行为和参数。ss / netstat查看连接状态区分 TIME_WAIT、ESTABLISHED 等。perf分析 CPU 占用热点确认瓶颈是不是在 epoll_wait 或 recv 上。tcpdump抓包看实际交互排查对端异常关闭等场景。6. 最后的几点个人体会多路 IO 复用是 Linux 高并发编程绕不开的基石select、poll、epoll 三兄弟各有各的舞台但从实际生产环境看epoll 已经基本统治了高性能网络服务端。我最早学这块的时候也是从 select 一路写到 epoll每次切换都经历了“原来还能这样”的顿悟。如果读者只是刚接触建议务必亲手把上面这段 echo 服务端跑起来再试试把 EPOLLET 去掉换成 LT对比一下行为差异然后自己写一个压力测试脚本观察 select / poll / epoll 在不同并发数下的表现。只有亲手踩过一遍这些坑才能真正把多路复用的模型刻在脑子里。后续如果感兴趣还可以继续延伸 Reactor 模式、事件驱动框架甚至自己封装一个迷你版网络库这条路值得慢慢走。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询