本博文主要针对UNP一书中的第六章内容来聊聊I/O复用技术以及其在网络编程中的实现
1. 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复用技术的最大优势就是系统开销小,系统不必创建进程/线程,也不必维护这些进程/进程,从而大大减小了系统的开销。
2. I/O模型Unix下常见的I/O模型有五种,分别是:阻塞式I/O,非阻塞式I/O,I/O复用,信号驱动式I/O和异步I/O。
Unix下对于一个输入操作,通常包含两个不同的阶段:
(1) 等待数据准备好
(2) 从内核向进程复制数据
例如:对于一次read函数操作来说,数据先会被拷贝到操作系统内核的缓冲区去,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
再比如对于一次socket流传输来说,首先等待网络上的数据到达,然后复制到内核的某个缓冲区,然后再把内核缓冲区的数据复制到进程缓冲区。
下面就以上述两个阶段来阐述五种I/O模型。
2.1 阻塞式I/O模型 2.1.1 趣解模型假定一个特定的场景,你的一个好朋友找你借钱,你身上没有充足的现金,于是,你要去银行取钱,银行人多,你只能在那里排队,在这段时间内,你不能离开队伍去干你自己的事情。时间都浪费在排队上面了。这就是典型的阻塞式I/O模型。
2.1.2 网络模型默认情况下,所有的套接字都时阻塞的,以数据报套接字为例
如上图,我们把recvfrom函数视为系统调用,进程调用recvform函数后就阻塞于此,等待数据报的到达,一直到内核把数据报准备好后,就将数据从内核复制到用户进程,随后用户进程再对这些数据进行处理。
这种模型的好处就是,能够及时获得数据,没有延迟,但是就像上面趣解模型中讲到,对用户来说,这段时间一直要处于等到状态,不能去做其他的事情,在性能方面付出了代价。
2.2 非阻塞式I/O模型 2.2.1 趣解模型还是去取钱的例子,假设你无法忍受一直在那里排队,而是去旁边的商场逛逛,然后隔一段时间回来看看还有在排队没,有的话再继续去逛逛,直到有一次你回来看到没有人排队了为止。这就是非阻塞式I/O模型。
2.2.2 网络模型进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。
如上图所示,前三次询问都返回一个错误,即内核没有数据报准备好,到第四次调用recvform函数时,数据被准备好了,它被复制到应用进程缓存区,于是recvform成功返回,应用进程随后处理数据。
这种模型相对于阻塞式来说,
优点在于:应用进程不必阻塞在recvfrom调用中,而是可以去处理其他事情
缺点在于:如趣解模型中所说,你来回跑银行带来了很大的延时,可能在你来回的路上叫到了你的号。在网络模型中即可以表现在任务完成的响应延迟增大了,隔一段时间轮询一次recvform,数据报可能在两次轮询之间的任意时间内准备好,这将会导致整体数据吞吐量的降低。
2.3 I/O复用模型 2.3.1 趣解模型现在,银行都会按一个显示屏,上面会显示轮到几号客户了。这个时候,你就不用每次都去跑进去看还有排队没,而是远远的看看显示屏上轮到你没有,如果显示了你的名字,你就去取钱就行了。这就是I/O复用模型。
2.3.2 网络模型有了I/O复用技术,我们可以调用select或poll函数,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。
如上图所示,进程受阻于select调用,等待可能多个套接字中的任一个变为可读。当select返回套接字可读这一条件时,应用进程就调用recvfrom把所读的数据报复制到应用进程缓冲区。
进程阻塞在select,如果进程还有其他的任务的话就能体现到I/O复用技术的好处,那个任务先返回可读条件,就去执行哪个任务。从单一的等待变成多个任务的同时等待。
这种模型较之前的模型来说,可以不必多次轮询内核,而是等到内核的通知。
2.4 信号驱动式I/O模型 2.4.1 趣解模型你还是不满意银行的服务,虽然不必排队,但你在商场逛的也不放心啊,你还是要盯着显示屏,深怕没有看到显示屏上面你的名字,于是,银行也退出了全新的服务,你去银行取钱的时候,银行目前人多不能及时处理你的业务,而是叫你留下手机号,等到空闲的时候就短信通知你可以去取钱了。这就是信号驱动式I/O模型。
2.4.2 网络模型我们可以用信号,让内核在描述符就绪时发送SIGIO信号告知我们。
如上图所示,进程建立SIGIO的信号处理程序(就要趣解模型中的留下手机号),并通过sigaction系统调用安装一个信号处理函数,该系统调用将立即返回,进程继续工作,知道数据报准备好后,内核产生一个SIGIO信号,告知应用进程以及准备好,于是就在信号处理程序中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环让他读取数据报。
这种模型的好处就是,在数据报没有准备好的期间,应用进程不必阻塞,继续执行主循环,只要等待来自信号处理函数的通知即可。
2.5 异步I/O模型 2.5.1 趣解模型你细细的想了想自己取钱时为了什么,无非时借给你的朋友,银行都退出了网上银行服务,你只需要知道你的好朋友的银行卡号,然后在网银中申请转账,银行后台会给你处理,然后把钱打到你朋友的账户下面,等这些都处理好后,银行会给你发一条短信,告诉你转账成功,这个时候你就可以跟你的好朋友说,钱已经打给你了。这就是异步I/O模型,取钱借钱的繁琐事就交给银行后台给你处理吧。
2.5.2 网络模型POSIX规范中提供一些函数,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们。
如上图所示,我们调用aio_read函数(POSIX异步I/O函数以aio_或lio_开头),给内核传递描述符,缓冲区指针,缓冲区大小和文件偏移,并告诉内核完成整个操作后通知我们。
不同于信号驱动式I/O模型,信号是在数据已复制到进程缓冲区才产生的。
2.6 各种I/O模型的比较以一张图来说明五种I/O操作的差异:
同步I/O操作:导致请求进程阻塞,直到I/O操作完成
异步I/O操作:不导致进程阻塞
可知,前四种都属于同步I/O操作慢系统都会阻塞与recvfrom操作,而异步I/O不会。
3. select函数select函数用于I/O复用,该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的事件才唤醒它。
3.1 函数原型它的函数原型时:
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) 返回正数,表示已经准备好的描述符个数,在这种情况下,三个描述符集中依旧打开的位对应于已准备好的描述符
3.2 使用select函数修改的str_cli函数 #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)); } } } 3.3 批量输入在上一节提到的str_cli版本中,仍然存在一个问题。假设客户在标准输入中批量输入数据,在输入完最后一个数据后,碰到了EOF,str_cli返回到main函数,main函数随后终止。但是,在这个过程中,标准输入的EOF终止符并不意味着我们也同时完成了从套接字的读入,可能仍有请求在去往服务器的路上,或者仍有应答在返回客户的路上。
原因就处在于此:
if (Fgets(sendline, MAXLINE, fp) == NULL) return; /* all done */当碰到EOF终止符的时候,str_cli函数选择了立即返回,而此时,我们更需要的是找到一个条件来判断套接字的读取是否完成。
3.4 shutdown函数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两次等效。
3.5 str_cli函数再修改版 #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); } } } 4. TCP回射服务器程序(采用select函数)在【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; //没有已连接套接字了 } } } } 5. pselect函数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不会修改此值。
6. poll函数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数组并把该数组中元素的数据通知内核成了调用者的责任,内核不再需要知道这些固定大小的数据类型。
7. TCP回射服务器再修改版 #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; //没有已连接套接字了 } } } }