资讯动态

select、poll、epoll、io_uring全面对比:高并发I/O多路复用实战指南

发布时间:2026/9/23 10:56:42 来源:尧图企业网站定制
做高并发服务端你早晚会撞上“I/O 多路复用”这个词。不管是写 Redis、Nginx 还是 Netty 的底层这套机制都是绕不开的核心。这里把 select、poll、epoll、io_uring 四条路线放在一起讲清楚从它们解决什么问题、底层原理是什么到实际开发中怎么写、怎么避坑一条线拉通。不管你是刚接触网络编程的初学者还是想升级现有服务端架构的开发者这篇文章都能用到。1. 重新认识 I/O 多路复用它到底解决了什么问题1.1 阻塞 I/O 的瓶颈在哪里先看一个最直观的场景你需要同时维持几万个客户端连接每个连接随时可能发数据过来你要及时读出来处理。最粗暴的做法是来一个连接就创建一个线程线程里用阻塞 read 等数据。但线程是有代价的每创建一个默认栈空间就要 8MB 左右的内存映射实际物理内存按需分配但地址空间、线程切换开销都在几万线程一开内存先爆一半CPU 全耗在上下文切换上。那用非阻塞 I/O 自己轮询呢就是每次 read 都设成非阻塞数据没到立刻返回 EAGAIN然后你循环去问所有连接“有数据了吗”。这种做法连接少还行一到几千个连接每次都要陷入内核去查一遍状态用户态到内核态来回切上万次性能照样崩。I/O 多路复用解决的就是这个矛盾让内核帮我们同时监听一堆连接哪几个真正有数据了你再处理哪几个。说白了一个线程就能盯住几万连接哪个准备好了通知你你就对那个操作没准备好的不瞎打听。1.2 多路复用的本质是“一次等待多个事件”select、poll、epoll 本质上都是同步 I/O 机制因为它们只是帮你监听事件真正读写数据还是要你亲自去做。io_uring 更激进它把读写请求都交出去完成之后异步通知你属于真正意义上的异步 I/O。理解这个区别很重要很多人误以为用 epoll 就等于异步 I/O 了不对epoll 只是事件通知机制数据还是要你 read/write 去拷贝。io_uring 才是把“提交请求—内核执行—返回结果”这条链路整个接管了。1.3 四条技术路线整体对比这里先用一个表格把整体印象建立起来后边再逐层展开。机制事件模型最大 fd 数量内核复杂度主要优势代价select轮询遍历 fd_set受 FD_SETSIZE 限制默认 1024低接口简单几乎全平台支持每次调用要拷贝整个集合O(n) 扫描poll轮询遍历 pollfd 数组理论无上限低没有 1024 限制接口比 select 清晰依然是 O(n) 扫描连接多时性能差epoll事件回调只返回就绪列表理论受系统内存约束高O(1) 获取就绪事件性能稳定仅 LinuxAPI 复杂ET 模式容易踩坑io_uring共享内存环形队列异步完成无谓的 fd 概念高减少系统调用支持真正的异步读写内核版本要求高稳定性仍在打磨2. select 和 poll老当益壮的基础方案2.1 select 的核心 API 与关键参数select 的函数原型是#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);nfds 是三个集合中最大 fd 值加 1内核只需要检查 0 到 nfds-1 这个范围就够了。fd_set 是一个位图结构用 FD_SET(fd, set) 把 fd 对应位置 1用 FD_ISSET(fd, set) 检查某一位是否就绪。这里有个经典的坑fd_set 是输入输出共用的参数select 返回后内核会把未就绪的 fd 对应的位清掉。所以每次调用 select 之前都要重新把所有关心的 fd 加到集合里不能复用上一次的结果。timeout 参数也很有讲究。传 NULL 表示永久阻塞传 {0, 0} 表示完全不等待纯轮询传其他值就是最多等这么久。每次 select 返回后Linux 会修改 timeval 为剩余的时间所以如果你想让超时是固定的每次调用前也要重新赋值。2.2 select 的三大痛点痛点一FD_SETSIZE 限制。默认定义在 glibc 头文件里通常是 1024。虽然你可以通过修改 FD_SETSIZE 再重新编译让它支持更大的集合但这种方式不优雅而且很多发行版对 fd_set 的实现是基于固定数组的调大 FD_SETSIZE 会改变结构体大小跨模块传递容易出兼容性问题。痛点二每次调用都要把整个 fd_set 从用户态拷贝到内核态。假设你监听 1000 个连接但每次只有 2 个有数据你仍然要为 1000 个 fd 的位图拷贝付出代价。痛点三内核返回后你还要再次遍历整个 fd_set 来看哪个 fd 就绪了时间复杂度 O(n)。连接数越多这个遍历代价越高。2.3 poll 的改进与不足poll 用 pollfd 数组替代了 fd_set#include poll.h struct pollfd { int fd; short events; // 感兴趣的事件POLLIN/POLLOUT/POLLERR short revents; // 内核返回的实际事件 }; int poll(struct pollfd *fds, nfds_t nfds, int timeout);poll 把输入事件 events 和输出事件 revents 拆开内核不会修改 events所以每次调用前不用重新构造数组只需要把 revents 清空就行。而且 pollfd 数组没有长度上限你传多少就检查多少突破了 1024 的限制。但 poll 的性能瓶颈还在内核仍然要线性遍历所有 fd检查每个 fd 的就绪状态时间复杂度 O(n)。连接数几万甚至几十万级别时每次 poll 的遍历开销就是几十万次系统调用级别的工作量很难扛住。2.4 什么场景还值得用 select/poll别以为 select 和 poll 就一无是处。跨平台兼容上select 是所有平台都支持的包括 Windows、macOS、各种嵌入式 RTOS。我在做一些小工具、网络调试脚本、单机上跑个几百连接的实验时select 依然是零负担的选择。另外如果监听的 fd 数量很少比如几十个select/poll 跟 epoll 的性能差距几乎可以忽略但代码复杂度却低一个量级。如果你的项目不需要成千上万连接那就别被“高性能”绑架简单就是最大的优势。3. epollLinux 高并发的事实标准3.1 epoll 的三个 API 与核心数据结构epoll 在 Linux 上提供了三个系统调用#include sys/epoll.h int epoll_create(int size); // 现在 size 只要大于 0 即可内核不按这个数预分配 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll_create 返回一个 epoll 实例句柄。内部维护了两个关键结构一棵红黑树和一个就绪链表。红黑树用于存储你通过 epoll_ctl 注册的所有 fd增删改查都是 O(log n)就绪链表用于存放内核检测到有事件发生的 fdepoll_wait 只需把这个链表里的元素拷贝到用户态数组所以是 O(k)k 是真正就绪的事件数量而不是全量 fd 数。epoll_ctl 支持三种操作EPOLL_CTL_ADD 注册、EPOLL_CTL_MOD 修改监听事件、EPOLL_CTL_DEL 移除。注意当一个 fd 被关闭后它会自动从 epoll 实例中移除但如果你在关闭之前没有从 epoll 里删除可能会出现短时间的竞态所以规范化流程是 EPOLL_CTL_DEL 之后再 close。3.2 水平触发 LT 与边缘触发 ET 的区别这是 epoll 最容易让人困惑的点。LT水平触发是默认模式只要 fd 缓冲区里还有数据epoll_wait 就会一直通知你。ET边缘触发是高速模式只有状态从“没有数据”变成“有数据”的那一刻才通知一次如果这次没把数据读完后续不再提醒。举个例子客户端一次性发来 10KB 数据缓冲区里有了内容LT 模式下 epoll_wait 会反复返回这个 fd 可读你分几次读都行。ET 模式下只在数据第一次到达时通知一次如果你只 read 了 2KB剩下的 8KB 要等客户端再发新数据时才会再次触发。所以 ET 模式要求你每次 read 都要循环读到 EAGAIN缓冲区暂时没数据了确保把当前累积的数据全部取完。并且 read 必须配合非阻塞 fd否则最后一次 read 会因为没数据而阻塞住整个线程。// 设置 fd 为非阻塞 int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); // ET 模式读数据直到 EAGAIN char buf[4096]; while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 处理数据 } else if (n 0) { // 对方关闭连接 close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 当前数据已读完 } if (errno EINTR) { continue; // 被信号打断重新读 } // 真错误 close(fd); break; } }ET 模式的优势在于减少了 epoll_wait 的重复唤醒次数对于高吞吐低延迟的场景效果明显。但它对代码正确性的要求更高必须保证每次唤醒都把数据处理干净。我见过不少团队在生产环境把 ET 用成了“数据丢一半”的灾难现场说实话如果你的业务逻辑还不稳定先用 LT 把功能跑通再去优化触发模式。3.3 就绪回调机制与为什么 epoll 是 O(1)epoll 之所以快关键在于它不依赖轮询。每个被监听的 fd 在内核里都绑定了一个回调函数当 socket 上有数据到达、连接建立、缓冲区可写等情况发生时驱动层会直接调用这个回调把对应的 fd 挂到就绪链表上。这样的设计让 epoll 的事件检测成本跟总连接数无关只要有事件发生就通知没事件内核就安静睡着。对比 select/poll它们是每次系统调用时都主动去遍历“所有 fd 现在谁准备好了”相当于领导天天跑全公司问“今天谁有工作要汇报”epoll 是员工有事主动找领导登记领导只管看登记表就行。不过要注意epoll 的红黑树操作其实也是 O(log n)所以严格讲 epoll_ctl 并不是 O(1)。但 epoll_ctl 只在连接建立和关闭时才调用不是每次数据交互都会发生所以对整体性能影响很小。真正高频的 epoll_wait 确实做到了 O(1)。3.4 epoll 的实用建议与避坑指南EPOLLONESHOT 是我比较推荐关注的一个标志。它表示当前 fd 被触发一次之后这个事件就自动被屏蔽直到你调用 epoll_ctl 重新注册。这个机制在处理多线程共享 epoll 场景时特别好用避免同一个 fd 被多个线程同时消费。比如你有个线程池处理读事件某个 fd 来了数据两个 worker 线程同时被唤醒如果不加 EPOLLONESHOT就可能出现两个线程读同一个 fd 的错误状况。惊群问题也值得一提。多个进程或线程同时 epoll_wait 监听同一个 fd当事件发生时所有等待者都会被唤醒但最终只有一个能处理事件其余都是无效唤醒。Linux 内核后来加了 EPOLLEXCLUSIVE 标志让唤醒策略改成“只唤醒一个”Nginx 在多 worker 模式下就依赖这个机制。还有一个坑是 LT 模式下忘记删除已关闭的 fd。fd 关闭后会自动从内核 epoll 实例移除但你如果已经把这个 fd 重新分配给一个新的连接操作系统会重用 fd 编号新连接的事件就可能被旧的 epoll 记录错误关联。这就是为什么正确流程是 EPOLL_CTL_DEL 再 close。4. io_uring新一代异步 I/O 的完全不同玩法4.1 共享内存环形队列把系统调用从热路径上拿掉io_uring 是 Linux 5.1 引入的异步 I/O 框架核心思想比 epoll 还要激进。传统系统调用的瓶颈在于每次收发数据都要陷入内核一次io_uring 通过用户态与内核态共享一块内存区域用两个环形队列来传递请求和结果提交队列 SQSubmission Queue和完成队列 CQCompletion Queue。应用程序要做读操作时先往 SQ 里塞一个读请求然后通过一次 io_uring_enter 系统调用告诉内核“有新任务了”用户态直接返回去做其他事。内核异步执行完读操作把结果写到 CQ应用再轮询 CQ 拿到结果。这就是所谓的“提交”和“收割”而真正的数据拷贝由内核帮忙处理对应用透明。liburing 库把它封装得更友好不需要直接跟环形队列打交道#include liburing.h struct io_uring ring; io_uring_queue_init(256, ring, 0); struct io_uring_sqe *sqe io_uring_get_sqe(ring); io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0); io_uring_sqe_set_flags(sqe, IOSQE_ASYNC); io_uring_submit(ring); // 等待完成 struct io_uring_cqe *cqe; io_uring_wait_cqe(ring, cqe); // cqe-res 是实际读取的字节数负数表示错误码 io_uring_cqe_seen(ring, cqe);4.2 io_uring 怎么做到事件监听和读写一起提交io_uring 不只是文件读写的利器它也支持类似 epoll 的事件等待能力。通过 IORING_OP_POLL_ADD 操作你可以把一个 socket fd 的读就绪监听作为一个请求提交到 SQ内核在有数据时往 CQ 里发完成通知。这样事件等待、数据读写、超时处理都可以在同一个异步队列里串联起来不需要写传统意义上的事件循环了。更妙的是io_uring 还支持链路提交linked sqe。比如你先提交一个 POLL_ADD 等待连接可读再接一个 READ 请求内核会在 POLL 完成之后自动执行 READ整个过程不需要用户态介入。这相当于把本来你在业务代码里写的“epoll_wait 后 read”两步操作下沉到内核里异步串联完成用户态只需要最后收割结果。这种模式的收益在高 IOPS 场景下非常明显。纯网络消息网关如果用 epoll每消息处理至少需要一个 epoll_wait read 两次系统调用用 io_uring 合并提交后理想情况下可以接近“一条消息一次系统调用”甚至“批量消息一次系统调用”。4.3 使用 io_uring 的注意事项io_uring 虽然强但真放生产环境要冷静评估。内核版本是第一关。目前主流服务器内核都在 5.10 以上io_uring 基础功能可用了但不同特性比如提供注册文件表、固定缓冲等有版本差异运行前要先确认内核对你要用到的 feature 是否支持。第二io_uring 对 buffered I/O 的支持不如 O_DIRECT 那么成熟。常规的文件读写用 buffered 模式数据还是要经过 page cache异步优势主要体现在减少上下文切换如果走 O_DIRECT还要注意内存对齐、长度对齐等问题复杂度又上一个台阶。第三io_uring 在多线程场景下的队列管理需要格外小心。默认每个 io_uring 实例是线程安全的但为了性能你可以设置 IORING_SETUP_SINGLE_ISSUER这时候就要求必须单线程提交请求多线程并发写 SQ 可能导致数据竞争。性能优化选项和安全约束要一起考虑。第四稳定性问题。io_uring 的 bug 在早期内核版本里不少特别是和某些文件系统、某些网卡驱动组合时会有难以复现的 hung task 或者数据错误。我见过有的团队上了 io_uring 解决性能问题结果线上出了凌晨偶发卡死排查链路非常痛苦。如果项目不追求极致性能epoll 线程池依然是更稳妥的路线。4.4 io_uring 与 epoll 的选型判断从实用角度看我的建议是分场景大多数业务服务器HTTP 服务、消息推送、API 网关epoll 已经足够好生态成熟、问题排查经验多、资料丰富。追求极致性能的基础设施类项目比如存储引擎、数据库、消息队列值得引入 io_uring但要做好充足的压测、故障演练和应急回滚预案。还有一类折中方案是混合使用核心网络事件循环用 epoll文件读写和大块数据复制走 io_uring两边互补。这样既不会因为 io_uring 初期的稳定性问题影响到全部流量又能在关键 IO 路径上吃到异步红利。5. 踩坑记录与排查技巧实录5.1 多路复用机制典型问题速查表现象可能原因排查方向select 每次都返回 0timeout 后没有 fd 就绪fd_set 没有正确重建检查每次 select 前是否重新 FD_SET检查 nfds 是否传了最大值1select 返回但 FD_ISSET 全部为假有异常事件但 readfds/exceptfds 没配对把 exceptfds 也遍历一遍处理带外数据poll 返回后 revents 都是 0超时返回events 和 revents 搞混了确认 revents 是在调用后被内核赋值的epoll_wait 阻塞不返回没有事件就绪fd 注册到了别的 epoll 实例反复检查 epoll_ctl 的 fd 和 epfd 参数是否配对epoll ET 模式数据读不全读到 EAGAIN 前就 break 了确认循环逻辑返回 0 继续读返回 -1 EAGAIN 才停epoll_wait 返回很多无事件 fdLT 模式下没有删除已就绪但不想处理的 fd每次处理完要判断是否移除监听io_uring_submit 报 EBUSYSQ 队列满了提交前检查 io_uring_sq_space_left考虑扩容队列io_uring_cqe 的 res 为负数读取操作失败res 是错误码的相反数打印 -res 并对照 errno 映射多线程下 io_uring 频繁 CPU 飙升线程间提交请求竞争 SQ 锁按线程拆分独立 io_uring 实例或设置 SINGLE_ISSUER5.2 fd 生命周期管理是最大的隐性雷区我调试过太多诡异的问题最后都指向 fd 生命周期管理。比如 fd 被关闭后操作系统复用了这个编号新连接继承了旧连接在 select 集合或 epoll 红黑树里的残留导致新连接还没数据就被误报有事件。解决这个问题的唯一正规做法是在关闭 fd 之前先把它从多路复用器里移除再 close。用 RAII 思想把“注册、注销、关闭”三个动作绑成一个不可分割的逻辑单元永远不要手动 close 一个你还注册在监听的 fd。5.3 惊群、热点与线程模型优化多线程共享同一个 epoll fd 时惊群问题几乎必然存在。现代内核虽然支持 EPOLLEXCLUSIVE但它只保证唤醒数量减少并不能完全替代合理的线程模型设计。我在实践中比较推荐的目标是单 epoll 实例的唤醒线程数控制在 2 到 4 个每个唤醒线程把事件按 fd 哈希分发到不同的工作线程尽量减少锁竞争。另一种做法是 SO_REUSEPORT 多 epoll 实例让内核做负载均衡应用层也避免跨线程访问同一个 fd但这要求业务上允许同一端口多进程绑定。5.4 性能调优的测量方法做多路复用性能对比时不要只盯着 QPS 一个指标。我们压测通常要同时记录事件延迟分位数P99、系统调用次数、CPU 用户态与内核态占比、上下文切换次数、内存占用。这里有一个常见误区并发连接数越高并不代表压力越大真正考验的是每秒事件发生量和单事件处理耗时。连接再多如果都是空闲的select 和 epoll 差异也不大一旦每连接数据包频率高系统调用开销就立刻放大差距。所以压测场景要分两种海量低活跃连接、小量高吞吐连接两个方向都要覆盖才能得出可靠结论。5.5 从实际案例看 select 升级 epoll 的收益分享一个早年优化推送网关的案例。当时旧架构用的是 select支持的上限卡在 1024 连接用户量一上来就频繁报“连接数超限”。迁移到 epoll 之后单实例连接数直接飙到 5 万CPU 占用反而下降了 20% 左右原因就是 select 每次都要拷贝和遍历大 fd_set白白消耗 CPU。但迁移过程不是改个 API 就行。原来 select 代码里到处是 FD_ISSET 遍历迁移时如果还套用旧的“遍历全部 fd”思路最终会得到一个“伪 epoll”epoll_wait 拿到了就绪列表代码里又去遍历整个 fd_map 查状态把 O(k) 活活浪费成 O(n)。正确的做法是把就绪 fd 当作事件驱动入口接到 fd 后直接靠 fd 查上下文而不是靠遍历找上下文。6. 一个最小可运行的实践框架推荐收藏6.1 从 select 到 epoll 的演进对比这里给一个非常简化的框架对比。select 版本是暴力循环检查epoll 版本是事件回调驱动结构上差异明显。// select 版本被动遍历 fd_set readset; FD_ZERO(readset); int maxfd 0; for (int i 0; i client_count; i) { FD_SET(client[i], readset); maxfd max(maxfd, client[i]); } int ret select(maxfd 1, readset, NULL, NULL, NULL); for (int i 0; i client_count; i) { if (FD_ISSET(client[i], readset)) { handle_read(client[i]); // 有数据再处理 } }// epoll 版本事件唤醒 // 每个 fd 注册时绑定 client[i] 的上下文指针 struct epoll_event ev; ev.events EPOLLIN | EPOLLET; ev.data.ptr context[i]; epoll_ctl(epfd, EPOLL_CTL_ADD, context[i]-fd, ev); // 主循环 struct epoll_event events[128]; int n epoll_wait(epfd, events, 128, -1); for (int i 0; i n; i) { struct context *ctx events[i].data.ptr; handle_read_event(ctx); // 从 ptr 直接拿到上下文没有遍历 }6.2 用 liburing 封装一层事件循环用 liburing 写事件循环的思路不太一样你提交完等待事件内核帮你把事件和数据读都干掉。下面是一个用 POLL_ADD READ 组合的方式等连接可读后自动读数据的一小段示例逻辑static void prep_accept_or_read(struct io_uring *ring, int fd) { struct io_uring_sqe *sqe io_uring_get_sqe(ring); if (is_listen_fd(fd)) { io_uring_prep_accept(sqe, fd, NULL, NULL, 0); } else { io_uring_prep_recv(sqe, fd, buf[fd], sizeof(buf[fd]), 0); } } void event_loop(struct io_uring *ring) { while (1) { struct io_uring_cqe *cqe; int ret io_uring_wait_cqe(ring, cqe); // cqe-user_data 里携带了 fd 标识 int fd cqe-user_data; if (cqe-res 0) { // 处理错误和关闭 } else { // 处理数据 } io_uring_cqe_seen(ring, cqe); } }这个示例只是把骨架搭出来真正生产级代码还需要处理多队列、满队列重提、超时事件、连接关闭清理等一堆细节。但思路是对的io_uring 让你从“等待事件 手动读”的组合模式切换成“提交完整任务 等一个结果”的模型。6.3 自己动手压测的建议脚本知识看再多不如跑一轮实测。我建议你本地开一个 echo 服务分别用 select、poll、epoll、io_uring 实现再用 wrk 或 k6 打压力测试。压测时注意把结果记录成下面这种格式方便横向对比实现方式QPSP99 延迟系统调用次数/秒内核态 CPU 占比select82002.1ms6400042%poll89001.9ms6100040%epoll234000.8ms2400018%io_uring287000.6ms610011%具体数字因机器和场景而异不构成标准答案但量级差异能让你清楚每种方案的成本模型。看到系统调用次数从几万降到几千你就能理解 io_uring 存在的理由了。我个人在实际操作中的体会是多路复用的选型从来不是“哪个最先进就选哪个”而是“哪个的坑你控制得住”。早期项目追求稳定select 或 poll 完全够用中期扛并发epoll 是最佳平衡点到了追求极致 IOPS 的时候再认真考虑 io_uring。先把基础概念吃透再逐步向底层深入这条路走下来你对整个网络编程体系的理解会上一个台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价