I/O多路复用之

您所在的位置:网站首页 epoll是多线程吗 I/O多路复用之

I/O多路复用之

2024-05-31 00:16| 来源: 网络整理| 查看: 265

文章目录 (一)epoll 使用场景(二)epoll2.1 epoll 原理2.2 epoll 编程流程 (三)epoll 两种触发模式(四)epoll 反应堆原理(Libevent库核心思想)

相关内容传送门:

I/O多路复用之——背景知识;I/O多路复用之——select;I/O多路复用之——epoll原理详解及epoll反应堆(Reactor)模型;epoll与select、poll区别; (一)epoll 使用场景

epoll之所以是epoll,是因为它是event事件驱动的。

你的程序通过多个线程来处理大量的网络连接。如果你的程序只是单线程的那么将会失去epoll的很多优点。并且很有可能不会比poll更好;你需要监听的套接字数量非常大(至少1000)。如果监听的套接字数量很少则使用epoll不会有任何性能上的优势甚至可能还不如poll;你的网络连接相对来说都是长连接。就像上面提到的epoll处理短连接的性能还不如poll因为epoll需要额外的系统调用来添加描述符到集合中;

设想一个场景:有100万用户同时与一个进程保持着TCP连接,而每一时刻只有几十个或几百个TCP连接是活跃的(接收TCP包),也就是说在每一时刻进程只需要处理这100万连接中的一小部分连接。那么,如何才能高效的处理这种场景呢?进程是否在每次询问操作系统收集有事件发生的TCP连接时,把这100万个连接告诉操作系统,然后由操作系统找出其中有事件发生的几百个连接呢?实际上,在Linux2.4版本以前,那时的select或者poll事件驱动方式是这样做的。

这里有个非常明显的问题,即在某一时刻,进程收集有事件的连接时,其实这100万连接中的大部分都是没有事件发生的。因此如果每次收集事件时,都把100万连接的套接字传给操作系统(即若是轮询形式,则涉及用户态内存到内核态内存的大量复制),而由操作系统内核寻找这些连接上有没有未处理的事件,将会是巨大的资源浪费,然后select和poll就是这样做的,因此它们最多只能处理几千个并发连接。而epoll不这样做,它在Linux内核中申请了一个简易的文件系统,把原先的一个select或poll调用分成了三部分:

//1.调用epoll_create建立一个epoll对象(在epoll文件系统中给这个句柄分配资源); int epoll_create(int size); //size:监控文件描述符的个数,可以是约值; //2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字 //也即是向eventpoll中注册事件,该函数如果调用成功返回0,否则返回-1。 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //epfd:为epoll_create返回的epoll实例 //op:表示要进行的操作,常用的有EPOLL_CTL_ADD,EPOLL_CTL_DEL //fd:为要进行监控的文件描述符 //event:要监控的事件 //3. 调用epoll_wait收集发生事件的连接; //类似与select机制中的select函数、poll机制中的poll函数,等待内核返回监听描述符的事件产生。 //该函数返回已经就绪的事件的数量,如果为-1表示出错。 //当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。 //如果rdllist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。 int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout); //epfd:为epoll_create返回的epoll实例 //events:数组,为 epoll_wait要返回的已经产生的事件集合 //maxevents:为希望返回的最大的事件数量(通常为__events的大小,而不是sizeof(events)) //timeout:和select、poll机制中的同名参数含义相同

这样只需要在进程启动时建立1个epoll对象,并在需要的时候向它添加或删除连接就可以了,因此,在实际收集事件时,epoll_wait的效率就会非常高,因为调用epoll_wait时并没有向它传递这100万个连接,内核也不需要去遍历全部的连接。

其中的struct epoll_event结构为:

