epoll是怎么实现的呢?其实很简单,从这3个方法就可以看出,它比select聪明的避免了每次频繁调用“哪些连接已经处在消息准备好阶段”的 epoll_wait时,是不需要把所有待监控连接传入的。这意味着,它在内核态维护了一个数据结构保存着所有待监控的连接。这个数据结构就是一棵红黑树,它的结点的增加、减少是通过epoll_ctrl来完成的。
图中左下方的红黑树由所有待监控的连接构成。左上方的链表,同是目前所有活跃的连接。于是,epoll_wait执行时只是检查左上方的链表,并返回左上方链表中的连接给用户。这样,epoll_wait的执行效率能不高吗?
最后,再看看epoll提供的2种玩法ET和LT,即翻译过来的边缘触发和水平触发。其实这两个中文名字倒也有些贴切。这2种使用方式针对的仍然是效率问题,只不过变成了epoll_wait返回的连接如何能够更准确些。
例如,我们需要监控一个连接的写缓冲区是否空闲,满足“可写”时我们就可以从用户态将响应调用write发送给客户端 。但是,或者连接可写时,我们的“响应”内容还在磁盘上呢,此时若是磁盘读取还未完成呢?肯定不能使线程阻塞的,那么就不发送响应了。但是,下一次epoll_wait时可能又把这个连接返回给你了,你还得检查下是否要处理。可能,我们的程序有另一个模块专门处理磁盘IO,它会在磁盘IO完成时再发送响应。那么,每次epoll_wait都返回这个“可写”的、却无法立刻处理的连接,是否符合用户预期呢?
于是,ET和LT模式就应运而生了。LT是每次满足期待状态的连接,都得在epoll_wait中返回,所以它一视同仁,都在一条水平线上。ET则不然,它倾向更精确的返回连接。在上面的例子中,连接第一次变为可写后,若是程序未向连接上写入任何数据,那么下一次epoll_wait是不会返回这个连接的。ET叫做 边缘触发,就是指,只有连接从一个状态转到另一个状态时,才会触发epoll_wait返回它。可见,ET的编程要复杂不少,至少应用程序要小心的防止epoll_wait的返回的连接出现:可写时未写数据后却期待下一次“可写”、可读时未读尽数据却期待下一次“可读”。
当然,从一般应用场景上它们性能是不会有什么大的差距的,ET可能的优点是,epoll_wait的调用次数会减少一些,某些场景下连接在不必要唤醒时不会被唤醒(此唤醒指epoll_wait返回)。但如果像我上面举例所说的,有时它不单纯是一个网络问题,跟应用场景相关。当然,大部分开源框架都是基于ET写的,框架嘛,它追求的是纯技术问题,当然力求尽善尽美。
基于epoll的"迷你"网络事件库网络事件库封装了底层IO复用函数,同时提供给外部使用的接口,提供的接口可以多种多样,但是一般有添加事件、删除事件、开始事件循环等接口。为了展示下网络事件库的是如何封装IO复用函数,同时学习epoll的使用,"迷你"网络事件库-tomevent今天诞生了 :) (ps:tomevent采用C++语言实现)。
既然是网络事件库,那首先需要定义一个事件的结构,LZ这里就使用Event结构体了,事件结构体中包含监听的文件描述符、事件类型、回调函数、传递给回调函数的参数,当然,这只是一个简单的事件结构,如果还需要其他信息可另外添加。
/** * event struct. */ struct Event { int fd; /* the fd want to monitor */ short event; /* the event you want to monitor */ void *(*callback)(int fd, void *arg); /* the callback function */ void *arg; /* the parameter of callback function */ };
定义一个事件处理接口IEvent,该接口定义了3个基本的事件操作函数,也就是添加事件、删除事件、开始事件循环。定义IEvent接口,与具体的底层IO技术解耦,使用具体的IO复用类来实现该接口,比如对应select的SelectEvent,或者是对应poll的PollEvent,当然,这里就用epoll对应的EpollEvent来实现IEvent接口(ps:c++中接口貌似应该称为抽象类,不过这里称为接口更合适一点)。