epoll为什么更高效 ?

1、什么是IO复用

可参考5种IO模式

思考一个问题:需不需要为每一个请求client都创建一个线程去处理请求

我们把视角放到应用B从TCP缓冲区中读取数据这个环节来。如果在并发的环境下,可能会N个人向应用B发送消息,这种情况下我们的应用就必须创建多个线程去读取数据,每个线程都会自己调用recvfrom 去读取数据。那么此时情况可能如下图:
在这里插入图片描述
如上图一样,并发情况下服务器很可能一瞬间会收到几十上百万的请求,这种情况下应用B就需要创建几十上百万的线程去读取数据,同时又因为应用线程是不知道什么时候会有数据读取,为了保证消息能及时读取到,那么这些线程自己必须不断的向内核发送recvfrom 请求来读取数据;

那么问题来了,这么多的线程不断调用recvfrom 请求数据,先不说服务器能不能扛得住这么多线程,就算扛得住那么很明显这种方式是不是太浪费资源了,线程是我们操作系统的宝贵资源,大量的线程用来去读取数据了,那么就意味着能做其它事情的线程就会少。

所以,有人就提出了一个思路,能不能提供一种方式,可以由一个线程监控多个网络请求(我们后面将称为fd文件描述符,linux系统把所有网络请求以一个fd来标识),这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据,这么做就可以节省出大量的线程资源出来,这个就是IO复用模型的思路。
在这里插入图片描述
正如上图,IO复用模型的思路就是系统提供了一种函数可以同时监控多个fd的操作,这个函数就是我们常说到的select、poll、epoll函数,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,select函数监控的fd中只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时询问线程再去通知处理数据的线程,对应线程此时再发起recvfrom请求去读取数据。

定义

进程通过将一个或多个fd传递给select(或者其他IO复用API,例如poll、epoll),阻塞在select操作上,select帮我们侦测多个fd是否准备就绪,当有fd准备就绪时,select返回数据可读状态,应用程序再调用recvfrom读取数据。
在这里插入图片描述
总结:
复用IO的基本思路就是通过slect或poll、epoll 来监控多fd ,来达到不必为每个fd创建一个对应的监控线程,从而减少线程资源创建的目的。

2、为什么选择epoll

2.1、select的缺陷

高并发的核心解决方案是1个线程处理所有连接的“等待消息准备好”,这一点上epoll和select是无争议的。但select预估错误了一件事,当数十万并发连接存在时,可能每一毫秒只有数百个活跃的连接,同时其余数十万连接在这一毫秒是非活跃的。select的使用方法是这样的:

返回的活跃连接 == select(全部待监控的连接)

什么时候会调用select方法呢?

在你认为需要找出有报文到达的活跃连接时,就应该调用。所以,调用select在高并发(连接请求多,活跃的连接也多)时是会被频繁调用的。这样,这个频繁调用的方法就很有必要看看它是否有效率,因为,它的轻微效率损失都会被“频繁”二字所放大。

1、它有效率损失吗:轮询的缺陷

内核中实现 select是用轮询方法,即每次检测都会遍历所有FD_SET中的句柄,显然,select函数执行时间与FD_SET中的句柄个数有一个比例关系,即 select要检测的句柄数越多就会越费时。

全部待监控连接是数以十万计的,返回的只是数百个活跃连接,这本身就是无效率的表现。被放大后就会发现,处理并发上万个连接时,select就完全力不从心了。

2、FD_SET是有限的:处理并发连接数目的缺陷
此外,在Linux内核中,select所用到的FD_SET是有限的,即内核中有个参数__FD_SETSIZE定义了每个FD_SET的句柄个数。这样就限制了select能够并发处理是事件的数目

#define __FD_SETSIZE         1024

3、调用过程的时间:数据拷贝缺陷
每次调用select,都需要把fd集合用户态拷贝到内核态,这个开销在fd很多时会很大

注意:
select与poll在内部机制方面并没有太大的差异。相比于select机制,poll只是取消了最大监控文件描述符数限制,并没有从根本上解决select存在的问题。

