1.简介
希望通过这篇文章,可以回答以下几个问题?
- 为什么需要IO多路复用?
- 什么是IO多路复用机制?
- IO多路复用的机制该怎么使用?
- epoll比select/poll相比,优势在哪里?
在了解I/O多路复用之前,先来了解流的概念。
1.1流的概念
一个流可以文件、socket、pipe等可以进行IO操作的内核对象。不管是文件,还是套接字,还是管道,我们都可以把他们看作流。
从流中读取数据或者写入数据到流中,可能存在这样的情况:读取数据时,流中还没有数据;写入数据时,流中数据已经满了,没有空间写入了。典型的例子为客户端要从socket流中读入数据,但是服务器还没有把数据准备好。此时有两种处理办法:
- 阻塞,等待数据准备好了,再读取出来返回;
- 非阻塞,通过轮询的方式,查询是否有数据可以读取,直到把数据读取返回。
接下来再来了解以下I/O同步、异步、阻塞、非阻塞的概念。
1.2 I/O同步、异步、阻塞、非阻塞
在IO操作过程中,可能会涉及到同步(synchronous)、异步(asynchronous)、阻塞(blocking)、非阻塞(non-blocking)、IO多路复用(IO multiplexing)等概念。他们之间的区别是什么呢?
以网络IO为例,在IO操作过程会涉及到两个对象:
- 一个是调用这个IO的process (or thread);
- 另外一个是一个就是系统内核(kernel)。
在一个IO操作过程中,以read为例,会涉及到两个过程:
- 等待数据准备好(Waiting for the data to be ready);
- 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
这两个阶段是否发生阻塞,将产生不同的效果。
阻塞VS非阻塞
阻塞IO:
- 在1、2阶段都发生阻塞;
- 调用阻塞IO会一直block住进程,直到操作完成;
非阻塞IO:
- 在第1阶段没有阻塞,在第2阶段发生阻塞;
- 调用非阻塞IO会在kernel准备数据的情况下立即返回;
- 非阻塞IO需要不断轮询,查看数据是否已经准备好了;
阻塞与非阻塞可以简单理解为调用一个IO操作能不能立即得到返回应答,如果不能立即获得返回,需要等待,那就阻塞了;否则就可以理解为非阻塞。
同步VS异步
同步IO:
同步IO操作将导致请求的进程一直被blocked,直到IO操作完成。从这个层次来,阻塞IO、非阻塞IO操作、IO多路复用都是同步IO。
异步IO:
异步IO操作不会导致请求的进程被blocked。当发出IO操作请求,直接返回,等待IO操作完成后,再通知调用进程。
多路复用IO
多路复用IO也是阻塞IO,只是阻塞的方法是select/poll/epoll。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理是select/epoll这个函数会不断轮询所负责的IO操作,当某个IO操作有数据到达时,就通知用户进程。然后由用户进程去操作IO。
1.3 多路复用概念
I/O多路复用是通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
阻塞I/O有一个比较明显的缺点是在I/O阻塞模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,需要多个进程或者多个线程,但是这种方式效率不高。
非阻塞的I/O需要轮询查看流是否已经准备好了,比较典型的方式是忙轮询。
忙轮询
忙轮询方式是通过不停的把所有的流从头到尾轮询一遍,查询是否有流已经准备就绪,然后又从头开始。如果所有流都没有准备就绪,那么只会白白浪费CPU时间。轮询过程可以参照如下:
while true {
for i in stream[]; {
if i has data
read until unavailable
}
}
无差别的轮询方式
为了避免白白浪费CPU时间,我们采用另外一种轮询方式,无差别的轮询方式。即通过引进一个代理,这个代理为select/poll,这个代理可以同时观察多个流的I/O事件。当所有的流都没有准备就绪时,会把当前线程阻塞掉;当有一个或多个流的I/O事件就绪时,就从阻塞状态中醒来,然后轮询一遍所有的流,处理已经准备好的I/O事件。轮询的过程可以参照如下:
while true {
select(streams[])
for i in streams[] {
if i has data
read until unavailable
}
}
如果I/O事件准备就绪,那么我们的程序就会阻塞在select处。我们通过select那里只是知道了有I/O事件准备好了,但不知道具体是哪几个流(可能有一个,也可能有多个),所以需要无差别的轮询所有的流,找出已经准备就绪的流。可以看到,使用select时,我们需要O(n)的时间复杂度来处理流,处理的流越多,消耗的时间也就越多。
最小轮询方式
无差别的轮询方式有一个缺点就是,随着监控的流越来越多,需要轮询的时间也会随之增加,效率也会随之降低。所以还有另外一种轮询方式,最小轮询方式,即通过epoll方式来观察多个流,epoll只会把发生了I/O事件的流通知我们,我们对这些流的操作都是有意义的,时间复杂度降低到O(k),其中k为产生I/O事件的流个数。轮询的过程如下:
while true {
active_stream[] = epoll_wait(epollfd)
for i in active_stream[] {
read or write till unavailable
}
}
select/poll/epoll都是采用I/O多路复用机制的,其中select/poll是采用无差别轮询方式,而epoll是采用最小的轮询方式。
1.4 I/O复用的优势
I/O多路复用的优势并不是对于单个连接能处理的更快,而是在于可以在单个线程/进程中处理更多的连接。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
2. I/O复用函数介绍
下面简单的介绍一下I/O复用函数。
2.1 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的全部位
2.2 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函数都是通过轮询的方式来查询某个文件描述符状态是否发生了变化,并且需要将整个文件描述符集合在用户空间和内核空间之间来回拷贝,这样随着文件描述符的数量增加,相应的开销也随之增加。
2.3 epoll函数
epoll是在Linux内核2.6引进的,是select和poll函数的增强版。与select相比,epoll没有文件描述符数量的限制。epoll使用一个文件描述符管理多个文件描述符,将用户关心的文件描述符事件存放到内核的一个事件列表中,这样在用户空间和内核空间只需拷贝一次。
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时,不会再次响应应用程序并通知此事件。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
2.4 epoll相比于select/poll的优势?
从上面对select/poll/epoll函数的介绍,可以知道epoll与select/poll相比,具有如下优势:
- 监视的描述符数量不受限制,所支持的FD上限是最大可以打开文件的数目;
- I/O效率不会随着监视fd的数量增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的,只有就绪的fd才会执行回调函数。
3.总结
IO复用机制可以同时监控多个描述符,当某个描述符就绪(读或写就绪),则立即通知相应程序进行读或写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的