可扩展的事件复用技术:epoll和kqueue

 

一般来讲我喜欢Linux更甚于BSD系统,可是我真的想在Linux上拥有BSD的kqueue功能。html

什么是事件复用技术

假设你有一个简单的web服务器,而且那里已经打开了两个socket链接。当服务器从两个链接那里都收到Http请求的时候,它应该返回一个Http响应给客户端。可是你无法知道那个客户端先发送的消息和何时发送的。BSD套接字接口的阻塞行为意味着,若是你在一个链接上调用recv()函数,你就没办法去响应另一个链接上的请求。这时你就须要I/O复用技术。 I/O复用技术的一个直接方式是让每一个链接都拥有一个进程/线程,这样链接上的阻塞行为就不会相互影响。这样,你就把全部繁琐的调度/复用问题交给了操做系统内核。这样的多线程架构伴随着的是高昂资源消耗。维护大量的线程对内核来讲没有什么必要。每一个链接上的独立栈不只要增长内存痕迹,同时也下降了CPU本地缓存能力。 那么咱们如何不使用线程-链接模式来实现I/O复用技术呢?你能够经过一个简单的忙等轮询来实现,即在每一个链接上进行非阻塞的套接字操做,但这种行为过于的浪费。咱们所要知道的只不过是哪一个套接字已经就绪。所以系统内核为应用与内核之间提供了一个单独的通道,这个通道在你的套接字变为就绪时会发出通知。这就是基于准备就绪模式下的select()/poll()工做模式。linux

概况: select()

select()和poll()的工做方式很是相似。让咱们先快速看一下select()函数web

select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)

调用select()函数,你的应用程序须要提供三个兴趣集:r,w和e。每个集合都是一个文件描述符的位图。例如,若是你关注从文件描述符6里面读取数据,那么r集合里面的第6个字节位就设成1。这个调用会被阻塞直到兴趣集中有更多的文件描述符就绪,所以你能够操纵这些文件描述符而不会被阻塞。在返回后,系统内核会覆写整个位图来指明哪些文件描述符已经就绪。 从扩展性角度,咱们能够找到4个问题:编程

  1. 这些位图的大小是固定的(FD_SETSIZE, 一般是1024),尽管也有一些方法能够绕过这个限制。
  2. 因为位图是由内核来覆写的,用户应用程序在每一次调用时须要重填兴趣集。
  3. 每一次调用时,用户应用程序和内核都须要扫描整个位图,用于指出哪些文件描述符属于兴趣集,哪些属于结果集。这对于结果集来讲特别的低效,由于他们看起来很是的稀疏(如在一个给定的时间内,只有不多的文件描述符会发生变化)。
  4. 内核必须为每一次调用去迭代整个兴趣集,以便找到哪些文件描述符已经就绪。假如没有一个就绪,内核就会迭代的为每一个套接字连接设置一个内部事件。

概况: poll()

poll()的设计意图就是解决这些问题。api

poll(struct pollfd *fds, int nfds, int timeout)

struct pollfd {
    int fd;
    short events;
    short revents;
}

poll()的实现不依赖于位图,而是用文件描述符数组(这样第一个问题就解决了)。经过对兴趣事件与结果事件采起分离字段,第二个问题也得以解决,由于用户程序能够维护并重用这个数组。若是poll函数可以拆分该数组而不是字段,那么第三个问题也就引刃而解。第四个问题是继承而来的并且是不可避免,由于poll()和select()都是无状态的,内核不会在内部维护兴趣集状态。数组

为何与扩展性有关?

若是你的网络服务器须要维护一个相对较小的链接数(如100个),而且链接率也比较低(如每秒100个), 那么poll()和select()就足够了。也许你根本不须要为事件驱动编程而苦恼,只要多进程/多线程架构就能够了。若是性能不是你关注的重点,那么灵活性与容易开发才是关键。Apache web服务器就是一个典型的例子。缓存

可是,若是你的服务器程序是网络资源敏感的(如1000个并发链接数或者一个较高的链接率),那么你就要真的在乎性能问题了。这种状况一般被称为c10k问题。你的网络服务器将很难执行任何有用的东西,除了在这样的高负荷下浪费宝贵的CPU周期。服务器

假设这里有10000并发链接。通常来讲,只有少许的文件描述符被使用,如10个已经读就绪。那么每次poll()/select()被调用,就有9990个文件描述符被毫无心义的拷贝和扫描。网络

正如更早时候提到过的,这个问题是因为select()/poll()接口的无状态产生的。Banga et al的论文(发布于USENIX ATC 1999)提供了一个新的建议:状态相关兴趣集。经过在内核内部维护兴趣集的状态,来取代每次调用都要提供整个兴趣集这样的方式。在decalre_interest()调用之上,内核持续的更新兴趣集。用户程序经过调用get_next_event()函数来分发事件。多线程

灵感一般来自于研究成果,Linux和Free BSD都有它们本身的实现, 分别是epoll和kqueue。但这又意味着缺乏了可移植性,一个基于epoll的程序是没法跑在Free BSD系统上的。有一种说法是kqueue技术上比epoll更优,因此看起来epoll也没有存在的理由了。

Linux中的epoll

