之前已经讲过IO复用,这次重新拿出来还是因为服务器的高并发处理。将epoll再进行一次深挖。
**情景:**假设此时有100万个用户和一个进程保持着连接, 但并不是所有的都会在一个时刻活跃,也就是说某个时间内只有少数的用户是活跃状态。select和poll对此的处理就是全部轮询,但是可想而知,效率低下至极。而且需要将这100w个连接进行用户和内核之间的拷贝,消耗内存。所以,select和poll并不能处理如此大量的连接。
这就是epoll的高明所在。步步道来。
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
这是epoll的三部曲:
- create一个epoll对象,进程开始时创建后,其他只需要往上连接就可以。
- ctl添加或者删除连接
- wait收集就绪的事件
epoll
create后,linux会创建一个eventpoll结构体。这个结构体中有两个epoll的高效保证成员。
epoll是怎么保证增删查的高效性的呢?红黑树,用来存储连接的套接字。还有就是会建立一个双向链表,存储就绪的事件。wait不需要盯着庞大的红黑树,只需要查看链表就可以。有数据就返回,没有就等到timeout返回。
当epollwait检查是否有事件发生时,只需要检查双向链表即可,每个事件都是一个下面的epitem结构体。
struct epitem {
...
//红黑树节点
struct rb_node rbn;
//双向链表节点
struct list_head rdllink;
//事件句柄等信息
struct epoll_filefd ffd;
//指向其所属的eventepoll对象
struct eventpoll *ep;
//期待的事件类型
struct epoll_event event;
...
}; // 这里包含每一个事件对应着的信息。
同时,epoll在检测到事件后,将事件复制到用户态内存(此处可以利用mmap映射,共享内存,效率加快),将事件数量也返回给用户。所以epoll的整体事件处理效率很快。同时,添加删除事件时,从红黑树查找也很快。所以可以更快的处理更多的连接操作。
总结:
create的时候,红黑树和就绪链表创建。ctl时,先检查红黑树上存在与否,存在直接返回,不存在则添加上去,然后向内核注册相应的回调函数,当事件就绪时通过此回调插入就绪链表。wait直接检查就绪链表即可。
LT&ET
水平模式下:LT,只要文件描述符还有数据可读,每次wait都会通知程序处理。
边沿模式下:ET,检测到IO,wait通知文件描述符,用户程序必须立刻处理,如果没有读完,下次不会通知此描述符,也就是会丢失数据。
我对这个理解是,水平模式下会通知所有就绪的文件描述符,也就是说检测状态是否就绪来通知。边沿模式则是检测状态变化通知,上一次2fd没有读完,下一次wait他不会有状态变化,因此不会通知。
怎么选择的话,我的理解是:水平模式下大家相等,雨露均沾。边沿模式下,就好像有vip了,我关心这个描述符,立即处理他的事,处理不完下次他还会来,再继续处理。
正题-epoll反应堆
第一步:
epoll反应堆是和epoll接口时是不同的,我称它为epoll反应堆模型:epoll+ET+非阻塞+自定义结构体。
为什么自定义结构体?上面我们说过红黑树上对应的结构体,他说到底还是传入了文件描述符本身,此处我们将自定义结构体指针传入。自定义结构体如下:
struct my_events {
int m_fd; //监听的文件描述符
void *m_arg; //泛型参数
void (*call_back)(void *arg); //回调函数
/*
* 可以在此处封装更多的数据内容
* 例如用户缓冲区、节点状态、节点上树时间等等
*/
};
/*
* 用户需要自行开辟空间存放my_events类型的数组,并在每次上树前用epoll_data_t里的 ptr指向一个my_events元素。
*/
可以看到,每个里面都有自己的回调函数,这样只需要将ptr指针传入,我们就可以让每个都有自己的处理函数。这样,在wait返回后,就可以直接调用回调函数。
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
/*
*****
*/
}
epoll模型步骤:
(1) 程序设置边沿触发以及每一个上树的文件描述符设置非阻塞
(2) 调用epoll_create()创建一个epoll对象
(3) 调用epoll_ctl()向epoll对象中进行增加、删除等操作
(4) 调用epoll_wait()(定时检测) 返回待处理的事件集合
(5) 依次调用事件集合中的每一个元素中的ptr所指向那个结构体中的回调函数
顺着我们的思路走一遍流程:
ET监听可读事件-》数据到来-》触发事件-》epoll_wait返回-》处理回调函数-》继续epoll_wait-》……循环
所以,只需改动一下就能成为我们的反应堆模型:
ET监听可读事件-》数据到来-》触发事件-》epoll_wait返回-》
接下来的回调函数内部我们需要干这些事(读取完数据-》此节点从树删除-》设置可写事件和对应的回调函数-》上树-》处理数据)-》
ET监听可写事件-》对方可读-》触发事件-》epoll_wait返回-》
接下来可写回调函数中(写完数据-》此节点从树删除-》设置可读事件和对应的回调函数-》上树-》处理数据)-》epoll_wait等待-》
……循环
看着好像有点摸不着头脑,第一是为什么要将节点从树上一直增删?
因为socket在完成收发的时候至少是要两个树上的位置的(一个读一个写),而这样增删只需要一个节点就可以了。
其实编程本来就是个很多时候不能双全的事,空间和时间。结合第二个一起看,为什么读写交叉设置?
因为上面一个原因,其实就是tcp的模式,一来一回,服务器收到,服务器回复。
还有就是可读不一定可写,如果在收到客户端数据后,接收端的接收滑动窗口已满,当前服务器就会阻塞在可写的状态,这样就出现了阻塞等待,如果多个一起,很容易就能使服务器效率下降。还有就是客户端发送完后就断开,这是服务器写时就会出错,导致进程终止。