套接字的默认状态是阻塞的,当发出一个不能立即完成的套接字调用时,进程将被投入睡眠,等待相应操作完成。可能阻塞的套接字调用有以下四类:
1.输入操作:包括read、readv、recv、recvfrom、recvmsg函数。如果进程对一个阻塞的TCP套接字调用这些函数,且该套接字的接收缓存中没有数据可读,该进程将投入睡眠,直到有数据到达。既然TCP是字节流协议,该进程的唤醒条件是一些数据到达,可能是单个字节,也可能是一个完整的TCP分节中的数据。如果想等固定数目的数据可读为止,可用我们的readn函数或指定MSG_WAITALL标志。
既然UDP是数据报协议,如果一个阻塞的UDP套接字的接收缓冲区为空,对它调用输入函数的进程将被投入睡眠,直到有UDP数据报到达。
对于非阻塞套接字,如果输入操作不能被满足(对TCP即至少有1个字节数据可读;对UDP即有1个完整数据报可读),相应调用会返回EWOULDBLOCK错误。
2.输出操作:包括write、writev、send、sendto、sendmsg函数。对于一个TCP套接字,内核将从应用进程缓冲区复制数据到该套接字的发送缓冲区,对于阻塞的套接字,如果其发送缓冲区没有空间,输出函数将被投入睡眠,直到有空间为止。
对于一个非阻塞TCP套接字,如果其发送缓冲区没有空间,输出函数将立即返回一个EWOULDBLOCK错误,如果其发送缓冲区中有一些空间,返回值是内核你能复制到该缓冲区中的字节数,这个字节数也称为不足计数。
UDP套接字不存在真正的发送缓冲区,内核只是复制应用进程数据并将其沿协议栈向下传送,渐次冠以UDP首部和IP首部。因此对于一个阻塞的UDP套接字,输出函数将不会因发送缓冲区空间不足而阻塞,但可能因其他原因阻塞。
3.接受外来连接(accept函数)。如果对一个阻塞的套接字调用accept,且没有新连接到达,调用进程将被投入睡眠。
如果对一个非阻塞套接字调用accept函数,且尚无新连接到达,accept函数立即返回EWOULDBLOCK错误。
4.发起外出连接(connect函数)。connect函数可用于UDP,但这样不会真正建立一个连接,只是使内核保存对端的IP地址和端口号。TCP连接的建立需要三次握手,且connect函数直到客户收到对于自己的SYN的ACK才返回,这意味着TCP的每个connect函数总会阻塞其调用进程至少一个到服务器的RTT时间。
如果对非阻塞的TCP套接字调用connect,并且连接不能立即建立,则连接照样会发起(仍会送出三路握手过程的第一个分组),但会返回EINPROGRESS错误,有些连接可以立即建立,通常发生在服务器和客户在同一主机上的情况下,因此对于非阻塞的connect函数,我们也要准备connect函数成功返回的情况发生。
按照传统,对于不能被满足的非阻塞式IO操作,System V会返回EAGAIN错误,而源自Berkeley的实现返回EWOULDBLOCK错误。由于历史原因,POSIX规定这种情况下这两个错误码都可以返回。当前大多系统把这两个错误码定义为相同的值(可检查sys/errno.h头文件),因此具体使用哪个关系不大。
我们之前str_cli函数使用的是阻塞式IO,如果标准输入有一行文本可读,我们调用read读入它,再调用writen把它发送给服务器,然而如果套接字发送缓冲区已满,writen调用将会阻塞,在阻塞于writen函数期间,可能来自套接字接收缓冲区的数据可读。我们的目标是开发这个函数的使用非阻塞IO的版本,可防止进程做有效工作期间发生阻塞。
非阻塞式IO的加入使str_cli函数的缓冲区管理显著复杂化了,使用标准IO的潜在问题和困难在非阻塞式IO操作中更为突出,我们避免使用标准IO。
我们维护两个缓冲区:to容纳从标准输入到服务器去的数据,fr容纳从服务器到标准输出来的数据。
上图中,toiptr指针指向从标准输入读入的数据可以存放的下一字节;tooptr指向下一个写到套接字的字节。有toiptr-tooptr
个字节需要写到套接字。可从标准输入读入的字节数是&to[MAXLINE] - toiptr
。一旦tooptr移动到toiptr,两个指针就一起恢复到缓冲区开始处。
以下是str_cli函数:
#include "unp.h"
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;
// 把连接到服务器的套接字、标准输入、标准输出设为非阻塞
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);
toiptr = tooptr = to; /* initialize buffer pointers */
friptr = froptr = fr;
stdineof = 0;
maxfdp1 = max(max(STDIN_FILENO, STDOUT_FILENO), sockfd) + 1;
for (; ; ) {
FD_ZERO(&rset);
FD_ZERO(&wset);
// 未读到EOF且to缓冲区有可用空间,则打开读描述符集中的标准输入位
if (stdineof == 0 && toiptr < &to[MAXLINE]) {
FD_SET(STDIN_FILENO, &rset); /* read from stdin */
}
// fr缓冲区有空间可用,则打开读描述符集中的套接字位
if (friptr < &fr[MAXLINE]) {
FD_SET(sockfd, &rset); /* read from socket */
}
// to缓冲区中有要写到套接字的数据,则打开写描述符集中的套接字位
if (tooptr != toiptr) {
FD_SET(sockfd, &wset); /* data to write to socket */
}
// fr缓冲区中有要写到标准输出的数据,则打开写描述符集中的标准输出位
if (froptr != friptr) {
FD_SET(STDOUT_FILENO, &wset); /* data to write to stdout */
}
// 没有为select函数设置超时
Select(maxfdp1, &rset, &wset, NULL, NULL);
if (FD_ISSET(STDIN_FILENO, &rset)) {
if ((n = read(STDIN_FILENO, toiptr, &to[MAXLINE] - toiptr)) < 0) {
// 忽略发生的EWOULDBLOCK错误,此错误不应该发生,因为select函数告知我们描述符可读,但read函数却返回该描述符不可读
if (errno != EWOULDBLOCK) {
err_sys("read error on stdin");
}
} else if (n == 0) {
// 输出一行标准错误以表示EOF,同时输出当前时间
fprintf(stderr, "%s: EOF on stdin\n", gf_time());
stdineof = 1; /* all done with stdin */
// 如果没有数据要发送了,调用shuwdown发送FIN到服务器
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 */
// 读到数据,打开写描述符集中的套接字位,使得后面在当前循环中对该位的测试为真,从而把数据write到套接字
FD_SET(sockfd, &wset); /* try and write to socket below */
}
}
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)) {
// 这种情况会发生,因为上边我们在不清楚write函数是否会成功的前提下手动打开了写描述符集中与标准输出对应的位
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 */
// 如果消息已经发完且输入也已经完成,发送FIN到服务器
if (stdineof) {
Shutdown(sockfd, SHUT_WR); /* send FIN */
}
}
}
}
}
}
以上代码中,我们从标准输入读到数据后,立即打开写描述符集中的套接字位,这里有多个手段可供选择,我们可以什么都不做,这样下次循环中会打开写描述符集中的套接字位,并调用slelect,但这样做是在已知有数据要写到套接字的情况下,还要进入下一轮循环以调用select。另一个手段是把写套接字的代码复制至此,但这可能造成错误(万一被复制的代码存在bug,而我们只在其中某个位置修复了,却忘了另一个位置)。再一个手段是创建一个写到套接字的函数,并调用此函数代替代码复制,但这要求该函数共享str_cli的某些局部变量,可能要让这些变量成为全局变量。
以上代码调用了gf_time函数,它返回指向时间字符串的指针:
#include "unp.h"
#include
char *gf_time(void) {
struct timeval tv;
static char str[30];
char *ptr;
if (gettimeofday(&tv, NULL) < 0) {
err_sys("gettimeofday error");
}
ptr = ctime(&tv.tv_sec);
strcpy(str, &ptr[11]);
/* Fri Sep 13 00:00:00 1986\n\0 */
/* 01234567890123456789012345 */
snprintf(str + 8, sizeof(str) - 8, ".%06ld", tv.tv_usec);
return str;
}
gf_time函数的输出格式为12:35:56.123456
。这里特意采用与tcpdump的时间戳一致的格式。str_cli函数中所有frprintf函数都写到标准错误,这使我们能与标准输出(服务器回射的文本行)分开(通过重定向标准输出和标准错误到不同的文件)。这样我们可以同时运行使用以上函数的TCP回射客户程序和tcpdump,并把得到的诊断输出与tcpdump输出放在一起按时间排序,从而使程序中发生的事件和TCP行为关联起来。
我们首先在主机solaris上运行tcpdump,指定捕获端口7(回射服务器)上的TCP分节,并将程序输出存在文件tcpd中:
然后在同一主机上运行我们的TCP客户,连接到主机linux上的标准echo服务器:
标准输入是文件2000.lines,标准输出发送到out,标准错误发送到diag。之后运行以下命令:
以验证回射文本行等同于输出文本行。最后中断tcpdump,然后整合tcpdump和diag文件的输出,对它们一起排序,以下是排序结果的一部分(tcpdump -N选项是以数字显示IP和端口):
上图中对包含SYN的过长的行进行了折行处理。我们还删掉了Solaris分节的DF(不可分片)位。
根据上图,可把发生的事件以时间线图描绘出来,如下图,时间按向下方向递增:
上图中没有画出ACK分节。当程序输出wrote N bytes to stdout
时,写标准输出的write函数已经返回,从而导致fr缓冲区空出空间,然后就可以从套接字接收缓冲区读数据到to缓冲区,从而可能导致对方TCP发送一个或多个分节的数据。
从以上时间线图可看出客户/服务器数据交换的动态性,使用非阻塞式IO使程序发挥出动态性的优势,只要IO操作可能发生,就执行合适的读写操作。通过select合适,可以让内核通知我们何时某个IO操作可以发生。
我们测试出非阻塞版客户程序完成以上操作的时间为6.9s,而阻塞版本使用相同的2000行文件、相同的客户和服务器主机(RTT为175ms)时,用时为12.3s。
以上str_cli函数的非阻塞版本较复杂,约135行代码;而使用select函数和阻塞式IO的版本有40行代码;最初的停等版本只有20行代码。我们从20行提升到40行的努力是值得的,因为批量模式下执行速度提高了几乎30倍。考虑到代码的复杂性,把应用编写为使用非阻塞IO是不值得的,更简单的方法是把任务划分到多个进程或多个线程中。
以下是str_cli函数的另一版本,它使用fork函数:
#include "unp.h"
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));
}
// 此处需要使用shutdown函数而非close函数,因为close函数只是将描述符引用计数减1,子进程仍然引用着这个描述符,从而不发送FIN
// 这就是使用shutdown函数的另一个理由,即使描述符的引用计数仍大于0,FIN也会被发送
Shutdown(sockfd, SHUT_WR); /* EOF on stdin, send FIN */
pause();
return;
}
以上程序中,子进程把来自服务器的文本行复制到标准输出,父进程把来自标准输入的文本行复制到服务器:
上图指出TCP连接是全双工的,且父子进程共享同一套接字。尽管套接字只有一个,其接收缓冲区和发送缓冲区也分别只有一个,但这个套接字由两个描述符在引用它。
考虑进程终止序列,正常的终止序列是从在标准输入上遇到EOF开始的,父进程读入来自标准输入的EOF后,调用shutdown发送FIN,之后,子进程仍需继续从服务器到标准输出执行数据复制,直到在套接字上读到EOF。
服务器进程过早终止也可能发生,此时子进程将在套接字上读到EOF,然后子进程需要告知父进程停止从标准输入到套接字复制数据,上例中子进程向父进程发送一个SIGTERM信号,以防父进程仍在运行。另一个处理手段是子进程无为地终止,父进程会捕获到SIGCHLD信号。如果子进程不通知父进程停止从标准输入到套接字复制数据,父进程将继续写出到已经接收FIN的套接字,它送给服务器的第一个分节会引发RST,而收到RST之后再写会导致内核向父进程发送SIGPIPE信号。
以上代码中,如果父进程在子进程前意外死亡,而子进程随后从套接字中读到EOF,会向进程ID为1的init进程发送SIGTERM信号,init进程是所有孤儿进程的继父,但子进程没有足够的权限向init进程发送该信号,如果子进程以超级用户特权运行,那么在发送信号前应检查getppid函数的返回值。
父进程完成数据复制后调用pause使自己进入睡眠状态,直到捕获一个信号(子进程来的SIGTERM信号),SIGTERM信号默认行为是终止进程。我们让父进程等待子进程的目的在于精确测量调用此版str_cli函数的TCP回射客户程序的执行时钟时间。正常情况下子进程在父进程之后结束,但我们用于测量时钟时间的是shell命令time,它要求父进程持续到测量结束时刻。
fork版比非阻塞版更简单,非阻塞版同时管理4个不同IO流,且由于这4个流都是非阻塞的,我们不得不考虑4个流的部分读和部分写问题。但fork版本中,每个进程只处理2个IO流,这里不需要非阻塞式IO,因为如果输入流没有数据可读,就没有数据往相应输出流写。
以下是所有str_cli函数的版本的TCP回射客户程序执行时钟时间,测试环境是从一个Solaris主机上向RTT为175ms的服务器上复制2000行文本:
考虑到非阻塞版本比fork版本代码的复杂性,推荐使用更简单的fork版本。
在一个非阻塞TCP套接字上调用conenct时,connect函数立即返回EINPROGRESS错误,但已经发起的TCP三路握手继续进行,之后我们使用select函数检测连接成功、失败的条件。非阻塞connect函数的用途:
1.把三路握手叠加在其他处理上,完成一个connect调用要花1个RTT时间,而RTT波动范围很大,从局域网的几ms到几百ms甚至广域网上的几s,这段时间我们可能有其他工作可执行。
2.同时建立多个连接。
3.既然使用select函数等待连接建立,可给select函数指定一个时间限制,使得我们能缩短connect函数的超时时间。许多实现有着从75s到数min的connect函数超时时间,应用有时想要一个更短的超时时间。
非阻塞connect要处理的细节:
1.尽管套接字是非阻塞的,如果连接到的服务器在同一主机上,当调用connect时连接通常立刻建立,我们需要处理这种情形。
2.源自Berkeley的实现和POSIX有关于select函数和非阻塞connect函数的两个规则:
(1)当连接成功建立,描述符变为可写。
(2)当连接建立遇到错误,描述符变为既可读又可写。
connect_nonb函数执行非阻塞connect调用,它的前3个参数与connect函数的一样,第四个参数是等待连接完成的秒数,0表示不设置超时(内核将使用通常的TCP连接超时):
#include "unp.h"
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(sockfd, F_SETFL, flags | O_NONBLOCK);
error = 0;
// 发起非阻塞connect,期望的错误是EINPROGRESS,表示连接正在建立尚未完成
if ((n = connect(sockfd, saptr, salen)) < 0) {
if (errno != EINPROGRESS) {
return -1;
}
}
/* Do whatever we want while the connect is taking place. */
// 如果connect函数返回0,说明连接已经建立
if (n == 0) {
// 如果没有此处的goto语句,下面的select函数会被调用,但select函数会立即返回,因为连接建立后套接字是可写的
// 此处加goto语句是为了避免不必要地调用select
goto done; /* connect completed immediately */
}
FD_ZERO(&rset);
FD_SET(sockfd, &rset);
// 描述符集的赋值可能是结构赋值,因为描述符集通常作为结构表示
wset = rset;
tval.tv_sec = nsec;
tval.tv_usec = 0;
if ((n = Select(sockfd + 1, &rset, &wset, NULL, nsec ? &tval : NULL)) == 0) {
// 如果已超时,关闭sockfd,防止三路握手继续进行
close(sockfd);
// 返回ETIMEDOUT给调用者
errno = ETIMEDOUT;
return -1;
}
if (FD_ISSET(sockfd, &rset) || FD_ISSET(sockfd, &wset)) {
len = sizeof(error);
// 取得待处理错误,如果连接成功建立,error值为0
// 如果连接建立发生错误,error值就是对应连接的errno值,如ECONNREFUSED、ETIMEDOUT等
// 此处我们遇到可移植性问题,如果发生错误,源自Berkeley的实现将在error中返回待处理错误,getsockopt函数本身返回0
// 而Solaris此时会让getsockopt函数返回-1,并把errno置为待处理错误
// 我们的程序能处理这两种情形
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 */
// 如果从getsockopt函数返回的error变量值非0,就把该值存入errno
if (error) {
close(sockfd); /* just in case */
errno = error;
return -1;
}
return 0;
}
连接建立成功时,select函数返回套接字可写,连接建立失败时,select函数返回套接字既可读又可写。
套接字的各种实现和非阻塞connect会带来移植性问题。首先,调用select前可能连接已经建立并有来自对端的数据到达,此时即使套接字不发生错误,也是可读可写的,这和建立连接失败的情况下套接字的读写条件一样,上例代码通过调用getsockopt并检查套接字上是否存在待处理错误来处理这种情形。
其次,既然我们不能通过套接字的可写条件判断连接建立是否成功,有以下方法可取代getsockopt函数:
(1)调用getpeername代替getsockopt函数,如果getpeername函数返回ENOTCONN错误,则连接建立失败,我们需要接着以SO_ERROR调用getsockopt取得套接字上待处理错误。
(2)以值为0的长度调用read,如果read调用失败,则连接建立失败,read函数返回的errno给出了连接失败的原因。如果连接建立成功,read函数应返回0。
(3)再次调用connect,它应该失败,如果errno是EISCONN,则套接字已连接。
非阻塞connect是网络编程中最不易移植的部分,避免该问题的一个较简单方法是为每个连接创建一个处理线程。
对于一个正常的阻塞式套接字,如果其上的connect调用在TCP三路握手前被中断(如捕获了某信号),假设被中断的connect函数不由内核重启,则它将返回EINTR。此时我们不能再次调用connect等待未完成连接继续完成,这样做会导致EADDRINUSE错误。此时我们只能用select函数,像上述那样判断。
以下非阻塞connect的例子出自Netscape(一款早期的网络浏览器)的Web客户程序,客户先建立一个与某个Web服务器的HTTP连接,再获取一个主页,该主页往往含有多个对其他网页的引用,客户可以使用非阻塞connect同时获取多个网页,以此取代每次只能获取一个网页的串行获取手段。下图是并行建立多个连接的例子:
上图中,最左边表示串行执行3个连接,总计使用29个时间单位。
中间情形并行执行2个连接。在时刻0启动前2个连接,当其中之一结束时,启动第三个连接。总计耗时差不多减半,为15个时间单位,但这是理想情况下,如果并行执行的连接共享同一个低速链路(如客户主机通过一个拨号调制解调器链路接入因特网),那么每个连接可能彼此竞用有限的资源,使得每个连接耗用更长时间,例如,10个时间单位的连接可能变为15,15个时间单位的可能变成20,4个时间单位的可能变为6,即使这样,总计耗时将是21,仍短于串行执行。
最右边情形并行执行所有3个连接,假设这3个连接之间没有干扰,就上例而言,本情形和中间情形耗时一样。
在处理Web客户时,第一个连接独立执行,来自该连接的页面含有多个其他网页的引用,随后并行访问包含的其他网页:
为进一步优化连接执行序列,客户可在第一个连接尚未完成前就开始分析从中陆续返回的数据,以便尽早获取其中含有的引用,并尽快启动相应的额外连接。
既然准备同时处理多个非阻塞connect,就不能使用上面的connect_nonb函数,因为它直到连接已经建立才返回,我们需要自行管理这些连接。
我们的程序最多读20个来自Web服务器的文件,最大并行连接数、服务器的主机名、要从服务器获取的每个文件的文件名都作为命令行参数指定,执行本程序的一个典型例子:
以上命名的参数指定并行执行最多3个连接、服务器的主机名、主页的文件名(/是服务器根网页)及随后读入的7个文件(本例中都是GIF图像),这7个文件在指定主页中被引用,现实的Web客户将读取指定主页,然后通过分析获取这些文件名,我们不想因加入HTML分析使本例复杂化,因此直接在命令行上指定了这些文件名。
以下是本例中每个文件都包含了web.h头文件:
#include "unp.h"
#define MAXFILES 20
#define SERV "80" /* port number or service name */
// 最多读MAXFILES个来自Web服务器的文件
// file结构中包含每个文件的信息,包括文件名、文件所在服务器主机名或IP、用于读取该文件的描述符、准备对文件执行什么操作
struct file {
char *f_name; /* filename */
char *f_host; /* hostname or IPv4/IPv6 address */
int f_fd; /* descriptor */
int f_flags; /* F_xxx below */
} file[MAXFILES];
#define F_CONNECTING 1 /* connect() in progress */
#define F_READING 2 /* connect() complete; now reading */
#define F_DONE 4 /* all done */
#define GET_CMD "GET %s HTTP/1.0\r\n\r\n"
/* globals */
int nconn, nfiles, nlefttoconn, nlefttoread, maxfd;
fd_set rset, wset;
/* function prototypes */
void home_page(const char *, const char *);
void start_connect(struct file *);
void write_get_cmd(struct file *);
home_page函数创建一个TCP连接,发出一个命令到服务器,然后读取主页,这是第一个连接,需要在我们并行建立多个连接前独立完成:
#include "web.h"
void home_page(const char *host, const char *fname) {
int fd, n;
char line[MAXLINE];
// 与服务器建立连接
fd = Tcp_connect(host, SERV); /* blocking connect() */
// 发送HTTP GET命令获取主页
n = snprintf(line, sizeof(line), GET_CMD, fname);
Writen(fd, line, n);
for (; ; ) {
if ((n = Read(fd, line, MAXLINE)) == 0) {
break; /* server closed connection */
}
printf("read %d bytes of home page\n", n);
/* do whatever with data */
}
printf("end-of-file on home page\n");
Close(fd);
}
以上home_page函数读取应答后,不做任何操作,然后关闭连接。
start_connect函数发起非阻塞connect:
#include "web.h"
void start_connect(struct file *fptr) {
int fd, flags, n;
struct addrinfo *ai;
// host_serv函数查找指定主机名和服务名,返回指向addrinfo结构的指针
ai = Host_serv(fptr->f_host, SERV, 0, SOCK_STREAM);
// 我们只使用获取的addrinfo结构数组中的第一个结构
fd = Socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
fptr->f_fd = fd;
printf("start_connect for %s, fd %d\n", fptr->f_name, fd);
/* Set socket nonblocking */
flags = Fcntl(fd, F_GETFL, 0);
Fcntl(fd, F_SETFL, flags | O_NONBLOCK);
/* Initiate nonblocking connect to the server */
if ((n = connect(fd, ai->ai_addr, ai->ai_addrlen)) < 0) {
if (errno != EINPROGRESS) {
err_sys("nonblocking connect error");
}
fptr->f_flags = F_CONNECTING;
// 把select函数检测到该描述符可读或可写当做连接建立完毕的指示
FD_SET(fd, &rset); /* select for reading and writing */
FD_SET(fd, &wset);
if (fd > maxfd) {
maxfd = fd;
}
// 如果connect函数成功返回,说明连接已建立
} else if (n >= 0) { /* connect is already done */
write_get_cmd(fptr); /* write() the GET command to server */
}
}
以下是write_get_cmd函数,它发送一个HTTP GET命令到服务器:
#include "web.h"
void write_get_cmd(struct file *fptr) {
int n;
char line[MAXLINE];
// 构造命令并写入套接字
n = snprintf(line, sizeof(line), GET_CMD, fptr->f_name);
Writen(fptr->f_fd, line, n);
printf("wrote %d bytes for %s\n", n, fptr->f_name);
// 设置F_READING标志,同时它清除F_CONNECTING标志(如果设置了)
fptr->f_flags = F_READING; /* clears F_CONNECTING */
FD_SET(fptr->f_fd, &rset); /* will read server's reply */
if (fptr->f_fd > maxfd) {
maxfd = fptr->f_fd;
}
}
start_connect函数中,我们把套接字设为非阻塞后,没有把它重置回默认的阻塞模式,这没有问题,因为我们在write_get_cmd函数只往套接字中写入少量数据(一个GET命令)比套接字发送缓冲区小得多。即使write_get_cmd函数中写数据时因为非阻塞标志导致已发送数据量小于要发送的数据量,我们的writen函数也能对此进行处理。套接字处于非阻塞模式对后续的读也没有影响,因为我们总是在select函数返回该套接字可读后才对读它。
以下是Web客户程序的main函数:
#include "web.h"
int main(int argc, char **argv) {
int i, fd, n, maxnconn, flags, error;
char buf[MAXLINE];
fd_set rs, ws;
if (argc < 5) {
err_quit("usage: web <#conns> ..." );
}
maxnconn = atoi(argv[1]);
nfiles = min(argc - 4, MAXFILES);
for (i = 0; i < nfiles; ++i) {
file[i].f_name = argv[i + 4];
file[i].f_host = argv[2];
file[i].f_flags = 0;
}
printf("nfiles = %d\n", nfiles);
home_page(argv[2], argv[3]);
FD_ZERO(&rset);
FD_ZERO(&wset);
maxfd = -1;
nlefttoread = nlefttoconn = nfiles;
nconn = 0;
while (nlefttoread > 0) {
while (nconn < maxnconn && nlefttoconn > 0) {
/* find a file to read */
for (i = 0; i < nfiles; ++i) {
if (file[i].f_flags == 0) {
break;
}
}
if (i == nfiles) {
err_quit("nlefttoconn = %d but nothing found", nlefttoconn);
}
start_connect(&file[i]);
++nconn;
--nlefttoconn;
}
rs = rset;
ws = wset;
n = Select(maxfd + 1, &rs, &ws, NULL, NULL);
for (i = 0; i < nfiles; ++i) {
flags = file[i].f_flags;
if (flags == 0 || flags & F_DONE) {
continue;
}
fd = file[i].f_fd;
if ((flags & F_CONNECTING) && (FD_ISSET(fd, &rs) || FD_ISSET(fd, &ws))) {
n = sizeof(error);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &n) < 0 || error != 0) {
err_ret("nonblocking connect failed for %s", file[i].f_name);
}
/* connection established */
printf("connection established for %s\n", file[i].f_name);
FD_CLR(fd, &wset); /* no more writeability test */
write_get_cmd(&file[i]); /* write() the GET command */
} else if ((flags & F_READING) && FD_ISSET(fd, &rs)) {
if ((n = Read(fd, buf, sizeof(buf))) == 0) {
printf("end-of-file on %s\n", file[i].f_name);
Close(fd);
file[i].f_flags = F_DONE; /* clears F_READING */
FD_CLR(fd, &rset);
--nconn;
--nlefttoread;
} else {
printf("read %d bytes from %s\n", n, file[i].f_name);
}
}
}
}
exit(0);
}
以上程序中有两个优化没有执行,以避免使程序更复杂。首先,select函数告知已经就绪的描述符数量n,当我们已经处理了n个描述符后,就不用再检查其他描述符了。其次,我们可以减少maxfd的值,省得select函数检查那些不再设置的位,但以上程序处理的描述符数很可能都小于10,相比额外造成的复杂性,这些优化是否值得添加令人怀疑。
下图给出了获取某个Web服务器的主页并后跟来自该服务器的9个图像文件所需的时钟时间,到该服务器的RTT约150ms,主页大小为4017字节,9个图像文件的平均大小为1621字节,TCP分节大小为512字节,为便于比较,下图还包含了二十六章中线程版本web客户的数据:
主要的性能改善是在同时连接数为3时取得的,同时连接数为4或更多时性能增长要少。
如果网络中存在拥塞,上例技术就会有缺陷,,当一个客户到一个服务器建立多个连接时,这些连接在TCP层并无通信,即使其中一个连接遇到分组丢失(隐含网络已经拥塞的信息),IP地址对相同的其他连接也不会得到通知,此时这些连接很可能马上遇到分组丢失(不会共享慢启动和拥塞避免算法),这些额外连接是在往已经拥塞的网络中发送更多分组,这个技术还会增加服务器主机的负荷。
当有一个已完成的连接准备好被accept时,select函数将返回该连接的监听套接字的可读条件,因此,当使用select函数在某个监听套接字上等待外来连接时,就没有必要把监听套接字设为非阻塞,因为如果select函数告诉我们监听套接字可读,那么随后的accept函数不应该阻塞。
但这里有一个可能让我们掉入陷阱的定时问题,我们使用下例代码来说明它,该代码中,TCP回射客户程序改写为建立连接后发送一个RST到服务器:
#include "unp.h"
int main(int argc, char **argv) {
int sockfd;
struct linger ling;
struct sockaddr_in servaddr;
if (argc != 2) {
err_quit("usage: tcpcli " );
}
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));
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);
}
之后我们修改TCP回射服务器程序,在select函数返回监听套接字可读条件后,调用accept前暂停:
if (FD_ISSET(listenfd, &rset)) { /* new client connection */
+ printf("listening socket readable\n");
+ sleep(5);
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *)&cliaddr, &clilen);
}
以上代码中,以加号开头的两行是新加的。
这里我们在模拟一个繁忙的服务器,它无法在select函数返回监听套接字的可读条件后就马上调用accept。通常情况下服务器的这种迟钝没有问题(实际这就是要维护一个已完成连接队列的原因),但结合上连接建立后到来客户的RST,就出现问题了。
当客户在服务器调用accept前中止某个连接时,源自Berkeley的实现不把这个中止的连接返回服务器,而其他实现应该返回ECONNABORTED错误,但往往代之以EPROTO错误。考虑一个源自Berkeley实现上的例子:
1.客户建立一个连接并随后中止它。
2.select函数向服务器进程返回可读条件,但服务器要过一小段时间才调用accept。
3.服务器从select函数返回到调用accept期间,服务器TCP收到来自客户的RST。
4.这个已完成的连接被服务器TCP驱出队列,假设队列中此时没有其他已完成连接。
5.服务器调用accept,但由于没有任何已完成连接,服务器于是阻塞。
服务器会一直阻塞在accept调用上,直到某个其他客户建立一个连接。在此期间,就上例而言,服务器就单纯阻塞在accept调用上,无法处理其他已就绪描述符。
本问题和拒绝服务攻击有些类似,但本缺陷在有客户建立连接时,服务器就能脱出阻塞中的accept函数。
本问题的解决方法:
1.当select函数返回监听套接字上的可读条件时(有已完成连接准备好被accept),总是把该监听套接字设为非阻塞。
2.后续accept调用中忽略以下错误:EWOULDBLOCK(源自Berkeley的实现,在客户终止连接时非阻塞accept函数会产生此错误)、ECONNABORTED(POSIX实现,客户中止连接时)、EPROTO(SVR 4实现,客户中止连接时)、EINTR(如果有信号被捕获)。
select函数通常结合非阻塞式IO一起使用,以便判断描述符何时可写或可读。使用非阻塞式IO的版本是所有TCP回射客户版本中执行最快的,尽管其代码修改起来比较复杂。之后我们展示的fork版本把客户程序划分为两部分由不同进程分别执行要简单得多。
非阻塞connect使我们能在TCP三路握手期间做其他处理,而不是光阻塞在connect函数上,但非阻塞connect不可移植,不同实现有不同方法指示连接已成功建立或已碰上错误。我们使用非阻塞connect开发了一个Web客户程序,它同时打开多个TCP连接以减少从单个服务器取得多个文件所需时钟时间,但考虑到TCP的拥塞避免机制,它是对网络不利的。
来自对端的数据可能在本端connect调用返回前到达套接字,这是可能的,服务器在accept函数返回后立即发送数据,而三路握手中的第二个分节到达时客户却比较忙,那么来自服务器的数据可能在客户的connect调用前到达。