标签:
本博文主要针对UNP一书中的第六章内容来聊聊I/O复用技术以及其在网络编程中的实现
I/O多路复用是指内核一旦发现进程指定的一个或者多个I/O条件准备就绪,它就通知该进程。I/O复用适用于以下场合:
(1) 当客户处理多个描述符(一般是交互式输入或网络套接字),必须适用I/O复用
(2) 当一个客户处理多个套接字时,这种情况很少见,但也可能出现
(3) 当一个TCP服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用
(4) 如果一个服务器既要适用TCP,又要适用UDP,一般就要使用I/O复用
(5) 如果一个服务器要处理多个服务或者多个协议,一般就要使用I/O复用
与多线程和多进程技术相比,I/O复用技术的最大优势就是系统开销小,系统不必创建进程/线程,也不必维护这些进程/进程,从而大大减小了系统的开销。
Unix下常见的I/O模型有五种,分别是:阻塞式I/O,非阻塞式I/O,I/O复用,信号驱动式I/O和异步I/O。
Unix下对于一个输入操作,通常包含两个不同的阶段:
(1) 等待数据准备好
(2) 从内核向进程复制数据
例如:对于一次read函数操作来说,数据先会被拷贝到操作系统内核的缓冲区去,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
再比如对于一次socket流传输来说,首先等待网络上的数据到达,然后复制到内核的某个缓冲区,然后再把内核缓冲区的数据复制到进程缓冲区。
下面就以上述两个阶段来阐述五种I/O模型。
假定一个特定的场景,你的一个好朋友找你借钱,你身上没有充足的现金,于是,你要去银行取钱,银行人多,你只能在那里排队,在这段时间内,你不能离开队伍去干你自己的事情。时间都浪费在排队上面了。这就是典型的阻塞式I/O模型。
默认情况下,所有的套接字都时阻塞的,以数据报套接字为例
如上图,我们把recvfrom函数视为系统调用,进程调用recvform函数后就阻塞于此,等待数据报的到达,一直到内核把数据报准备好后,就将数据从内核复制到用户进程,随后用户进程再对这些数据进行处理。
这种模型的好处就是,能够及时获得数据,没有延迟,但是就像上面趣解模型中讲到,对用户来说,这段时间一直要处于等到状态,不能去做其他的事情,在性能方面付出了代价。
还是去取钱的例子,假设你无法忍受一直在那里排队,而是去旁边的商场逛逛,然后隔一段时间回来看看还有在排队没,有的话再继续去逛逛,直到有一次你回来看到没有人排队了为止。这就是非阻塞式I/O模型。
进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。
如上图所示,前三次询问都返回一个错误,即内核没有数据报准备好,到第四次调用recvform函数时,数据被准备好了,它被复制到应用进程缓存区,于是recvform成功返回,应用进程随后处理数据。
这种模型相对于阻塞式来说,
优点在于:应用进程不必阻塞在recvfrom调用中,而是可以去处理其他事情
缺点在于:如趣解模型中所说,你来回跑银行带来了很大的延时,可能在你来回的路上叫到了你的号。在网络模型中即可以表现在任务完成的响应延迟增大了,隔一段时间轮询一次recvform,数据报可能在两次轮询之间的任意时间内准备好,这将会导致整体数据吞吐量的降低。
现在,银行都会按一个显示屏,上面会显示轮到几号客户了。这个时候,你就不用每次都去跑进去看还有排队没,而是远远的看看显示屏上轮到你没有,如果显示了你的名字,你就去取钱就行了。这就是I/O复用模型。
有了I/O复用技术,我们可以调用select或poll函数,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。
如上图所示,进程受阻于select调用,等待可能多个套接字中的任一个变为可读。当select返回套接字可读这一条件时,应用进程就调用recvfrom把所读的数据报复制到应用进程缓冲区。
进程阻塞在select,如果进程还有其他的任务的话就能体现到I/O复用技术的好处,那个任务先返回可读条件,就去执行哪个任务。从单一的等待变成多个任务的同时等待。
这种模型较之前的模型来说,可以不必多次轮询内核,而是等到内核的通知。
你还是不满意银行的服务,虽然不必排队,但你在商场逛的也不放心啊,你还是要盯着显示屏,深怕没有看到显示屏上面你的名字,于是,银行也退出了全新的服务,你去银行取钱的时候,银行目前人多不能及时处理你的业务,而是叫你留下手机号,等到空闲的时候就短信通知你可以去取钱了。这就是信号驱动式I/O模型。
我们可以用信号,让内核在描述符就绪时发送SIGIO信号告知我们。
如上图所示,进程建立SIGIO的信号处理程序(就要趣解模型中的留下手机号),并通过sigaction系统调用安装一个信号处理函数,该系统调用将立即返回,进程继续工作,知道数据报准备好后,内核产生一个SIGIO信号,告知应用进程以及准备好,于是就在信号处理程序中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环让他读取数据报。
这种模型的好处就是,在数据报没有准备好的期间,应用进程不必阻塞,继续执行主循环,只要等待来自信号处理函数的通知即可。
你细细的想了想自己取钱时为了什么,无非时借给你的朋友,银行都退出了网上银行服务,你只需要知道你的好朋友的银行卡号,然后在网银中申请转账,银行后台会给你处理,然后把钱打到你朋友的账户下面,等这些都处理好后,银行会给你发一条短信,告诉你转账成功,这个时候你就可以跟你的好朋友说,钱已经打给你了。这就是异步I/O模型,取钱借钱的繁琐事就交给银行后台给你处理吧。
POSIX规范中提供一些函数,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们。
如上图所示,我们调用aio_read函数(POSIX异步I/O函数以aio_或lio_开头),给内核传递描述符,缓冲区指针,缓冲区大小和文件偏移,并告诉内核完成整个操作后通知我们。
不同于信号驱动式I/O模型,信号是在数据已复制到进程缓冲区才产生的。
以一张图来说明五种I/O操作的差异:
同步I/O操作:导致请求进程阻塞,直到I/O操作完成
异步I/O操作:不导致进程阻塞
可知,前四种都属于同步I/O操作慢系统都会阻塞与recvfrom操作,而异步I/O不会。
select函数用于I/O复用,该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的事件才唤醒它。
它的函数原型时:
int select(int maxfdp1, fd_set *readset, fd_set *writeset , fd_set *exceptset , const struct timeval *timeout);
对于timeout参数:
(1) timeout==NULL,表示要永远等待下去,直到有一个描述符准备好I/O时才返回
(2) *timeout的值为0,表示不等待,检查描述符就立即返回,这称为轮询。
(2) *timeout的值不为0,表示等待一段固定的时间,再有一个描述符准备好I/O时返回,但是不能超过由该参数制定的时间。
对于readset,writeset和exceptset三个参数:
这三个描述符说明了可读,可写和处于异常条件的描述符集合
对于描述集fd_set结构,提供了如下四个操作函数
#include <sys/select.h>
int FD_ISSET(int fd,fd_set *fdset); //设定描述集中的某个描述符
void FD_CLR(int fd,fd_set *fdset);//关掉描述集中的某个描述符
void FD_SET(int fd,fd_set *fdset);//打开描述集中的某个描述符
void FD_ZERO(fd_set *fdset);//清除集合内所有元素
对于maxfdp1参数:
指定待测试的描述符个数,它的值时待测试的最大描述符编号加1,即从上面三个描述符集中的最大描述符编号加1。
对于返回值:
select返回值有三种情况:
(1) 返回值为-1时,表示出错,如果在指定的描述符一个都没有准备好时捕捉一个信号,则返回-1
(2) 返回0,表示没有描述符准备好,指定的时间就超过了。
(3) 返回正数,表示已经准备好的描述符个数,在这种情况下,三个描述符集中依旧打开的位对应于已准备好的描述符
#include "unp.h"
void
str_cli(FILE *fp, int sockfd)
{
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
FD_ZERO(&rset);
for ( ; ; ) {
FD_SET(fileno(fp), &rset);//标准输入描述符
FD_SET(sockfd, &rset);//socket描述符
maxfdp1 = max(fileno(fp), sockfd) + 1;//最大描述符编号+1
Select(maxfdp1, &rset, NULL, NULL, NULL);//调用select,阻塞于此
//如果返回的套接字可读,就用readline读入回射文本
if (FD_ISSET(sockfd, &rset)) { /* socket is readable */
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
//如果标准输入可读,就先用fgets读入一行文本
if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if (Fgets(sendline, MAXLINE, fp) == NULL)
return; /* all done */
Writen(sockfd, sendline, strlen(sendline));
}
}
}
在上一节提到的str_cli版本中,仍然存在一个问题。假设客户在标准输入中批量输入数据,在输入完最后一个数据后,碰到了EOF,str_cli返回到main函数,main函数随后终止。但是,在这个过程中,标准输入的EOF终止符并不意味着我们也同时完成了从套接字的读入,可能仍有请求在去往服务器的路上,或者仍有应答在返回客户的路上。
原因就处在于此:
if (Fgets(sendline, MAXLINE, fp) == NULL)
return; /* all done */
当碰到EOF终止符的时候,str_cli函数选择了立即返回,而此时,我们更需要的是找到一个条件来判断套接字的读取是否完成。
shutdown函数提供了关闭TCP连接其中一半的方法,也正是为了解决上一小节发现的问题。
假设在标准输入碰到EOF终止符时,我们只关闭发送这一端,也就是给服务器发送一个FIN,告诉它我们已经完成了数据发送,但是仍然保持套接字描述打开以便读取。
这点跟close函数有点像,但是考虑到close函数有如下两个限制:
(1) close把描述符的引用计数减1,仅在该计数变为0时才关闭该套接字。但是使用shutdown可以不管引用计数就激发TCP的正常连接终止序列
(2) close终止读和写两个方向的数据传送。shutdown只是关闭单方向的读或写。
其函数原型如下:
int shutdown(int sockfd , int howto);//若成功则返回0,若出错返回-1
关于该函数的第二个参数howto:
(1) SHUT_RD 关闭连接的读这一半,套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃
(2) SHUT_WR 关闭连接的写这一半,对于TCP套接字来说,这称为半关闭,当前留在套接字发送缓冲区的数据将被发送,后跟TCP正常的连接终止序列。
(3) SHUT_RDWR 关闭读半部和写半部,这与调用shutdown两次等效。
#include "unp.h"
void
str_cli(FILE *fp, int sockfd)
{
int maxfdp1, stdineof;
fd_set rset;
char buf[MAXLINE];
int n;
stdineof = 0;
FD_ZERO(&rset);
for ( ; ; ) {
if (stdineof == 0)//套接字读取完成标识符
FD_SET(fileno(fp), &rset);//关闭select描述符集中的标准输入描述符
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);
if (FD_ISSET(sockfd, &rset)) { /* socket is readable */
if ( (n = Read(sockfd, buf, MAXLINE)) == 0) {
if (stdineof == 1)
return; /* normal termination */
else
err_quit("str_cli: server terminated prematurely");
}
Write(fileno(stdout), buf, n);
}
if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if ( (n = Read(fileno(fp), buf, MAXLINE)) == 0) {//若读取的字节数为0
stdineof = 1;//表明套接字读取数据完成
Shutdown(sockfd, SHUT_WR); /* send FIN *///关闭读这一半
FD_CLR(fileno(fp), &rset);
continue;
}
Writen(sockfd, buf, n);
}
}
}
在【unix网络编程第三版】阅读笔记(四):TCP客户/服务器实例中我们采用fork生成子进程来处理每个客户的需求。
如今,有了select函数,就不必创建那么多子进程了,避免了为每一个客户创建一个子进程的所有开销,本节就将其改写成任意个客户的单进程版本。
select函数的描述符集中需要存储每个客户的连接套接字。于是我们很容易想到用采用一个数组client[FD_SETSIZE]来保存所有已连接的套接字。
每次有新客户连接的时候,就在client数组中找到第一个可用项来保存该连接套接字。
具体解释见代码注释:
#include "unp.h"
int
main(int argc, char **argv)
{
int i, maxi, maxfd, listenfd, connfd, sockfd;
int nready, client[FD_SETSIZE];
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
maxfd = listenfd; //初始化maxfd,在传入select函数时需要+1
maxi = -1; //记录client数组中最后一个非-1数所占的序号
for (i = 0; i < FD_SETSIZE; i++)
client[i] = -1; //初始化client数组,为-1表示该项可用
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
for ( ; ; ) {
rset = allset; //初始化描述符集
nready = Select(maxfd+1, &rset, NULL, NULL, NULL);//注意此处为最大描述符编号+1,返回已准备好的描述符个数
if (FD_ISSET(listenfd, &rset)) { //检测到有新客户连接
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);//连接新客户,获得已连接套接字
#ifdef NOTDEF
printf("new client: %s, port %d\n",
Inet_ntop(AF_INET, &cliaddr.sin_addr, 4, NULL),
ntohs(cliaddr.sin_port));
#endif
for (i = 0; i < FD_SETSIZE; i++)
if (client[i] < 0) {//找到第一个可用项
client[i] = connfd; //存储套接字描述符
break;
}
if (i == FD_SETSIZE)//限制最大连接个数
err_quit("too many clients");
FD_SET(connfd, &allset); /* add new descriptor to set */
if (connfd > maxfd)
maxfd = connfd; //重置maxfd为最大描述符编号+1
if (i > maxi)
maxi = i; //client数组中最后一个描述符所占的序号
if (--nready <= 0)
continue; //没有已连接套接字了
}
for (i = 0; i <= maxi; i++) { /* check all clients for data */
if ( (sockfd = client[i]) < 0)
continue;
if (FD_ISSET(sockfd, &rset)) {
if ( (n = Read(sockfd, buf, MAXLINE)) == 0) {
/*connection closed by client */
Close(sockfd);//直接关闭套接字
FD_CLR(sockfd, &allset);
client[i] = -1;
} else
Writen(sockfd, buf, n);
if (--nready <= 0)
break; //没有已连接套接字了
}
}
}
}
pselect函数由POSIX发明,是select的变种。
#include <sys/select.h>
int pselect(int maxfdp1,fd_set *restrict readfds,fd_set *restrict writefds,fd_set *restrict exceptfds,const struct timespec *restrict tsptr,const sigset_t *restrict sigmask);
相对于select函数,pselect函数有如下几点不同:
(1) pselect使用timespec结构,新结构的tv_nsec指定纳秒数,而原结构里的tv_usec指定微妙级
(2) pselect增加了第六个参数:一个指向信号掩码的指针。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止的信号处理函数设置的全局变量,然后调用pselect,告诉它重新设置信号掩码。
(3)pselect的超时值设为了const,保证了调用pselect不会修改此值。
poll函数的功能与select相似,不过在处理流设备时,它能够提供额外的信息。
#include <poll.h>
int poll(struct pollfd *fdarray,nfds_t nfds,int timeout);//若有就绪描述符就返回其数目,如超时则返回0,若出错就返回-1
对于第一个参数:为指向一个结构数组第一个元素的指针,每个数组元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件。
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
要测试的条件由events成员指定,函数在相应的revents成员中返回该描述符的状态。
这里每个描述符都有两个变量,一个为调用值,一个为返回结果,避免了使用值结果参数。
该结构中events和revents成员所用的常值如下表:
该表中,前四个处理输入,中间三个处理输出,最后三个处理异常。
就TCP/UDP而言,如下几种情况引起poll返回特定的revent
(1) 所有正规TCP数据和所有UDP数据都被认为时普通数据
(2) TCP的带外数据被认为时优先级带数据
(3) 当TCP连接的读半部关闭时,也被认为时普通数据,随后的读操作将返回0
(4) TCP连接存在错误既可认为是普通数据,也可认为时错误,无论哪种情况,随后的读操作都会返回-1,并把errno设为合适的值
(5) 在监听套接字上有新的连接可用既可认为时普通数据,也可认为时优先级数据。
(6) 非阻塞式connect的完成被认为是使相应套接字可写
对于第二个参数nfds:表示结构数组中元素的个数
对于第三个参数timeout:指定poll函数返回前等待多长时间。
|:timeout值:|:说明:|
|:–:|:–:|
|INFINT|永远等待|
|0|立即返回,不阻塞进程|
|大于0|等待指定数目的毫秒数|
在select函数中,FD_SETSIZE以及每个描述符集中最大描述符数目这些都涉及到固定值。但是在poll函数中分配一个pollfd数组并把该数组中元素的数据通知内核成了调用者的责任,内核不再需要知道这些固定大小的数据类型。
#include "unp.h"
#include <limits.h> /* for OPEN_MAX */
int
main(int argc, char **argv)
{
int i, maxi, listenfd, connfd, sockfd;
int nready;
ssize_t n;
char buf[MAXLINE];
socklen_t clilen;
struct pollfd client[OPEN_MAX];
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
client[0].fd = listenfd;
client[0].events = POLLRDNORM;
for (i = 1; i < OPEN_MAX; i++)//由调用者指定OPEN_MAX
client[i].fd = -1; //初始化为-1,表示可用
maxi = 0; //client数组中已用项的最大序号
for ( ; ; ) {
nready = Poll(client, maxi+1, INFTIM);
if (client[0].revents & POLLRDNORM) {//新客户连接
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);//返回已连接客户套接字
#ifdef NOTDEF
printf("new client: %s\n", Sock_ntop((SA *) &cliaddr, clilen));
#endif
for (i = 1; i < OPEN_MAX; i++)//与select不同,这里的最大值均由调用者指定
if (client[i].fd < 0) {//找到第一个可用项
client[i].fd = connfd; //保存已连接套接字描述符
break;
}
if (i == OPEN_MAX)
err_quit("too many clients");
client[i].events = POLLRDNORM;
if (i > maxi)
maxi = i; //更新已用项的最大序号值
if (--nready <= 0)
continue; //没有已连接套接字了
}
for (i = 1; i <= maxi; i++) { //检查client数组中所有项
if ( (sockfd = client[i].fd) < 0)
continue;
//有些实现在一个连接上接收到RST时返回的时POLLERR事件,而其他实现返回的只是POLLRDNORM事件
if (client[i].revents & (POLLRDNORM | POLLERR)) {//查看返回的revents状态
if ( (n = read(sockfd, buf, MAXLINE)) < 0) {
if (errno == ECONNRESET) {
//由用户来关闭该套接字
#ifdef NOTDEF
printf("client[%d] aborted connection\n", i);
#endif
Close(sockfd);
client[i].fd = -1;
} else
err_sys("read error");
} else if (n == 0) {
//由用户来关闭该套接字
#ifdef NOTDEF
printf("client[%d] closed connection\n", i);
#endif
Close(sockfd);
client[i].fd = -1;
} else
Writen(sockfd, buf, n);
if (--nready <= 0)
break; //没有已连接套接字了
}
}
}
}
【unix网络编程第三版】阅读笔记(五):I/O复用:select和poll函数
标签:
原文地址:http://blog.csdn.net/terence1212/article/details/51906808