实例
《深入理解Nginx模块开发与架构解析第2版》
设想一个场景:有100万用户同时与一个进程保持着TCP连接,而每一时刻只有几十个或几百个TCP连接是活跃的(接收到TCP包),也就是说,在每一时刻,进程只需要处理这100万连接中的一小部分连接。那么,如何才能高效地处理这种场景呢?进程是否在每次询问操作系统收集有事件发生的TCP连接时,把这100万个连接告诉操作系统,然后由操作系统找出其中有事件发生的几百个连接呢?实际上,在Linux内核2.4版本以前,那时的select或者poll事件驱动方式就是这样做的。
select
select: 当系统I/O事件发生时,select仅仅知道有事件要处理;因此只能无差别的轮询所有的流,找出能读出/写入数据的流。所以select的时间复杂度是O(n),select有最大连接数限制。
poll
poll,和select没有区别,它将用户传入的数据拷贝到内核空间,然后查询每个fd对应的设备状态。没有最大连接数限制。轮询的时间复杂度为O(n)。
epoll
结合第一节提到的实例,在某一时刻,进程收集有事件连接时,其实这100万连接中的大部分都是没有事件发生的。但是,select/poll依然会把这100万连接的套接字传给操作系统,其中真正有事件处理的只有几百个。这样的情况下,将会造成巨大的资源浪费。因此select/poll只能处理几千个并发连接。
接下来我们就介绍目前最流行的epoll。epoll在Linux中申请了一个简易的文件系统,把原先的一个select/poll分成了三部分:调用epoll_create建立一个epoll对象,调用epoll_ctl向epoll对象中添加这100万个连接的套接字,调用epoll_wait收集发生事件的连接。因此在实际运行中epoll_wait运行的效率非常高。
当某一个进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体有两个成员与epoll的使用方式密切相关。
struct eventpoll {
......
//红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,也就是这个epoll监控的事件
struct rb_root rbr;
//双向链表rdllist保存着将要通过epoll_wait返回给用户的,满足条件的事件
struct list_head rdllist;
......
}
struct epitem{
......
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
......
}
《深入理解Nginx模块开发与架构解析第2版》
epoll操作过程需要三个接口,
int epoll_create(int size); //创建一个epoll的句柄,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);
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数据一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初试分配内部数据结构的一个建议。
当创建好epoll句柄后,他就会占用一个fd值,在linux下如果查看/proc/进程id/fd,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
-epfd:是epoll_create()的返回值。
-op:表示op操作,用三个宏来定义表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
-fd:是需要监听的fd(文件描述符)
-epoll_event:是告诉内核需要监听什么事,struct 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可以是以下几个宏的集合:
EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列中。
epoll_ctl()用于向内核注册新的描述符或者改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将I/O准备的描述符加入到一个链表中管理,进程调用epoll_wait()便可以得到事件完成的描述符。epoll只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待epfd的io事件,做多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法就是永久阻塞)。该函数返回需要处理的事件数目,如果返回0表示已超时。
进一步进行解释:
每一个epoll对象都有一个独立的eventpoll结构体,这个结构体会在内核空间中创造独立的内存,用于存储使用epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂在rbr红黑树中。这样搜索、插入、删除都会很快(epoll_ctl方法会很快)。
所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说,相应的事件发生时会调用这里的回调函数(ep_poll_callback),它会把这样的事件放到rdlist双向链表中。
这里就体现了epoll的优势:当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则把这里的事件复制到用户态内存中,同时将事件数量返回给用户。因此,epoll_wait的效率非常高。
select、poll和epoll比较
select的timeout参数的精度为1ns,poll和epoll为1ms
select有最大描述符数量的限制
如果需要监控小于1000个描述符,或者需要监控的描述符状态变化多,而且非常短暂,都没有必要使用epoll。因为epoll中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过epoll_ctl()进行系统调用,频繁系统调用降低效率。
epoll优势:需要监控百万个文件描述符,但是只需要处理其中一小部分的文件描述符,最好这些都是长连接。(epoll_wait()的效率最高)。
参考资料
- https://github.com/CyC2018/CS-Notes/blob/master/notes/Socket.md
- 《深入理解Nginx模块开发与架构解析第2版》