[Linux][网络][高级IO][三][IO多路转接][epoll]详细讲解


1.IO多路转接之epoll

1.epoll初识

  • 按照man手册的说法:是为处理大批量句柄而作了改进的poll
    • 但个人认为是epoll是poll pro max版本:P
  • 它是在2.5.44内核中被引进的
    • 它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法

2.epoll_create()

  • 功能:创建一个epoll模型,通过句柄相勾连
    • 用完之后,必须调用close()关闭
  • 原型:int epoll_create(int size);
  • 参数size:现在已经被废弃,忽略即可 – 随便写,128/256即可
  • 返回值
    • 成功返回一个文件描述符
    • 失败返回-1,同时errno被设置

3.epoll_ctl()

  • 参数
    • epfd:epoll_create()的返回值 – epoll的句柄
    • op:表示要进行的操作,用三个宏表示
      • EPOLL_CTL_ADD:注册新的fd到epfd中
      • EPOLL_CTL_MOD:修改已经注册的fd的监听事件
      • EPOLL_CTL_DEL:从epfd中删除一个fd
    • fd:需要监听的fd
    • event:是一个epoll()监听的结构,告诉内核需要监听什么事,其中event成员可以是以下几个宏的集合
      • **EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
        • 让epoll监控,接收函冲去有没有数据,如果有,告诉用户调用recv去接收
      • EPOLLOUT:**表示对应的文件描述符可以写
        • 让epoll监控,发送缓冲区还有没有空闲的空间,如果有,才可以调用send等拷贝函数将数据拷贝到底层的发送缓冲区
      • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
      • EPOLLERR:表示对应的文件描述符发生错误
      • EPOLLHUP:表示对应的文件描述符被挂断
      • EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的
      • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
  • 返回值
    • 成功返回0
    • 失败返回-1,同时errno被设置
  • struct epoll_event结构如下
