什么是事件复用技术
假设你有一个简单的web服务器,并且那里已经打开了两个socket连接。当服务器从两个连接那里都收到Http请求的时候,它应该返回一个Http响应给客户端。但是你没法知道哪个客户端先发送的消息和什么时候发送的。BSD套接字接口的阻塞行为意味着,如果你在一个连接上调用recv()函数,你就没办法去响应另外一个连接上的请求。这时你就需要I/O复用技术。 I/O复用技术的一个直接方式是让每个连接都拥有一个进程/线程,这样连接上的阻塞行为就不会相互影响。这样,你就把所有繁琐的调度/复用问题交给了操作系统内核。这样的多线程架构伴随着的是高昂资源消耗。维护大量的线程对内核来说没有什么必要。每个连接上的独立栈不仅要增加内存痕迹,同时也降低了CPU本地缓存能力。 那么我们如何不使用线程-连接模式来实现I/O复用技术呢?你可以通过一个简单的忙等轮询来实现,即在每个连接上进行非阻塞的套接字操作,但这种行为过于的浪费。我们所要知道的只不过是哪个套接字已经就绪。因此系统内核为应用与内核之间提供了一个单独的通道,这个通道在你的套接字变为就绪时会发出通知。这就是基于准备就绪模式下的select()/poll()工作模式。
概况:select()
select()和poll()的工作方式非常类似。让我们先快速看一下select()函数
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)
调用select()函数,你的应用程序需要提供三个兴趣集:r,w和e。每一个集合都是一个文件描述符的位图。例如,如果你关注从文件描述符6里面读取数据,那么r集合里面的第6个字节位就设成1。这个调用会被阻塞直到兴趣集中有更多的文件描述符就绪,因此你可以操纵这些文件描述符而不会被阻塞。在返回后,系统内核会覆写整个位图来指明哪些文件描述符已经就绪。 从扩展性角度,我们可以找到4个问题:
- 这些位图的大小是固定的(FD_SETSIZE, 通常是1024),尽管也有一些方法可以绕过这个限制。
- 由于位图是由内核来覆写的,用户应用程序在每一次调用时需要重填兴趣集。
- 每一次调用时,用户应用程序和内核都需要扫描整个位图,用于指出哪些文件描述符属于兴趣集,哪些属于结果集。这对于结果集来说特别的低效,因为他们看起来非常的稀疏(如在一个给定的时间内,只有很少的文件描述符会发生变化)。
- 内核必须为每一次调用去迭代整个兴趣集,以便找到哪些文件描述符已经就绪。假如没有一个就绪,内核就会迭代的为每个套接字链接设置一个内部事件。
概况: poll()
poll()的设计意图就是解决这些问题。
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。它是完全基于完成通知模型的,可用于磁盘文件和非磁盘文件。 最后原文在这里
将想法付诸于实践,借此来影响他人是一个人存在的真正价值。