UNP_16_非阻塞式IO

Posted on Mar 16, 2020

16.1 概述

  套接字的默认状态是阻塞的。这表示发出一个不能立即完成的套接字调用时,其进程会被投入睡眠,等待相应操作完成。可能阻塞的套接字调用可分为下面 4 种:

  1. 输入操作,包括 read,readv,recv,recvfrom 和 recvmsg 共 5 个函数。如果某个进程对一个阻塞的 TCP 套接字调用这些输入函数之一,并且缓冲区中没有数据可以读取,进程会进入休眠,知道数据到达。TCP 是字节流协议,该进程的唤醒只要一些数据,如果要设置固定的量,可以使用 readn 或者设置 MSG_WAITALL 标志。对于非阻塞的套接字,如果输入操作不被满足,调用会立即返回一个 EWOULDBLOCK 错误
  2. 输出操作,包括 write,writev,send,sendto 和 sendmsg 共 5 个函数,和输入类似。对于一个非阻塞的 TCP 套接字,如果发送缓冲区没有空间,输出函数调用将立即返回一个 EWOULDBLOCK 错误。
  3. 接受外来连接,即 accept 函数。如果对一个阻塞的套接字调用该 accept 函数,并且没有新的连接到达,调用进程将被投入睡眠。如果一个非阻塞的套接字调用 accept 函数,并且尚无新的连接到达,accept 调用将立即返回一个 EWOLULDBLOCK 错误。
  4. 发起外出连接,即用于 TCP 的 connect 函数。TCP 连接的建立涉及一个三路握手过程,而且 connect 函数一直要等到客户收到对于自己的 SYN 的 ACK 才会返回。这意味着 TCP 的每个 connect 总会阻塞其调用进程至少一个 RTT 时间。如果对一个非阻塞的 TCP 套接字调用 connect,并且连接不能立即建立,那么连接的建立能照样发起,不过会返回一个 EINPROGRESS 错误,注意这个错误和上面的错误并不相同。还需要注意同一主机上的连接会立即建立完成,通常发生在同一主机的情况下。因此对于非阻塞的 connect,我们也要预备 connect 成功返回的情况发生。

16.2 非阻塞读和写:str_cli 函数(修订版)

非阻塞 I/O 使得缓冲区的管理较为复杂,同时避免使用标准 I/O 函数。

