关于epoll的实现--简单概要

 

上篇讨论select时,我们总结到:

总结:

  1. 从上面看,在第一次所有监听都没有事件时,调用 select 需要把进程挂到所有监听的文件描述符一次
  2. 有事件到来时,不知道是哪些文件描述符有数据可以读写,需要把所有的文件描述符都轮询一遍才能知道
  3. 可以从流程图中看出,需要进行bitmap的用户态到内核态的多次拷贝,以及对此集合的操作
  4. 在没有超出时间限制时,就会进行死循环,一次次的监听,此时调用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的区别:

  1. select需要在每次select时,将fd拷贝到内核中,首先遍历一边,将每个fd加入到监听队列中,这一点和epoll是类似的,不过epoll是创建一个eventpoll 结构体的实例,然后创建一片缓冲区,建立红黑树的数据结构,但是只需建立一次,而select再次进行监听时,又需要进行重新拷贝。
  2. select始终使用fd_set这个集合来维护所有的事件,当有事件就绪时,将此位 置为1,这样当检查时就需要遍历,而epoll只需要返回就绪好的事件链表就行,拷贝数据开销小,
  3. select开启下一次遍历,当有事件就绪时,只是设置fd_set对应位为1,但是epoll在刚开始设置回调函数时,就写明,在事件回调已就绪时,就将其加入到就绪链表中,不需要像select那样,回头还要遍历n个fd,检查哪些事件是就绪的
  4. 最后将链表拷贝回用户空间的,在select是fd_set集合,只不过就绪的事件的位为1,这需要自己再次遍历这n个fd,但是epoll返回的是就绪好的事件链表,不需要遍历所有事件。

不过并不是说epoll就一定比select高效,得看使用场景,如果老师说,每个知识点都是重点,那你还得瑟准备红笔,蓝笔,标签等等,然后给每个知识点贴标签,那不是多此一举嘛!老老实实的一个一个的复习每个知识点就行了。也就是说当100万数据,其中80%都是活跃的连接,那未必有select快。

参考:论epoll的实现

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值