前言
epoll全称又叫 event poll,是 Linux 内核提供的一种基于事件回调机制的 I/O 多路复用技术,用于高效处理大量并发的网络连接。它是 Linux 特有的系统调用,在处理高并发场景时性能显著优于传统的 select 和 poll。
何为多路复用?
- 多路:存在多个待服务的对象(甲方)
- 复用:只由一个执行单元提供服务(乙方)
串联起来就是:多路复用是指由一个执行单元,同时对多个对象提供服务,形成一对多的服务关系。
在Linux操作系统中,对 IO 多路复用的概念有着更加明确的定义:
- 多路:存在多个需要监听 I/O event 的 fd(linux 中,一切皆文件,所有事物均可抽象为一个文件句柄 file descriptor,简称 fd)
- 复用:一个自旋的主循环 loop thread(线程是内核视角下的最小调度单位,多路复用通常为循环模型 loop model,因此称为loop thread)不断遍历多个 fd,若有对应的I/O请求到来时,处理读取到的数据。
I/O多路复用的简单实现
BIO模式
通过一个自旋的for循环,环形遍历待执行的fd列表,每轮执行一个,结束才处理下一个,到达列表尾部时,重置回到起点
伪代码
// 多个待服务的 fd
fds = [fd1,fd2,fd3,...]
// 遍历 fd 列表,末尾和首部相连,形成循环
i = 0
for {
// 获取本轮待处理的 fd
fd = fds[i]
// 从 fd 中读数据
data = read(fd)
// 处理数据
handle(data)
// 推进遍历
i++
if i == len(fds){
i = 0
}
}
存在问题:每次从列表中获取一个fd,假如当前处理的某个fd(监听I/O请求),并没有对应的I/O请求时,会陷入阻塞态,会影响后续其它任务,例如其它fd监听有新的I/O请求到了,却无法被正常处理
NIO模式
自旋的for循环中,遍历到某一个 fd 的时候,倘若 io event 已就绪就正常读取,否则就即时返回并抛出一个特定类型的错误,让 loop thread 能够正常执行下去,为其他 fd 提供服务。为了避免CPU控制,可以在前后两次遍历中间sleep一小会。
伪代码
// 多个待服务的 fd
fds = [fd1,fd2,fd3,...]
// 遍历 fd 列表,末尾和首部相连,形成循环
i = 0
for {
// 获取本轮待处理的 fd
fd = fds[i]
// 尝试从 fd 中读数据,失败时不阻塞,而是抛出错误
data,err = tryRead(fd)
// 读取数据成功,处理数据
if err == nil{
handle(data)
}
// 小睡一秒后再推进流程
sleep(1s)
// 推进遍历
i++
if i == len(fds){
i = 0
}
}
存在问题:sleep时长不好把控,太长也会影响新I/O请求到来时,无法及时响应;太短了会导致CPU空转,假如一段时间内fd列表中都没有对应的I/O事件到来,那么这个自旋线程一直在空转。
I/O多路复用的优雅实现
用户态视角下的程序对于I/O事件的到达没能做到准确把控,需要引入操作系统内核的帮助,通过几个内核对外暴露的接口,实现I/O多路复用的优雅实现。
linux 内核提供了三种经典的多路复用技术,是一个持续演化改进的过程: select -> poll -> epoll
poll 在 select 的基础之上做了改进,但治标不治本,优化得不够彻底. 我们核心还是来对比看看 select 和 epoll 之间的共性和差异。
select模型
特点:
- 一次可以处理多个 fd,体现多路. 但 fd 数量有限,最多 1024 个
- loop thread 通过 select 将一组 fd 提交到内核做监听
- 当 fd 中无任何 io event 就绪时,loop thread 会陷入阻塞
- 每当这组 fd 中有 io event 到达时,内核会唤醒 loop thread
- loop thread 无法精准感知到哪些 fd 就绪,需要遍历一轮 fd 列表,逐个判断哪些描述符就绪,进行相应 I/O 操作
- 托付给内核的 fd 列表只具有一轮交互的时效. 新的轮次中,loop thread 需要重新将监听的 fd 列表再传递给内核一次,所以会有反复的内核态和用户态之间数据拷贝的过程
不足:
- fd 文件描述符数量受限
- 线性扫描,效率较低:无论就绪描述符数量多少,用户态都需遍历整个集合,时间复杂度为 O(n),在高并发场景下性能显著下降
- 内存拷贝开销大:每次调用 select 都需将描述符集合(fd列表)从用户态拷贝到内核态,内核处理完成后再拷贝回用户态,频繁操作时开销明显。
epoll模型
包含三个指令(系统调用):epoll_create、epoll_ctl、epoll_wait
(1)epoll_create
- 在内核开辟空间,创建一个 epoll 池子用于批量存储管理后续监听I/O请求的 fd,可以通过 epoll_ctl 往池子中增删改 fd。
- linux 内核中,实现 epoll 池的数据结构采用的是红黑树(自平衡二叉查找树),保证了所有增、删、改操作的平均时间复杂度维持在 O(logN) 的对数级水平。
- func epollcreate1(flags int32)
(2)epoll_ctl
- 在某个 epoll 池子中进行一个 fd 的增删改操作,注册、修改或删除对特定文件描述符(fd)的事件监听
- func epollctl(epfd, op, fd int32, ev *epollevent) int32, 通过参数中的 op 枚举指定操作(注册事件、修改事件、删除事件),通过参数中的 ev 指定监听的回调事件类型
(3)epoll_wait
- 等待事件发生,如果池子里fd当中所设置的回调事件发生了,loop thread会被唤醒,否则陷入阻塞
- func epollwait(epfd int32, ev *epollevent, nev, timeout int32) int32
- 当事件发生时,从参数指定中就可以直接关联到对应的fd信息了,不需要再像select那样遍历一轮 fd 列表,逐个判断是哪些描述符就绪了
特点:
- 每次处理的 fd 数量理论无上限
- loop thread 通过 epoll_create 操作创建一个 epoll 池子
- loop thread 通过 epoll_ctl 每次将一个待监听的 fd 添加到 epoll 池中。
- 相比select,epoll 通过将创建池子和增删改 fd两个操作解耦,内核通过
mmap机制共享内存,能复用池子里fd的数据,不用每次都重新进行内核态/用户态之间的拷贝,前提就是提前申请好了一个池子存放着。mmap是操作系统内核提供的一个方法,可以将内核空间的缓冲区映射到用户空间。原理是把磁盘文件映射到内存,用户通过修改内存就可以修改磁盘文件,使用这种方式可以获得很大的I/O提升,省去了用户空间到内核空间的复制开销
- 每当 fd 列表中有 fd 就绪事件到达时,会唤醒 loop thread. 同时内核会将处于就绪态的 fd 直接告知 loop thread,相比select,减少了一次额外遍历的操作,时间复杂度由 O(N) 优化到 O(1)
epoll的两种工作模式:
- 水平触发(Level Triggered, LT,默认模式):只要文件描述符处于就绪状态(如可读),epoll_wait 就会不断返回该事件。前向兼容 select/poll
- 边缘触发(Edge Triggered, ET):仅在文件描述符状态变化(如从无数据变为有数据)时触发一次事件。减少事件触发次数,性能更高。 但必须一次性处理完所有数据,否则未处理的数据不会再次通知。
小结
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 有上限,通常最多1024个 | 理论无上限 | 理论无上限 |
| 事件检测机制 | 轮询 O(N) | 轮询 O(N) | 事件驱动 O(1) |
| 数据拷贝 | 每次调用都需要复制fd列表 | 每次调用都需要复制fd列表 | 仅在注册一个fd时复制一次,存放在池子里 |
| 工作模式 | 仅支持水平触发 | 仅支持水平触发 | 支持水平触发和边缘触发 |
| 适用场景 | 少量连接,事件发生频繁,实现简单 | 中等量连接 | 适合处理万级以上的并发连接(如 Nginx、Redis 等高性能服务器) |