epoll 与 select,poll 一样,其本质目的都是为了实现IO多路复用,将对多个文件描述符的等待时间重叠,提高 IO 的效率。但 epoll 不同的是,epoll 几乎解决了 select 和 poll 的所有缺点,具备之前所说的一切优点。
虽然 poll 的存在解决了使用 select 太复杂的问题以及 poll 并没有文件描述符最大数量的限制。但 poll 并没有解决 select 本质上的不足。poll 与 select 一样,存在需要系统轮询遍历和大量内核态与用户态来回拷贝的问题。
epoll 是 eventpoll,它在本质上解决了上述的问题,进一步提高了 IO 的效率。
epoll 存在3个相关的函数,虽然在个数上相较于 select,poll 更多了,但使用起来,其实更方便了。
要使用 epoll 时,首先调用 epoll_create() ,目的是创建一个 epoll 实例,为后续的操作做铺垫。
int epoll_create(int size);
● 这里的参数 size 在实际使用当中是被忽略的。
● 函数的返回值是一个文件描述符,需要通过该文件描述符完成后续操作。
● 在使用完后,需要调用 close() 关闭。
epoll 的事件注册函数,它有三个作用:
向内核中注册要监听的文件描述符和关心的事件;
修改已注册文件描述符所关心的事件;
删除一个已注册的文件描述符;
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
● epfd:表示已经创建的 epoll 实例,也就是 epoll_create() 的返回值;
● op:表示所进行的操作,用三个宏表示;
EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
EPOLL_CTL_MOD:修改已经注册的 fd 中所关心的事件;
EPOLL_CTL_DEL:删除一个已经注册的 fd;
● fd:需要监听的文件描述符;
● event:表示需要内核关心的事件;
struct epoll_event 的结构如下:
events 可以是以下几个宏的集合
● EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
● EPOLLOUT:表示对应的文件描述符可以写;
● EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
● EPOLLERR:表示对应的文件描述符发生错误;
● EPOLLHUP:表示对应的文件描述符被挂断;
● EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式这是相对于水平触发(Level Triggered)来说的。
● EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL实例里。
用于获取已经就绪的事件。
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
● events:在用户层就需要提前分配好 epoll_event 结构体数组,epoll 会把已经就绪的事件拷贝到这个数组中;
● maxevents:events 数组所能存放的最大元素个数,这个值不能大于调用 epoll_create() 中的 size;
● timeout:超时时间,单位是毫秒,填 0 是非阻塞,填 -1 是阻塞,填具体值则是超时时间;
epoll 是如何解决 select 和 poll 存在的问题的呢?其实主要在于三个方面,红黑树 + 链表 + 回调函数。
当一个进程调用 epoll_create 函数时,内核会创建一个 eventpoll 的结构体,而这个结构体中有两个成员与 epoll 的使用密切相关,一个是红黑树的根节点,一个是链表的头节点。
struct eventpoll
{
...
//红黑树的根节点,这颗红黑树中存放着所有需要监听的事件
struct rb_root rbr;
//链表的头节点,链表中存放着已经就绪的事件,通过 epoll_wait 返回给用户
struct list_head rdlist;
...
}
内核会对每一个事件构建一个 epitem 的结构体,红黑树与链表中的节点就是基于这个 epitem 来构建的。
struct epitem
{
...
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll* ep; //回指向所属的eventpoll对象
struct epoll_event event; //关系的事件
...
}
使用 epoll 的步骤如下:
● 首先调用 epoll_create 创建 epoll 实例。每个 epoll 实例都有一个独立的 eventpoll 结构体;
● 然后调用 epoll_ctl ,将需要监听的文件描述符进行注册,内核会为这次动作构建一个红黑树的节点,并插入到红黑树中;
● 之后,内核会为这个事件与网卡驱动程序建立回调关系,当事件就绪时,会调用已经建立好的回调方法。这个回调方法会将发生的事件添加到链表中;
● 最后调用 epoll_wait ,内核先检查这个链表是否为空,若不为空则直接将链表中的数据拷贝到用户层的 events 数组中,而这个操作的时间复杂度是 O(1)。
● 接口使用方便,含义清晰明了。使用时,不需要每次都循环设置关注的文件描述符,而且将关心事件与就绪事件分开了,做到了输入输出参数分离;
● 数据拷贝更轻量化。epoll 会在使用 EPOLL_CTL_ADD 时将文件描述符拷贝到内核的红黑树,以及将少量的就绪事件拷贝到用户层的 events 数组。不需要每次将所有关心的文件描述符拷贝到内核与从内核拷贝到用户;
● 使用了回调机制。避免了内核每次轮询遍历要关心的文件描述符,而是文件描述符就绪时,将其添加到链表中。并且这个操作的时间复杂度是 O(1),即使文件描述符很多,而就绪的仍是少数;
● 文件描述符没有数量限制。
epoll 存在两种工作模式,一种是 LT (Level Triggered) —— 水平触发;一种是 ET (Edge Triggered) —— 边缘触发。
select,poll 的工作模式只有 LT,而 LT 是 epoll 的默认工作模式。如果要将 epoll 设置成 ET 模式,则需要调用 epoll_ctl 函数,为某一个文件描述符,在关心的事件 events 中添加上 EPOLLET。当然,epoll 的工作模式是相较于其监听的一个文件描述符来说的。
先通过一个例子来理解下这两种工作模式。
假如现在有一家菜鸟驿站,这家驿站中有两名员工,一个名叫张三,他是一个很负责的人;一个名叫李四,他是一个很懒的人;而你呢,由于之前在网上买的东西有急用,而且同时买了几件;你在这一天内,会多次进入驿站,并询问当天的工作人员,是否有你的快递;
假如这天上班的是张三,张三比较负责,会严格记录当前时刻存在快递的人。
你进入驿站,询问张三:“目前有我的快递吗?”
张三说:“是的,有你的。”
这时候你拿走了一部分快递,然而你并没有拿完。
过了一会,你又到了驿站,并询问张三,说:“目前有我的快递吗?”
张三说:“是的,有你的。”
这时候,你还是之拿走了一部分。
......
一段时间之后,你终于把快递拿完了。
假如今天上班的是李四,李四比较懒,只会在快递到来的时候记录收件人的名字。就算一次到来多个快递,你没拿完,李四也会认为你拿完了,并将你的名字去除。
当你第一次进入驿站,询问李四:“目前有我的快递吗?”
李四说:“是的,有你的。”
但是,你这次并没有拿完所有的快递。
当你之后再进入到驿站时,询问李四:“目前是否有我的快递?”
李四回答说:“抱歉,当前并没有你的快递。”
情况一:
如果之后,你又有新的快递到了,李四则会将你的名字记录上,并在你下次到来时告知你。
情况二:
如果之后,再没有你的新快递到达,李四则会一直告诉你,没有你的快递。
并且对于之后你再到达驿站询问李四,李四的回答都是目前没有你的快递。
而你对工作人员是百分百信任的,当他对你说NO的时候,你都会离开驿站...
这时候,就发生了一件错误的事,你永远也拿不到之前没拿完,剩余的快递了。
在上面的例子中,张三的工作模式就是 LT ,李四的工作模式就是 ET。
本质上,LT 工作模式下,只要文件描述符上有数据,文件描述符就会一直就绪;而 ET 工作模式下,只有当一个文件描述符的数据增多时,才会就绪;
使用 ET 模式的 epoll ,需要将文件描述符设置为非阻塞。
● 假设文件描述符为阻塞,当我们需要读取数据时,由于是循环读取,当读到最后一次,没有数据可读的时候,就会被阻塞住;
● 而我们需要的是,有数据就读取;没有数据时,就直接返回。
● 所以需要将文件描述符设置为非阻塞;
目前看来,LT 是一种比 ET 更靠谱的工作模式,在 LT 模式下,就算分多次拿取数据,你也一定能拿完所有的数据;而在 ET 模式下,一旦第一次没拿完所有的数据,之后就拿不到剩余的数据了。
但实际上,在大多数情况下,ET 的效率是要高于 LT 的。因为在 ET 模式下,一次没拿完数据,之后的数据便不再提示上层拿取。这样做的好处是,在一次事件就绪时,倒逼着程序员在本次就将所有数据处理完,而不能一次只处理一部分数据。
这样一来,由于不会调用多次 epoll_wait 去处理相同事件的部分数据,使得 epoll_wait 的调用次数就减少了。
但是,如果在 LT 模式下也能做到一次将数据处理完,其实性能也是一样的。
另外,ET 下的代码复杂度更高了。