论epoll的实现
论epoll的实现
- 上一篇博客 论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系统调用吧。
- 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对象。参数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的内存对象,每次使用时都是使用空闲的已分配好的对象。
- static int __init eventpoll_init(void)
- {
- ... ...
- /* Allocates slab cache used to allocate "struct epitem" items */
- epi_cache = kmem_cache_create("eventpoll_epi", sizeof(struct epitem),
- 0, SLAB_HWCACHE_ALIGN|EPI_SLAB_DEBUG|SLAB_PANIC,
- NULL, NULL);
- /* Allocates slab cache used to allocate "struct eppoll_entry" */
- pwq_cache = kmem_cache_create("eventpoll_pwq",
- sizeof(struct eppoll_entry), 0,
- EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL);
- ... ...
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返回的。