论epoll的实现

论epoll的实现

  1. 上一篇博客 论select的实现 里面已经说了为什么 select 比较慢。poll 的实现和 select 类似,只是少了最大 fd 限制,如果有兴趣可以自己去看代码。我这里来简单来过一下 epoll 的实现。

1) 一次添加

select / poll 为了实现简单,不对已有的 fd 进行管理。每次需要传入最大的轮询 fd, 然后每个监听 fd 挂到设备一次导致性能不佳。epoll 对于监听的 fd,通过 epoll_ctl 来把对应的 fd 添加到红黑树,实现快速的查询和添加,删除。

1.1) epoll 实例创建

使用 epoll 之前会使用 epoll_create 创建一个 epoll 实例,它实际上是一个文件, 只是存在于内存中的文件。下面实现来自 linux-2.6.24 的 fs/eventpoll.c:

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_create 的参数 size 是老版本的实现,使用的是 hash 表, size 应该是用来算 bucket 数目,后面因为使用红黑树,这个参数不再使用, 可以忽略。

1.2) 添加 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;...}}

接着调用 ep_insert 是添加 fd 到红黑树以及把进程的回调函数添加文件句柄的监听队列,当有事件到来时,会唤醒进程。

static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
             struct file *tfile, int fd){...// 创建 epitem 并设置回调函数 ep_ptable_queue_procinit_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 可以读写,只要只要拷贝这个就绪列表,不用查询。我们下面会来看看 epoll_wait 的实现。

2) 快速查询

epoll 之所以快,除了没有多次重复挂载事件之外,在有读写事件到来的实现,也是很高效。没有像 select/poll 那样, 需要轮询所有的fd, 才能知道哪些 fd 有事件需要处理。

epoll 有一个专门的链表用来存放哪些 fd 有事件到来,用户空间需要查询哪些 fd 有读写等待处理,只需要拷贝这个链表即可。

我们使用系统调用 epoll_wait, 会到内核调用 sys_epoll_wait, 这个函数的主要实现就是调用了 ep_poll:

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 在实现 epoll_wait的时候,并不会去查询 fd 的可读写状态。 而是等待 fd 有读写到来时, 通过回调函数把有事件到来的 fd 主动拷贝到 rdllist。

另外一个有一个细节点就是, 当用户在拷贝事件到用户空间时,刚好有事件到来,那么这些读写事件会不会正好丢了。答案是当然不会,epoll 准备了另外一个链表,叫 overflow list, 当检查正在拷贝时,会把这些 fd 临时放到这个链表,下次再拷贝到 rdllist.

3) 总结

select/poll 还有比较坑的是,每次查询到 fd 读写事件结果之后,需要把所有 fd 对应的结果的 bitmap 拷贝到用户空间。 比如监听 100w 的 fd, 只有一个 fd 有读写事件, 却要拷贝 100w fd的结果 bitmap。

对比来看,select/poll 实现极为简单,但并不适合用来维护大量的连接。





开发高性能网络程序时,windows开发者们言必称iocp,linux开发者们则言必称epoll。大家都明白epoll是一种IO多路复用技 术,可以非常高效的处理数以百万计的socket句柄,比起以前的select和poll效率高大发了。我们用起epoll来都感觉挺爽,确实快,那么, 它到底为什么可以高速处理这么多并发连接呢?


先简单回顾下如何使用C库封装的3个epoll系统调用吧。

  1. int epoll_create(int size);  
  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
  3. int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);  

使用起来很清晰,首先要调用epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。

epoll_ctl可以操作上面建立的epoll,例如,将刚建立的socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll,不再监控它等等。

epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。


从上面的调用方式就可以看到epoll比select/poll的优越之处:因为后者每次调用时都要传递你所要监控的所有socket给 select/poll系统调用,这意味着需要将用户态的socket列表copy到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存 到内核态,非常低效。而我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已 经在epoll_ctl中拿到了要监控的句柄列表。


所以,实际上在你调用epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。


在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。


epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些 socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上 建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

  1. static int __init eventpoll_init(void)  
  2. {  
  3.     ... ...  
  4.   
  5.     /* Allocates slab cache used to allocate "struct epitem" items */  
  6.     epi_cache = kmem_cache_create("eventpoll_epi"sizeof(struct epitem),  
  7.             0, SLAB_HWCACHE_ALIGN|EPI_SLAB_DEBUG|SLAB_PANIC,  
  8.             NULL, NULL);  
  9.   
  10.     /* Allocates slab cache used to allocate "struct eppoll_entry" */  
  11.     pwq_cache = kmem_cache_create("eventpoll_pwq",  
  12.             sizeof(struct eppoll_entry), 0,  
  13.             EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL);  
  14.   
  15.  ... ...  


epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄 给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树 用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这 个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非 常高效。


而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已,如何能不高效?!


那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的 红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个 socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。


如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create 时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然 后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。


最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时次次返回这个句柄,而ET模式仅在第一次返回。


这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait, 会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是 ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要 它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从 epoll_wait返回的。






原文地址:https://www.cnblogs.com/zengyiwen/p/5755209.html