上篇讨论select时,我们总结到:
总结:
- 从上面看,在第一次所有监听都没有事件时,调用 select 需要把进程挂到所有监听的文件描述符一次
- 有事件到来时,不知道是哪些文件描述符有数据可以读写,需要把所有的文件描述符都轮询一遍才能知道
- 可以从流程图中看出,需要进行bitmap的用户态到内核态的多次拷贝,以及对此集合的操作
- 在没有超出时间限制时,就会进行死循环,一次次的监听,此时调用select的进程是被阻塞的,然后一直轮询检查,直到有call_back,那么就会唤醒该进程,然后检查文件集合,所以在这里就会进行很多次频繁的检查
我们可以看到,select是在一个死循环中遍历每一个fd_set,首先需要把每一个fd挂到监听队列中,然后在重新进行n次遍历,在没有超时的情况下,直到有一个fd可以读写,然后修改此fd所在fd_set中的值,然后等遍历完这n个fd,跳出循环,free_wait会删除所有在唤醒队列中的fd;然后将fd再拷贝回用户态,然后用户下,还需遍历这n个文件描述符,才能知道哪些是可以读写的。然后下一次重新挂select时,又要重新设置fd_set,然后从用户态拷贝进内核态,进行同样的操作;
这会产生一种弊端:假设有100万用户同时与一个进程保持着连接,而每一时刻只有几十个或几百个TCP连接是活跃的,也就是说在每一时刻进程只需要处理这100万连接中的一小部分连接。如果此时选择了select,那么就需要拷贝这100万个集合,然后在内核态遍历检查,,然后再将其拷贝回去,再遍历检查。可想而知,很多的遍历和拷贝时间是在做无用功的;
而epoll则实现了不一样的操作,下面把epoll的实现简单说一下:
首先介绍一下,epoll给的一些函数:
epoll操作过程需要三个接口,分别如下:
(这里api的介绍,我直接把别人写的弄了过来)
#include <sys/epoll.h>
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);
1. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
。
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
- epfd:是这个epoll的句柄。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添 加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
下面简单介绍其实现原理:
(下面结合我自己举的例子,给出一定形象的总结)
select / poll 为了实现简单,不对已有的 fd 进行管理。每次都需要把一个个fd挂到监听队列中,然后如果有callback,则只修改fd_set,不告诉你是哪一个fd发生了变化,就感觉像是,只死记硬背,不给画出考试重点,不给总结,然后回头把一整本笔记都给你,叫你一页一页的去翻,去仔仔细细的查看每个知识点。你说烦不烦;但是epoll就不一样了,epoll 对于监听的 fd,通过 epoll_ctl
来把对应的 fd 添加到红黑树,实现快速的查询和添加,删除。就像给每一个知识点贴上一个标签,快速查询;然后通过epoll_wait,告诉记录者,你不仅要监听这些知识点是否考,回头还要在笔记上给要考的知识点贴上标签,突出只要看这些考试重点就可以了,根本没必要把老师讲过的每个知识点都看一遍。这一步就相当于,当监听的事件来临时,需要把它放在rdllist这个链表中,回头给用户,告诉他们这些都是可读写的(拷贝的消耗大大减少)。还有个小插曲,当老师突然告诉你,这个考试点不靠了,那你就可以直接把标签给撕了就行,你看多快,也多方便。
1.epoll实例创建
使用 epoll 之前会使用 epoll_create
创建一个 epoll 实例,它实际上是一个文件, 只是存在于内存中的文件。
asmlinkage long sys_epoll_create(int size) {
...
struct eventpoll *ep;
...
// 创建 eventpoll 实例
if (size <= 0 || (error = ep_alloc(&ep)) != 0)
goto error_return;
...
// 为 epoll 文件添加文件操作函数
error = anon_inode_getfd(&fd, &inode, &file, "[eventpoll]",
&eventpoll_fops, ep);
}
创建一个epoll实例,就是在内核中创建一个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时间到后即使链表没数据也返回。
2.在epoll中添加需要监听的fd
asmlinkage long sys_epoll_ctl(int epfd, int op, int fd,
struct epoll_event __user *event) {
...
// 先查找 fd 是否已经存在
epi = ep_find(ep, tfile, fd);
error = -EINVAL;
switch (op) {
// 如果是添加,就插入到 eventpoll 实例的红黑树
case EPOLL_CTL_ADD:
if (!epi) {
epds.events |= POLLERR | POLLHUP;
// 添加监听的 fd 到epoll
error = ep_insert(ep, &epds, tfile, fd);
} else
error = -EEXIST;
break;
...
}
}
添加fd到红黑树中,都会为每一个fd创建一个结构体:
struct epitem {
...
//红黑树节点
struct rb_node rbn;
//双向链表节点
struct list_head rdllink;
//事件句柄等信息
struct epoll_filefd ffd;
//指向其所属的eventepoll对象
struct eventpoll *ep;
//期待的事件类型
struct epoll_event event;
...
}; // 这里包含每一个事件对应着的信息。
接着调用 ep_insert
是添加 fd 到红黑树以及把进程的回调函数添加文件句柄的监听队列,当有事件到来时,会唤醒进程:
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
...
// 创建 epitem 并设置回调函数 ep_ptable_queue_proc
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
...
// 这里会回调 ep_ptable_queue_proc, 并查询 fd 可读写状态
revents = tfile->f_op->poll(tfile, &epq.pt);
...
// epitem 添加到 eventpoll 的红黑树
ep_rbtree_insert(ep, epi);
...
}
ep_ptable_queue_proc 这个回调函数, 除了把进程添加文件句柄的监听列表,并注册回调函数为 ep_poll_callback
。 这个函数会查询 fd 的读写状态, 如果当前文件句柄可以读写,就把当前的 fd 添加到就绪队列。后续查询是否有 fd 可以读写,只要拷贝这个就绪列表,不用查询
// 当 poll 函数唤醒时就调用该函数
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead, poll_table *pt)
{
// 从注册的结构中struct ep_pqueue中获取项epi
struct epitem *epi = ep_item_from_epqueue(pt);
// eppoll_entry主要完成epitem和epitem事件发生时的callback(ep_poll_callback)函数之间的关联
struct eppoll_entry *pwq;
// 申请eppoll_entry 缓存, 加入到等待队列中, 和链表中
if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache,GFP_KERNEL)))
{
// 初始化等待队列函数的入口. 也就是 poll 醒来时要调用的回调函数
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
pwq->whead = whead;
pwq->base = epi;
// 加入到等待队列中
add_wait_queue(whead, &pwq->wait);
// 将等待队列 llink 的链表挂载到 eptiem等待链表中
list_add_tail(&pwq->llink, &epi->pwqlist);
epi->nwait++;
}
...
}
// poll 到来时, 调用的回调函数. 判断poll 事件是否到来, 是否加入到就绪队列中了
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
...
// 事件epi在是否在准备列表中
if (ep_is_linked(&epi->rdllink))
goto is_linked;
// 重要 : 将 fd 加入到 epoll 监听的就绪队列中
list_add_tail(&epi->rdllink, &ep->rdllist);
...
}
从这里可以看出,它是怎么主动的贴上这是考试重点的标签的,它在刚开始fd事件创建的时候,就将此事件放到监听队列中,设置回调函数,如果数据准备就绪,那么就会自动唤醒回调函数,将其放入epoll实例的准备就绪的链表中,这样就比较高效,,用户只需要查看就绪的链表就可以了,不需要轮询查询了,不像select,需要每次进入select,放入监听队列需要轮询一次,然后还要在遍历fd,检查是否可读,但是不画出重点。
3.快速的查询就绪事件,epoll_wait
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
...
res = 0;
if (list_empty(&ep->rdllist)) {
// 如果没有事件, 不断等待读写事件到来
// 直到超时,或者有读写事件
for (;;) {
/*
* We don't want to sleep if the ep_poll_callback() sends us
* a wakeup in between. That's why we set the task state
* to TASK_INTERRUPTIBLE before doing the checks.
*/
// 当前进程设置为可中断
set_current_state(TASK_INTERRUPTIBLE);
// 有连接就绪
if (!list_empty(&ep->rdllist) || !jtimeout)
break;
if (signal_pending(current)) {
res = -EINTR;
break;
}
}
}
//如果 rdllist 不为空, 说明有事件到来。
eavail = !list_empty(&ep->rdllist);
spin_unlock_irqrestore(&ep->lock, flags);
// 拷贝到用户空间
if (!res && eavail &&
!(res = ep_send_events(ep, events, maxevents)) && jtimeout)
goto retry;
return res;
}
可以看出,epoll_wait只要查询rdllist是否为空,如果不为空,那么就能得到就绪的事件了。而且它在刚才的回调函数里设置了,只将就绪的事件加入到链表中,那么拷贝到用户空间的数据是很小的。
这里的ep_send_events函数也得注意一下,如果在链表从内核态拷贝到用户态的过程中,还有事件也准备就绪了,那该事件会不会丢了呢?答案是不会的,看一下ep_send_events这个函数:
1.它将rdllist复制到一个新的链表中,然后将这个新的链表拷贝到用户空间,并清空rdllist
2.创建ovflist链表,如果在将链表拷贝到用户空间的时侯又有事件准备就绪,那么就加入到ovflist中,拷贝到就绪链表rdllist中
// 向用户空间发送就绪事件队列
static int ep_send_events(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents)
{
int eventcnt, error = -EFAULT, pwake = 0;
unsigned int revents;
unsigned long flags;
struct epitem *epi, *nepi;
struct list_head txlist;
INIT_LIST_HEAD(&txlist);
...
// 将 rdllist 就绪队列的链表放入到 txlist 链表中, 此时 rdllist 就为空链表了
list_splice(&ep->rdllist, &txlist);
INIT_LIST_HEAD(&ep->rdllist);
// 清空ovflist, 原因是后面执行将链表复制到用户空间的时侯还有消息到来, 就保存在ovflist中
ep->ovflist = NULL;
spin_unlock_irqrestore(&ep->lock, flags);
// 将就绪的队列放入传回到用户空间
for (eventcnt = 0; !list_empty(&txlist) && eventcnt < maxevents;)
{
// 取出第一个消息数据到来的结构, 然后移除这个数据结构
epi = list_first_entry(&txlist, struct epitem, rdllink);
list_del_init(&epi->rdllink);
// 确保有需要文件的存在, 并且也有操作的掩码存在
revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL);
revents &= epi->event.events;
// 就绪队列发送至用户空间
if (revents) {
if (__put_user(revents, &events[eventcnt].events) ||__put_user(epi->event.data, &events[eventcnt].data))
goto errxit;
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS;
// 唤醒消息的个数
eventcnt++;
}
// 是否是下降沿有效
if (!(epi->event.events & EPOLLET) && (revents & epi->event.events))
list_add_tail(&epi->rdllink, &ep->rdllist);
}
error = 0;
errxit:
spin_lock_irqsave(&ep->lock, flags);
// 在发送给用户空间的时侯又有数据传来时, 将事件放入到 ovflist 队列中. 在将数据加入到就绪队列事件中
for (nepi = ep->ovflist; (epi = nepi) != NULL; nepi = epi->next, epi->next = EP_UNACTIVE_PTR)
{
if (!ep_is_linked(&epi->rdllink) && (epi->event.events & ~EP_PRIVATE_BITS))
list_add_tail(&epi->rdllink, &ep->rdllist);
}
ep->ovflist = EP_UNACTIVE_PTR;
...
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
// 如果 poll 队列不为空, 则唤醒的次数++
if (waitqueue_active(&ep->poll_wait))
pwake++;
}
...
// 返回累计的值
return eventcnt == 0 ? error: eventcnt;
}
至此,epoll函数的大致详解就结束了;
总结:
我并没有仔细看过源代码,但是通过分析大致的流程和实现思路,知道了其epoll的具体操作流程,以及和select的实际区别在哪,可以看出,就像我举的例子那样,epoll函数在刚开始将事件加入的时候,就主动设置回调函数,将其加入到就绪链表中,从而轮询检查只需要查看链表是否为空,且其事件的数据结构是红黑树,可以实现快速的插入和删除以及查询;
epoll与select的区别:
- select需要在每次select时,将fd拷贝到内核中,首先遍历一边,将每个fd加入到监听队列中,这一点和epoll是类似的,不过epoll是创建一个eventpoll 结构体的实例,然后创建一片缓冲区,建立红黑树的数据结构,但是只需建立一次,而select再次进行监听时,又需要进行重新拷贝。
- select始终使用fd_set这个集合来维护所有的事件,当有事件就绪时,将此位 置为1,这样当检查时就需要遍历,而epoll只需要返回就绪好的事件链表就行,拷贝数据开销小,
- select开启下一次遍历,当有事件就绪时,只是设置fd_set对应位为1,但是epoll在刚开始设置回调函数时,就写明,在事件回调已就绪时,就将其加入到就绪链表中,不需要像select那样,回头还要遍历n个fd,检查哪些事件是就绪的
- 最后将链表拷贝回用户空间的,在select是fd_set集合,只不过就绪的事件的位为1,这需要自己再次遍历这n个fd,但是epoll返回的是就绪好的事件链表,不需要遍历所有事件。
不过并不是说epoll就一定比select高效,得看使用场景,如果老师说,每个知识点都是重点,那你还得瑟准备红笔,蓝笔,标签等等,然后给每个知识点贴标签,那不是多此一举嘛!老老实实的一个一个的复习每个知识点就行了。也就是说当100万数据,其中80%都是活跃的连接,那未必有select快。
参考:论epoll的实现