在介绍epoll之前,先说说poll。我们都知道,select通过固定的参数位置加输入输出型参数来进行数据的传递。这样做就有一个很大的缺陷,操作麻烦。用户自己还需要创建一个新的数组,将进行监听的源数据保留下来。同时还有一个硬伤,就是select监听的fd是有上限的,这个上限只能通过修改内核的属性来实现增强。如果我们的服务器业务很大的话,就会发现select不够用。
所以有后来出现了poll,poll针对select进行了改进,他将输入的源和输出的数据分离开来,这样就不用新创建一个数组保留源数据。同时因为是使用结构体,不再使用位图的方法,所以poll没有了数量的限制。
poll
函数原型和参数
#include <sys/poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
//pollfd结构体
struct pollfd{
int fd;
short events;
short revents;
}
参数说明:
fds:是一个pollfd* 结构体数组,每一个结构体中包括了三部分:监听的文件描述符、关心的事件集、返回的事件集。
nfds:是数组的大小
timeout:表示poll愿意等待的时间,如果为NULL,表示阻塞等待;如果为0,表示非阻塞;如果大于0,表示周期等待设定时间,超时了返回。
poll中events和revents的标志位:
返回值:如果小于0,表示出错;如果等于0,表示超时返回;如果大于0,表示返回已经就绪的描述符数目。
poll的优点和缺点
优点:
poll很好的解决了之前select的数量上限问题,同时接口也更加的好使用。不再需要考虑源数据的保留,每次进行select之前都需要重新赋值的问题。
缺点:
poll由于是使用了结构体数组作为参数保存结果的。那么我们依然需要遍历这个数组,才知道我们所关心的哪一个fd中哪一个事件就绪。这样就会导致当监听数量增大的时候,性能大大减小。如果当监听的数量很大,但是一段时间内活跃的用户很少的话,就会导致效率及其低下,因为需要遍历寻找,这是一个O(n)的时间复杂度。其次,poll使用的也是输出型参数,每次调用都需要将数据从用户态调入到内核态,再将数据取出来,这样会有大量的消耗。
epoll
正是因为poll还有着这些问题,后来出现了epoll。epoll跟之前的select和poll使用了不一样的思路,可以称为当前linux下性能最好的多路I/O就绪通知方法。man手册上说,他是为了处理大批量句柄而做了改进的poll。
epoll的相关函数调用
#include <sys/epoll.h>
//创建一个epoll句柄
int epoll_create(int size);
//成功返回socket fd,失败返回-1
参数size指的是可以创建的文件描述符的最大值。这个函数在内存中申请一个空间,该空间是一个epoll专用的。该size就是socket fd的最大值。最后需要使用close()函数将fd释放。
#include <sys/epoll.h>
//添加、删除、删除一个epoll事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
//成功返回0,失败返回-1
epfd:epoll_create返回的socket fd
op:操作选项。有三个选项,用宏来定义的。
- EPOLL_CTL_ADD:添加一个新的fd到epfd中。
- EPOLL_CTL_MOD:修改一个fd的监听事件。
- EPOLL_CTL_DEL:删除epfd中的一个fd。
fd:需要监听的fd。
event:期望监听的事件。epoll_event结构体如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
其中events是一个32整型的宏的集合,data是一个联合。events可以使用的值如下:
- EPOLLIN:触发该事件,表示对应的文件描述符上有可读数据。(包括对端SOCKET正常关闭);
- EPOLLOUT:触发该事件,表示对应的文件描述符上可以写数据;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
#include <sys/epoll.h>
//等待监听事件的就绪
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
events:传入型参数,用来保存epoll_wait返回的就绪事件。该参数不能是一个空指针,内核只会讲数据拷贝到我们制定的events中,并不会创建新的空间。
maxevents:是events的大小,该大小不能超过epoll_create指定的大小。
timeout:超时时间。如果指定为-1,表示阻塞等待;如果指定为0,表示非阻塞;如果大于0,指定愿意等待的时间。
返回值:如果返回0,表示超时返回;如果返回大于0,是的是对应I/O已经就绪的fd数目;小于0,表示失败。
一般调用epoll,就只需要使用三个epoll函数,同时最后加上close关闭就好。
epoll工作原理
当我们调用了epoll_create的时候,在内核中就会创建一个eventpoll结构体,这个结构体的内容很多,其中有两个成员与epoll的使用密切相关。eventpoll.rdllist
是一个双向链表,eventpoll.rbr
是一个红黑树。这两个结构的成员都是一个epitem结构体。epitem结构体如下:
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄
struct eventpo;; *ep; //指向所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当我们用epoll_create函数的时候,创建了一个epoll句柄,每个句柄都有一个自己的eventpoll结构体,用来存放epoll中添加的事件。这些事件会被挂在红黑树中,这个红黑树使用关心的fd作为关键字,用关心的事件作为值。这样就做到了插入、查询、删除、修改的时间复杂度是O(log₂n)。同时如果出现重复的写入也不会有什么大影响。
在调用epoll_creat的时候,内核还会创建一个回调函数(ep_epoll_\callback)。当内核发现有关心的事件就绪的时候,该回调函数就会将在红黑树中对应的节点加入到双向链表中。epoll_wait函数就可以直接从双向链表中直接获得。如果双向链表不为空就将事件复制给用户空间,然后返回数量。这样获得就绪事件的时间复杂度就是O(1)。
epoll工作方式
epoll有两种工作方式,分别是水平触发(LT)和边缘触发(ET)。
设置一个场景:有一个tcp socket添加入epoll描述符,对端发送了3K的数据,此时调用了epoll_wait函数,因为有数据写入,此时epoll_wait函数返回。此时调用read读取,read每次只读取1K的数据。接着继续调用epoll_wait函数。
epoll默认的工作方式是水平触发方式(LT):当epoll_wait函数返回的时候,LT模式的epoll会读取数据,如果一次没有读完,epoll_wait下次调用还会返回,然后让epoll再次读取,直到当前的数据被读取完。当前场景下,第一次read了1K,还剩下2K没有读取,再次调用epoll_wait的时候,依然会返回,通知epoll再次读取。
可以在创建句柄的时候,添加选项EPOLLIN | EPOLLET
,让LT模式修改为ET模式:ET模式下,当epoll_wait返回的时候,epoll只有一次机会将数据获取完。如果没有获取完,下次调用epoll_wait的时候,只要缓冲区中的数据没有发生变化(增加)epoll_wait不会再次通知epoll获取数据。这样就可能导致数据的丢失。为了防止这种情况产生,在ET模式的时候,必须一次性将数据获取完。但是就如例子所说,缓冲区有3K的数据,read每次只获取1K,我们就可以通过循环读取的方式读取完缓冲区的数据。很不巧的是,read只有读取到阻塞才表示将数据读取完全,所以我们还需要将fd设置为非阻塞的。这样当read返回错误同时errno为EAGAIN的时候,表示数据被读取完全。可以进行下一次的epoll_wait。
两种方式的对比,LT模式可以使用阻塞和非阻塞式的读写。ET只能使用非阻塞式的读写。同时select和poll只有LT模式。
epoll的优点
- fd的上限很大,而且可以同ulimit进行修改,所以可以说fd没有上限。对比于select中有着数组fd_set大小的限制。
- epoll利用红黑树来保存监听事件,进行事件的查询、删除、修改、添加的时候,复杂度为O(log₂n)。
- epoll不再需要使用轮询的方式查找就绪事件,只要在双向链表中获取,其中每一个元素都是已经就绪的。
- epoll的接口使用很方便。
- epoll使用了回调函数机制,这就大大减少了操作系统的负担。
epoll的使用场景
都说epoll的高性能,但是是具有一定场景的。并不是适用于所有的场景。epoll适用于多连接中只有一部分连接活跃的情况,这样情况中使用select和poll需要使用轮询的方式访问全部监听的事件,但是epoll速度就很快。如果多连接中连接数量少,我们可以直接使用select或poll处理即可。
同时epoll还有惊群问题,epoll的惊群问题指的是,监听同一个socket的进程会被挂在等待队列中,如果当这个socket到来的时候,这里所以子进程都会被唤醒。但是最后只有一个子进程可以成功获得资源。此时就导致了大量的无用功,浪费了资源。解决这个问题可以在accept阻塞函数加上锁,竞争到锁的才可以进行获取socket。