一、I/O复用技术

I/O多路复用是指内核一旦发现进程指定的一个或者多个I/O条件准备就绪,它就通知该进程。I/O复用适用于以下场合:

  • 当客户处理多个描述符(一般是交互式输入或网络套接字),必须适用I/O复用
  • 当一个客户处理多个套接字时,这种情况很少见,但也可能出现
  • 当一个TCP服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用
  • 如果一个服务器既要适用TCP,又要适用UDP,一般就要使用I/O复用
  • 如果一个服务器要处理多个服务或者多个协议,一般就要使用I/O复用

与多线程和多进程技术相比,I/O复用技术的最大优势就是系统开销小,系统不必创建进程/线程,也不必维护这些进程/进程,从而大大减小了系统的开销。

二、I/O模型

Unix下常见的I/O模型有五种,分别是:阻塞式I/O,非阻塞式I/O,I/O复用,信号驱动式I/O和异步I/O。

Unix下对于一个输入操作,通常包含两个不同的阶段:

  • 等待数据准备好
  • 从内核向进程复制数据

例如:对于一次read函数操作来说,数据先会被拷贝到操作系统内核的缓冲区去,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

再比如对于一次socket流传输来说,首先等待网络上的数据到达,然后复制到内核的某个缓冲区,然后再把内核缓冲区的数据复制到进程缓冲区。

下面就以上述两个阶段来阐述五种I/O模型。

阻塞式I/O模型

趣解模型

假定一个特定的场景,你的一个好朋友找你借钱,你身上没有充足的现金,于是,你要去银行取钱,银行人多,你只能在那里排队,在这段时间内,你不能离开队伍去干你自己的事情。时间都浪费在排队上面了。这就是典型的阻塞式I/O模型。

网络模型

默认情况下,所有的套接字都时阻塞的,以数据报套接字为例

这里写图片描述
这里写图片描述

如上图,我们把recvfrom函数视为系统调用,进程调用recvform函数后就阻塞于此,等待数据报的到达,一直到内核把数据报准备好后,就将数据从内核复制到用户进程,随后用户进程再对这些数据进行处理。

这种模型的好处就是,能够及时获得数据,没有延迟,但是就像上面趣解模型中讲到,对用户来说,这段时间一直要处于等到状态,不能去做其他的事情,在性能方面付出了代价。

非阻塞式I/O模型

趣解模型

还是去取钱的例子,假设你无法忍受一直在那里排队,而是去旁边的商场逛逛,然后隔一段时间回来看看还有在排队没,有的话再继续去逛逛,直到有一次你回来看到没有人排队了为止。这就是非阻塞式I/O模型。

网络模型

进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。

这里写图片描述
这里写图片描述

如上图所示,前三次询问都返回一个错误,即内核没有数据报准备好,到第四次调用recvform函数时,数据被准备好了,它被复制到应用进程缓存区,于是recvform成功返回,应用进程随后处理数据。

这种模型相对于阻塞式来说,

优点在于:应用进程不必阻塞在recvfrom调用中,而是可以去处理其他事情

缺点在于:如趣解模型中所说,你来回跑银行带来了很大的延时,可能在你来回的路上叫到了你的号。在网络模型中即可以表现在任务完成的响应延迟增大了,隔一段时间轮询一次recvform,数据报可能在两次轮询之间的任意时间内准备好,这将会导致整体数据吞吐量的降低。

I/O复用模型

趣解模型

现在,银行都会按一个显示屏,上面会显示轮到几号客户了。这个时候,你就不用每次都去跑进去看还有排队没,而是远远的看看显示屏上轮到你没有,如果显示了你的名字,你就去取钱就行了。这就是I/O复用模型。

网络模型

有了I/O复用技术,我们可以调用select或poll函数,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。

这里写图片描述
这里写图片描述

如上图所示,进程受阻于select调用,等待可能多个套接字中的任一个变为可读。当select返回套接字可读这一条件时,应用进程就调用recvfrom把所读的数据报复制到应用进程缓冲区。