typedef union epoll_data
{
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

struct epoll_event
{
  uint32_t events;  /* Epoll events */
  epoll_data_t data;    /* User data variable */
} __EPOLL_PACKED;

4.epoll_wait()

  • 功能:收集在epoll监控的事件中已经发生的事件,内核告知用户,哪些文件描述符上的什么时间已经就绪
  • 原型:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 参数
    • epfd:epoll_create()的返回值 – epoll的句柄
    • event:分配好的epoll_event结构体数组
    • maxevents:告诉内核这个events有多大
    • timeout
      • 表示epoll()的超时时间,单位是毫秒
      • 设置为0,表示非阻塞等待
      • 设置为-1,表示阻塞等待
  • 返回值
    • 执行成功则返回文件描述词状态已改变的个数
      • 有几个fd上的事件就绪,就返回几
      • epoll返回的时候,会将所有的event按照顺序放入到events数组中,一共有**[返回值]**个
      • 如果底层就绪的sock非常多,revs承装不下,怎么办?
        • 不影响,一次拿不完,那就下一次拿(LT模式)
    • 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
    • 当有错误发生时则返回-1,错误原因存于errno

5.epoll工作原理

  • 当某一进程调用epoll_create()时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关
struct eventpoll
{
    // ....
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
    // ....
};
  • 每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl()向epoll对象中添加进来的事件
    • 这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来
    • 而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系
      • 也就是说,当响应的事件发生时,会调用这个回调方法
        • 不需要OS进行频繁遍历了
    • 这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中(本质是队列)
void ep_poll_callback(int fd)
{
    // 1.根据红黑树上节点要关心的事件,结合已经发生的事件
    // 2.自动根据fd和已经发生的事件,构建就绪节点
    // 3.自动将构建好的节点,插入到就绪队列中
}
  • 在epoll中,对于每一个事件,都会建立一个epitem结构体
struct epitem
{
    struct rb_node rbn;       // 红黑树节点
    struct list_head rdllink; // 双向链表节点
    struct epoll_filefd ffd;  // 事件句柄信息
    struct eventpoll *ep;     // 指向其所属的eventpoll对象
    struct epoll_event event; // 期待发生的事件类型
}
  • 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
    • 如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户

    • 这个**[检查是否有事件就绪]**操作的时间复杂度是O(1)

      请添加图片描述

6.epoll使用过程三部曲

  • 调用epoll_create()创建一个epoll句柄
  • 调用epoll_ctl(),将要监控的文件描述符进行注册
  • 调用epoll_wait(),等待文件描述符就绪

7.epoll的优点(和select缺点对应)

  • 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开
  • 数据拷贝轻量:只在合适的时候调用EPOLL_CTL_ADD将文件描述符结构拷贝到内核中,这个操作并不频繁
    • 而select/poll都是每次循环都要进行拷贝
  • 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait()返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响
  • 没有数量限制:文件描述符数目无上限
  • 注意网上有些博客说,epoll中使用了内存映射机制
    • 内存映射机制:内核直接将就绪队列通过mmap的方式映射到用户态,避免了拷贝内存这样的额外性能开销
    • 这种说法是不准确的,用户定义的struct epoll_event是在用户空间中分配好的内存,势必还是需要将内核的数据拷贝到这个用户空间的内存中的

8.思考 && 问题

  • 细节1:红黑树中,是要有key值的?
    • 这里由文件描述符充当
  • **细节2:**用户只需要设置关系,获取结果即可,不用再关心任何对fd与event的管理细节
  • 细节3:epoll为什么高效呢?
    • epoll底层用红黑树管理事件,以前都是用数组
    • 以前事件就绪需要OS去遍历数组,而现在只需要通过回调函数回调即可,不需要OS花更多精力在事件检测上了
    • 以前事件就绪,还是需要OS去遍历数组,现在有就绪队列,获取就绪节点的时间复杂度为O(1)
  • **细节4:**底层只要有fd就绪了,OS自己会构建节点,连入到就绪队列中,上层只需要通过epoll_wait()不断地从就绪队列中将数据拿走,就完成了获取就绪事件的任务
    • 即:生产者消费者模型,存在共享资源
    • epoll已经保证所有的epoll接口都是线程安全的
  • 细节5:如果底层没有就绪事件呢?上层应该怎么办?
    • 阻塞等待

2.epoll工作方式

0.感性理解 && 铺垫

  • 你正在吃鸡,眼看进入了决赛圈,你妈饭做好了,喊你吃饭的时候有两种方式:
    • 如果你妈喊你一次,你没动,那么你妈会继续喊你第二次,第三次…(亲妈,水平触发)
    • 如果你妈喊你一次,你没动,你妈就不管你了(后妈,边缘触发)
  • epoll有两种工作方式
    • 水平触发(LT)
    • 边缘触发(ET)
  • 假如有这样一个例子:
    • 已经把一个tcp socket添加到epoll描述符
    • 这个时候socket的另一端被写入了2KB的数据
    • 调用epoll_wait,并且它会返回,说明它已经准备好读取操作
    • 然后调用read,只读取了1KB的数据
    • 继续调用epoll_wait…

1.水平触发(Level Triggered)工作模式

  • epoll默认状态下就是LT工作模式
    • 当epoll检测到socket上事件就绪的时候,可以不立刻进行处理,或者只处理一部分
    • 如上面的例子,由于只读了1K数据,缓冲区中还剩1K数据,在第二次调用epoll_wait()时,epoll_wait()仍然会立刻返回并通知socket读事件就绪
    • 直到缓冲区上所有的数据都被处理完,epoll_wait()才不会立刻返回
    • 支持阻塞读写和非阻塞读写

2.边缘触发(Edge Triggered)工作模式

  • 如果在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志,epoll进入ET工作模式
    • 当epoll检测到socket上事件就绪时,必须立刻处理
    • 如上面的例子,虽然只读了1K的数据,缓冲区还剩1K的数据,在第二次调用epoll_wait()的时候,epoll_wait()不会再返回了
      • 也就是说,ET模式下,文件描述符上的事件就绪后,只有一次处理机会
    • ET的性能比LT性能更高(epoll_wait()返回的次数少了很多)
      • Nginx默认采用ET模式使用epoll
    • 只支持非阻塞的读写
  • 数据从无到有、从有到多(变化)的时候,epoll才会通知
  • select和poll其实也是工作在LT模式下,epoll既可以支持LT,也可以支持ET

3.对比LT和ET

  • LT是epoll的默认行为,使用ET能够减少epoll触发的次数,但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完
  • 相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比LT更高效一些
    • 但是在LT情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的
  • 另一方面,ET的代码复杂程度更高了

4.理解ET模式和非阻塞文件描述符

  • 使用ET模式的epoll,需要将文件描述设置为非阻塞

    • 这个不是接口上的要求,而是"工程实践"上的要求
  • 假设这样的场景: 服务器接受到一个10k的请求,会向客户端返回一个应答数据,如果客户端收不到应答,不会发送第二个10k请求

    请添加图片描述

  • 如果服务端写的代码是阻塞式的read,并且一次只read 1k数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的9k数据就会待在缓冲区中

    请添加图片描述

  • 此时由于epoll是ET模式,并不会认为文件描述符读就绪,epoll_wait()就不会再次返回,剩下的9k数据会一直在缓冲区中,直到下一次客户端再给服务器写数据,epoll_wait()才能返回

  • 但是问题来了:

    • 服务器只读到1k个数据,要10k读完才会给客户端返回响应数据

    • 客户端要读到服务器的响应,才会发送下一个请求

    • 客户端发送了下一个请求,epoll_wait()才会返回,才能去读缓冲区中剩余的数据

      请添加图片描述

  • 所以,为了解决上述问题(阻塞read不一定能一下把完整的请求读完),于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来

  • 而如果是LT没这个问题,只要缓冲区中的数据没读完,就能够让epoll_wait()返回文件描述符读就绪

5.epoll的使用场景

  • epoll的高性能,是有一定的特定场景的,如果场景选择的不适宜,epoll的性能可能适得其反
    • 对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用epoll
    • **例如:**典型的一个需要处理上万个客户端的服务器,例如各种互联网APP的入口服务器,这样的服务器就很适合epoll
  • 如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用epoll就并不合适
  • 具体要根据需求和场景特点来决定使用哪种IO模型

6.思考 && 问题

  • 为什么一定要听ET的?
    • 如果不取,底层再也不通知了,上层调用时,无法再获取该fd的就绪事件了,无法再调用recv了
    • 即:变相的数据丢失了
    • 强逼着程序猿一次响应就绪过程中就把所有的数据都处理完
  • 怎么保证把本轮数据全部读完了呢?
    • 必须一直循环读取,在最后一次正常读取完毕,势必还要进行下一次读取(无法确认是否读取完成)
      • 必然会阻塞,导致进程挂起
    • 为了避免这个问题,在ET模式下工作,sock必须被设置为非阻塞
    • 只要循环读取一直读取直到读取出错EAGAIN
  • 可以暂时不处理LT的通知事件吗?
    • 可以,因为如果不取,或者取了一部分,用户并不担心,因为底层还是会让fd就绪,还有读取的机会
  • 在LT情况下如果也能做到每次就绪的文件描述符都立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的
  • ET模式为何更高效?
    • 更少的返回次数
    • ET****模式强逼着程序猿一次响应就绪过程中就把所有的数据都处理完,应用层尽快的取走了缓冲区中的数据,那么在单位时间下,该模式下工作的服务器,就可以在一定程度上,给发送方回送一个更大的接收窗口,所以对方就可以有更大的滑动窗口,一次向服务器发送更多的数据,提高IO吞吐

相关推荐

  1. Linux高级IO——转接之select

    2024-05-16 15:24:17       149 阅读
  2. Linux高级IO——转接之poll

    2024-05-16 15:24:17       38 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-05-16 15:24:17       94 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-05-16 15:24:17       100 阅读
  3. 在Django里面运行非项目文件

    2024-05-16 15:24:17       82 阅读
  4. Python语言-面向对象

    2024-05-16 15:24:17       91 阅读

热门阅读

  1. 自定义一个starter

    2024-05-16 15:24:17       32 阅读
  2. ESP32 Arduino 定时器中断

    2024-05-16 15:24:17       36 阅读
  3. vue3-响应式API(工具函数)-unRef

    2024-05-16 15:24:17       37 阅读
  4. 【数据库】高并发场景下的数据库开发注意要点

    2024-05-16 15:24:17       38 阅读
  5. 什么是Vue.js? Vue.js简介

    2024-05-16 15:24:17       32 阅读