概念
-
I/O多路复用是通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
-
fd
文件描述符(fd): 在linux系统中打开文件就会获得文件描述符,它是个很小的非负整数。每个进程在PCB(Process Control Block)中保存着一份文件描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的指针。
-
轮询方式
-
忙轮询
阻塞I/O有一个比较明显的缺点是在I/O阻塞模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,需要多个进程或者多个线程,但是这种方式效率不高。
非阻塞的I/O需要轮询查看流是否已经准备好了,比较典型的方式是忙轮询。
忙轮询的方式即不断地把所有的流从头到尾循环一遍,看是否有准备好的。缺点在于如果没有准备好的就会白白浪费CPU的时间。
-
无差别的轮询方式
无差别轮询方式的运行机制是通过一个代理(select/poll),通过代理来观察流的状态,在没有流准备好时就将线程阻塞,当有一个或多个流的I/O事件就绪时,就从阻塞状态中醒来,然后轮询一遍所有的流,处理已经准备好的I/O事件。
如果I/O事件准备就绪,那么我们的程序就会阻塞在select处。我们通过select那里只是知道了有I/O事件准备好了,但不知道具体是哪几个流,所以需要无差别的轮询所有的流,找出已经准备就绪的流。可以看到,使用select时,我们需要O(n)的时间复杂度来处理流,处理的流越多,消耗的时间也就越多。
-
最小轮询方式
最小轮询方式是通过epoll来进行代理来监控流的状态。epoll只会把发生了I/O事件的流通知我们,我们对这些流的操作都是有意义的,时间复杂度降低到O(k)。
总结:忙轮询以及无差别轮询的理解方式可以从字面上先理解,忙即一直去做轮询,无差别区别于忙轮询的点在于没有流准备好时就将线程阻塞,这在一定程度上优化了忙轮询的问题,但他们从根本上仍然要将所有的流都询问一遍,当一次准备好的流较少时,效率仍不理想。而采用epoll的最小轮询方式使用的思路是通过监控执行回调函数的思路来实现,所有免除了每次都全部轮询的问题。
-
注意点
首先IO多路复用也是阻塞IO,对于阻塞IO即在等待数据准备好(Waiting for the data to be ready),将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)两个过程中都发送阻塞,同时会一直block住进程,直到操作完成。
可以把这个过程理解为去食堂打饭,你跟食堂大妈说我要一份红烧肉盖饭,大妈听到你的要求之后先去后厨做,做完后再从后厨端出来两步。而你在这两个过程中一直在窗口前面等待。
多路复用IO也是阻塞IO,只是阻塞的方法是select/poll/epoll。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理是select/epoll这个函数会不断轮询所负责的IO操作,当某个IO操作有数据到达时,就通知用户进程。然后由用户进程去操作IO。
阻塞方法
select
系统提供Select函数来实现多路复用输入/输出模型,Select系统调用是用来让我们的程序监视多个文件句柄的状态变化。程序会阻塞在select函数上,直到被监视的文件句柄中有一个或多个发生了状态变化。
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfd,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
参数说明:
- maxfd:需要监视的最大的文件描述符值+1;
- readset:需要检测的可读文件描述符的集合;
- writeset:需要检测的可写文件描述符的集合
- exceptset:需要检测的异常文件描述符的集合
- timeout:超时时间;超时时间有三种情况:
- NULL:永远等待下去,仅在有一个描述字准备好I/O时才返回;
- 0:立即返回,仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生;
- 特定的时间值: 如果在指定的时间段里没有事件发生,select将超时返回;
函数返回值有三种情况:
- 返回0表示超时了;
- 返回-1,表示出错了;
- 返回一个大于0的数,表示文件描述符状态改变的个数;
fd_set是一个文件描述符集合,可以通过以下宏来操作:
- FD_CLR(inr fd,fd_set* set):用来清除文件描述符集合set中相关fd的位
- FD_ISSET(int fd,fd_set *set):用来测试文件描述符集合set中相关fd的位是否为真
- FD_SET(int fd,fd_set*set):用来设置文件描述符集合set中相关fd的位
- FD_ZERO(fd_set *set):用来清除文件描述符集合set的全部位
深入的理解select模型的关键点在于理解fd_set,为了说明方便,我们取fd_set长度为1个字节,fd_set中的每一个bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8个fd。
-
执行fd_set set;FD_ZERO(&set);则set用位表示为 0000,0000 。
-
若fd = 5 ,则执行 FD_SET(fd,&set)后,set变为 0001,0000 (第5位置为1)
-
若再加入fd=2 ,fd=1,则set变为 0001.0011
-
执行select(6,&set,0,0,0)阻塞等待
-
若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。没有可读事件发生时 fd = 5 被清空。
poll
Poll的处理机制与Select类似,只是Poll选择了pollfd结构体来处理文件描述符的相关操作
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生了的事件 */
} ;
每一个pollfd结构体都指定了一个文件描述符fd,events代表了需要监听该文件描述的事件掩码,可选的有:
-
POLLIN:有数据可读。
-
POLLRDNORM:有普通数据可读。
-
POLLRDBAND:有优先数据可读。
-
POLLPRI:有紧迫数据可读。
-
POLLOUT:写数据不会导致阻塞。
-
POLLWRNORM:写普通数据不会导致阻塞。
-
POLLWRBAND:写优先数据不会导致阻塞。
-
POLLMSGSIGPOLL:消息可用。
revents代表文件描述符的操作结果掩码,内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回,除此之外,revents域还可以包含以下事件:
- POLLER:指定的文件描述符发生错误。
- POLLHUP:指定的文件描述符挂起事件。
- POLLNVAL:指定的文件描述符非法。
poll函数原型
# include <poll.h>
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
参数说明:
-
fds:需要被监视的文件描述符集合;
-
nfds:被监视的文件描述符数量;
-
timeout:超时时间,有三种取值:
-
负数:无限超时,一直等到一个指定事件发生;
-
0:立即返回,并列出准备好的文件描述符;
-
正数:等待指定的时间,单位为毫秒;
-
poll函数与select函数的最大不同之处在于:select函数有最大文件描述符的限制,一般1024个,而poll函数对文件描述符的数量没有限制。但select和poll函数都是通过轮询的方式来查询某个文件描述符状态是否发生了变化,并且需要将整个文件描述符集合在用户空间和内核空间之间来回拷贝,这样随着文件描述符的数量增加,相应的开销也随之增加。
epoll
epoll是什么?按照man手册的说法:是为处理大批量句柄而作了改进的poll。当然,这不是2.6内核才有的,它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)
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函数:
- 用创建一个epoll的句柄;
- size用来告诉内核这个监听的数目一共有多大,占用一个fd值;
epoll_ctl函数:
-
epoll的事件注册函数;
-
参数:
-
epfd:epoll_create()的返回值;
-
op:动作,有三种取值:
- EPOLL_CTL_ADD:注册新的fd到epfd中;
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL:从epfd中删除一个fd;
-
fd:需要监听的fd;
-
event: 告诉内核需要监听什么事件,取值有:
- EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误; EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列;
-
epoll_wait函数:
- 等待事件的产生;
- 参数:
- events:从内核得到事件的集合;
- maxevents:事件集合的大小;
- timeout:超时时间,0会立即返回,-1表示永久阻塞,正数表示一个指定的值;
工作模式
epoll对文件描述符的操作由两种模式:水平触发LT(level trigger)和边沿触发ET(edge trigger)。默认的情况下为LT模式。LT模式与ET模式的区别在于:
- LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
- ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
epoll相比于select/poll的优势?
从上面对select/poll/epoll函数的介绍,可以知道epoll与select/poll相比,具有如下优势:
监视的描述符数量不受限制,所支持的FD上限是最大可以打开文件的数目;
I/O效率不会随着监视fd的数量增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的,只有就绪的fd才会执行回调函数。
Reactor设计模式
如图所示,EventHandler抽象类表示IO事件处理器,它拥有IO文件句柄Handle(通过get_handle获取),以及对Handle的操作handle_event(读/写等)。继承于EventHandler的子类可以对事件处理器的行为进行定制。Reactor类用于管理EventHandler(注册、删除等),并使用handle_events实现事件循环,不断调用同步事件多路分离器(一般是内核)的多路分离函数select,只要某个文件句柄被激活(可读/写等),select就返回(阻塞),handle_events就会调用与文件句柄关联的事件处理器的handle_event进行相关操作。