进程阻塞在select,如果进程还有其他的任务的话就能体现到I/O复用技术的好处,那个任务先返回可读条件,就去执行哪个任务。从单一的等待变成多个任务的同时等待。

这种模型较之前的模型来说,可以不必多次轮询内核,而是等到内核的通知。

信号驱动式I/O模型

趣解模型

你还是不满意银行的服务,虽然不必排队,但你在商场逛的也不放心啊,你还是要盯着显示屏,深怕没有看到显示屏上面你的名字,于是,银行也退出了全新的服务,你去银行取钱的时候,银行目前人多不能及时处理你的业务,而是叫你留下手机号,等到空闲的时候就短信通知你可以去取钱了。这就是信号驱动式I/O模型。

网络模型

我们可以用信号,让内核在描述符就绪时发送SIGIO信号告知我们。

这里写图片描述
这里写图片描述

如上图所示,进程建立SIGIO的信号处理程序(就要趣解模型中的留下手机号),并通过sigaction系统调用安装一个信号处理函数,该系统调用将立即返回,进程继续工作,知道数据报准备好后,内核产生一个SIGIO信号,告知应用进程以及准备好,于是就在信号处理程序中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环让他读取数据报。

这种模型的好处就是,在数据报没有准备好的期间,应用进程不必阻塞,继续执行主循环,只要等待来自信号处理函数的通知即可。

异步I/O模型

趣解模型

你细细的想了想自己取钱时为了什么,无非时借给你的朋友,银行都退出了网上银行服务,你只需要知道你的好朋友的银行卡号,然后在网银中申请转账,银行后台会给你处理,然后把钱打到你朋友的账户下面,等这些都处理好后,银行会给你发一条短信,告诉你转账成功,这个时候你就可以跟你的好朋友说,钱已经打给你了。这就是异步I/O模型,取钱借钱的繁琐事就交给银行后台给你处理吧。

网络模型

POSIX规范中提供一些函数,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们。

这里写图片描述
这里写图片描述

如上图所示,我们调用aio_read函数(POSIX异步I/O函数以aio_或lio_开头),给内核传递描述符,缓冲区指针,缓冲区大小和文件偏移,并告诉内核完成整个操作后通知我们。

不同于信号驱动式I/O模型,信号是在数据已复制到进程缓冲区才产生的。

各种I/O模型的比较

以一张图来说明五种I/O操作的差异:

这里写图片描述
这里写图片描述

同步I/O操作:导致请求进程阻塞,直到I/O操作完成

异步I/O操作:不导致进程阻塞

可知,前四种都属于同步I/O操作慢系统都会阻塞与recvfrom操作,而异步I/O不会。

三、select函数

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) 返回正数,表示已经准备好的描述符个数,在这种情况下,三个描述符集中依旧打开的位对应于已准备好的描述符

使用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));
		}
	}
}

批量输入

在上一节提到的str_cli版本中,仍然存在一个问题。假设客户在标准输入中批量输入数据,在输入完最后一个数据后,碰到了EOF,str_cli返回到main函数,main函数随后终止。但是,在这个过程中,标准输入的EOF终止符并不意味着我们也同时完成了从套接字的读入,可能仍有请求在去往服务器的路上,或者仍有应答在返回客户的路上。

原因就处在于此:

	if (Fgets(sendline, MAXLINE, fp) == NULL)
				return;		/* all done */

当碰到EOF终止符的时候,str_cli函数选择了立即返回,而此时,我们更需要的是找到一个条件来判断套接字的读取是否完成。

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两次等效。

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);
		}
	}
}

四、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;				//没有已连接套接字了
			}
		}
	}
}

五、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不会修改此值。

六、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数组并把该数组中元素的数据通知内核成了调用者的责任,内核不再需要知道这些固定大小的数据类型。

七、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;				//没有已连接套接字了
			}
		}
	}
}