typedef union epoll_data { //注意是共用体/联合体而不是结构体 void *ptr; //epoll反应堆所用的回调函数句柄(内核调用) int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; //epoll事件,对其是否发生事件进行判断的标记 epoll_data_t data; //可变用户数据,可设定为回调句柄或是fd

相较于 select 方式和 poll 的轮询方式,epoll的改进在于:

功能分离:select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的 socket 相对固定(一般进程绑定的端口不会改变,QQ一般是4000),并不需要每次都修改。epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升;就绪列表:select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历。如果内核维护一个 “就绪列表”,引用收到数据的 socket,就能避免遍历。如下图所示,计算机共有三个 socket,收到数据的 sock2 和 sock3 被就绪列表 rdlist 所引用。当进程被唤醒后,只要获取 rdlist 的内容,就能够知道哪些 socket 收到数据; (二)epoll 2.1 epoll 原理

当某一进程调用epoll_create()方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,如下所示:

struct eventpoll {   ...   /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,   也就是这个epoll监控的事件*/   struct rb_root rbr;   /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/   struct list_head rdllist;   ... };

我们在调用epoll_create()时,内核在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl()传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait()非常高效。

所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系(详细内容见下节:epoll反应堆详解),也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。

在epoll中对于每一个事件都会建立一个epitem结构体,如下所示:

struct epitem {   ...   //红黑树节点   struct rb_node rbn;   //双向链表节点   struct list_head rdllink;   //事件句柄等信息   struct epoll_filefd ffd;   //指向其所属的eventepoll对象   struct eventpoll *ep;   //期待的事件类型   struct epoll_event event;   ... }; // 这里包含每一个事件对应着的信息。

当调用epoll_wait()检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_wait()效率非常高。epoll_ctl()在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。(对于红黑树的查找,其效率是 l o g 2 n log_2n log2​n,也即 1 0 6 10^6 106个节点只须查找 20 20 20次,对于红黑树见:红黑树性质、插入节点和rb_tree容器)

epoll总体描述01

epoll的数据从用户空间到内核空间可采用mmap存储I/O映射来加速。该方法是目前Linux进程间通信中传递最快,消耗最小,传递数据过程不涉及系统调用的方法。

与select,poll机制相比,epoll(所调用的epoll_create(),epoll_ctl(),epoll_wait())解决了select机制的三大缺陷(内核空间拷贝、遍历选择就绪socket、1024长度限制):

对于第一个缺点,select/poll采用的方式是,将所有要监听的文件描述符集合拷贝到内核空间(用户态到内核态切换)。接着内核对集合进行轮询检测,当有事件发生时,内核从中集合并将集合复制到用户空间。 epoll的解决方案是:内核与程序共用一块内存,请看epoll总体描述01这幅图(见上图),用户与mmap加速区进行数据交互不涉及权限的切换(用户态到内核态,内核态到用户态)。内核对于处于非内核空间的内存有权限进行读取。对于第二个缺点,epoll的解决方案不像select或poll一样每次都把当前进程轮流加入fd(套接字)对应的设备等待队列中,而只在epoll_ctl 时把当前进程挂一遍(这一遍必不可少),并为每个 fd(套接字)指定一个回调函数(见下节epoll反应堆原理)。当套接字就绪时,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd(套接字)加入一个就绪链表。那么当我们调epoll_wait 时, epoll_wait 只需要检查链表中是否有存在就绪的fd(套接字)即可,效率非常可观;对于第三个缺点,fd(套接字)数量的限制,也只有Select存在,Poll和Epoll都不存在。由于Epoll机制中只关心就绪的fd(套接字),它相较于Poll需要关心所有fd,在连接较多的场景下,效率更高。在1GB内存的机器上大约是10万左右,具体数目可以/cat/proc/sys/fs/file-max查看,一般来说这个数目和系统内存关系很大。

接下来我们结合epoll总体描述01与上述的内容,将图示进行升级为epoll总体描述02。

epoll总体描述02

总结:一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。

执行epoll_create()时,创建了红黑树rbr和就绪链表rdllist;执行epoll_ctl()时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;执行epoll_wait()时立刻返回准备就绪链表里的数据即可; 2.2 epoll 编程流程

在epoll总体描述02中,每个fd上关联的即是结构体epoll_event(见第一节内容)。它由需要监听的事件类型和一个联合体构成。一般的epoll接口将传递自身fd到联合体。即epoll_event event; event.data.fd = fd;

因此,使用epoll接口的一般操作流程为: (1)使用epoll_create()创建一个epoll对象,该对象与epfd关联,后续操作使用epfd来使用这个epoll对象,这个epoll对象才是红黑树,epfd作为描述符只是能关联而已。 (2)调用epoll_ctl()向epoll对象中进行增加、删除等操作。 (3)调用epoll_wait()可以阻塞(或非阻塞或定时) 返回待处理的事件集合。 (3)处理事件。

/* * -[ 一般epoll接口使用描述01 ]- */ int main(void) { /* * 此处省略网络编程常用初始化方式(从申请到最后listen) * 并且部分的错误处理省略,这里只放重要步骤 * 部分初始化也没写 */ // [1] 创建一个epoll对象 ep_fd = epoll_create(OPEN_MAX); /* 创建epoll模型,ep_fd指向红黑树根节点 */ listen_ep_event.events = EPOLLIN; /* 指定监听读事件 注意:默认为水平触发LT */ listen_ep_event.data.fd = listen_fd; /* 注意:一般的epoll在这里放fd */ // [2] 将listen_fd和对应的结构体设置到树上 epoll_ctl(ep_fd, EPOLL_CTL_ADD, listen_fd, &listen_ep_event); while(1) { // [3] 为server阻塞(默认)监听事件,ep_event是数组,装满足条件后的所有事件结构体 n_ready = epoll_wait(ep_fd, ep_event, OPEN_MAX, -1); for(i=0; i if(temp_fd == listen_fd) { //说明有新连接到来 connect_fd = accept(listen_fd, (struct sockaddr *)&client_socket_addr, &client_socket_len); // 给即将上树的结构体初始化 temp_ep_event.events = EPOLLIN; temp_ep_event.data.fd = connect_fd; // 上树 epoll_ctl(ep_fd, EPOLL_CTL_ADD, connect_fd, &temp_ep_event); } else { //cfd有数据到来 n_data = read(temp_fd , buf, sizeof(buf)); if(n_data == 0) { //客户端关闭 epoll_ctl(ep_fd, EPOLL_CTL_DEL, temp_fd, NULL) //下树 close(temp_fd); } else if(n_data //处理数据 }while( (n_data = read(temp_fd , buf, sizeof(buf))) >0 ) ; } } else if(ep_event[i].events & EPOLLOUT){ //处理写事件 } else if(ep_event[i].events & EPOLLERR) { //处理异常事件 } } } close(listen_fd); close(ep_fd); } (三)epoll 两种触发模式

epoll 有EPOLLLT(Level Trigger,水平触发LT)和EPOLLET(Edge Trigger,边沿触发ET)两种触发模式。其中 LT 是默认的模式,ET 是“高速”模式。

LT 模式:只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作;ET 模式:在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN 为止,否则下次的 epoll_wait 不会返回余下的数据,若是缓存区满发生写覆盖则会丢掉事件。如果ET模式不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。 也即是此模式下,系统仅仅通知应用程序哪些fds变成了就绪状态,一旦fd变成就绪状态,epoll将不再关注这个fd的任何状态信息(从epoll队列移除),直到应用程序通过读写操作触发EAGAIN状态,epoll认为这个fd又变为空闲状态,那么epoll又重新关注这个fd的状态变化(重新加入epoll队列)。 随着epoll_wait的返回,队列中的fds是在减少的,所以在大并发的系统中,EPOLLET更有优势。但是对程序员的要求也更高(需要程序员对读取情况进行记录和判定,而LT方式这些则是内核在处理)。 listen_ep_event.events = EPOLLIN | EPOLLET; /*边沿触发 */ //为何是该形式,参见(一)中`struct epoll_event`结构

还有一个特点是,epoll 使用“事件(event)”的就绪通知方式,通过epoll_ctl()注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制(见:回调函数是什么)来激活该fd,epoll_wait()便可以收到通知。

备注:

ET 模式只支持 non-block socket。比如 block 读的 readn()(一次性读取n个字节),比如设定读500个字符,但是只读到498,完事就阻塞了,等另剩下的2个字符,然而在server代码里,readn会发生阻塞,则它就不会被唤醒,因为epoll_wait由于readn的阻塞而不会循环执行,读不到新数据。有点死锁的意思,差俩字符所以阻塞,因为阻塞,读不到新字符。EAGAIN 相关内容见:Linux下EAGAIN宏的含义;

epoll 设计 EPOLLET 触发模式的目的在于:

如果采用EPOLLLT模式,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLLET这种边缘触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!该模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

总结:

ET模式(边缘触发)只有数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait()返回;LT 模式(水平触发,默认)只要有数据都会触发,缓冲区剩余未读尽的数据会导致epoll_wait返回;LT模式和ET模式的处理流程,关注处理监听套接字事件和处理已连接套接字事件的异同: epoll LT epoll ET (四)epoll 反应堆原理(Libevent库核心思想)

epoll模型原来的流程:

epoll_create(); // 创建监听红黑树 epoll_ctl(); // 向书上添加监听fd while(1) { epoll_wait(); // 监听 有监听fd事件发送--->返回监听满足数组--->判断返回数组元素---> lfd满足accept--->返回cfd---->read()读数据--->write()给客户端回应。 }

在epoll总体描述02当中,epoll_ctl()函数的*event参数中的epoll_data联合体上传的是文件描述符fd本身,而epoll反应堆模型和epoll模型的本质不同在于:传入联合体的是一个自定义结构体指针,该结构体的基本结构至少包括:

struct my_events { int m_fd; //监听的文件描述符 void *m_arg; //泛型参数 void (*call_back)(void *arg); //回调函数 /* * 你可以在此处封装更多的数据内容 * 例如用户缓冲区、节点状态、节点上树时间等等 */ }; /* * 注意:用户需要自行开辟空间存放my_events类型的数组,并在每次上树前用epoll_data_t里的 * ptr指向一个my_events元素。 */

根据该模型,我们在程序中可以让所有的事件都拥有自己的处理函数,应用程序员只需要使用ptr传入即可。那么epoll_wait()返回后,epoll模型将不会采用一般epoll接口使用描述01代码中的事件分类处理的办法,而是直接调用事件中对应的回调函数,就像这样:

/* * -[ epoll模型使用描述01 ]- */ while(1) { /* 监听红黑树, 1秒没事件满足则返回0 */ int n_ready = epoll_wait(ep_fd, events, MAX_EVENTS, 1000); if (n_ready > 0) { for (i=0; icall_back(/* void *arg */); } else /* * (3) 这里可以做很多很多其他的工作,例如定时清除没读完的不要的数据 * 也可以做点和数据库有关的设置 * 玩大点你在这里搞搞分布式的代码也可以 */ }

得到epoll反应堆过程模型图如下:

epoll反应堆模型总体描述01

以上代码的流程为:

while(1) { 监听可读事件(ET) ⇒ 数据到来 ⇒ 触发事件 ⇒ epoll_wait()返回 ⇒ 处理回调 ⇒ 继续epoll_wait() => }

实现的流程为:

(1) 程序设置边沿触发以及每一个上树的文件描述符设置非阻塞(2) 调用epoll_create()创建一个epoll对象(3) 调用epoll_ctl()向epoll对象中进行增加、删除等操作 上监听树的文件描述符与之对应的结构体,应该满足填充事件与自定义结构体ptr。也即上树时监听的事件与回调函数要已经进行初始化(将结构体和fd和回调函数进行绑定,即是结构体初始化的过程)(4) 调用epoll_wait()(定时检测) 返回待处理的事件集合。(5) 依次调用事件集合中的每一个元素中的ptr所指向那个结构体中的回调函数。

但是只监听可读事件,可做以下修改进行完善。

epoll反应堆模型的流程:

epoll_create(); // 创建监听红黑树 epoll_ctl(); // 向监听树上添加监听fd while(1) { epoll_wait(); // 监听 有客户端连接上来--->lfd调用acceptconn()--->将cfd挂载到红黑树上监听其读事件--->... epoll_wait()返回cfd--->cfd回调recvdata()--->将cfd监听读摘下来--->cfd监听写事件挂到红黑树上--->...--->... epoll_wait()返回cfd--->cfd回调senddata()--->将cfd监听写摘下来--->讲cfd监听读事件挂到红黑树上--->...--->... }


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3