2.2、epoll高效的奥秘

e对应的英文单词就是enhancement,中文翻译为增强,加强,提高,充实的意思。所以epoll模型会显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

epoll为啥这么高效?

  • 1、新建epoll描述符 == epoll_create()
    • epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。
    • epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。 这个文件描述符使用如下epoll_create函数来创建
  • 2、添加或者删除所有待监控的连接 == epoll_ctl()
    • epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的,红黑树本身插入和删除性能比较好,时间复杂度O(logN)
  • 3、返回的活跃连接 == epoll_wait( epoll描述符 )
    • epoll_wait只需要检查rdlist双向链表中是否有存在注册的事件

epoll的核心是3个API,核心数据结构是:1个红黑树和1个链表

下图可以很好地表现epoll处理事件的流程,注意箭头方向
在这里插入图片描述

2.2.1、 mmap

mmap使用原理参考

无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝? ——》mmap

1、select:内核如何把FD消息通知给用户空间?

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);
  • 第一个参数:nfds最大的文件描述符

  • 第二个参数:&rset读文件描述符集合

    • &rset 是一个 bitmap 用来表示哪一个文件描述符被启用了或者说被监听了。
    • 假设我们有3个请求,3个请求对应的文件描述符为:1,5,9,那bitmap010001000100000..."1"就代表第几个文件描述符被占用。
  • 剩下三个参数分别为写文件描述符集合、异常信息、超时时间。

文件描述符是什么,请参考此链接

我们最关心的其实是&rest 因为我们是读取网络数据
在这里插入图片描述
注意:

  • 从内核态到用户态的切换是有一个开销,无论多少个文件修改,1个文件修改,3个文件修改 都要切换拷贝。

  • 不能知道是哪一个文件被修改,select 无法通知是哪一个或者哪几个文件描述符被修改,需要整个重新遍历一遍,这个复杂度是O(n)的。

2、epoll:内核如何把FD消息通知给用户空间?
在这里插入图片描述

  • epoll是通过内核与用户空间mmap同一块内存实现的
  • mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址
    • 不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见
    • 减少用户态和内核态之间的数据交换的复制开销。内核可以直接看到epoll监听的句柄,效率高。

在这里插入图片描述

2.2.2、红黑树

上面mmap出来的内存如何保存epoll所监听的套接字,必然也得有一套数据结构

这里为了便于理解,先介绍socket 和 文件描述符之间的关系

套接字也是文件。具体数据传输流程如下:

  • server端监听到有连接时,应用程序会请求内核创建Socket;

  • Socket创建好后会返回一个文件描述符给应用程序;

  • 当有数据包过来网卡时,内核会通过数据包的源端口,源ip,目的端口等在内核维护的一个ipcb双向链表中找到对应的Socket,并将数据包赋值到该Socket的缓冲区

  • 应用程序请求读取Socket中的数据时,内核就会将数据拷贝到应用程序的内存空间,从而完成读取Socket数据

在这里插入图片描述
这里红色箭头的方向:

  • 表征服务器处理完客户端的请求以后,向客户端发送响应的执行顺序;
  • 所以,当服务器接受客户端请求的时候,红色箭头是相反的,自行脑补,哈哈

注意:
操作系统针对不同的传输方式(TCP,UDP)会在内核中各自维护一个Socket双向链表,当数据包到达网卡时,会根据数据包的源端口,源ip,目的端口从对应的链表中找到其对应的Socket,并会将数据拷贝到Socket的缓冲区,等待应用程序读取。

epoll在实现上采用红黑树去存储所有套接字:

  • 当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理
  • 红黑树本身插入和删除性能比较好,时间复杂度O(logN)。
  • 通过epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的。

关键数据结构的定义

struct eventpoll
{
    spin_lock_t lock;            //对本数据结构的访问
    struct mutex mtx;            //防止使用时被删除
    wait_queue_head_t wq;        //sys_epoll_wait() 使用的等待队列
    wait_queue_head_t poll_wait; //file->poll()使用的等待队列
    struct list_head rdllist;    //事件满足条件的链表
    struct rb_root rbr;          //用于管理所有fd的红黑树
    struct epitem *ovflist;      //将事件到达的fd进行链接起来发送至用户空间
}


