I/O 多路复用:select、poll、epoll
前面顺着文件 I/O、阻塞 / 非阻塞、零拷贝一路往下看时,很快就会碰到另一组在服务端开发里绕不开的概念:
- I/O 多路复用
selectpollepoll- 就绪事件
- 非阻塞 socket
这一块如果只是背结论,其实很容易卡在几个似懂非懂的地方,比如:
- 为什么一个线程也能管很多连接
epoll_wait()到底在等什么- 为什么
epoll还属于同步 I/O - 为什么工程里几乎总是
epoll + 非阻塞 - LT 和 ET 到底差在哪
我一开始最容易混的点,是把“内核帮我盯很多 fd”直接理解成“内核已经替我把数据处理好了”。后来慢慢顺下来之后,感觉这部分最核心的一句话其实是:
I/O 多路复用不是帮我把数据读出来,而是帮我高效地等一批 fd,哪个就绪了就先处理哪个。
只要先把这句话立住,后面很多细节就会顺很多。
一、为什么会有 I/O 多路复用
先从最朴素的服务端模型开始想。
假设现在有很多客户端连接到服务器,如果按最直接的写法做,一个连接通常就会对应一个线程,线程里干的事情大概是:
read(conn_fd, buf, ...);
process(...);
write(conn_fd, buf, ...);这种写法很直观,但问题也很明显:
- 连接一多,线程数会跟着暴涨
- 大量线程其实都在等 I/O
- 线程切换开销高
- 栈内存和调度成本也会上来
如果继续往下想,很自然会想到另一个极端:
那我能不能只开一个线程,然后挨个去问所有连接有没有数据?
这当然也能做,但如果没有数据时还在不停轮询,就会浪费大量 CPU。
所以 I/O 多路复用真正想解决的问题是:
我既不想一个连接一个线程,也不想自己傻轮询所有 fd,那能不能让内核帮我统一等这些连接,谁准备好了再通知我?
这就是它存在的原因。
二、I/O 多路复用到底在复用什么
“多路复用”这个词一开始挺容易让人误会,好像是多个线程复用一个连接,或者多个请求复用一条通道。
但放到这里,更准确的理解是:
它复用的是同一个线程的等待能力,让一个线程同时等待多个 fd 的 I/O 事件。
也可以理解成:
把原来每个连接各自阻塞在
read()上,改成统一阻塞在一个“等待很多 fd 的入口”上。
所以它真正做的不是“把多个连接揉成一个连接”,而是:
- 一个线程
- 一次等待
- 监听很多 fd
- 哪些 fd 就绪了就处理哪些
三、I/O 多路复用的共同工作流程
不管是 select、poll 还是 epoll,整体流程其实都可以先抽象成一样。
1. 先告诉内核:我关心哪些 fd
这些 fd 可能是:
- 监听 socket
- 已建立连接的 socket
- pipe
- 其它支持事件通知的 fd
同时还会告诉内核,我关心的是:
- 可读
- 可写
- 异常
2. 调用等待接口
比如:
select()poll()epoll_wait()
这一步的意思其实就是:
现在我先别忙,等这些 fd 里有人准备好了再叫我。
如果没有事件,线程就会睡在这里。
3. 内核监视这些 fd 的状态变化
一旦发生这些情况:
- 某个连接收到数据
- 某个 socket 现在可以继续写了
- 监听 socket 上有新连接可
accept
内核就知道:某些 fd 已经“就绪”了。
4. 等待接口返回一批就绪结果
返回的不是数据本身,而是:
- 哪个 fd 就绪了
- 就绪类型是什么
5. 用户态再自己调用 accept / read / write
这一点特别重要。
I/O 多路复用只是告诉我:
现在值得你去处理这个 fd 了。
但真正的数据读写,还是要靠:
accept()read()write()
自己来完成。
四、为什么它仍然属于同步 I/O
这个点特别容易和“异步 I/O”混在一起。
表面上看,多路复用像是:
- 内核替我盯很多 fd
- 事件来了再通知我
很像异步。
但更准确的区分标准不在“有没有通知”,而在:
I/O 完成这件事,到底是不是还要我自己来收尾。
放到 I/O 多路复用里,它做的是:
- 内核通知你哪个 fd 就绪了
- 但数据还没有自动拷进你的用户缓冲区
- 你还要自己去
read()
所以它更准确地说是:
就绪通知,而不是完成通知。
这就是为什么它通常仍然被归到同步 I/O。
五、为什么工程里通常是“多路复用 + 非阻塞”
这个点如果不想清楚,后面 epoll 很容易一直理解得半对半不对。
我一开始也会觉得:
- 既然
epoll_wait()已经帮我等到事件了 - 后面直接
read()不就行了吗
后来才慢慢意识到,问题不在 epoll_wait(),而在:
epoll_wait()返回之后,处理就绪 fd 的那个线程,通常还是同一个事件循环线程。
假设一次 epoll_wait() 返回了 3 个可读 fd:
- fd=10 可读
- fd=11 可读
- fd=12 可读
如果处理 fd=10 时用了阻塞式 read(),而且代码又写成“我想把这次请求一次性读完整”,那就可能发生:
- 这次其实只有一部分数据到了
- 你继续读剩下的数据
- 线程卡在阻塞式
read()上
这样一来:
- fd=11 虽然已经就绪了,没人处理
- fd=12 也一样
- 这个线程也回不到下一轮
epoll_wait()
所以问题不是“epoll_wait() 阻塞了”,而是:
处理 ready fd 的线程,被某一个 fd 的阻塞式读写拖住了。
这就是为什么工程里通常会把这些 socket 设成非阻塞。
非阻塞的意义不是“读得更快”,而是:
不能让一个 fd 把整个事件循环卡死。
六、select 到底慢在哪
select 是最经典也最老的一种多路复用方式。
它的思路很直接:
- 用户态准备一组 fd 集合
- 每次调用
select()时交给内核 - 内核检查这批 fd 哪些就绪了
- 返回后用户态再自己去遍历检查结果
这套机制的问题主要有几个。
1. fd 数量限制比较明显
fd_set 通常有大小限制,连接数多时不太方便。
2. 每次都要重新提交整套监听集合
也就是说,每次 select() 都要重新告诉内核:
我这次关心的是这批 fd。
3. 内核要全量扫描
内核要把这批 fd 从头看到尾,找出谁就绪了。
4. 用户态返回后还得再扫一遍
即使只有一个 fd 就绪了,也得遍历整套集合。
所以 select 的问题可以压成一句话:
每次都全量提交、全量扫描、全量检查。
七、poll 比 select 好在哪
poll 可以理解成 select 的改良版。
它不再用 fd_set 位图,而是用一个数组来描述监听项,所以在表达上更灵活,也没有 select 那种那么明显的 fd 数量限制。
但如果从核心工作方式看,它其实没有本质变化:
- 每次调用仍然要把整组 fd 交给内核
- 内核仍然要扫描整组 fd
- 返回后用户态仍然要扫描整组结果
所以更准确地说:
poll比select更灵活,但没有解决“每次全量扫描”这个根问题。
八、epoll 为什么更适合高并发
epoll 真正厉害的地方,不是只比 select/poll 多支持几个 fd,而是它换了思路。
我现在更愿意把它理解成两部分:
- 兴趣列表:我关心哪些 fd
- 就绪列表:哪些 fd 现在真的有事件了
1. 兴趣列表常驻内核
这一点是它和 select/poll 很不一样的地方。
用 epoll_ctl() 把 fd 加进去之后,这份监听集合就常驻在内核里了,不需要每次等待事件时都重新提交一遍。
2. 就绪事件单独组织
某个 fd 一旦发生你关心的事件,比如可读、可写,内核会把它放到 ready list,或者至少标记为 ready 并挂到可返回队列里。
这样一来,epoll_wait() 返回时,更像是在做:
直接把“这次已经就绪的 fd 列表”交给你。
而不是让你重新扫整份监听集合。
这也是为什么在“总连接很多,但真正同时活跃的不多”的场景里,epoll 优势特别明显。
九、epoll_wait() 到底在做什么
如果只看表面,epoll_wait() 很容易被理解成“帮我读数据”。其实不是。
它真正做的事情更接近:
- 当前线程进入内核
- 如果没有 ready 事件,就先睡着
- 某个 fd 发生你关心的事件
- 内核把这个事件放入 ready list,并唤醒线程
epoll_wait()从 ready list 中取出一批事件- 把这些事件描述填入用户态传进来的
events[]数组 - 返回这次有多少个事件
这里很关键的一点是:
epoll_wait()拷回用户态的不是 socket 数据本身,而是事件描述信息。
例如:
- 哪个 fd 就绪了
- 是可读还是可写
- 附带的
data.fd/data.ptr
所以它确实仍然有:
- 用户态 → 内核态
- 内核态 → 用户态
也确实仍然有内核到用户态的拷贝,但拷的是:
一批
epoll_event结果,而不是业务数据。
十、为什么 epoll 不是“没有拷贝”
这个点很容易顺手和零拷贝搞混。
更准确一点说,epoll 从来不是为了消灭所有拷贝。
它优化的是:
- 不再每次都把整份监听 fd 集合从用户态拷到内核态
- 不再每次都全量扫描所有 fd
- 只按批返回 ready 事件
但它依然会有:
- 一次系统调用带来的态切换
- 一次把事件描述从内核拷到用户态数组的过程
所以:
epoll省掉的是“全量监听集合的重复提交和重复扫描”,不是所有内核/用户态之间的拷贝。
十一、LT 和 ET 到底差在哪
epoll 再往下走,几乎一定会碰到 LT 和 ET。
1. LT:水平触发
我现在更喜欢把 LT 理解成:
只要这个 fd 还保持在可读 / 可写状态,内核就会继续提醒你。
例如某个 socket 还有数据没读完:
- 这次你没读干净
- 下次
epoll_wait()还会继续告诉你它可读
所以 LT 的特点是:
- 语义更稳
- 更不容易漏事件
- 更好写
2. ET:边缘触发
ET 更像是:
只有状态从“不可读”变成“可读”的那一下,才提醒你一次。
如果你这次通知来了,但没有把数据读完,后面可能就不再提醒了。
所以 ET 模式下,一个核心习惯就是:
一旦收到可读事件,就要尽量一直读到返回
EAGAIN。
监听 socket 同理,来了新连接也通常要一直 accept() 到 EAGAIN。
3. LT 和 ET 怎么选
我现在更愿意这样记:
- LT:更稳、更好写
- ET:更克制,事件通知更少,但更依赖代码写对
如果只是学习和自己搭 demo,LT 更容易上手;如果在高性能事件驱动框架里,ET 更常见,但一般框架已经把细节封装掉了。
十二、epoll 服务器里的线程通常怎么分工
最典型的结构通常是:
1. I/O 线程
负责:
epoll_wait()accept()- 非阻塞
read() - 非阻塞
write() - 维护连接状态
2. 业务线程池
如果请求处理逻辑比较重,就把业务计算丢给 worker 线程池。
但即使这样,I/O 线程本身也不能在网络读写上阻塞,否则它负责的事件循环还是会停住。
所以从职责上看:
epoll解决的是“怎么高效等很多连接”,不是“怎么自动完成业务处理”。
十三、小结
把这一块重新收一下,我现在更愿意这样理解:
I/O 多路复用的核心,是让一个线程通过
select、poll、epoll这类机制同时等待多个 fd 的事件。它真正解决的是“怎么高效等待很多连接”,而不是“内核替我把数据直接读好”。其中select/poll的问题在于每次调用都要重新提交整套监听集合,并且内核和用户态都要做全量扫描;epoll则把兴趣列表常驻在内核里,同时单独维护 ready 事件,epoll_wait()返回时只按批把就绪事件描述拷到用户态,因此更适合高并发场景。工程里通常会把 socket 设成非阻塞,因为真正容易拖死事件循环的不是epoll_wait()本身,而是它返回之后对某个 fd 进行阻塞式read/write。至于 LT 和 ET,可以理解成前者是“只要还没处理干净就继续提醒”,后者是“状态变化时提醒一次”,所以 ET 更依赖把数据一次性处理到EAGAIN。把这些边界分开之后,I/O 多路复用这块就会清楚很多。
