select,poll和深入探索epoll
我们首先先了解一下什么是I/O复用:
我们用一个单进程或者单线程的服务器程序去监听多个文件描述符上是否有关注的事件发生,如果某些文件描述符上有事件发生,则程序接着处理有事件发生的文件描述符,其他的不用理会,这样就可以极大的提高程序的性能。
常用的I/O复用技术有select,poll,epoll
我们介绍前两种,主要探索第三种epoll
一:select
#include <sys/select.h>
int select(int maxfd, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 返回值:-1失败,0超时,>0表示就绪的文件描述符的个数
- maxfd:最大的文件描述符的值+1(select的底层的数据结构是位数组,无符号长整形的32数组,每一位代表一个关注的文件描述符,一共32*32 = 1024位)
- readfds,writefds,exceptfds:分别指向写事件,读事件,异常事件的文件描述符集合,并在select调用时,将用户关注的文件描述符传递给系统内核;select返回时,内核也是通过在线修改这三个变量,来通知应用程序的哪些文件描述符就绪。
- timeout:超时时间(传NULL表示永久阻塞,直到有文件描述符就绪)
其中又有四个函数用来控制文件描述符集合:
#include <sys/select.h>
- FD_ZERO(fd_set *fdset); //清除fd_set的所有位
- FD_SET(int fd, fd_set *fdset); //设置fd_set的位fd
- FD_CLR(int fd, fd_set *fdset); //清除fd_set的位fd
- int FD_ISSET(int fd, fd_set *fdset); //测试fd_set的位fd是否被设置
select的缺点:
- select监听的文件描述符个数是由限制的,在linux内核中设置为1024
- 从select参数上看,关注的事件只有三个,读写和异常
- select我们将监听的文件描述符放到位数组中,将需要关注的事件注册到其对应的fd_set中,最后其传入内核进行监听,内核会返回已就绪的文件描述符的个数,但是不知道是哪些,所以我们需要将整个文件描述符通过FD_ISSET全部遍历一遍查看其是否被设置,并处理就绪的文件描述符,处理之后再将文件描述符传给内核继续监听。少量文件描述符下,效率还不错,但是当大量的文件描述符被监听的时候,则遍历效率极低。
所以之后提出了poll
二:poll
#include <poll.h>
int poll(struct pollfd fds[], unsigned int nfds, long timeout);
struct pollfd
{
int fd; //文件描述符
short events; //注册的事件,监听的事件
short revents; //就绪的事件,由内核填充
};
- fds:指向一个结构体数组(结构体中声明了被监听的描述符和想关注的事件)
- nfds:结构体数组的长度(用户给定的nfds数不可以超过struct file结构支持的最大fd数,默认是256)
- timeout:超时时间,-1为永久阻塞
- 返回值:-1失败,0超时,>0表示就绪的文件描述符的个数
poll的事件类型:
poll对于select的优化:
- poll关注的文件描述符的个数增大,65535
- poll将关注的事件和内核修改的事件分开表示,每次调用都不需要全部清零了
- poll关注的事件类型更加的多了
poll的缺陷:虽然相比于select,poll解决了前两个问题,监听的文件描述符也多了,监听的事件也不仅仅是读写和异常了,但是第三个问题还是没有解决(poll和select返回的都是已就绪的文件描述符个数,哪些就绪哪些没有就绪,依旧得遍历一遍,遍历就绪的文件描述符的时间复杂度O(n))
需要注意的是在linux2.6之后使用的是epoll,因为epoll完美的解决的了poll和select存在的问题。
三:epoll
epoll的用法:将原来的poll和select调用分成了三个部分
#include <sys/epoll.h>
①:int epoll_create(int size); //创建内核事件表,返回事件表标识
epoll_create()函数是用来创建一个内核事件表,也就是创建一个文件,底层结构是红黑树。
epoll_create()调用会返回一个文件描述符,之后所有的使用都通过这个文件描述符来访问。
②:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //对内核事件表的操作,比如插入,删除,修改。
epoll_ctl系统调用,通过此调用可以向内核事件表中添加,删除,修改感兴趣的事件,返回0表示成功,-1表示失败
int op:EPOLL_CTL_ADD EPOLL_CTL_MOD EPOLL_CTL_DEL
struct epoll_event
{
__uint32_t events; //epoll事件
epoll_data data; //用户数据 存放fd
}
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
③:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//epoll_wait()系统调用,通过此调用可以收集已经就绪的事件
- events:指向一个数组,保存内核返回的所有就绪的文件描述符
- maxevents:数组的长度
- timeout:超时时间,-1为永久阻塞
- 返回值:-1失败,0超时,>0表示就绪的文件描述符的个数
下面我们看看linux内核到底是如何实现epoll机制的:
①:当进程调用epoll_create()的时候,linux内核会创建一个eventpoll结构体出来,这个结构体中有两个成员,具体结构如下:
struct eventpoll
{
struct rb_root rbr; //红黑树的根节点,这个树中存储着所有添加到epoll中需要监控的事件
struct list_head rdlist; //双链表中存储着将要通过epoll_wait返回给用户就绪的事件
};
特别注意:
- 每一个epoll对象都有一个独立的eventpoll结构体,用于存放红黑树根结点,通过epoll_ctl添加进来的事件,会放在epoll_event结构体中,这些事件将被挂载到红黑树上,以实现增加效率。
- 每一个添加到epoll中的事件都会设置一个回调函数,当事件就绪后,就会调用这个回调函数,这个回调方法叫ep_epoll_callback,它会将已发生就绪的事件添加到relist双链表中
- 在epoll中,每一个事件都会建立一个epitem结构体,如下图所示:
struct epitem
{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
- 当调用epoll_wait()来检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可,如果rdlist不为空,则将发生的事件拷贝到用户态,同时将事件的数量返回给用户。
epoll数据结构示意图:
内核事件表的底层数据结构是红黑树(插入的是关注的事件)
就绪描述符的底层数据结构是双链表(插入的是就绪的对象)
epoll_wait()的功能就是不断查看rdlist双链表中是否有epitem结构体,如果没有就一直检查,直到超时,如果有,则将其fd收集返回给用户。
这个时候需要介绍一下ET和LT模式:
LT模式:电平触发
当epoll_wait检测到文件描述符上有事件发生,并将此事件通知应用程序之后,应用程序可以不立即处理该事件,当下次调用epoll_wait时,还会向应用程序通知这个事件,直到此事件被处理。
如果用户没有处理就绪的文件描述符或者没有处理完,则内核会再次提醒
ET模式:当epoll_wait检测到文件描述符上有事件发生,并将此事件通知应用程序之后,应用程序必须立即处理该事件,并且需要将该事件处理完成,因为epoll_wait下次再被调用时,不会再向应用程序通知该事件。从而降低了同一事件被重复触发的次数,从而效率比LT模式高一些。
内核只会将就绪描述符通知用户一次,如果用户没有处理就绪的文件描述符或者没有处理完,则内核不会再次提醒,只能等下次事件触发,内核将fd重新插入到rdllist中去。
ET高效模式是epoll系统调用独有的,ET就是边缘触发的意思,有了ET模式,重复的事件就不会总出来打扰程序的判断,故而经常被使用,那么EPOLLET的原理是什么呢?
上面我们讲到,epoll给fd关注的事件都挂上了一个回调函数ep_epoll_callback,当关注的事件就绪时,将fd放入到rdllist双链表中,这样epoll_wait只需要检查这个rdllist双链表就可以知道哪些fd有事件就绪了。
那么把rdllist里的fd拷贝到用户空间,这个任务是ep_events_transfer这个函数做的:
其中关键步骤是将rdllist中的fd挪到txlist里(挪完rdllist就空了),接着才将txlist中的fd返回给用户空间,但是最后会有一部分的fd从txlist中“返还”给rdllist,以便下次还能从rdllist中拷贝。
那么是将哪一部分fd返还给了rdllist呢?
我们可以看源代码中的判断:
重新被返还给rdllist的fd,是“没有标上EPPOLLET模式”且“事件还被关注”了的fd。
那么下次epoll_wait当然会又把rdllist中的fd拿出来拷贝给用户空间了。
四:最后,我们将select,poll,以及epoll做一个总结对比:
- select和poll每轮轮询都需要将关注的文件描述符和事件传给内核,而epoll每个文件描述符只需要传一次,不需要每次都传。(用户上传内核方式)
- select和poll在内核中是以轮询的方式实现的,时间复杂度为O(n),而epoll是采用回调函数的方式进行监测的,时间复杂度为O(1)(内核检查关注事件方式)
- select和poll返回后,为了找到就绪描述符,需要遍历所有元素,时间复杂度为O(n),而epoll直接拿到了就绪的描述符,不需要遍历所有元素,时间复杂度为O(1)(用户找到就绪描述符方式)