我自己对三种 IO 多路复用的理解
文章目录
- 一、select:位图驱动的基础实现
- 二、poll:动态数组替代位图的优化
- 三、epoll:内核事件驱动的高性能方案
- 总结
这篇算是自己的笔记吧,就写的随性点了。
其实 IO 多路复用的核心逻辑很统一:主线程要维护所有文件描述符(FD)和对应的连接信息,通过内核监控这些 FD 上的事件,等事件就绪后,用 FD 快速定位到连接信息,再做数据的读写处理。因为所有 IO 操作最终都要依赖 FD,所以我们封装 Socket 类时,底层都会藏一个私有的 _sockfd 成员,专门用来对接系统调用(比如 recv、send),这样业务层就不用直接操作裸 FD 了。
下面就按 select、poll、epoll 三个模型,分别说说我的理解,包括流程、细节和注意事项:
一、select:位图驱动的基础实现
select 是最基础的 IO 多路复用模型,核心靠「位图(fd_set)」管理要监控的 FD,逻辑简单但限制不少。
核心工作流程
- 连接建立:客户端发起连接,监听 FD 触发可读事件,主线程调用
accept拿到client_fd,用这个 FD 创建 Socket 对象(内部_sockfd = client_fd),再把client_fd作为 key,Socket 对象和客户端 IP 分别存入_clients和_client_ips容器(比如map),完成 FD 与连接信息的绑定。 - 事件监控初始化:每次进入事件循环,都要先重置位图 —— 调用
FD_ZERO清空,再用FD_SET把监听 FD 加进去,接着遍历_clients,把所有客户端 FD 逐个加入位图。同时还要更新_max_fd(当前最大的 FD 号),因为select要靠这个值确定遍历范围,减少无效循环。 - 阻塞等待事件:调用
select(_max_fd + 1, &read_fds, nullptr, nullptr, nullptr),把位图从用户态拷贝到内核态,主线程阻塞。内核会盯着位图里的所有 FD,一旦有事件(比如数据可读、新连接),就会修改位图,把就绪的 FD 对应的 bit 置 1。 - 就绪事件处理:内核把修改后的位图拷贝回用户态,唤醒主线程。主线程先判断监听 FD 是否就绪(用
FD_ISSET),若是则处理新连接;再遍历_clients里的所有客户端 FD,逐个用 FD_ISSET 检查是否就绪,就绪的话就通过 FD 找到 Socket 对象,调用Recv()读数据,再触发业务回调(比如回显)。 - 连接清理:如果
Recv()返回值 ≤ 0(0 表示客户端正常断开,负数表示连接异常),就调用Socket->Close()(内部close(_sockfd)),再从_clients和_client_ips里删掉这个 FD 的记录,避免后续误操作。
关键注意事项
- 位图必须每次循环重置:因为 select 会修改
fd_set,把未就绪的 FD 对应的 bit 置 0,不重置的话,下次监控就会漏掉之前的 FD。 - 要及时删除无效 FD:客户端断开后,一定要从容器里删掉对应的 FD,不然下次 FD_SET 会把已关闭的 FD 加入位图,导致 select 误判或系统调用报错。另外,删除无效 FD 也能控制
_max_fd的大小,减少遍历开销。 - FD 数量有限制:默认
FD_SETSIZE = 1024,虽然能通过修改内核参数扩大,但不推荐 —— 因为遍历范围会跟着变大,效率会急剧下降。 - 遍历效率低:不管有多少个 FD 就绪,主线程都要从 0 遍历到
_max_fd,哪怕只有 1 个 FD 就绪,也得扫完整段范围。
优缺点
- 优点:跨平台支持(Linux、Windows 都能用),实现简单,适合连接数少的场景(比如嵌入式设备)。
- 缺点:FD 数量受限、每次要拷贝整个位图、遍历效率低,高并发下性能拉胯。
二、poll:动态数组替代位图的优化
poll 本质是对 select 的改进,核心变化是用「动态数组(struct pollfd)」替代位图管理 FD,解决了 select 的 FD 数量限制,但没解决遍历效率的问题。
核心工作流程
- 连接建立:和 select 完全一致 ——
accept拿到client_fd,创建 Socket 对象,存入容器绑定信息。 - 事件监控初始化:每次循环要清空
pollfd数组,重新构造。先创建一个pollfd结构体,绑定监听 FD,设置关注的事件(比如POLLIN可读事件),加入数组;再遍历_clients,给每个客户端 FD 都创建对应的pollfd结构体,设置POLLIN事件,逐个加入数组。这里不用维护_max_fd,因为数组的长度就是要监控的 FD 数量。 - 阻塞等待事件:调用
poll(fds.data(), fds.size(), -1),把整个数组从用户态拷贝到内核态,主线程阻塞。内核会遍历数组里的每个pollfd,监控对应的 FD 事件。 - 就绪事件处理:内核不单独返回就绪列表,而是修改数组里每个
pollfd的revents字段(比如就绪则设为POLLIN),再把数组拷贝回用户态。主线程遍历整个数组,通过revents判断 FD 是否就绪:若是监听 FD 则处理新连接,若是客户端 FD 则读数据、处理业务。 - 连接清理:和 select 一致,客户端断开后关闭 FD,从容器中删除记录,下次构造数组时就不会再包含这个无效 FD 了。
关键注意事项
pollfd结构体的作用:每个pollfd里有三个字段 ——fd(要监控的文件描述符)、events(用户要关注的事件)、revents(内核返回的实际事件),这三个字段要区分清楚,不能用events判断就绪状态。- 数组需要每次重建:因为客户端可能随时断开,数组本身不维护连接状态,所以每次循环都要重新构造,确保只包含有效 FD。
- 事件类型更灵活:poll 支持的事件比 select 多,比如
POLLOUT(可写)、POLLERR(错误)、POLLHUP(连接挂起),能满足更多场景需求。
优缺点
- 优点:解决了 select 的 FD 数量限制(仅受系统最大 FD 上限,可通过
ulimit -n调整),事件类型更丰富,兼容性也不错。 - 缺点:还是要每次拷贝整个数组(开销随 FD 数量增加而变大),内核和用户态都要遍历整个数组,时间复杂度还是 O (n),高并发下效率依然不高。
三、epoll:内核事件驱动的高性能方案
epoll 是 Linux 专门为高并发设计的模型,完全抛弃了 select/poll 的 “遍历 + 重复拷贝” 逻辑,改用内核数据结构管理事件,效率直接上了一个台阶。
核心工作流程
- 初始化特殊步骤:epoll 启动时,要先调用
epoll_create1(EPOLL_CLOEXEC)创建一个 epoll 实例 —— 这个实例本质是内核里的两个核心数据结构:红黑树(用来管理所有注册的事件)和就绪队列(双向链表,用来存就绪的事件)。创建完实例后,还要调用epoll_ctl把监听 FD 注册到内核红黑树里,设置关注EPOLLIN事件。 - 连接建立:监听 FD 就绪,
accept拿到client_fd,创建 Socket 对象,存入容器。接着调用epoll_ctl(EPOLL_CTL_ADD),把client_fd和关注的EPOLLIN事件注册到内核红黑树里 —— 这一步只需要做一次,后续循环不用再重复注册。 - 事件监控:进入事件循环后,直接调用
epoll_wait(_epoll_fd, events.data(), _max_events, -1),主线程阻塞。内核通过红黑树监控所有注册的 FD,一旦某个 FD 事件就绪,就会通过回调机制,把对应的事件节点加入就绪队列,不需要遍历所有 FD。 - 就绪事件处理:
epoll_wait直接把就绪队列里的事件拷贝到用户态的events数组中,返回就绪事件的数量。主线程只需要遍历这个就绪数组(长度是就绪事件数,远小于总 FD 数),就能拿到所有就绪的 FD。之后判断是监听 FD 还是客户端 FD,分别处理新连接或数据读写。 - 连接清理:客户端断开后,先调用
epoll_ctl(EPOLL_CTL_DEL),把对应的 FD 从内核红黑树里删除,再关闭 FD、清理容器中的记录,避免无效事件占用资源。
关键注意事项
- 触发模式选择:epoll 支持水平触发(LT,默认)和边缘触发(ET)。LT 和 select/poll 行为一致,只要 FD 有未处理数据就持续通知,易实现但可能有冗余通知;ET 只在 FD 状态变化时通知一次(比如从无数据到有数据),效率更高,但需要配合非阻塞 IO 一次性读完所有数据(循环
recv直到返回EAGAIN),否则会丢失数据。 _max_events不是 FD 上限:_max_events是epoll_wait单次能返回的最大就绪事件数,不是能监控的 FD 总数,一般设为1024或4096即可,根据业务调整。- 零拷贝优势:epoll 只有在注册(ADD)、删除(DEL)事件时,才需要把少量数据(FD 和事件类型)从用户态拷贝到内核态;
epoll_wait只拷贝就绪事件,比 select/poll 每次拷贝整个集合的开销小得多。 - 错误事件独立处理:内核会通过
revents单独标记EPOLLERR、EPOLLHUP等错误事件,不需要额外判断,容错性更强。
优缺点
- 优点:FD 数量无限制(受系统参数)、时间复杂度 O (m)(m 是就绪事件数)、拷贝开销小、支持 LT/ET 模式,是高并发场景(比如 Web 服务器、网关)的首选。
- 缺点:只能在 Linux 系统使用,实现比 select/poll 复杂,需要理解内核数据结构和触发模式的差异。
总结
这三种 IO 多路复用模型,本质都是 “单线程 / 进程管理多个 FD”,核心差异在于「FD 管理方式」和「事件筛选逻辑」:
- select 用位图,限制多、效率低,适合简单场景;
- poll 用动态数组,解决了 FD 限制,但还是要遍历拷贝,适合中等连接数;
- epoll 用内核红黑树 + 就绪队列,不用遍历、拷贝少,适合高并发。