void str_cli(FILE *fp, int sockfd)
{
	int			maxfdp1, val, stdineof;
	ssize_t		n, nwritten;
	fd_set		rset, wset;
	char		to[MAXLINE], fr[MAXLINE];
	char		*toiptr, *tooptr, *friptr, *froptr;
    // 使用 fcntl 把所有 3 个描述符都设置为非阻塞,包括连接到服务器的套接字,标准输入和标准输出
	val = Fcntl(sockfd, F_GETFL, 0);
	Fcntl(sockfd, F_SETFL, val | O_NONBLOCK);

	val = Fcntl(STDIN_FILENO, F_GETFL, 0);
	Fcntl(STDIN_FILENO, F_SETFL, val | O_NONBLOCK);

	val = Fcntl(STDOUT_FILENO, F_GETFL, 0);
	Fcntl(STDOUT_FILENO, F_SETFL, val | O_NONBLOCK);
    // 初始化指向两个缓冲区的指针,并把最大描述符加一,作为 select 的第一个参数
	toiptr = tooptr = to;	/* initialize buffer pointers */
	friptr = froptr = fr;
	stdineof = 0;

	maxfdp1 = max(max(STDIN_FILENO, STDOUT_FILENO), sockfd) + 1;
	for ( ; ; ) {
        // 准备调用 select,两个描述符集都先清零,并注册每个套接字相应的监听事件
		FD_ZERO(&rset);
		FD_ZERO(&wset);
		if (stdineof == 0 && toiptr < &to[MAXLINE])
			FD_SET(STDIN_FILENO, &rset);	/* read from stdin */
		if (friptr < &fr[MAXLINE])
			FD_SET(sockfd, &rset);			/* read from socket */
		if (tooptr != toiptr)
			FD_SET(sockfd, &wset);			/* data to write to socket */
		if (froptr != friptr)
			FD_SET(STDOUT_FILENO, &wset);	/* data to write to stdout */
        // 调用 select,等待四个条件之一变为真。这里并没有设置超时
		Select(maxfdp1, &rset, &wset, NULL, NULL);
        // 如果标准输入可读,调用 read,第三个参数指向可用空间
		if (FD_ISSET(STDIN_FILENO, &rset)) {
			if ( (n = read(STDIN_FILENO, toiptr, &to[MAXLINE] - toiptr)) < 0) {
                // 通常不会发生,意味着 select 说可读,但是又读不出来
				if (errno != EWOULDBLOCK)
					err_sys("read error on stdin");
			} else if (n == 0) {
                // 标准输入处理结束,设置 stdineof 标志
                // 如果发送缓冲区没有数据发送,就发送 FIN 给服务器,如果还有的话就发送完再发送 FIN
				fprintf(stderr, "%s: EOF on stdin\n", gf_time());
				stdineof = 1;			/* all done with stdin */
				if (tooptr == toiptr)
					Shutdown(sockfd, SHUT_WR);/* send FIN */

			} else {
				fprintf(stderr, "%s: read %d bytes from stdin\n", gf_time(), n);
                // 添加到接收缓冲区中
				toiptr += n;			/* # just read */
                
				FD_SET(sockfd, &wset);	/* try and write to socket below */
			}
		}
        // 和 read 部分类似
		if (FD_ISSET(sockfd, &rset)) {
			if ( (n = read(sockfd, friptr, &fr[MAXLINE] - friptr)) < 0) {
				if (errno != EWOULDBLOCK)
					err_sys("read error on socket");

			} else if (n == 0) {
				fprintf(stderr, "%s: EOF on socket\n", gf_time());
				if (stdineof)
					return;		/* normal termination */
				else
					err_quit("str_cli: server terminated prematurely");

			} else {
				fprintf(stderr, "%s: read %d bytes from socket\n",
								gf_time(), n);
				friptr += n;		/* # just read */
                // 打开写描述符集中与标准输出对应的位打开,尝试在第三部分中将这些数据写出到标准输出
				FD_SET(STDOUT_FILENO, &wset);	/* try and write below */
			}
		}
		if (FD_ISSET(STDOUT_FILENO, &wset) && ( (n = friptr - froptr) > 0)) {
			if ( (nwritten = write(STDOUT_FILENO, froptr, n)) < 0) {
				if (errno != EWOULDBLOCK)
					err_sys("write error to stdout");

			} else {
				fprintf(stderr, "%s: wrote %d bytes to stdout\n",
								gf_time(), nwritten);
				froptr += nwritten;		/* # just written */
				if (froptr == friptr)
					froptr = friptr = fr;	/* back to beginning of buffer */
			}
		}

		if (FD_ISSET(sockfd, &wset) && ( (n = toiptr - tooptr) > 0)) {
			if ( (nwritten = write(sockfd, tooptr, n)) < 0) {
				if (errno != EWOULDBLOCK)
					err_sys("write error to socket");

			} else {
				fprintf(stderr, "%s: wrote %d bytes to socket\n",
								gf_time(), nwritten);
				tooptr += nwritten;	/* # just written */
				if (tooptr == toiptr) {
					toiptr = tooptr = to;	/* back to beginning of buffer */
					if (stdineof)
						Shutdown(sockfd, SHUT_WR);	/* send FIN */
				}
			}
		}
	}
}

下面还提供了使用 fork 来优化性能的版本,两个进程分别处理输入输出:

