I/O 多路复用基础
预备知识:fd 与”就绪”
fd 是什么
fd(file descriptor,文件描述符)是一个非负整数,是进程访问内核资源的句柄。
1 | 进程的 fd 表 内核打开文件表 实际对象 |
- Linux “一切皆文件”:普通文件、socket、管道、终端,以及
eventfd、timerfd、signalfd都用 fd 表示。 - 0/1/2 分别是标准输入、标准输出、标准错误。
- 网络编程里,每个 TCP 连接对应一个 fd,”监视多个 fd”就是”监视多个连接”。
- 普通磁盘文件不能放进 epoll(
epoll_ctl返回EPERM):磁盘文件永远被认为”就绪”,多路复用对它没意义。
什么叫”就绪”
就绪 = 此刻对该 fd 执行读/写,不会阻塞。 并不保证数据一定读得完,只保证这次调用能立刻返回。
以 TCP socket 为例:
| 类型 | 触发条件 |
|---|---|
| 可读 | ① 接收缓冲区有数据(≥ SO_RCVLOWAT,默认 1 字节)② 对端关闭连接(收到 FIN, read 返回 0)③ 监听 socket 的已完成连接队列有新连接( accept 不会阻塞)④ socket 上有待处理的错误 |
| 可写 | ① 发送缓冲区有足够空闲空间 ② 非阻塞 connect 完成(成功或失败都会变可写)③ 出错时也会触发可写 |
| 异常 | select 的 exceptfds 对应带外数据(OOB);epoll 对应 EPOLLPRI |
epoll 额外的事件位:
EPOLLIN/EPOLLOUT:可读 / 可写EPOLLRDHUP:对端关闭或半关闭(不必等read返回 0 才发现)EPOLLERR/EPOLLHUP:出错 / 挂断(无需主动注册,内核总会报告)
实用结论:新连接的发送缓冲区几乎总是空的,所以一开始就可写。若一上来就注册
EPOLLOUT且用 LT 模式,epoll_wait会一直立刻返回(忙等)。正确做法:只在”写不完”时才注册EPOLLOUT,写完立即取消。
“为什么需要 I/O 多路复用”
传统”一连接一线程”模型,连接数上万时线程切换和内存开销太大。I/O 多路复用的核心思想是:用一个线程同时监视多个 fd,哪个就绪了就处理哪个,避免为每个连接阻塞等待。
| 一连接一线程 | I/O 多路复用 | |
|---|---|---|
| 内存 | 每线程栈约 8MB(虚拟),万级连接压力大 | 一个线程管成千上万连接 |
| 切换 | 频繁上下文切换 | 极少 |
| 适用 | 连接少、逻辑重 | 连接多、单次处理轻(IO 密集) |
实际工业方案通常是 多线程 + 多路复用:one loop per thread(muduo 模型),每个线程各自一个 epoll,主线程负责 accept 并分发连接。
四种机制逐个讲
select(最古老,POSIX 通用)
1 | int select(int nfds, fd_set* readfds, fd_set* writefds, |
- 用 fd_set 位图存 fd,默认最多 1024 个(
FD_SETSIZE)。 - 每次调用都要把 fd_set 从用户态拷贝到内核态,返回后还要遍历全部 fd 找就绪的,复杂度 **O(n)**。
- fd_set 会被内核修改,所以每次循环都要重新设置。
- 优点:跨平台、超时精度到微秒。
内部工作流程:
- 传入 3 个位图(读/写/异常)和
nfds(最大 fd + 1)。 - 内核把位图拷贝进内核。
- 内核从 0 遍历到 nfds-1,对每个被关注的 fd 调用其
poll方法检查是否就绪。 - 都没就绪就睡眠;被唤醒后再遍历一遍。
- 把结果位图拷贝回用户态(覆盖原位图,所以每轮要重置)。
- 用户态再遍历一遍找出被置位的 fd。
O(n) 体现在:内核遍历 + 用户遍历 + 来回拷贝,连接越多越慢。
poll(select 的改进)
1 | int poll(struct pollfd* fds, nfds_t nfds, int timeout); |
- 用 pollfd 数组代替位图,没有 1024 上限。
- 事件(
events)和结果(revents)分开,不用每次重置。 - 但本质没变:仍然**每次拷贝整个数组 + 线性遍历,O(n)**。
epoll(Linux 高性能方案)
1 | int epoll_create1(int flags); |
核心改进:
- fd 只注册一次(
epoll_ctl),不用每次调用重复传入,省掉拷贝。 - 内核用 红黑树管理所有被监视的 fd,用 就绪链表存放已就绪的 fd。
- 当 fd 就绪时,内核通过回调把它挂到就绪链表,
epoll_wait直接返回就绪列表,复杂度 **O(1)**(与总连接数无关,只与活跃连接数有关)。 - 无 fd 数量硬上限(受系统内存和
ulimit限制)。
内部原理:
epoll_create 在内核创建一个 eventpoll 对象,包含三样东西:
- 红黑树:存所有注册的 fd(
ADD/MOD/DEL快速查找去重,O(log n)) - 就绪链表 rdllist:存已就绪的 fd
- 等待队列:存阻塞在
epoll_wait上的线程
流程:
epoll_ctl(ADD):把 fd 插入红黑树,**同时给该 socket 的等待队列挂一个回调ep_poll_callback**。- 网卡收到数据 → 中断 → 协议栈处理 → 数据进入 socket 接收缓冲区 → 触发回调 → 回调把该 fd 挂到 rdllist,并唤醒
epoll_wait的线程。 epoll_wait:链表为空就睡眠,非空就把就绪事件拷贝到用户态返回。
关键点:不再遍历所有 fd,而是事件到来时由回调主动把就绪 fd 送进链表。
两种触发模式(必考):
| LT(水平触发,默认) | ET(边缘触发) | |
|---|---|---|
| 触发时机 | 只要缓冲区有数据/可写就一直通知 | 仅在状态变化时通知一次 |
| 编程要求 | 可以不一次读完 | 必须循环读到 EAGAIN,且 fd 必须设为非阻塞 |
| 特点 | 简单、不易丢事件 | 系统调用次数少、效率高,但容易写错 |
用例子理解:socket 缓冲区有 2000 字节,这次只读了 500 字节——
- LT:
epoll_wait返回前,内核发现该 fd 仍然就绪,会把它重新放回就绪链表,下次还会通知。 - ET:不会放回。除非又有新数据到达,否则永远不再通知,剩下 1500 字节就”饿死”了。
ET 模式读数据(fd 必须非阻塞):
1 | while (true) { |
监听 socket 用 ET 时,accept 也要循环到 EAGAIN,否则多个连接同时到达会漏掉。
设为非阻塞:
1 | int flags = fcntl(fd, F_GETFL, 0); |
IOCP(Windows 的完成端口)
- 关键区别:select/poll/epoll 都是 Reactor 模型(通知你”可以读了”,你自己去读),而 IOCP 是 Proactor 模型(异步 I/O,内核替你把数据读好,通知你”读完了”)。
- 流程:创建完成端口 → 将 socket 与之绑定 → 投递异步操作(
WSARecv/AcceptEx等)→ 工作线程调用GetQueuedCompletionStatus获取完成事件。 - 通常配合线程池,线程数约等于 CPU 核数的 1~2 倍。
1 | // 1. 创建完成端口 |
- 内核维护完成队列;工作线程池从中取已完成的事件。
- 内核控制并发线程数(创建时指定),并优先唤醒最近运行过的线程(LIFO),减少上下文切换。
Reactor vs Proactor 类比:
- Reactor:外卖做好了,店家打电话通知你,你自己下楼去取(内核通知可读,你自己
read)。 - Proactor:你提前下单并说明送到哪,外卖直接送到你手里,送到后再通知你(你提前提供 buffer,内核读完放进去,再通知”读完了”)。
对比总结(面试时可直接背)
| select | poll | epoll | IOCP | |
|---|---|---|---|---|
| 平台 | 跨平台 | Unix | Linux | Windows |
| fd 上限 | 1024 | 无 | 无 | 无 |
| 数据结构 | 位图 | 数组 | 红黑树+就绪链表 | 完成队列 |
| 时间复杂度 | O(n) | O(n) | O(1)(活跃数) | O(1) |
| 模型 | Reactor | Reactor | Reactor | Proactor |
| 用户态/内核拷贝 | 每次 | 每次 | 注册一次 | — |
面试高频追问
epoll 一定比 select 快吗?
不一定。连接数少、且大部分都活跃时,select/poll 因为简单,开销差不多甚至更低;epoll 的优势在连接数大、活跃连接占比小的场景。ET 为什么必须非阻塞?
ET 要求一次性读完数据,需要循环read直到返回EAGAIN。如果 fd 是阻塞的,最后一次read会卡住整个线程。LT 为什么也建议配非阻塞?
“就绪”通知可能是过期的:比如多线程下另一个线程已把数据读走,或内核收到数据但校验失败丢弃。此时对阻塞 fd 再read会卡死线程;非阻塞 fd 只会返回EAGAIN,更安全。epoll 为什么高效?(三点)
① fd 注册一次,避免重复拷贝;② 红黑树管理,增删查 O(log n);③ 事件驱动回调 + 就绪链表,不用遍历全部 fd。epoll 的
EPOLLONESHOT是什么?
事件触发一次后自动禁用该 fd,需要重新MOD启用。
解决的问题:线程 A 正在处理 fd 5,fd 5 又来新数据,epoll_wait唤醒线程 B 也去处理 fd 5,两个线程同时操作同一连接导致数据错乱。EPOLLONESHOT保证同一时刻只有一个线程处理该 fd,处理完再重新武装。Reactor 和 Proactor 区别?
Reactor:内核通知”可读/可写”,应用自己完成 I/O;Proactor:内核完成 I/O 后通知应用。Linux 的真异步 I/O 支持较弱(io_uring是新方向),所以 Linux 上主流是 Reactor。为什么不直接用多线程?
见前文对比表:万级连接下线程栈内存和上下文切换开销过大;通常采用”多线程 + 多路复用”组合。
一分钟回答模板
“I/O 多路复用是让一个线程同时监听多个 fd 的机制。select 用位图,有 1024 限制,每次都要拷贝并线性扫描;poll 改用数组去掉了数量上限,但仍是 O(n);epoll 是 Linux 下的方案,fd 只注册一次,内核用红黑树管理、就绪链表返回,复杂度与活跃连接数相关,适合高并发,并且支持 LT 和 ET 两种模式,ET 需要配合非阻塞 I/O 循环读取。IOCP 是 Windows 的方案,和前三者最大不同是它属于 Proactor 异步模型,由内核完成读写后再通知应用,通常搭配线程池使用。”

