select、poll、epoll:为什么只返回就绪连接还不够
从一万个连接只有两个活跃的场景出发,拆开监听登记、内核等待、就绪回调和结果返回,解释红黑树的职责,以及非阻塞读写、LT 与 ET 的处理边界。
讲 IO 多路复用,经常会看到这样一张表:select 用数组,poll 用链表,epoll 用红黑树;前两个遍历全部连接,epoll 只返回就绪连接,所以更快。
这张表容易把人带到一个疑问上:既然内核已经知道哪些连接就绪,直接把这些连接集中返回不就行了吗?为什么 select、poll 不这样做?这和红黑树到底有什么关系?
这个疑问抓住了结果返回的区别,但还需要往前追一步:内核怎样得到这份就绪结果,又把哪些工作保留到了下一次等待?只改变返回数组的形状,可以节省应用查找结果的成本;要减少每次重新检查全部连接的成本,还需要持久的监听登记和事件通知机制。
本文放在“工程原理与选型”中,讨论 Linux 网络 Socket 的就绪通知。接口语义依据 Linux man-pages,内核实现限定在 Linux v6.12 的 fs/select.c 与 fs/eventpoll.c。下面的一万个连接是用来分析操作次数的模型,不是性能测试;Python 示例可以验证行为,不能证明吞吐提升多少。普通磁盘文件、异步完成通知以及具体框架的线程模型,不在这次展开范围内。
一、先把等待、就绪和读取分开
假设一个服务端已经建立了一万个 TCP 连接,大部分客户端暂时没有发送请求。服务端需要等到其中某些连接有事情可处理,再读取请求、执行业务并发送响应。
每条连接在进程中通常通过一个文件描述符访问。文件描述符,简称 FD,是进程打开文件或 Socket 后得到的一个整数编号,例如 42。程序把这个编号交给 recv,内核才能找到对应的 Socket。它不是请求内容,也不是跨所有进程唯一的连接编号;关闭后,同一个数字可以分配给另一个新对象。
如果服务端在连接 A 上调用阻塞式 recv,而 A 没有数据,调用线程就可能睡在那里。此时 B 即使来了数据,这条线程也没机会处理它。为每个连接创建线程可以绕开这个串行等待,但会引入线程栈、调度和同步成本。
另一种方法是把 Socket 设为非阻塞:暂时无数据时,读取立即以 EAGAIN 或 EWOULDBLOCK 返回。线程可以继续处理其他连接。不过,如果它不停对一万个 Socket 逐个尝试读取,CPU 仍会消耗在大量“现在没有”的检查上。
IO 多路复用把等待集中到一次调用里:应用提交关注的对象和事件,内核等待其中某些对象达到可操作的状态,再通知应用。线程在等待期间可以睡眠,并不需要自己一直轮询。
这里的“就绪”表示当前可以尝试相应的 I/O。可读可能意味着有字节可取,也可能意味着已经到达流结束,或者下一次调用会报告错误;它不保证一个完整业务请求已经到齐。可写通常意味着发送缓冲区有一定空间,也不保证整个响应一次写完。
因此,一次网络请求至少有三个不同边界:内核报告可以尝试读取,应用实际读到字节,应用完成协议解析和业务处理。多路复用负责第一个边界。即使等待接口已经返回,应用仍要检查实际读写结果。recv 的返回语义
二、同样两条连接就绪,三种接口交回什么
为了看清结果布局,先把集合缩成五个 FD:10、11、12、13、14。当前只有 11 和 14 可读。这里故意使用不连续于数组下标的 FD,避免把“第几个元素”和“描述符编号”混为一谈。
select:按 FD 编号设置位,再检查返回的位
select 用位集合表达关注对象。应用把 10 到 14 对应的位设为 1,调用后,返回集合中只有当前满足条件的位保留。程序再通过 FD_ISSET 检查哪些编号对应的位为 1。
fd_set ready = watched; // watched 是应用自己保留的监听集合
int n = select(max_fd + 1, &ready, NULL, NULL, NULL);
if (n > 0) {
for (int fd = 0; fd <= max_fd; ++fd) {
if (FD_ISSET(fd, &ready)) {
/* 尝试非阻塞读取,检查实际返回结果 */
}
}
}
这只是说明结果布局的片段,省略了错误处理。ready 会被调用修改,所以程序通常保留一份原始集合,每轮重建传入的副本。返回数量能告诉程序有多少个就绪位,却没有另外生成一个 [11, 14] 数组。可以根据返回计数提前结束查找,但最坏情况下仍需要检查很大的编号范围。
常见 Linux 用户态 fd_set 的 FD_SETSIZE 是 1024,限制的是能安全表示的 FD 编号必须小于 1024,不是“这个进程最多只能建立 1024 个连接”。如果进程先打开了很多其他文件,一个新 Socket 的编号为 1100,即使只监听这一个 Socket,也不能安全地把它交给这套固定大小的宏。select 的位集合与限制
poll:数组位置不变,把结果写进 revents
poll 的用户接口接收一个 pollfd 数组。每个元素含 FD、关注事件 events 和返回事件 revents。它没有要求 FD 编号落在固定的 1024 位范围内,但仍受进程资源限制。
调用前:
下标 0 1 2 3 4
fd 10 11 12 13 14
events 可读 可读 可读 可读 可读
调用后:返回数量 2,数组仍然有 5 个元素
revents 0 可读 0 0 可读
程序检查 revents,才知道是 11 和 14。返回的不是两个紧挨着的结果元素。监听一万个连接但只有两个活跃时,这种布局仍可能要求应用检查大量空结果。poll 的结构定义
有些材料把 poll 描述成“链表版本的 select”。这会混淆接口和内核内部的临时存储:Linux 某些内部结构确实会链接起来,但调用者传递、读取的是数组,不能因此推导出用户态需要遍历链表。
epoll:登记与等待拆开,领取一批就绪事件
epoll 先创建一个内核对象,应用用 epoll_ctl 添加、修改或删除关注项。后续调用 epoll_wait 时,提交的是结果缓冲区和容量,不再重复提交完整监听名单。
如果已经登记这五个 FD,某次等待可以返回:
返回数量:2
events[0]:应用登记的连接标识 11,事件 可读
events[1]:应用登记的连接标识 14,事件 可读
这里的标识来自注册时设置的 epoll_event.data。C 程序可以放 FD,也可以放连接对象的标识,并不是内核无条件填入一个 data.fd。应用只处理本次返回的有效元素,顺序也不保证按 FD 编号排序。
结果缓冲区的容量限制的是这一次最多领取多少个事件,不是总共允许监听多少个连接。一万个连接都就绪而容量只有 256 时,需要分批领取。epoll_ctl、epoll_wait
三、只把就绪项集中返回,能优化到哪一步
现在可以回答最初问题的一半:完全可以设计一个“接收完整名单,只返回紧凑就绪数组”的新接口。内核逐个检查一万个 Socket,发现两个可读,就把这两个结果填入输出数组。应用不需要再翻一万个结果位置。
但这个接口只改变了输出形式。每次调用仍要告诉内核监听谁,内核仍需要检查这份名单。可以把一轮等待拆成四项工作:提交监听范围、建立或使用等待关系、确认当前就绪项、把结果交给应用。把最后一步做得紧凑,不会自动消除前三步。
左侧流程表示 poll 的基本路径:调用传入数组,内核检查对象,没有就绪事件时等待,唤醒后继续检查,最后在原位置填写结果。右侧把登记放在等待之外,后续通知把候选项送入就绪列表,领取时只围绕这些候选项工作。两边都有睡眠和唤醒,差异在于保存了什么状态、醒来后需要重新找什么。
一个容易误解的地方是:select、poll 也会借助内核等待队列获得唤醒,并非在等待期间持续烧 CPU,也不是只有 epoll 才有回调。Linux v6.12 中,__pollwait 为这次调用建立等待关系,pollwake 负责唤醒等待者;do_poll、do_select 执行集合检查。这些临时关系随本次调用结束而清理,不等于 epoll 那种跨多次等待保留的监听对象。Linux v6.12:fs/select.c
因此,不能只说“poll 不知道哪个 FD 触发,epoll 知道”。更准确的问题是:有没有把与具体监听项相关的就绪信息持久组织起来,让下一轮等待和结果收集继续使用?epoll 为此增加了一个需要维护生命周期的内核对象。
既然可以改进,为什么不直接修改 poll?已有程序按照固定数组位置读取 revents。如果内核突然把数组压缩,原来的索引与 FD 对应关系就变了;如果又要保留长期监听状态,还必须增加创建、修改、删除和释放接口。保留原接口的调用约定,再提供新的接口,是一种兼容性选择。这里是在分析接口约束,不是在声称某项历史决策只有这一个原因。
四、epoll 怎样维护监听对象和就绪候选项
epoll 内部需要回答两个不同问题:程序登记了哪些对象,其中哪些对象可能已经可以处理?同一个连接可以同时出现在“已登记集合”和“就绪候选列表”里,二者不互相替代。
以 Linux v6.12 为例,eventpoll 中的 rbr 是保存监听项的红黑树,rdllist 是就绪链表。红黑树是一种保持高度受控的有序树,可以避免查找在最坏情况下退化成一条长链。它服务于添加、修改、删除等管理操作;它没有按“是否收到数据”替所有连接排序。
对应源码中的路径可以分三步阅读:
- 添加时,
ep_insert创建监听项,经目标文件的 poll 方法挂接等待关系,并检查初始就绪状态。登记时已经有数据的连接也需要被发现,不能只等未来的新数据。 - Socket 的状态变化触发等待队列通知,
ep_poll_callback根据关注的事件处理对应监听项,将符合条件且尚未入队的项加入就绪结构,并在需要时唤醒等待者。 - 等待返回路径通过
ep_send_events遍历候选项,再确认状态,向用户结果区写入事件。默认模式下,如果对象仍满足条件,还会保留继续报告它的机会。
这说明就绪链表保存的是需要处理的候选项,不是一份永远准确、永远只包含新数据的快照。别的线程可能已经读走数据;同一项的多次通知也可能合并;最终返回前还可能需要重新检查。
上面的字段和函数名都来自同一版本源码,便于读者定位。锁、并发扫描时的新通知处理、嵌套 epoll 等细节没有画进简图,不能拿这段模型替代完整实现。Linux v6.12:fs/eventpoll.c
这时再问“把红黑树换成别的结构行不行”,答案就清楚了:可以讨论用哈希表等结构管理登记对象,但要重新评估查找、内存、并发和生命周期成本。无论选择什么管理结构,都仍然需要解决通知如何关联监听项、候选项如何入队以及何时重新检查的问题。红黑树的选择和减少全量扫描的设计,处在不同层面。
五、一万个连接、两个请求,实际走完一轮
回到一万个已经建立的连接。假设服务端只用一个线程读取这些连接,Socket 均为非阻塞,每个连接有独立输入缓冲区,协议用换行符标记一条请求结束。连接登记已经完成,当前只有 A 和 B 收到了数据。
内核网络栈把字节放入对应 Socket 的接收缓冲区,引起可读状态的通知。相关监听项进入就绪候选列表。服务端调用 epoll_wait,或原本就在其中睡眠,此时获得一批可读事件。此处交回的是事件信息;请求字节仍留在 Socket 缓冲区。
服务端处理 A,调用 recv,把拿到的字节追加到 A 的输入缓冲区。假设第一次只拿到 GET /ite,没有换行,解析器不能立即当作完整命令执行;下一次读取拿到 m/42\n,拼起来才得到完整请求。即使一次可读事件对应好几个请求,也需要按协议逐个拆开,不能把事件数量当成请求数量。
读到暂时没有更多数据时,非阻塞读取返回 EAGAIN。这表示本轮可以停止从内核取数据,不表示连接损坏,也不表示应用缓冲区已经没有半条请求。服务端保存解析状态,再处理 B。B 的逻辑相同,因此 A 暂时没有后续数据不会把线程阻塞住。
业务完成后,服务端把响应交给 send。如果只接受了前 100 字节,剩余部分要留在输出缓冲区;如果暂时写不进去,就关注可写事件,之后从正确偏移继续。输出全部交给内核后,取消无必要的可写关注,因为健康连接大部分时间都可写,长期监听容易产生大量无用唤醒。send 的短写与错误语义
最后还要分清确认边界:epoll_wait 返回可写,只表示可以尝试发送;send 成功接受若干字节,只表示这些字节进入本机发送路径,不保证远端应用已经处理;远端回传业务确认,才可能成为协议约定的完成证据。一个支付或下单接口不能靠 Socket 可写就认定业务成功。
这一轮避免了应用检查其余 9998 个空闲结果位置,也复用了已有登记,但请求读取、协议解析、响应发送和业务执行都没有消失。如果业务处理占住线程 200 ms,其他连接照样排队。站内《Redis 为什么快,也为什么会突然变慢》讨论的事件循环阻塞,发生在这个等待机制之后。
六、LT 和 ET 到底改变了什么
先看一个具体状态:A 的接收缓冲区有 100 字节,应用本次只读了 40 字节,剩下 60 字节还在内核里。下一次等待应不应该继续报告 A?这就是理解两种通知方式的起点。
水平触发,简称 LT,可以理解为“条件仍成立,就继续有机会报告”。epoll 默认采用这种模式。如果 A 仍然可读,后续等待可以再次返回它。应用每次只读一部分,然后回去等待,通常不会仅因没有新字节到达而遗漏剩余数据。
边缘触发,简称 ET,关注就绪状态相关的变化通知。对这个例子,不能指望仅仅因为剩余 60 字节一直没读,下一次等待就一定重新提示。应用若读一口就睡回去,而客户端又在等响应、没有继续发送,双方就可能一直等下去。ET 不应被简化成“每个网络包通知一次”,通知可能合并,也不和业务消息一一对应。
图里的循环是常见安全起点:拿到可读通知后反复读取,正数就累积并解析;EINTR 表示被信号打断,可以重试;EAGAIN 表示当前读尽,回到等待;返回 0 表示对端发送方向已经结束,进入协议收尾;其他错误按连接策略处理。
在高负载下,单条热连接如果始终有数据,读到 EAGAIN 可能占用线程很久。实现可以给每次处理设置字节或时间预算,但 ET 下预算用完时,必须把这个连接留在应用自己的待处理队列中,之后主动继续读,直到读尽或关闭。不能提前停止后就完全依赖下一次内核通知。这里用应用调度补足公平性,换来了额外的队列和状态管理。
所以 ET 没有替应用“把活干完”,它把更多继续处理的责任交给了应用。LT 也不代表可以使用阻塞 Socket:在并发读、状态变化等情况下,通知之后的实际读取仍可能暂时无数据。非阻塞设置与检查返回值才是防止事件循环被卡住的基础。
七、几个会让事件循环卡住或空转的边界
最常见的空转来自长期关注可写。应用没有待发送数据,Socket 却一直可写,等待接口不断立即返回;程序醒来后发现无事可做,又继续等。处理方式是让关注事件随输出状态变化:输出队列从空变为非空时尝试发送,需要等待空间才保留可写关注,排空后取消。
另一类故障是把错误或关闭当成普通可读。流式 Socket 的读取返回 0,表示对端已经有序关闭发送方向。应用要根据协议决定是否还应发送剩余响应,并清理最终不再使用的连接。EPOLLHUP 也不等于所有已收字节都已被应用处理,不能看到它就无条件丢弃输入;EPOLLERR 则应进一步检查错误状态。关闭策略要结合读取结果和协议状态。
并发处理还会遇到 FD 复用。假设事件数组里保存了数字 42,另一个执行路径关闭旧连接,新连接恰好也拿到 42,迟到的工作就可能作用到新连接上。工程上需要明确谁负责关闭,怎样让事件批次中的旧项失效,以及连接对象何时才能释放。可以采用连接代次标识和受控的对象生命周期;只拿整数 FD 当永久身份不足以解决问题。
有些实现用 EPOLLONESHOT 把一次通知之后的监听暂时禁用,交给工作线程处理,再通过 EPOLL_CTL_MOD 重新启用。它适合协调处理权,但忘记重新启用会让连接不再收到通知;它本身也不替代对象生命周期和并发访问规则。应用要把“谁处理、谁重启监听、异常时谁收尾”写成明确路径。
等待自身也会失败或提前结束。返回 0 是超时,没有事件可以处理;返回 -1 且错误为 EINTR 是信号中断,可以结合退出标志决定重试。如果服务有整体截止时间,重试前应根据单调时钟计算剩余预算,不能每次重新使用完整超时。登记失败也必须处理,例如 FD 无效、对象不支持或资源耗尽;忽略失败后继续认为“已监听”,会形成永远等不到事件的连接。
这些故障有一个共同点:就绪通知只是调度输入,连接状态仍由应用维护。把等待 API 换成 epoll,不会自动得到完整的连接管理器。
八、用一个小实验验证“就绪不等于读完”
下面用 Python 标准库创建一对相连的 Socket,在 Linux 上分别观察 LT 和 ET。只写入一次 abc,第一次通知后故意只读一个字节,然后再次等待。每种模式使用一对全新的 Socket,避免上一次读取状态干扰下一轮。
import select
import socket
def observe(edge):
reader, writer = socket.socketpair()
try:
reader.setblocking(False)
with select.epoll() as ep:
flags = select.EPOLLIN | (select.EPOLLET if edge else 0)
ep.register(reader.fileno(), flags)
writer.sendall(b"abc")
assert ep.poll(1.0), "第一次应观察到可读"
assert reader.recv(1) == b"a"
again = ep.poll(0.05)
if edge:
assert again == [], "没有新写入,不应靠剩余字节重报"
else:
assert again, "LT 下还有未读取字节,应继续可读"
assert reader.recv(16) == b"bc"
try:
reader.recv(16)
except BlockingIOError:
print("ET" if edge else "LT", "剩余字节已读尽")
else:
raise AssertionError("writer 尚未关闭,此处应暂时无数据")
finally:
reader.close()
writer.close()
observe(False)
observe(True)
把它保存为 epoll_observe.py,在 Linux 中运行 python3 epoll_observe.py。Windows 原生 Python 没有 select.epoll,需要 Linux、WSL 或相应容器。这个受控实验没有其他读线程、额外写入或并发关闭,因此能把“持续可读”和“新通知”的差异单独露出来。若改变这些条件,就不能继续把结果解释成 ET 在任何情况下都只通知一次。
要验证性能,应另建实验,固定总连接数、活跃比例、连接增删频率、每次读写量和业务处理成本,比较 CPU、吞吐与延迟分位数。只提高连接数而不控制活跃比例,会把大量空闲连接和大量持续传输连接混在一起。只记录平均延迟,也可能漏掉热连接独占事件循环造成的尾延迟。
还可以用系统调用追踪确认应用是否每轮重新提交监听数组、是否频繁修改 epoll 登记,以及 recv 后是否正确遇到暂时无数据。追踪本身会干扰性能,适合解释调用行为,不宜直接当作无开销的基准测量。
九、怎样比较成本,怎样选择
令 N 为监听项总数,K 为本轮返回的就绪项数量,M 为本轮新增、修改或删除的监听操作数。poll 的数组传递和集合检查随 N 增长,应用查找非零结果最坏也随 N 增长。select 还受到最高 FD 编号及位集合表示的影响,不能完全按数组元素数理解。
epoll 把登记工作分摊到连接生命周期。每轮不必重新提交 N 个监听项,结果交付围绕候选与实际返回事件展开;但还有 M 次管理操作、事件回调、同步和状态复查。把 epoll 整体写成 O(1) 会掩盖这些成本,更会掩盖应用必须处理 K 个事件和对应字节的事实。
| 条件 | 更值得关注的取舍 |
|---|---|
| N 很大,K 很小,连接集合稳定 | epoll 可以复用登记,避开大量空闲项的重复检查 |
| N 很小,逻辑简单 | poll 的接口和生命周期管理可能已经足够 |
| 大量连接持续活跃,K 接近 N | 所有方案都要处理大量实际工作,epoll 的优势需要测量 |
| 连接快速创建、关闭或频繁改关注事件 | 关注登记开销、内存、回调与生命周期管理成本 |
| 每个请求执行很慢 | 先处理业务阻塞和调度,换等待接口不够 |
| 需要支持多个操作系统 | 通常通过成熟事件库封装各平台接口 |
select 仍能帮助理解位集合和老接口,但 Linux 新程序通常应考虑没有这套固定 FD 编号限制的接口。至于直接写 epoll 还是使用框架,应取决于是否需要自己承担连接管理、缓冲、定时器、公平调度和错误恢复。框架帮忙组织这些责任,并没有改变底层就绪语义。
现在回看“为什么另两个也不只复制就绪 FD”:紧凑返回可以单独实现,但它只解决结果查找。epoll 还把监听登记保留在内核中,让状态变化关联到具体监听项,再从候选列表领取事件。红黑树负责其中的登记管理;应用仍负责实际读写、协议状态和处理公平性。按这几项操作去理解,才有办法判断某个场景到底省掉了哪一笔工作。
参考资料
- Linux man-pages:select、poll、epoll 总览。
- epoll_ctl、epoll_wait:登记、事件数据与分批领取的接口约定。
- recv、send:实际读写与错误处理的边界。
- Linux v6.12 源码:fs/select.c、fs/eventpoll.c。
如果这篇文章对你有帮助