I/O 多路复用解决的是”一个线程怎么同时盯住上万个连接”的问题,而不是让单次读写变快。这篇从阻塞模型的线程瓶颈讲起,沿 select、poll、epoll 的演进拆解每一代到底改掉了什么,再讲清 LT 与 ET 的语义差异,最后给出 epoll 的适用边界——它不是银弹。

它解决什么问题

最直觉的 TCP 服务端写法是:主线程 accept 一个连接,就丢给一个新线程去阻塞读写。连接少时一切安好;连接冲到上万,问题全面爆发——每个线程默认预留数 MB 栈空间,内存先告急;几千个线程挤在几个核上,CPU 大量时间消耗在上下文切换;而绝大部分连接其实是空闲的,占着线程干等数据。

这就是 C10K 问题。先看一次网络读在内核里的两个阶段:

  1. 等数据就绪:数据在网卡、在路上,内核等它到达并放进内核缓冲区。这一步慢,且时长不可控。
  2. 拷贝数据:从内核缓冲区拷到用户态的应用缓冲区。这一步很快。

阻塞模型把线程钉死在第一阶段。多路复用换了思路:把一批文件描述符托付给内核,线程阻塞在一个专门的监听调用上,任一 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);                                    // 建实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev); // 增删改监听项
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout); // 等就绪事件

质变来自内核侧的数据结构。一个 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 (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) {
// 处理这批数据,继续循环把缓冲区抽干
} else if (n == 0) {
close(fd); break; // 对端关闭
} else {
if (errno == EAGAIN) break; // 读空了,正常退出点
if (errno == EINTR) continue; // 被信号打断,重试
close(fd); break; // 真错误
}
}

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。