epoll接口由3个调用组成:

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_ctl()和epoll_ctl()本质上是分别对应到declare_interest()和get_next_event() 函数的。epoll_create()建立一个相似于文件描述符的上下文,这个上下文其实暗指进程的上下文。 从内部机制来讲,epoll在Linux内核中的实现并不是很是不一样于select()/poll()的实现。惟一不一样的地方就是是否状态相关。由于本质上来讲它们的设计目标是同样的(基于套接字/管道的事件复用技术)。查看Linux分支树种的源代码文件fs/select.c(对应select和poll)和fs/eventpoll.c(对应epoll)能够获得更多的信息。 你也能够从这里找到Linus Torvalds对于epoll的早期一些想法。

Free BSD中的Kqueue

如epoll那样,kqueue一样支持每一个进程中有多个上下文(兴趣集)。kqueue()函数行为有点相似于epoll_create()。可是,kevent()却集成了epoll_ctl()(用于调整兴趣集)和epoll_wait()(获取事件) 的角色。

int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges, 
           struct kevent *eventlist, int nevents, const struct timespec *timeout);

事实上,kqueue从易于编程角度来看相比epoll要更复杂一些。这是由于kqueue设计更抽象一些,目的更宽泛。让咱们来看一下kevent结构体:

struct kevent {
     uintptr_t       ident;          /* 事件标识 */
     int16_t         filter;         /* 事件过滤器 */
     uint16_t        flags;          /* 通用标记 */
     uint32_t        fflags;         /* 特定过滤器标记 */
     intptr_t        data;           /* 特定过滤器数据 */
     void            *udata;         /* 不透明的用户数据标识 */
 };

这些字段的细节已经超出了本文的范围,但你可能已经注意到了这里没有显式的文件描述符字段。这是由于kqueue设计的目的并不是是为了替代基于套接字事件复用技术的select()/poll(),而是提供通常化的机制来处理多种操做系统事件。

过滤器字段指明了内核事件类型。若是它是EVFILT_READ或EVFILT_WRITE,kqueue就与epoll是同样的。这种状况下,ident字段表现为一个文件描述符。ident字段也可能表现为其余类型事件的标识,如进程号和信号数目,这取决于过滤器类型。更多的细节能够从man手册这篇文档里找到。

epoll和kqueue的比较

性能

从性能角度讲,epoll存在一个设计上的缺陷;它不能在单次系统调用中屡次更新兴趣集。当你的兴趣集中有100个文件描述符须要更新状态时,你不得不调用100次epoll_ctl()函数。性能降级在过渡的系统调用时表现的很是明显,这篇文章有作解释。我猜这是Banga et al原来工做的遗留,正如declare_interest()只支持一次调用一次更新那样。相对的,你能够在一次的kevent调用中指定进行屡次兴趣集更新。

非文件类型支持

另外一个问题,在我看了更重要一些,一样也是epoll的一个限制。它的设计目的是为了提升select()/poll()的性能,epoll只能基于文件描述符工做。这有什么问题吗? 一个常见的说法是“在unix中,全部东西都是文件”。大部分状况都是对的,但并不老是这样。例如时钟就不是,信号也不是,信号量也不是,包括进程也不是。(在Linux中)网络设备也不是文件。在类Unix系统中有好多事物都不是文件。你没法对这些事物采用select()/poll()/epoll()的事件复用技术。典型的网络服务器管理不少类型的资源,除了套接字外。你可能想经过一个单一的接口来管理它们,可是你作不到。为了不这个问题,Linux提供了不少补充性质的系统调用,如signalfd(),eventfd()和timerfd_create()来转换非文件类型到文件描述符,这样你就可使用epoll了。可是看起来不那么的优雅...你真的想让用一个单独的系统调用来处理每一种资源类型吗? 在kqueue中,多才多艺的kevent结构体支持多种非文件事件。例如,你的程序能够得到一个子进程退出事件通知(经过设置filter = EVFILT_PROC, ident = pid, 和fflags = NOTE_EXIT)。即使有些资源或事件不被当前版本的内核支持,它们也会在未来的内核中被支持,同时还不用修改任何API接口。

磁盘文件支持

最后一个问题是epoll并不支持全部的文件描述符;select()/poll()/epoll()不能工做在常规的磁盘文件上。这是由于epoll有一个强烈基于准备就绪模型的假设前提。你监视的是准备就绪的套接字,所以套接字上的顺序IO调用不会发生阻塞。可是磁盘文件并不符合这种模型,由于它们老是处于就绪状态。 磁盘I/O只有在数据没有被缓存到内存时会发生阻塞,而不是由于客户端没发送消息。磁盘文件的模型是完成通知模型。在这样的模型里,你只是产生I/O操纵,而后等待完成通知。kqueue支持这种方式,经过设置EVFILT_AIO 过滤器类型来关联到 POSIX AIO功能上,诸如aio_read()。在Linux中,你只能祈祷由于缓存命中率高而磁盘发生不阻塞(这种状况在一般的网络服务器上是个彩蛋),或者经过分离线程来使得磁盘I/O阻塞不会影响网络套接字的处理(如FLASH架构)。

在咱们以前的文章中,咱们建议了一种新的编程接口:MegaPipe。它是彻底基于完成通知模型的,可用于磁盘文件和非磁盘文件。 最后原文在这里

相关文章
相关标签/搜索