void str_cli(FILE *fp, int sockfd)
{
	pid_t	pid;
	char	sendline[MAXLINE], recvline[MAXLINE];
    // 创建一个子进程
	if ( (pid = Fork()) == 0) {		/* child: server -> stdout */
    // 子进程用来处理数据的接收并写到标准输出
		while (Readline(sockfd, recvline, MAXLINE) > 0)
			Fputs(recvline, stdout);

		kill(getppid(), SIGTERM);	/* in case parent still running */
		exit(0);
	}

		/* parent: stdin -> server */
        // 父进程处理标准输出的接收和数据的发送
	while (Fgets(sendline, MAXLINE, fp) != NULL)
		Writen(sockfd, sendline, strlen(sendline));
    // 发送完毕,发送一个 FIN
	Shutdown(sockfd, SHUT_WR);	/* EOF on stdin, send FIN */
    // 等待子进程接收操作
	pause();
	return;
}

两种版本均比 select 加阻塞 I/O 版本快出很多,fork 版本比非阻塞 I/O 版本略慢,但是代码简洁很多,推荐使用 fork 版本。

16.3 非阻塞 connect

  当在一个非阻塞的 TCP 套接字上使用 connect 时,会立即返回一个 EINPROGRESS 错误,不过已经发起的三路握手会继续进行。我们接着使用 select 检测这个连接获成功或失败的已建立条件。非阻塞 connect 上有三个用途:

  1. 我们可以把三路握手的时间进行叠加。完成一个 connect 要花费 RTT 时间,而 RTT 的波动较大,从局域网的几毫秒到广域网的几秒,这段时间可以充分利用
  2. 我们可以使用该技术同时建立多个连接。这个用途已随着 web 浏览器变得流行起来
  3. 既然使用 select 等待连接的建立,可以给 select 设置一个时间限制,使得可以缩短 connect 的超时,普通 connect 超时时间是 75s,我们想要一个更短的时间,就可以把 connect 设置为非阻塞状态,对于非阻塞 connect,要做如下处理:
    • 如果客户端和服务器处于同一个主机,连接立即建立,需要处理这种情形。
    • 关于 select 和 非阻塞 connect,当连接成功建立时,描述符变为可写;当遇到错误时,描述符即可读又可写。

16.4 非阻塞 connect:时间获取客户程序

如下代码执行了非阻塞 connect

int connect_nonb(int sockfd, const SA *saptr, socklen_t salen, int nsec)
{
	int				flags, n, error;
	socklen_t		len;
	fd_set			rset, wset;
	struct timeval	tval;
    // 先获取原套接字描述符
	flags = Fcntl(sockfd, F_GETFL, 0);
    // 调用 fcntl 设置为非阻塞
	Fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
    // 发起非阻塞的 connect,期望的错误是 EINPROGRESS,表示连接建立已经启动但是尚未完成。connect 返回的任何其他错误返回给本函数的调用者。
	error = 0;
	if ( (n = connect(sockfd, saptr, salen)) < 0)
		if (errno != EINPROGRESS)
			return(-1);

	/* Do whatever we want while the connect is taking place. */
    // 此时可以在 RTT 时间中做我们想做的事
    // h == 0,连接已经建立,处于同一主机,立即建立连接,直接跳转到 done
	if (n == 0)
		goto done;	/* connect completed immediately */
    // 调用 select 等带连接的建立完成
	FD_ZERO(&rset);
	FD_SET(sockfd, &rset);
	wset = rset;
	tval.tv_sec = nsec;
	tval.tv_usec = 0;
    // select 返回 0,超时情况发生,返回 ETIMEOUT 错误返回给调用者。还需要关闭套接字,防止三路握手继续下去
	if ( (n = Select(sockfd+1, &rset, &wset, NULL,
					 nsec ? &tval : NULL)) == 0) {
		close(sockfd);		/* timeout */
		errno = ETIMEDOUT;
		return(-1);
	}
    // 变成可读或者可读可写
	if (FD_ISSET(sockfd, &rset) || FD_ISSET(sockfd, &wset)) {
		len = sizeof(error);
        // 获取待处理错误,建立成功返回 0.建立出错,返回对应的 error
		if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len) < 0)
			return(-1);			/* Solaris pending error */
	} else
		err_quit("select error: sockfd not set");

