图解epoll 到底快在哪?三张图看懂 select、poll、epoll 的区别
理解 epoll,可以先看 select、poll 在等待事件时反复做了哪些工作,再看 epoll 怎样减少这些工作。核心是保存监听关系,并在事件发生时提前记录就绪对象。
一 select 和 poll 的重复开销
假设程序监听 A、B、C、D、E 五个连接,开始都没有事件,随后只有 B、D 可读。select、poll 都能让调用线程睡眠,数据到达后再唤醒它;它们的开销主要在下面三处。
每轮都要传入完整的监听范围。即使还是关注 A 到 E,下一次调用仍要把完整集合或数组交给内核,产生重复的参数拷贝。select 使用位图,返回时会修改集合,所以再次调用前还要恢复监听集合;poll 使用数组,可以复用用户态数组,但每次仍要提交它。
内核需要扫描,才能确定谁已就绪。调用时先检查监听项;如果没有事件就睡眠,被唤醒后再检查。即使只有 B、D 可读,也要检查 A 到 E,而不能直接从一个保存好的就绪链表中取出 B、D。连接越多,检查非活跃连接的工作越多。
应用还要从返回的标记中找出就绪连接。select 的结果位图 只保留 B、D 的就绪位,其他位清零;poll 的数组仍包含 A 到 E,在 B、D 对应项的
revents中设置就绪标记。应用需要检查位图或数组中的标记,找到本轮要处理的连接。
二 epoll 如何减少重复开销
epoll 将“管理监听关系”和“等待事件”分成了epoll_ctl()与epoll_wait(),并在内核中保存监听关系和就绪链表。三个问题分别对应三个改进:
| select 和 poll 的重复工作 | epoll 的解决方式 |
|---|---|
| 每轮传入完整监听范围 | 用epoll_ctl()注册,内核红黑树持续保存监听关系;需要增删改时才更新,epoll_wait()不再传入整份监听范围。 |
| 内核扫描全部监听项,寻找就绪连接 | 注册时建立回调关联;B、D 就绪时,回调直接找到对应监听记录,把它们加入就绪链表,这样就不用遍历全部监听项了。epoll_wait()主要复查这些候选。 |
| 应用检查集合或数组中的标记(需要二次处理才能拿到就绪的事件) | epoll_wait()把就绪链表的事件写入用户态数组,这个数组的事件都是就绪事件;应用逐项处理这些事件即可。 |
下面的图把同一次事件放到三种机制中比较:上方是用户态,下方是内核态,按编号看执行顺序。
接下来只看 epoll,把它拆成注册和数据到达两个阶段。
三 注册监听
假设程序已经创建了 epoll 实例,socket 101 当前没有数据可读。程序调用epoll_ctl(ADD),把 socket 101 加入监听范围。内核按图中的顺序完成以下工作。
找到 epoll 和 socket。内核通过文件描述符,找到这次使用的 epoll 实例,以及需要监听的 socket。
把监听记录放入红黑树。内核创建一条监听记录,叫作
epitem,记下目标 socket 和关注的事件,再把它加入红黑树。红黑树保存着这个 epoll 实例的全部监听记录。在 socket 上登记回调。回调就是提前登记好、在相应事件发生时被调用的函数。这里使用的是内核函数
ep_poll_callback:内核在 socket 的等待队列中登记一个调用它的回调项,并将这个回调项与监听记录关联起来。这个回调负责把“socket 可读了”变成“epoll 有事件可以处理了”。socket 变为可读时,内核调用它,它就直接找到对应的监听记录,加入就绪链表;如果有线程正在等待,再唤醒线程。
所以,回调的作用是记录就绪、通知等待线程。真正读取数据的工作,由应用拿到事件后调用
recv()完成。检查现在是否已经可读。本例还没有数据,所以就绪链表为空。如果注册时就已经可读,则会直接把对应监听项加入就绪链表。
注册完成后,程序调用epoll_wait()。如果没有就绪事件,并且允许阻塞,线程就进入epoll 的等待队列,等待通知。这里等待的是调用epoll_wait()的应用线程,epoll 不会为每个 socket 新建线程。
epoll 等待队列的作用,是记住哪些线程正在等事件。没有事件时,这些线程可以睡眠、让出 CPU;回调记录就绪后,再按需唤醒它们。就绪链表记录“哪些连接需要处理”,epoll 等待队列记录“哪些线程等待通知”。
到这里,监听关系已经建立好了,后续每次等待都可以复用。
四 数据到达
现在,socket 101 收到了数据,而程序正阻塞在epoll_wait()中。接下来看数据到达后的流程。
数据进入 socket。网卡收到数据,协议栈处理后,把数据交给目标 socket,保存在接收缓冲区中。
socket 发出可读通知。当 socket 满足可读条件时,触发它的等待队列中登记的回调。
回调找到对应的监听记录。利用注册时保存的关联,回调直接找到 socket 101 对应的
epitem,不用遍历全部连接,也不用重新搜索红黑树。把监听项加入就绪链表。回调把它记录为待处理的就绪项;如果已经在链表中,就不重复加入。它在红黑树中的监听关系仍然保留。
唤醒等待线程。回调唤醒阻塞在 epoll 等待队列中的线程,使程序有机会继续执行。
返回就绪事件。
epoll_wait()检查就绪链表中的候选,确认当前状态,再把符合条件的事件返回给程序。
程序拿到“socket 101 可读”的事件后,再调用recv()读取数据。
epoll 返回的是就绪事件,实际数据由程序随后通过recv()从 socket 接收缓冲区读取。
可以用下面两条流程记住它:
注册:找到连接 → 保存监听记录 → 登记回调 → 检查当前状态 通知:数据到达 → 回调记录就绪 → 唤醒等待线程 → 返回事件 → recv 读取数据红黑树记住“关注谁”,就绪链表记住“谁需要处理”,等待队列记住“哪些线程正在等事件”。