UNP_16_非阻塞式IO
16.1 概述
套接字的默认状态是阻塞的。这表示发出一个不能立即完成的套接字调用时,其进程会被投入睡眠,等待相应操作完成。可能阻塞的套接字调用可分为下面 4 种:
- 输入操作,包括 read,readv,recv,recvfrom 和 recvmsg 共 5 个函数。如果某个进程对一个阻塞的 TCP 套接字调用这些输入函数之一,并且缓冲区中没有数据可以读取,进程会进入休眠,知道数据到达。TCP 是字节流协议,该进程的唤醒只要一些数据,如果要设置固定的量,可以使用 readn 或者设置 MSG_WAITALL 标志。对于非阻塞的套接字,如果输入操作不被满足,调用会立即返回一个 EWOULDBLOCK 错误
- 输出操作,包括 write,writev,send,sendto 和 sendmsg 共 5 个函数,和输入类似。对于一个非阻塞的 TCP 套接字,如果发送缓冲区没有空间,输出函数调用将立即返回一个 EWOULDBLOCK 错误。
- 接受外来连接,即 accept 函数。如果对一个阻塞的套接字调用该 accept 函数,并且没有新的连接到达,调用进程将被投入睡眠。如果一个非阻塞的套接字调用 accept 函数,并且尚无新的连接到达,accept 调用将立即返回一个 EWOLULDBLOCK 错误。
- 发起外出连接,即用于 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 上有三个用途:
- 我们可以把三路握手的时间进行叠加。完成一个 connect 要花费 RTT 时间,而 RTT 的波动较大,从局域网的几毫秒到广域网的几秒,这段时间可以充分利用
- 我们可以使用该技术同时建立多个连接。这个用途已随着 web 浏览器变得流行起来
- 既然使用 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:
- 调用 getpeername 代替 getsockopt。如果 getpeername 以 ENOTCONN 错误失败返回,连接建立失败,接着使用 getsockopt 来获取待处理的错误并进行处理返回
- 以值为 0 的长度参数调用 read,如果失败,连接建立失败,read 返回 error 给出失败原因。如果连接建立成功, read 返回 0
- 再调用 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 上。