done:
    // 恢复套接字原来的文件状态标志
	Fcntl(sockfd, F_SETFL, flags);	/* restore file status flags */
    // 有 error 标志,关闭描述符并返回 -1
	if (error) {
		close(sockfd);		/* just in case */
		errno = error;
		return(-1);
	}
	return(0);
}

判断连接是否建立成功,可以使用下面的方式来代替 getsockopt:

  1. 调用 getpeername 代替 getsockopt。如果 getpeername 以 ENOTCONN 错误失败返回,连接建立失败,接着使用 getsockopt 来获取待处理的错误并进行处理返回
  2. 以值为 0 的长度参数调用 read,如果失败,连接建立失败,read 返回 error 给出失败原因。如果连接建立成功, read 返回 0
  3. 再调用 connect 一次。应该失败,如果错误是 EISCONN,套接字已经连接成功 不幸的是,非阻塞 connect 是网络编程中最不好一直的部分,一个简单的办法是为每一个连接创建一个处理线程。

被中断的 connect

  对于阻塞的 connect 在三路握手完成前,收到了某种信号导致 connect 中断,假设内核不会重启 connect,会返回 EINTR,我们不能再次调用 connect 等待未连接继续完成。这样做会导致 EADDRINUSE 错误,这种情况只能使用 select,建立成功 select 中套接字可写,失败时 select 返回套接字即可读又可写条件。

16.5 非阻塞 connect:web 客户程序

  UNP-16.5 给出了一个很完善的非阻塞 connect web 客户程序,这里就不摘抄了。该例子使用非阻塞 connect 来进行客户端的连接,可以同时并行处理多个连接,优化系统的性能

16.6 非阻塞 accept

  当有一个已完成的连接准备好被 accept 时,select 将作为可读描述符返会该连接的监听套接字。因此,如果我们使用 select 在某个监听套接字上等待一个外来链接,那就没有必要把该监听套接字设置为非阻塞,这是因为如果 select 告诉我们该套接字上已有连接就绪,那么随后的 accept 调用不应该阻塞。

int main(int argc, char **argv)
{
	int					sockfd;
	struct linger		ling;
	struct sockaddr_in	servaddr;

	if (argc != 2)
		err_quit("usage: tcpcli <IPaddress>");

	sockfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(SERV_PORT);
	Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

	Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));
    // 一旦连接建立,设置 SO_LINGER 套接字选项,把 l_onoff 标志设置为 1,把 l_linger 时间设置为 0,这样的设置导致连接被关闭在 TCP 套接字上发送一个 RST,我们随后关闭该套接字。
    // 同时在服务端模拟延迟,连接建立时(连接还处于队列中,等待调用 accept)收到 RST,会把这个连接从队列中去除,所以等到调用 accept 时,没有连接,服务器重新阻塞
	ling.l_onoff = 1;		/* cause RST to be sent on close() */
	ling.l_linger = 0;
	Setsockopt(sockfd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));
	Close(sockfd);

	exit(0);
}

解决注释中的问题方法如下:

  • 当使用 select 获悉某个监听套接字上有任何 accept 时,总把这个套接字设置为非阻塞。
  • 在后续的调用中忽略一下错误:EWOULDBLOCK、ECONNABORTED、EPROTO 和 EINTR 错误

小结

  • select 通常结合非阻塞 I/O 一起使用,以便判断描述符合适可读可写。这个版本的客户程序时我们给出的所有版本中最快的。
  • 非阻塞 connect 是我们能够在 TCP 三路握手发生期间做其他处理,而不是阻塞在 connect 上。