epoll网络I/O模型 学习笔记

124 阅读7分钟

前言

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 列表再传递给内核一次,所以会有反复的内核态和用户态之间数据拷贝的过程

不足:

  1. fd 文件描述符数量受限
  2. 线性扫描,效率较低:无论就绪描述符数量多少,用户态都需遍历整个集合,时间复杂度为 O(n),在高并发场景下性能显著下降
  3. 内存拷贝开销大:每次调用 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):仅在文件描述符状态变化(如从无数据变为有数据)时触发一次事件。减少事件触发次数,性能更高。 但必须一次性处理完所有数据,否则未处理的数据不会再次通知。

小结

特性selectpollepoll
最大连接数有上限,通常最多1024个理论无上限理论无上限
事件检测机制轮询 O(N)轮询 O(N)事件驱动 O(1)
数据拷贝每次调用都需要复制fd列表每次调用都需要复制fd列表仅在注册一个fd时复制一次,存放在池子里
工作模式仅支持水平触发仅支持水平触发支持水平触发和边缘触发
适用场景少量连接,事件发生频繁,实现简单中等量连接适合处理万级以上的并发连接(如 Nginx、Redis 等高性能服务器)