I/O多路复用详解
要想完全理解I/O多路复用,需先要了解I/O模型:
一、五种I/O模型
1、阻塞I/O模型
最流行的I/O模型是阻塞I/O模型,缺省情形下,所有套接口都是阻塞的。我们以数据报套接口为例来讲解此模型(我们使用UDP而不是TCP作为例子的原因在于就UDP而言,数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没有。然而对于TCP来说,诸如套接口低潮标记等额外变量开始活动,导致这个概念变得复杂)。
进程调用recvfrom,其系统调用直到数据报到达且被拷贝到应用进程的缓冲区中或者发生错误才返回,期间一直在等待。我们就说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。
2、非阻塞I/O模型
进程把一个套接口设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。也就是说当数据没有到达时并不等待,而是以一个错误返回。
3、I/O复用模型
调用select或poll,在这两个系统调用中的某一个上阻塞,而不是阻塞于真正I/O系统调用。 阻塞于select调用,等待数据报套接口可读。当select返回套接口可读条件时,调用recevfrom将数据报拷贝到应用缓冲区中。
4、信号驱动I/O模型
首先开启套接口信号驱动I/O功能,并通过系统调用sigaction安装一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据报准备好被读时,就为该进程生成一个SIGIO信号。随即可以在信号处理程序中调用recvfrom来读数据报,井通知主循环数据已准备好被处理中。也可以通知主循环,让它来读数据报。
5、异步I/O模型
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核拷贝到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:
信号驱动I/O:由内核通知我们何时可以启动一个I/O操作,
异步I/O模型:由内核通知我们I/O操作何时完成。
二、I/O复用的典型应用场合:
1、当客户处理多个描述字(通常是交互式输入和网络套接口)时,必须使用I/O复用。
2、如果一个服务器要处理多个服务或者多个协议(例如既要处理TCP,又要处理UDP),一般就要使用I/O复用。
三、支持I/O复用的系统调用
目前支持I/O复用的系统调用有select、pselect、poll、epoll:
1、select函数
select系统调用是用来让我们的程序监视多个文件句柄(file descrīptor)的状态变化的。程序会停在select这里等待,直到被监视的文件句柄有某一个或多个发生了状态改变。
文件在句柄在Linux里很多,如果你man某个函数,在函数返回值部分说到成功后有一个文件句柄被创建的都是的,如man socket可以看到“On success, afile descrīptor for the new socket is returned.”而man 2 open可以看到“open()and creat() return the new filedescrīptor”,其实文件句柄就是一个整数,看socket函数的声明就明白了:
int socket(int domain, int type, int protocol);
当然,我们最熟悉的句柄是0、1、2三个,0是标准输入,1是标准输出,2是标准错误输出。0、1、2是整数表示的,对应的FILE *结构的表示就是stdin、stdout、stderr,0就是stdin,1就是stdout,2就是stderr。
比如下面这两段代码都是从标准输入读入9个字节字符:
#include #include #include int main(int argc, char ** argv) { char buf[10] = ""; read(0, buf, 9); /* 从标准输入 0 读入字符 */ fprintf(stdout, "%s\n", buf); /* 向标准输出 stdout 写字符 */ return 0; } /* **上面和下面的代码都可以用来从标准输入读用户输入的9个字符** */ #include #include #include int main(int argc, char ** argv) { char buf[10] = ""; fread(buf, 9, 1, stdin); /* 从标准输入 stdin 读入字符 */ write(1, buf, strlen(buf)); return 0; } |
继续上面说的select,就是用来监视某个或某些句柄的状态变化的。select函数原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
函数的最后一个参数timeout显然是一个超时时间值,其类型是struct timeval *,即一个structtimeval结构的变量的指针,所以我们在程序里要申明一个struct timevaltv;然后把变量tv的地址&tv传递给select函数。struct timeval结构如下:
struct timeval { long tv_sec; /* seconds */ long tv_usec; /* microseconds */ }; |
第2、3、4三个参数是一样的类型: fd_set *,即我们在程序里要申明几个fd_set类型的变量,比如rdfds, wtfds,exfds,然后把这个变量的地址&rdfds, &wtfds, &exfds传递给select函数。这三个参数都是一个句柄的集合,第一个rdfds是用来保存这样的句柄的:当句柄的状态变成可读的时系统就会告诉select函数返回,同理第二个wtfds是指有句柄状态变成可写的时系统就会告诉select函数返回,同理第三个参数exfds是特殊情况,即句柄上有特殊情况发生时系统会告诉select函数返回。特殊情况比如对方通过一个socket句柄发来了紧急数据。如果我们程序里只想检测某个socket是否有数据可读,我们可以这样:
fd_set rdfds; /* 先申明一个 fd_set 集合来保存我们要检测的 socket句柄 */ struct timeval tv; /* 申明一个时间变量来保存时间 */ int ret; /* 保存返回值 */ FD_ZERO(&rdfds); /* 用select函数之前先把集合清零 */ FD_SET(socket, &rdfds); /* 把要检测的句柄socket加入到集合里 */ tv.tv_sec = 1; tv.tv_usec = 500; /* 设置select等待的最大时间为1秒加500毫秒 */ ret = select(socket + 1, &rdfds, NULL, NULL, &tv); /* 检测我们上面设置到集合rdfds里的句柄是否有可读信息 */ if(ret < 0) perror("select");/* 这说明select函数出错 */ else if(ret == 0) printf("超时\n"); /* 说明在我们设定的时间值1秒加500毫秒的时间内,socket的状态没有发生变化 */ else { /* 说明等待时间还未到1秒加500毫秒,socket的状态发生了变化 */ printf("ret=%d\n", ret); /*ret这个返回值记录了发生状态变化的句柄的数目,由于我们只监视了socket这一个句柄,所以这里一定ret=1,如果同时有多个句柄发生变化返回的就是句柄的总和了 */ /* 这里我们就应该从socket这个句柄里读取数据了,因为select函数已经告诉我们这个句柄里有数据可读 */ if(FD_ISSET(socket, &rdfds)) { /* 先判断一下socket这外被监视的句柄是否真的变成可读的了 */ /* 读取socket句柄里的数据 */ recv(...); } } |
注意select函数的第一个参数,是所有加入集合的句柄值的最大那个值还要加1。比如我们创建了3个句柄:
int sa, sb, sc; sa = socket(...); /* 分别创建3个句柄并连接到服务器上 */ connect(sa,...); sb = socket(...); connect(sb,...); sc = socket(...); connect(sc,...); FD_SET(sa, &rdfds);/* 分别把3个句柄加入读监视集合里去 */ FD_SET(sb, &rdfds); FD_SET(sc, &rdfds); |
在使用select函数之前,一定要找到3个句柄中的最大值是哪个,我们一般定义一个变量来保存最大值,取得最大socket值如下:
int maxfd = 0; if(sa > maxfd) maxfd = sa; if(sb > maxfd) maxfd = sb; if(sc > maxfd) maxfd = sc; |
然后调用select函数:
ret = select(maxfd + 1, &rdfds, NULL, NULL, &tv); /* 注意是最大值还要加1 */ |
同样的道理,如果我们要检测用户是否按了键盘进行输入,我们就应该把标准输入0这个句柄放到select里来检测,如下:
FD_ZERO(&rdfds);
FD_SET(0, &rdfds);
tv.tv_sec = 1;
tv.tv_usec = 0;
ret = select(1, &rdfds, NULL, NULL, &tv); /* 注意是最大值还要加1 */
if(ret < 0)
perror("select"); /* 出错 */
else if(ret == 0)
printf("超时\n"); /* 在我们设定的时间tv内,用户没有按键盘 */
else
{ /* 用户有按键盘,要读取用户的输入 */
scanf("%s", buf);
}
2、pselect函数
1、pselect使用timespec结构,而不使用timeval结构。timespec结构是POSIX的又一个发明。
struct timespec{
time_t tv_sec; //seconds
long tv_nsec; //nanoseconds
};
这两个结构的区别在于第二个成员:新结构的该成员tv_nsec指定纳秒数,而旧结构的该成员tv_usec指定微秒数。
2、pselect函数增加了第六个参数:一个指向信号掩码的指针。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止的信号处理函数设置的全局变量,然后调用pselect,告诉它重新设置信号掩码。
关于第二点,考虑下面的例子,这个程序的SIGINT信号处理函数仅仅设置全局变量intr_flag并返回。如果我们的进程阻塞于select调用,那么从信号处理函数的返回将导致select返回EINTR错误。然而调用select时,代码看起来大体如下:
|
问题是在测试intr_flag和调用select之间如果有信号发生,那么要是select永远阻塞,该信号就会丢失。有了pselect后,我们可以如下可靠地编写这个例子的代码:
|
在测试intr_flag变量之前,我们阻塞SIGINT。当pselect被调用时,它先以空集(zeromask)取代进程的信号掩码,再检查描述字,并可能进入睡眠。然而当pselect函数返回时,进程的信号掩码又被重置为调用pselect之前的值(即SIGINT被阻塞)。
3、poll函数
poll函数起源于SVR3,最初局限于流设备。SVR4取消了这种限制,允许poll工作在任何描述字上。poll提供的功能与select类似,不过在处理流设备时,它能够提供额外的信息。
|
第一个参数是指向一个结构数组第一个元素的指针。每个数组元素都是一个pollfd结构,用于指定测试某个给定描述字fd的条件。
struct pollfd{
int fd; //descriptor to check
short events; //events of interest on fd
short revents; //events that occurred on fd
};
要测试的条件由events成员指定,而返回的结果则在revents中存储。常用条件及含意说明如下:
常量 | 说明 |
POLLIN | 普通或优先级带数据可读 |
POLLRDNORM | 普通数据可读 |
POLLRDBAND | 优先级带数据可读 |
POLLPRI | 高优先级数据可读 |
POLLOUT | 普通数据可写 |
POLLWRNORM | 普通数据可写 |
POLLWRBAND | 优先级带数据可写 |
POLLERR | 发生错误 |
POLLHUP | 发生挂起 |
POLLNVAL | 描述字不是一个打开的文件 |
注意:后三个只能作为描述字的返回结果存储在revents中,而不能作为测试条件用于events中。
poll()接受一个指向结构''''''''structpollfd''''''''列表的指针,其中包括了你想测试的文件描述符和事件。事件由一个在结构中事件域的比特掩码确定。当前的结构在调用后将被填写并在事件发生后返回。在SVR4(可能更早的一些版本)中的 "poll.h"文件中包含了用于确定事件的一些宏定义。事件的等待时间精确到毫秒(但令人困惑的是等待时间的类型却是int),当等待时间为0时,poll()函数立即返回,-1则使poll()一直挂起直到一个指定事件发生。下面是pollfd的结构。
struct pollfd { int fd; /* 文件描述符 */ short events; /* 等待的事件 */ short revents; /* 实际发生了的事件 */ };
于 select()十分相似,当返回正值时,代表满足响应事件的文件描述符的个数,如果返回0则代表在规定事件内没有事件发生。如发现返回为负则应该立即查看 errno,因为这代表有错误发生。
如果没有事件发生,revents会被清空,所以你不必多此一举。
这里是一个例子
/* 检测两个文件描述符,分别为一般数据和高优先数据。如果事件发生 则用相关描述符和优先度调用函数handler(),无时间限制等待,直到 错误发生或描述符挂起。*/ #include #include #include #include #include #include #include #include #define NORMAL_DATA 1 #define HIPRI_DATA 2 int poll_two_normal(int fd1,int fd2) { struct pollfd poll_list[2]; int retval; poll_list[0].fd = fd1; poll_list[1].fd = fd2; poll_list[0].events = POLLIN|POLLPRI; poll_list[1].events = POLLIN|POLLPRI; while(1) { retval = poll(poll_list,(unsigned long)2,-1); /* retval 总是大于0或为-1,因为我们在阻塞中工作 */ if(retval < 0) { fprintf(stderr,"poll错误: %s\n",strerror(errno)); return -1; } if(((poll_list[0].revents&POLLHUP) == POLLHUP) || ((poll_list[0].revents&POLLERR) == POLLERR) || ((poll_list[0].revents&POLLNVAL) == POLLNVAL) || ((poll_list[1].revents&POLLHUP) == POLLHUP) || ((poll_list[1].revents&POLLERR) == POLLERR) || ((poll_list[1].revents&POLLNVAL) == POLLNVAL)) return 0; if((poll_list[0].revents&POLLIN) == POLLIN) handle(poll_list[0].fd,NORMAL_DATA); if((poll_list[0].revents&POLLPRI) == POLLPRI) handle(poll_list[0].fd,HIPRI_DATA); if((poll_list[1].revents&POLLIN) == POLLIN) handle(poll_list[1].fd,NORMAL_DATA); if((poll_list[1].revents&POLLPRI) == POLLPRI) handle(poll_list[1].fd,HIPRI_DATA); } }
4.epoll精髓
在linux的网络编程中,很长的时间都在使用select来做事件触发。在linux新的内核中,有了一种替换它的机制,就是epoll。
相比于select,epoll最大的好处在于它不会随着监听fd数目的增长而降低效率。因为在内核中的select实现中,它是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。并且,在linux/posix_types.h头文件有这样的声明:
#define __FD_SETSIZE 1024
表示select最多同时监听1024个fd,当然,可以通过修改头文件再重编译内核来扩大这个数目,但这似乎并不治本。
epoll的接口非常简单,一共就三个函数:
1. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:
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队列里
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
--------------------------------------------------------------------------------------------
从man手册中,得到ET和LT的具体描述如下
EPOLL事件有两种模型:
Edge Triggered (ET)
Level Triggered (LT)
假如有这样一个例子:
1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符
2. 这个时候从管道的另一端被写入了2KB的数据
3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作
4. 然后我们读取了1KB的数据
5. 调用epoll_wait(2)......
Edge Triggered 工作模式:
如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。在上面的例子中,会有一个事件产生在RFD句柄上,因为在第2步执行了一个写操作,然后,事件将会在第3步被销毁。因为第4步的读取操作没有读空文件输入缓冲区内的数据,因此我们在第5步调用epoll_wait(2)完成后,是否挂起是不确定的。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用ET模式的epoll接口,在后面会介绍避免可能的缺陷。
i 基于非阻塞文件句柄
ii 只有当read(2)或者write(2)返回EAGAIN时才需要挂起,等待。但这并不是说每次read()时都需要循环读,直到读到产生一个EAGAIN才认为此次事件处理完成,当read()返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。
Level Triggered 工作模式
相反的,以LT方式调用epoll接口的时候,它就相当于一个速度比较快的poll(2),并且无论后面的数据是否被使用,因此他们具有同样的职能。因为即使使用ET模式的epoll,在收到多个chunk的数据的时候仍然会产生多个事件。调用者可以设定EPOLLONESHOT标志,在epoll_wait(2)收到事件后epoll会与事件关联的文件句柄从epoll描述符中禁止掉。因此当EPOLLONESHOT设定后,使用带有EPOLL_CTL_MOD标志的epoll_ctl(2)处理文件句柄就成为调用者必须作的事情。
然后详细解释ET, LT:
LT(leveltriggered)是缺省的工作方式,并且同时支持block和no-blocksocket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.
ET(edge-triggered)是高速工作方式,只支持no-blocksocket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(onlyonce),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认(这句话不理解)。
在许多测试中我们会看到如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当我们遇到大量的idle-connection(例如WAN环境中存在大量的慢速连接),就会发现epoll的效率大大高于select/poll。(未测试)
另外,当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,
读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:
while(rs)
{
buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
if(buflen < 0)
{
// 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读
// 在这里就当作是该次事件已处理处.
if(errno == EAGAIN)
break;
else
return;
}
else if(buflen == 0)
{
// 这里表示对端的socket已正常关闭.
}
if(buflen == sizeof(buf)
rs = 1; // 需要再次读取
else
rs = 0;
}
还有,假如发送端流量大于接收端的流量(意思是epoll所在的程序读比转发的socket要快),由于是非阻塞的socket,那么send()函数虽然返回,但实际缓冲区的数据并未真正发给接收端,这样不断的读和发,当缓冲区满后会产生EAGAIN错误(参考mansend),同时,不理会这次请求发送的数据.所以,需要封装socket_send()的函数用来处理这种情况,该函数会尽量将数据写完再返回,返回-1表示出错。在socket_send()内部,当写缓冲已满(send()返回-1,且errno为EAGAIN),那么会等待后再重试.这种方式并不很完美,在理论上可能会长时间的阻塞在socket_send()内部,但暂没有更好的办法.
ssize_t socket_send(int sockfd, const char* buffer, size_t buflen)
{
ssize_t tmp;
size_t total = buflen;
const char *p = buffer;
while(1)
{
tmp = send(sockfd, p, total, 0);
if(tmp < 0)
{
// 当send收到信号时,可以继续写,但这里返回-1.
if(errno == EINTR)
return -1;
// 当socket是非阻塞时,如返回此错误,表示写缓冲队列已满,
// 在这里做延时后再重试.
if(errno == EAGAIN)
{
usleep(1000);
continue;
}
return -1;
}
if((size_t)tmp == total)
return buflen;
total -= tmp;
p += tmp;
}
return tmp;
}