我们看到上面的TCP客户同时处理两个输入:标准输入和TCP套接字。我们遇到的问题就是在客户阻塞于(标准输入上的)fgets调用期间,服务器进程会被杀死。服务器TCP虽然正确地给客户TCP发送一个FIN,但是既然客户进程阻塞于从标准输入读入的过程,它将看不到这个ROF,知道从套接字读时为止(可能已经过了很长时间)。这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力成为I/O复用,是由select和poll这两个函数支持的。
I/O复用典型使用在下列网络应用场合:
1)当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用
2)一个客户同时处理多个套接字是可能的,不过比较少见。在16.5节结合一个web客户的上下文给出这种场合使用select的例子
3)如果一个TCP服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用
4)如果一个服务器既要处理TCP,又要处理UDP,一般就要使用I/O复用。8.15节有这么一个例子
5)如果一个服务器要处理多个服务或者镀铬协议(在13.5节讲述的inetd守护进程),就要用I/O复用
I/O复用并非只限于网络编程,许多重要的应用程序也需要使用这项技术
在UNIX下可用的5种I/O模型:
阻塞式I/O;
非阻塞式I/O;
I/O复用(select和poll);
信号驱动式I/O;
异步I/O
在上述所说的那样,一个输入操作通常包括两个不同的阶段:
1)等待数据准备好;
2)从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核总的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
阻塞时I/O模型:
最流行的I/O模型,本书到目前为止的所有例子都使用该模型。默认情形下,所有套接字都是阻塞的。
使用UDP而不是TCP为例子的原因在于就UDP而言,数据准备好读取的概念比较简单:要么整个数据报已经收到,要么还没有。对于TCP而言,诸如套接字低水位标记等额外变量开始起作用,道指这个概念复杂。
我们把recvfrom函数视为系统调用,因为我们正在区分应用进程和内核。不管如何实现,一般都会从在应用进程空间中国运行切换到在内核空间中运行,一端时间之后再切换回来。 在上图中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发送错误才返回。最常见的错误是系统调用被信号中断,我们说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。
非阻塞式I/O模型:
进程把一个套接字设置成非阻塞是在通知内核:当所有请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。将在16章中详细介绍非阻塞是I/O
前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。接着处理数据。
当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们成为轮询,应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间,不过这种模型偶尔也会遇到。
I/O复用模型:
有了I/O复用,我们就可以调用select或者poll,阻塞在这两个系统调用中的某一个,而不是阻塞在真正的I/O系统调用上。下图展示了I/O复用模型
我们阻塞与select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所可读数据报复制到应用进程缓冲区。比较上面两图,I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,其优势在于可以等待多个描述符就绪
信号驱动式U/O模型:
可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。称为信号驱动式I/O
我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理。也可以立即通知循环,让它读取数据报。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
异步I/O模型:
告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动I/O是由内核通知我们如何启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。
我们调用aio_read函数,给内核传递描述符、缓冲区指针。缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等到I/O完成期间,我们的进程不被阻塞。
前四种模型的主要区别在于第一阶段,因为它们的第二阶段是一样的:在数据从内核复制到调用者的缓冲区期间,进程阻塞与recvfrom调用。相反,异步I/O模型在这两个阶段都要处理。
select函数允许进程指示内核等待多个事件中的任一个发生,并仅在一个或者多个事件发生或经过某个指定的时间后才唤醒进程。也就是说,我们调用select告知内核对哪些描述符(就读、写或异常条件)感兴趣以及等待多长时间。我们所关心的描述字不受限于套接口,任何描述字都可用select来监听。
#include <sys/select.h> #include <sys/time.h> int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval * timeout); /****** 返回,准备好描述字的正数目,0----超时,-1------出错*******/
struct timeval{ long tv_sec; /* seconds */ long tv_usec; /* microsecond * / };
1、永远等待下去 仅在有一个描述字准备好I/O时才返回,需要将此指针设置为空指针
2、等到固定时间 在有一个秒数字准备好I/O时才返回,需要将此指针指向的结构timeval中指定的秒数和微秒数
3、根本不等待 同上,只不过秒数和微秒数都为0
timeout参数的const限定词表示它在函数返回时不会被select修改。举例,如果我们指定一个10s的超时值,不过在定时器到时之前select就返回(结果可能是有一个或多个描述符就绪,也可能是得到EINTR错误),那么timeout参数指向的timeval结构不会被更新成该函数返回时剩余的秒数。
如何给这三个参数中的每一个参数指定一个或多个描述符值是一个设计上的问题。select使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符。举例:假设使用32位整数,那么该数组的第一个元素对应于描述符0~31,第二个元素对应于描述符32~63,这些都是技术细节,只要知道如何使用函数即可。中间的三个参数readset、writeset和exceptset指定我们要让内核测试读写和异常条件所需的描述字。
void FD_ZERO(fd_set * fdset); // 将集合清空 void FD_SET(int fd,fd_set * fdset); // 添加描述字fd到集合中 void FD_CLR(int fd,fd_set * fdset);//在集合fdset清除描述字fd int FD_ISSET(int fd,fd_set * fdset); //判断描述字fd 是否在集合fdset中
描述符集的初始化非常重要,因为作为自动变量分片的一个描述符集如果没有初始化,那么可能发生不可预期的后果。
如果我们对某个条件不感兴趣,可以让其指针为空。假设三个函数都为空,我们就有了一个比sleep()函数更为精确的睡眠函数。
参数maxfdp1指定被测试的描述字个数,它的值为最大的描述字大1.
函数select修改由指针readset、writeset、exceptset所指定的描述字集。这三个参数均为值-结果参数。当返回时,结果指示哪些描述字准备好,返回时,我们用宏FD_ISSET来测试结构fd_set中的描述字。描述字集中任何与没有准备好的描述字相对应的位返回时清0.
如果在任何描述字准备好之前定时器时间到,则返回0。返回-1表示出错。
描述符就绪条件:
下列四个条件中的任何一个满足时,套接字口准备好读:
1、套接字接收缓冲区中的数据字节数大于等于套接口结束缓冲区低潮限度的当前值。对这样的套接口的读操作将不阻塞并返回一个大于0 的值(即准备好读入的数据量)。我们可以用套接口选项SO_RCVLOWAT来设置此低潮限度,对于TCP和UDP套接口,其值缺省为1.
2、连接的读这一半关闭(也就是说接收了FIN的TCP连接)。对这样的套接口的读操作将不阻塞且返回0(即文件结束符)。
3、套接口是一个监听套接口且已完成的连接数为非0.
4、有一个套接口错误待处理。
下面的三种情况满足其中一种即可认为可以写:
1、套接口发送缓冲区的可用空间字节数大于等于套接口发送缓冲区低潮限度的当前值,且或者套接口已连接,或者套接口不要求连接(例如UDP套接口)。这意味着,如果我们将这样的套接口设置为非阻塞,写操作将不阻塞且返回一个正值。我们呢可以y9ong套接口选项SO_SNDLOWAT来设置此低潮限度,对于TCP和UDP套接口,其缺省值一般为2048.
2、连接的写这一半关闭。
3、有一个套接口错误待处理,
下面通过一段代码讲述select函数的使用方式
//str_cli 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); maxfdp1 = max(fileno(fp), sockfd) + 1; Select(maxfdp1, &rset, NULL, NULL, NULL); if (FD_ISSET(sockfd, &rset)) { /* socket is readable */ if (Readline(sockfd, recvline, MAXLINE) == 0) err_quit("str_cli: server terminated prematurely"); Fputs(recvline, stdout); } if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */ if (Fgets(sendline, MAXLINE, fp) == NULL) return; /* all done */ Writen(sockfd, sendline, strlen(sendline)); } } }
1、如果对方TCP发送数据,套接口就变为可读且read返回大于0的值(即数据的字节数)
2、如果对方TCP发送一个FIN(对方进程终止),套接口就变成为刻度切read返回0(文件结束)
3、如果对方TCP发送一个RST(对方主机崩溃并重新启动),套接口就变为了可读且read返回-1
由8~13行可知,我们仅需一个描述字集以检查可读性,此集合由FD_ZERO初始化,并用FD_SET打开两位,一个对应于标准I/O文件指针fp,一位对应于套接口sockfd.函数fileno把标准I/O文件指针转换为其对应的描述字。由于我们希望本调用阻塞,一直到某事件准备好,所以最后一个参数(时间限制)也是空指针。
14~18行:如果在selecrt返回时套接口是可读的,则由readline来读,由fputs输出。
19~23行:如果标准输入可读,则由fgets读入一行,并用writen将其写到套接口。
这个函数是由select调用来驱动。而下面的函数是由fgets函数驱动的。
void str_cli(FILE *fp, int sockfd) { char sendline[MAXLINE], recvline[MAXLINE]; while (Fgets(sendline, MAXLINE, fp) != NULL) { Writen(sockfd, sendline, strlen(sendline)); if (Readline(sockfd, recvline, MAXLINE) == 0) err_quit("str_cli: server terminated prematurely"); Fputs(recvline, stdout); } }
当然,这里只是讲述了select函数的简单实用,没有考虑详细的使用方法,比如,如果在输入文件的时候为批量输入,也就是在输入端保持着一直输入的状况,在最后一个请求发送的时候,还会有接受没有完成应答。输入文件已经结束,但是输入的文件结束符并不意味着我们已经完成了从套接口的读入,可能仍有请求在去往服务器的路上,或是在去往客户的路上仍有应答。
我们需要一种方法关闭TCP连接的一半,也就是说,我们想给服务器发一个FIN,告诉我们已完成了数据发送,但仍为读而开发套接口描述字。其实这个任务可以由shutdown函数完成。关于这个函数的使用在这里不再讲述。关闭网络连接的方法为close,但是有的限制可以由shutdown来避免。
#include <poll.h> int poll(struct pollfd *fdarray,unsigned long nfds,int timeout); /***** 返回,准备好描述字的个数,0---超时,-1---出错******/第一个参数是指向一个结构数组第一个元素的指针,每个数组元素都是一个pollfd结构,它规定了为测试一给定的描述字fd的一些条件。
struct pollfd{ int fd; short events; short revents; };要测试的条件由成员events规定,函数在相应的revents成员中返回的描述字的状态。(每个描述字有两个变量,一个为调用值,另一个为结果,一词避免使用值-结果参数)。这两个成员中的每一个都由指定某个条件的一位或者多为组成。下图列出了用于指定标志events并测试标志revents的一些常值
下面给出了一段使用poll监听套接口的代码
/* include fig02 */ for ( ; ; ) { nready = Poll(client, maxi+1, INFTIM); if (client[0].revents & POLLRDNORM) { /* new client connection */ 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++) if (client[i].fd < 0) { client[i].fd = connfd; /* save descriptor */ break; } if (i == OPEN_MAX) err_quit("too many clients"); client[i].events = POLLRDNORM; if (i > maxi) maxi = i; /* max index in client[] array */ if (--nready <= 0) continue; /* no more readable descriptors */ } for (i = 1; i <= maxi; i++) { /* check all clients for data */ if ( (sockfd = client[i].fd) < 0) continue; if (client[i].revents & (POLLRDNORM | POLLERR)) { if ( (n = read(sockfd, buf, MAXLINE)) < 0) { if (errno == ECONNRESET) { /*4connection reset by client */ #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) { /*4connection closed by client */ #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; /* no more readable descriptors */ } } } }其实具体含义和上边的select相似,自己体味即可。
ps:
更过关于同步/异步的文章