struct epitem
{
    struct rb_node rbn;            //用于主结构管理的红黑树
    struct list_head rdllink;       //事件就绪队列
    struct epitem *next;           //用于主结构体中的链表
    struct epoll_filefd ffd;         //每个fd生成的一个结构
    int nwait;
    struct list_head pwqlist;     //poll等待队列
    struct eventpoll *ep;          //该项属于哪个主结构体
    struct list_head fllink;         //链接fd对应的file链表
    struct epoll_event event;  //注册的感兴趣的事件,也就是用户空间的epoll_event
 }

在这里插入图片描述
注意

在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。

2.2.3、双向链表

添加以及返回事件

  • 1、当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备(网卡)驱动程序建立回调关系
    2、当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数其实就所把这个事件添加到rdllist这个双向链表中。

  • 3、那么当我们调用epoll_wait时,epoll_wait只需要检查rdlist双向链表中是否有存在注册的事件(红黑树),效率非常可观。这里也需要将发生了的事件复制到用户态内存中即可。

epoll_wait的工作流程:这里对理解原理很重要

  • 1、epoll_wait调用ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。
  • 2、文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用。
  • 3、ep_poll_callback将相应fd对应epitem加入rdlist导致rdlist不空,进程被唤醒,epoll_wait得以继续执行。
  • 4、ep_events_transfer函数将rdlist中的epitem拷贝到txlist中,并将rdlist清空
  • 5、ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对用的poll方法。此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。

3、epoll与select、poll的对比

  • 1、 用户态将文件描述符传入内核的方式
    • select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。
    • poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听。
    • epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。
  • 2、内核态检测文件描述符读写状态的方式
    • select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
    • poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历。
    • epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。
  • 3、找到就绪的文件描述符并传递给用户态的方式
    • select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
    • poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
    • epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。
      • 这里返回的文件描述符是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。
  • 4、重复监听的处理方式
    • select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
    • poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
    • epoll:无需重新构建红黑树,直接沿用已存在的即可。

总结

系统调用selectpollepoll
事件集合用户通过3个参数分别传入感兴趣的可读,可写及异常等事件内核通过对这些参数的在线修改来反馈其中的就绪事件,这使得用户每次调用select都要重置这3个参数统一处理所有事件类型,因此只需要一个事件集参数。用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用epoll_wait时,无需反复传入用户感兴趣的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件
应用程序索引就绪文件描述符的时间复杂度O(n)O(n)O(1)
最大支持文件描述符数一般有最大值限制1024(32位机器,32 * 32,64位机器 32 * 64)无限制无限制(1GB内存的机器上大约是10万左右,这个数目和系统内存关系很大,面试问过
工作模式LTLTLT、ET
内核实现和工作效率采用轮询方式检测就绪事件,时间复杂度:O(n)采用轮询方式检测就绪事件,时间复杂度:O(n)采用回调方式检测就绪事件,时间复杂度:O(1)
消息传递方式内核需要将消息传递到用户空间,都需要内核拷贝动作内核需要将消息传递到用户空间,都需要内核拷贝动作mmap共享内存,减少拷贝开销

你的linux,socket epoll连接数最大达到过多少

4、epoll如何使用 以及 LT/ET 使用过程解析

参考上一篇博客:epoll如何使用 以及 LT/ET 使用过程解析

参考

1、https://zhuanlan.zhihu.com/p/21374980
2、《Linux高性能服务器编程》
3、https://www.cnblogs.com/lojunren/p/3856290.html
4、https://www.jianshu.com/p/e1e70caa3795
5、http://blog.chinaunix.net/uid-28541347-id-4232252.html
6、https://www.cnblogs.com/-wenli/p/13380616.html
7、https://bbs.gameres.com/thread_842984_1_1.html
8、https://baijiahao.baidu.com/s?id=1641172494287388070&wfr=spider&for=pc

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