目录
2.3.3 epoll 底层用到的数据结构(红黑树+双向链表)+结构体
2.3.3 对比 select和poll 的遗留缺点,epoll的解决方法
一、什么是IO多路复用
文件描述符(fd) 表示的是对某个文件操作的句柄。当然socket套接字也算是fd。一般来说,想对fd进行读写操作,就要操作到fd,例如 read(),但read()本身是BIO,即阻塞IO,当对fd调用read()时,如果暂时没有数据输入到fd,那么read()将会处于阻塞状态,直到有数据输入,read()才会返回。
那么我们就可以想,如果现在有一个客户端连接进服务器,想要跟服务端通信,那么服务端就 对表示这个服务器的 sd(socket也能当作fd),调用read(),此时,若客户端有信息进入,read()返回,否则,read()会一直阻塞。
那么如果有两个客户端连接进来了,也想跟服务器通信,那怎么办呢?答案是开多一条线程,让另一个线程对第二个客户端调用read(),并阻塞到客户端有信息进入服务器为止。那这样就出现问题了,实际应用中,客户端不可能只有几个啊,可能有上万个客户端想要跟服务端通信,那么也要开上万个线程?那显然是不实际的。
所以解决方法就是 IO多路复用。IO多路复用一般有 select()、poll()、epoll()方式,他们都是对连接进服务端的 客户端socket就行监控,例如现在有100个 客户端socket,那么就监控这100个,如果这100个socket中有信息进入,则IO多路复用会返回,否则,就阻塞。即IO多路复用可以同时阻塞多个I/O操作,而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写(就是监听多个socket)。
正因为阻塞I/O只能阻塞一个I/O操作,而I/O复用模型能够阻塞多个I/O操作,所以才叫做多路复用。
二、select、poll、epoll的区别和总结
IO多路复用一般分为:select、poll、epoll 三种方式。三个都属于系统调用。
2.1 select
select 的大致过程如下:
用户进程调用 select() 监控用户指定的多个文件描述符,若没有一个文件描述符有数据返回,则阻塞,若有文件描述符有数据返回,则会对这个文件描述符调用read()进行读取数据。
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
上面的 readfds、writefds、exceptfds 都是要监控的文件描述符的集合,这些集合在调用 select 前后是会发生变化的,
前:表示关心的文件描述符集合
后:有数据的集合(如不是在超时返回的情况下)
换句话说就是:我们先是设置了要监控的各个I/O的文件描述符到fd_set集合,然后调用select(),最后fd_set集合只剩下有"异常"(包括读、写、异常)的文件描述符,举例就是,select前,readfds里的是 要监控的文件描述符的集合,select后,readfds里的则是 有数据信息进入的文件描述符的集合。
注意: fd_set全是位图,位图就是只有0、1值的数组。 三组fd_set均将某些fd位 置0,只有那些可读,可写以及有异常条件待处理的fd位仍然为1。由于 select的底层是位图,位图是数组,所以select所能监控的文件描述符的数量是有上限的,因为数组就是定长的嘛。
select 的缺点:
1. 内核/用户数据拷贝频繁,操作复杂。
在调用 select() 之前,需要手动在 程序中 维护一个包含要监控的文件描述符的 文件描述符集合 fd_set。把需要监听的文件描述符加到fd_set中。用户为了检测时间是否发生,还需要在用户程序手动维护一个数组,存储监控文件描述符。当内核事件发生,在将fd_set集合中没有事件发生的文件描述符清空,然后拷贝到用户区,和数组中的文件描述符进行比对。再调用select也是如此。每次调用,都需要来回拷贝。
2. 单个进程监控的文件描述符有限,通常为1024*8个文件描述符
3. 轮询时间效率低
select 检测时间是否发生的方式是通过轮询各个 文件描述符。当文件描述符的数量大的时候,轮询的效率很低,所以select 监控时的时间复杂度为O(n)。
2.2 poll
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同于select的位图方式,poll是通过设置结构体pollfd(如下)中fd和 event 参数来实现read/write,比如读为POLLIN,写为POLLOUT,出错为POLLERR
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch,eg:POLLIN\POLLOUT\POLLERR */
short revents; /* returned events witnessed */
};
而且poll 没有文件描述符的数量限制,因为poll() 内部是使用链表进行记录,而 select() 是使用的bit位序列(位图)进行记录。
优缺点:
1. 相对于select,poll 没有监听文件描述符的数目上限。
2. 由于 poll 监听文件描述符的方式都是轮询,跟select 一样,所以 poll 在高并发下的表现也不是特别好。
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket
。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
所以对于poll来说,select的大部分问题,poll都具有。拿select为例,加入我们的服务器需要支持100万的并发连接。则在FD_SETSIZE(最大fd连接数)为1024的情况下,我们需要开辟100个并发的进程才能实现并发连接。除了进程上下调度的时间消耗外。从内核到用户空间的无脑拷贝,数组轮询等,也是系统难以接受的。因此,基于select实现一个百万级别的并发访问是很难实现的。
2.3 epoll
epoll是在 Linux内核2.6版本中提出的,epoll可以看作是 select 和 poll 的增强版。
select、poll监听文件描述符的方式是轮询,epoll是通过回调函数,采用回调的方式,只有活跃可用的fd才会调用callback函数,也就是说 epoll 只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。通俗形容如下:
epoll 与 select/poll 的流程对比
select在每次被调用之前,都要把要监控的文件描述符fd加到监控的集合(也可以叫做等待队列),然后再调用select阻塞,直到有fd返回。这是select低效的原因之一------将“维护等待队列”和“阻塞进程”两个步骤合二为一。而epoll 则把这两个步骤分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程(解耦)。显而易见的,效率就能得到提升。如下图。
2.3.1 epoll的大致工作流程:
我们在调用epoll_create时,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的文件描述符fd,当epoll_wait调用时,仅仅观察这个rdllist双向就绪链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
所有添加到epoll中的 fd 都会与设备(如网卡)驱动程序建立回调关系,也就是说相应 fd 的监听事件 发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的 fd 放到上面的rdllist双向就绪链表中。
epoll 特点:
- 没有最大并发连接的限制,能打开的fd上限远大于1024(1G的内存能监听约10万个端口)
- 采用回调的方式,效率提升。只有活跃可用的fd才会调用callback函数,也就是说 epoll 只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。
- 内存拷贝。使用mmap()文件映射内存来加速与内核空间的消息传递,减少复制开销。
2.3.2 操作epoll 的接口
#include <sys/epoll.h>
int epoll_create(int 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);
epoll_create (int size):
创建(即返回)一个epfd句柄,参数size表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1。更重要的是,调用epoll_create时,Linux内核会创建一个eventpoll结构体(下面会详细讲)。
epoll_ctl (int epfd, int op, int fd, struct epoll_event *event):
注册要监听的时间类型。参数中,epfd是epoll_create()创建的epoll句柄。 op则表示要执行的操作,如把 fd 放到eventpoll结构体中监听,把fd从eventpoll结构体中删除。
event 参数表示 与 fd关联的事件,epoll是直接把 fd 和 要监听的事件关联在一起的,比较主要的事件如下(以宏的形式表示):
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
epoll_wait (int epfd, struct epoll_event * events, int maxevents, int timeout):
等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0
调用epoll 的代码示例:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}
2.3.3 epoll 底层用到的数据结构(红黑树+双向链表)+结构体
我们在调用 epoll_create()时,Linux内核会帮我们创建一个eventpoll结构体,这个结构体中只有两个成员,一个是表示红黑树,一个是表示就绪队列双向链表,但都和epoll的使用方式密切相关,结构体如下所示:
eventpoll 结构体:
struct eventpoll {
...
/*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
也就是这个epoll监控的事件*/
struct rb_root rbr;
/*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
struct list_head rdllist;
...
};
同时在 内核cache里 建了个红黑树用于存储以后epoll_ctl传来的socket(表示客户端连接的fd)外
还会再建立一个就绪队列链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个就绪队列链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
当向系统中添加一个fd时,就创建对应的一个epitem结构体(即 fd 和 epitem结构体是一一对应的),这是内核管理epoll的基本数据结构:
epitem结构体:
(每个文件描述符 fd 都对应一个epitem结构体)
struct epitem {
struct rb_node rbn; //用于主结构管理的红黑树
struct list_head rdllink; //事件就绪队列
struct epitem *next; //用于主结构体中的链表
struct epoll_filefd ffd; //这个结构体对应的被监听的文件描述符信息
int nwait; //poll操作中事件的个数
struct list_head pwqlist; //双向链表,保存着被监视文件的等待队列,功能类似于select/poll中的poll_table
struct eventpoll *ep; //该项属于哪个主结构体(多个epitm从属于一个eventpoll)
struct list_head fllink; //双向链表,用来链接被监视的文件描述符对应的struct file。因为file里有f_ep_link,用来保存所有监视这个文件的epoll节点
struct epoll_event event; //注册的感兴趣的事件,也就是用户空间的epoll_event
}
那么在内核中维护的红黑树的结构就如下:
为什么会出现红黑树,因为 epoll_ctl 向epoll对象加入 fd 时,是要先搜索这个fd是否已经存在的,在大量fd 的情况下,红黑树的查询效率要比链表好。
总体而言:
epoll_event 结构体 和 epoll_data_t 结构体:
这两个结构体其实是 出现在 epoll_ctl方法中的,回顾一下 epoll_ctl 方法:
int epoll_ctl (int epfd, int op, int fd, struct epoll_event *event);
即epoll_event结构体是 epoll_ctl 方法的一个参数,而 epoll_data_t 结构体又是 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 */
};
epoll_event结构体的定义如上,分为 events 和 data 两部分。
events 是epoll 注册的事件,如 EPOLLIN(读事件),EPOLLOUT(写事件)等。这个参数在epoll_ctl
注册事件时,可以明确告知注册事件的类型。
第二个成员变量 data 是一个联合体:
fd 是 具体的文件描述符,这个很好理解。
所以通过上述过程,epoll_event 就相当于把 fd 和 对应要监听的事件绑定在一起了。进而把这个绑定在一起的组合利用 epoll_ctl 注册进 epoll 的红黑树中。
2.3.4 epoll 的两种触发模式(LT和ET)
epoll 有 LT 和 ET 两种触发模式,LT是默认的模式,ET是高速的模式。
-
ET模式(边缘触发)只有数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返回;
-
LT 模式(水平触发,默认)只要有数据都会触发,缓冲区剩余未读尽的数据会导致epoll_wait返回。
什么时候用ET,什么时候用LT?
ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此ET模式效率比LT模式高。
为什么呢?
因为如果采用LT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。
而采用ET这种边缘触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
2.3.3 对比 select和poll 的遗留缺点,epoll的解决方法
第一个:(每次调用 select/poll 时会反复在用户态和内核态中来回复制 fd 集合)
select和poll 会在每一次调用时,把fd集合从用户态拷贝到系统的内核态,若多次调用select、poll,则会造成多次这样的拷贝,这个开销在fd很多时会很大。
epoll的解决方案是:调用epoll_wait,相当于调用 select、poll 。而在 epoll_ctl 期间,把需要监听的 fd 一次性拷贝到内核,这就避免了 调用 epoll_wait时把fd集合重复拷贝。
第二个:(select/poll 存在大量无效轮询)
每次调用select/poll ,都会对现有的fd进行轮询,但是活跃的fd可能很少,但是轮询过程则是把所有的 fd 都轮询一遍,所以fd越多,一般而言,select/poll的效率就越低。
epoll并不是像select一样去逐个轮询的监控fd的事件状态,而是事先就建立了fd与之对应的回调函数,当事件激活后主动回调callback函数,这也就避免了遍历事件列表的这个操作,所以epoll并不会像select和poll一样随着监控的fd变多而效率降低。
第三个:(文件描述符上限的限制)
epoll没有最大监听文件描述符数目的限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。