I/O 多路复用详解:select、poll、epoll 原理与区别
I/O 多路复用解决的是”一个线程怎么同时盯住上万个连接”的问题,而不是让单次读写变快。这篇从阻塞模型的线程瓶颈讲起,沿 select、poll、epoll 的演进拆解每一代到底改掉了什么,再讲清 LT 与 ET 的语义差异,最后给出 epoll 的适用边界——它不是银弹。
它解决什么问题
最直觉的 TCP 服务端写法是:主线程 accept 一个连接,就丢给一个新线程去阻塞读写。连接少时一切安好;连接冲到上万,问题全面爆发——每个线程默认预留数 MB 栈空间,内存先告急;几千个线程挤在几个核上,CPU 大量时间消耗在上下文切换;而绝大部分连接其实是空闲的,占着线程干等数据。
这就是 C10K 问题。先看一次网络读在内核里的两个阶段:
- 等数据就绪:数据在网卡、在路上,内核等它到达并放进内核缓冲区。这一步慢,且时长不可控。
- 拷贝数据:从内核缓冲区拷到用户态的应用缓冲区。这一步很快。
阻塞模型把线程钉死在第一阶段。多路复用换了思路:把一批文件描述符托付给内核,线程阻塞在一个专门的监听调用上,任一 fd 就绪就返回,然后只处理那几个就绪的。”多路”指多个连接,”复用”指复用同一个线程。
注意:多路复用仍是同步 I/O——它只告诉你谁就绪了,真正把数据从内核搬到用户态的 read 还得你自己调。
核心原理
select:位图时代
select 用一个 fd_set 位图表达关心哪些 fd,每位置 1 表示监听。它留下四个硬伤:
- 数量上限:位图大小由 glibc 的
FD_SETSIZE定死,默认 1024,只能安全表示 0~1023 号 fd; - 每轮整图拷贝:调用前用户态填好位图拷进内核,返回时内核原地改写再拷回来;
- 传入即传出:内核把没就绪的位清零,所以每轮都得
FD_ZERO后重新FD_SET,关心列表无法复用; - 返回后 O(N) 扫描:返回值只有就绪数量,具体谁就绪要拿
FD_ISSET挨个问。一万个连接只来一个数据,也得检查一万次。
poll:换数据结构,没换模型
poll 把位图换成 pollfd 结构体数组,events 字段由调用者填(输入),revents 由内核回填(输出)。这改对了两件事:没有 1024 的硬上限了,监听规模只看数组大小和 RLIMIT_NOFILE;输入输出字段分离,关心列表不必每轮重建。
但最要命的两点原封不动:每次调用仍要把整个数组拷进内核,返回后仍要 O(N) 遍历找就绪项。poll 只是把接口擦干净了,性能模型和 select 相同。
epoll:质变在哪
epoll(Linux 2.5.44 引入)把”一个调用做所有事”拆成三个系统调用:
int epoll_create1(int flags); // 建实例 |
质变来自内核侧的数据结构。一个 epoll 实例对应内核里的 eventpoll:
- 一棵红黑树:存所有
epoll_ctl注册进来的 fd,增删改是 O(log N)。fd 只登记一次,之后长期留在内核,不像 select/poll 每轮把全量列表搬进去。 - 一条就绪链表:注册时内核给 fd 挂上回调;网卡来数据、fd 变可读时,回调把它挂进就绪链表,并唤醒阻塞在
epoll_wait上的线程。
于是 epoll_wait 要做的只是看就绪链表空不空:有就把就绪事件拷给用户态,没有就睡觉。返回的 events 数组里全是就绪项,遍历成本只与本轮就绪数相关,与注册总量无关。注册十万个 fd、只有三个来数据,就只处理三个。
这里要纠正一个流传甚广的讹传:”epoll 快是因为 mmap 共享内存、零拷贝”。翻内核实现就能看到,epoll_wait 返回时是实打实把就绪事件拷到用户态数组的。它省掉的拷贝是另一份——select/poll 那种”每轮把全量 fd 列表搬进内核”的重复拷贝。
深入机制:LT 与 ET
epoll 比 select/poll 多出的能力是两种触发模式。
**水平触发(LT)**是默认模式:只要 fd 上还有数据没读完,每次 epoll_wait 都会通知你。边缘触发(ET)要显式加 EPOLLET:只在状态发生变化的那一刻通知一次。
用 man page 的经典例子:对端往 socket 写了 2 KB 数据。LT 下你只读 1 KB,下次 epoll_wait 还会提醒你”剩 1 KB 没读”;ET 下通知只来一次,剩下的 1 KB 除非对端再写新数据,否则可能长期躺在缓冲区里,连接迟迟得不到处理。
所以 ET 有两条铁律:fd 必须非阻塞,并且循环读到 EAGAIN 为止:
for (;;) { |
fd 若还是阻塞的,最后一次没数据的 read 会把整个线程卡死——这就是 ET 与非阻塞必须成对出现的原因。ET 的收益是减少唤醒次数,代价是编程门槛:漏读到 EAGAIN 是最常见也最隐蔽的 bug;反过来为”抽干”在单个 fd 上读个不停,又会饿死其他连接,工程上常给每个 fd 设单轮处理预算。Nginx 这类极致性能场景用 ET,绝大多数业务用 LT 就够了。
工程实践
- 默认选 LT,逻辑简单不易错;确认读写路径严谨后再考虑 ET。
- 生产级事件循环的边角:
epoll_wait返回EINTR要重试;ET 下accept同样要循环到EAGAIN;EPOLLERR/EPOLLHUP/EPOLLRDHUP与读写事件一并判断;write可能短写,没发完的数据自己缓存并按需注册EPOLLOUT;多线程共用一个 fd 时用EPOLLONESHOT配合重新武装。 - 惊群:多个线程在同一 listen fd 上等事件,一个连接到来可能全部唤醒、只有一个
accept成功。Linux 4.5+ 提供EPOLLEXCLUSIVE缓解。 - 现实佐证:Redis 的单线程事件循环底层就是多路复用(Linux 上选 epoll);Java NIO 的
Selector在 Linux 上的实现同样是 epoll。
对比与权衡
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | FD_SETSIZE,通常 1024 |
无硬上限,受 RLIMIT_NOFILE |
无硬上限,受 fd 数与内存约束 |
| 每轮传参 | 整位图进出内核,且需重建 | 整数组进内核,输入不用重建 | 注册一次,wait 不重复传集合 |
| 找就绪项 | O(N) 扫描位图 | O(N) 遍历数组 | 只返回就绪链表里的项 |
| 触发模式 | 仅 LT | 仅 LT | LT,可选 ET |
| 平台 | 跨平台 | 类 Unix | Linux 专有 |
epoll 的边界必须说清:维护红黑树、挂回调、走就绪链表都有固定成本。连接少且个个活跃时,每次 epoll_wait 几乎全员就绪,优势缩水,接近”花更多钱办了 select 一把梭的事”——活跃比例趋近 100% 时它就退化为事实上的轮询。epoll 的主场是海量连接加稀疏活跃:几万长连接挂着,同一时刻只有少数来数据。另外它是 Linux 专有,跨平台要用 libevent、libuv 这类封装(macOS 走 kqueue,Windows 走 IOCP)。
面试视角
I/O 多路复用解决什么问题?
解决”等”的问题:一个线程把一批 fd 交给内核统一监听,谁就绪处理谁,不必为每个连接配一个阻塞线程。它不加速单次读写,就绪后的 read 仍由应用发起,所以属于同步 I/O。
select、poll、epoll 的区别?
三点:数据结构上,select 用固定位图(1024 上限)、poll 用数组(解除上限)、epoll 把监听集合长期存在内核;性能模型上,前两者每轮全量拷贝加 O(N) 扫描,epoll 注册一次、等待时只取就绪项;触发模式上,只有 epoll 支持 LT/ET 两种语义。
epoll 为什么快?
两个省:fd 集合只注册一次,省掉每轮全量拷贝;fd 就绪时内核回调把它挂进就绪链表,epoll_wait 只返回就绪项,省掉全量扫描。注意不是 mmap 零拷贝——就绪事件返回时照样拷贝到用户态。
LT 和 ET 的区别,ET 要注意什么?
LT 下缓冲区有剩余数据就持续通知;ET 只在状态变化沿通知一次。ET 必须配非阻塞 fd,并循环 read 到 EAGAIN,否则剩余数据可能永远等不到下一次通知。
epoll 一定比 select/poll 快吗?
不是。连接少而活跃时,回调与数据结构的维护成本让优势缩水,接近轮询。它的价值在”大量 fd、少量活跃”的稀疏场景。
小结
- 多路复用让一个线程盯多个连接,解决的是阻塞模型的线程膨胀问题,本身是同步 I/O。
- select 到位图、poll 到数组、epoll 到”内核常驻监听集合 + 就绪链表”,演进主线是消除每轮的全量拷贝与全量扫描。
- LT 持续通知、ET 沿触发一次;ET 必须配非阻塞 fd 并读到
EAGAIN。 - epoll 不是银弹:活跃连接密集时退化为轮询,且为 Linux 专有;跨平台交给 libevent/libuv。

