I/O多路复用之——epoll原理详解及epoll反应堆(Reactor)模型
Posted 清水寺扫地僧
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了I/O多路复用之——epoll原理详解及epoll反应堆(Reactor)模型相关的知识,希望对你有一定的参考价值。
文章目录
(一)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
log2n,也即
1
0
6
10^6
106个节点只须查找
20
20
20次,对于红黑树见:红黑树性质、插入节点和rb_tree容器)
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。
总结:一颗红黑树,一张准备就绪句柄链表,少量的内核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<n_ready; i++) {
temp_fd = ep_event[i].data.fd;
if(ep_event[i].events & EPOLLIN){
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 < 0) {}
do {
//处理数据
}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模式不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。
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 反应堆原理(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; i<n_ready; i++)
events[i].data.ptr->call_back(/* void *arg */);
}
else
/*
* (3) 这里可以做很多很多其他的工作,例如定时清除没读完的不要的数据
* 也可以做点和数据库有关的设置
* 玩大点你在这里搞搞分布式的代码也可以
*/
}
得到epoll反应堆过程模型图如下:
以上代码的流程为:
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监听读事件挂到红黑树上--->...--->...
}
(五)select、poll、epoll 的对比
以上是关于I/O多路复用之——epoll原理详解及epoll反应堆(Reactor)模型的主要内容,如果未能解决你的问题,请参考以下文章