UNP Chapter 21 - 带外数据
21.1. 概述
许多传输层有带外数据(out-of-band data)的概念,有时也称为加速数据(expedited data).
21.2. TCP带外数据
TCP没有真正的带外数据,而是提供了一个我们要讨论的紧急模式(urgent mode)。假设一个进程已向一个TCP套接口写入了N字节数据,并且这些数据被TCP放入套接口发送缓冲区等待发送给对方。我们在图21.1中展示了这种状态,并且标记了从1到N的数据字节。
进程现在使用send函数和MSG_OOB标志发送一个包含ASCII字符a的带外数据字节:
send(fd, "a", 1, MSG_OOB);
TCP将数据放置在套机口发送缓冲区的下一个可用位置,并设置这个连接的TCP紧急指针(urgent pointer)为下一个可用位置。我们在图21.2中展示了这种状态,并且标记带外字节为“OOB”
TCP紧急指针有一个比用MSG_OOB标志写入的数据多一个字节的序列号。
给定如图21.2所示的TCP套接口发送缓冲区状态,由TCP发送的下一个分节将会在TCP头部中设置URG标志,并且头部中的紧急偏移(urgent offset)字段也将指向带外字节后的字节。
如果我们发送多字节的带外数据又会如何呢,就像:
send(fd, "abc", 3, MSG_OOB);
在这个例子中,TCP的紧急指针指向最后那个字节后面,也就是说最后那个字节(字母c)被认为是带外字节。
下面从接收者的角度看一下:
1. 当TCP收到了一个设置URG标志的分节时,紧急指针被检查,看它是否指向新的带外数据。也就是说,这是否首次在从发送者到接收者的数据流中TCP的紧急模式指向这个特殊字节。发送者TCP发送多个包含URG标志,但紧急指针却指向相同数据字节的分节,这种情况相当普遍。这些分节中只有第一个导致接收进程被通知有新带外数据到达。
2. 当新紧急指针到达时,接收进程被通知。首先,SIGURG信号发送给套接口属主,这里假设已调用fcntl和ioctl给套接口建立了属主,还假设属主进程已经为这个信号建立了信号处理程序。其次,如果进程阻塞在select调用中,等待这个套机口描述字有一个异常条件,那么select返回。当一个新紧急指针到达时,这两个对接收进程可能的通知就会发生,而不管是由紧急指针指向的实际数据字节已经到达接收者TCP
3. 当由紧急指针指向的实际数据字节到达接收者TCP时,这个数据字节可以被拉出带外或继续在线存放。SO_OOBINLINE套接口选项缺省时是不设置的,所以这个数据字节并不放入套接口接收缓冲区。相反,这个数据字节被放到这个连接的单独的1字节带外缓冲区。进程从这个特别的1字节缓冲区中读出的仅有方法是调用recv, recvfrom或者recvmsg并指定MSG_OOB标志。 然而不过进程设置了SO_OOBINLINE套接口选项,那么由TCP的紧急指针指向的单字节数据将被留在通常的套接口接收缓冲区里。在这种情况下,进程不能指定MSG_OOB标志去读带外数据字节。这个进程通过检查本连接的带外标志(out-of-band mark)来获悉何时它到达了这个数据字节。
使用SIGURG的简单例子
#include "unp.h" int main(int argc, char * * argv) { int sockfd; if(argc != 3) err_quit("usage: tcpsend01 <host> <port#>"); sockfd = Tcp_connect(argv[1], argv[2]); Write(sockfd, "123", 3); printf("wrote 3 bytes of normal data\n"); sleep(1); Send(sockfd, "4", 1, MSG_OOB); printf("wrote 1 byte of OOB data\n"); sleep(1); Write(sockfd, "56", 2); printf("wrote 2 bytes of normal data\n"); sleep(1); Send(sockfd, "7", 1, MSG_OOB); printf("wrote 1 byte of OOB data\n"); sleep(1); Write(sockfd, "89", 2); printf("wrote 2 bytes of normal data\n"); sleep(1); exit(0); }
发送9个字节,每个输出操作间有一个1秒的sleep。间断的目的是让每次write或send被作为单个TCP分节传送。
接收程序
#include "unp.h" int listenfd, connfd; void sig_urg(int); int main(int argc, char * * argv) { int n; char buff[100]; if(argc == 2) listenfd = Tcp_listen(NULL, argv[1], NULL); else if(argc = 3) listenfd = Tcp_listen(argv[1], argv[2], NULL); else err_quit("usage: tcprecv01 [ <host> ] <port#>"); connfd = Accept(listen, NULL, NULL); Signal(SIGURG, sig_urg); /* 建立SIGURG的信号处理程序 */ Fcntl(connfd, F_SETOWN, getpid()); /* 并用fcntl设置已连接套接口的属主 */ for( ; ; )/* 进程从套接口中读,输出每个由read返回的字符串,当发送者终止连接后,接收者也就终止了 */ { if( (n = Read(connfd, buff, sizeof(buff)-1) ) == 0 ) { printf("recived EOF\n"); exit(0); } buff[n] = 0; /* null terminate */ printf("read %d bytes: %s\n", n, buff); } } void sig_urg(int signo) { int n; char buff[100]; printf("SIGURG received\n"); n = Recv(connfd, buff, sizeof(buff)-1, MSG_OOB); buff[n] = 0; /* null terminate */ printf("read %d OOB byte: %s\n", n, buff); }
我们现在不用SIGURG信号而用select来重写带外接收者程序
#include "unp.h" int main(int argc, char * * argv) { int listenfd, connfd, n; char buff[100]; fd_set rset, xset; if(argc == 2) listenfd = Tcp_listen(NULL, argv[1], NULL); else if(argc == 3) listenfd = Tcp_listen(argv[1], argv[2], NULL); else err_quit("usage: tcprecv02 [ <host> ] <port#>"); connfd = Accept(listenfd, NULL, NULL); FD_ZERO(&rset); FD_ZERO(&xset); for( ; ; ) { FD_SET(connfd, &rset); FD_SET(connfd, &xset); Select(connfd + 1, &rset, NULL, &xset, NULL); if(FD_ISSET(connfd, &xset)) { n = Recv(connfd, buff, sizeof(buff) - 1, MSG_OOB); buff[n] = 0; /* null terminate */ printf("read %d OOB byte: %s \n", n, buff); } if(FD_ISSET(connfd, &rset)) { if( ( n = Read(connfd, buff, sizeof(buff) - 1) ) == 0 ) { printf("received EOF \n"); exit(0); } buff[n] = 0; /* null terminate */ printf("read %d bytes: %s \n", n, buff); } } }
问题是select一直指示一个异常条件,知道进程读越过带外数据。因为我们第一次读过带外数据后,内核清除了1字节的带外缓冲区,所以我们不能多次读。
解决方法是只有在读过普通数据后,才去select异常条件。
#include "unp.h" int main(int argc, char * * argv) { int listenfd, connfd, n, justreadoob = 0; /* 声明了一个叫做justreadoob的变量来指示我们是否刚读过带外数据,这个标志决定是否select异常条件 */ char buff[100]; fd_set rset, xset; if(argc == 2) listenfd = Tcp_listen(NULL, argv[1], NULL); else if(argc == 3) listenfd = Tcp_listen(argv[1], argv[2], NULL); else err_quit("usage: tcprecv03 [ <host> ] <port#>"); connfd = Accept(listenfd, NULL, NULL); FD_ZERO(&rset); FD_ZERO(&xset); for( ; ; ) { FD_SET(connfd, &rset); if(justreadoob == 0) FD_SET(connfd, &xset); Select(connfd+1, &rset, NULL, &xset, NULL); if(FD_ISSET(connfd, &xset)) { n = Recv(connfd, buff, sizeof(buff)-1, MSG_OOB); buff[n] = 0; /* null terminate */ printf("read %d OOB bytes: %s\n", n, buff); justreadoob = 1; /* 当我们设置justreadoob标志时,我们还必须清除这个描述字在异常集合中的那一位 */ FD_CLR(connfd, &xset); } if(FD_ISSET(connfd, &rset)) { if( ( n = Read(connfd, buff, sizeof(buff)-1) ) == 0 ) { printf("received EOF\n"); exit(0); } buff[n] = 0; /* null terminate */ printf("read %d bytes: %s \n", n, buff); justreadoob = 0; } } }
21.3. sockatmark函数
每当接收到带外数据时,就有一个相关联的带外标记。这是发送进程发送带外字节时在发送方普通数据流中的位置。接收进程读套接口时通过调用sockatmark函数确定是否在带外标记上。
#include <sys/socket.h> int sockatmark(int sockfd); /* 返回值:如果在带外标记上为1, 不在标记上为0, 出错为-1 */
使用通常的SIOCATMARK ioctl实现的这个函数
#include "unp.h" int sockatmark(int fd) { int flag; if( ioctl(fd, SIOCATMARK, &flag) < 0) return(-1); return(flag != 0 ? 1 : 0); }
不管接收进程在线(SO_OOBINLINE套接口选项)或是带外(MSG_OOB标志)接收带外数据,带外标记都能应用。带外标记的常见用法之一是接收者特殊的对待所有的数据,直到标记通过。
给出一个简单的例子来说明带外标记的以下两个特性
1. 带外标记总是指向刚好越过普通数据最后一个字节的地方。这意味着,如果带外数据在线接收,那么如果下一个要读的字节是被用MSG_OOB标志发送的,sockatmark就返回真。相反,如果SO_OOBINLINE套接口选项没有设置,那么如果下一个字节是跟在带外数据后发送的第一个字节,sockatmark就返回真。
2. 读操作总是会停在带外标记上,也就是,如果在套接口接收缓冲区中有100个字节,但带外标记前只有5个字节,进程执行read请求100字节,则只有标记前的5个字节返回。这种在标记处的强制停止允许进程调用sockatmark确定是否缓冲区指针在标记处。
图21.8是我们的发送程序: 它发送3字节的普通数据,1字节带外数据,接着另一字节的普通数据,在每个输出操作之间没有停顿。
#include "unp.h" int main(int argc, char * * argv) { int sockfd; if(argc != 3) err_quit("usage: tcpsend04 <host> <port#>"); sockfd = Tcp_connect(argv[1], argv[2]); Write(sockfd, "123", 3); printf("wrote 3 bytes of normal data\n"); Send(sockfd, "4", 1, MSG_OOB); printf("wrote 1 byte of OOB data\n"); Write(sockfd, "5", 1); printf("wrote 1 byte of normal data\n"); exit(0); }
图21.9是我们的接收程序: 它不使用SIGURG信号或select。相反,它调用sockatmark来确定何时遇到了带外字节。
#include "unp.h" int main(int argc, char * * argv) { int listenfd, connfd, n, on=1; char buff[100]; if(argc == 2) listenfd = Tcp_listen(NULL, argv[1], NULL); else if(argc ==3) listenfd = Tcp_listen(argv[1], argv[2], NULL); else err_quit("usage: tcprecv04 [ <host> ] <port#>"); Setsockopt( listenfd, SOL_SOCKET, SO_OOBINLINE, &on, sizeof(on) ); connfd = Accept(listenfd, NULL, NULL); sleep(5); for( ; ; ) { if(Sockatmark(connfd)) printf("at OOB mark\n"); if( (n = Read(connfd, buff, sizeof(buff)-1)) == 0) { printf("received EOF\n"); exit(0); } buff[n] = 0; /* null terminate */ printf("read %d bytes: %s\n", n, buff); } }
我们现在给出另一个简单例子来说明前面提到的带外数据的两个附加特性
1. TCP发送带外数据的通知(它的紧急指针),即使它因流控停止了数据的发送
2. 在带外数据到来之前,接收进程可得到指示:发送者已经送出了带外数据(用SIGURG信号或通过select)。如果接收进程接着调用指定MSG_OOB的recv,而带外数据却尚未到达,EWOULDBLOCK错误就会返回。
图21.10是发送程序
#include "unp.h" int main(int argc, char * * argv) { int sockfd, size; char buff[16384]; if(argc != 3) err_quit("usage: tcpsend04 <host> <port#>"); sockfd = Tcp_connect(argv[1], argv[2]); size = 32768; /* 进程设置它的套接口发送缓冲区大小为32768 */ Setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); Write(sockfd, buff, 16384); /* 写16384字节的普通数据 */ printf("wrote 16384 bytes of normal data\n"); sleep(5); /* sleep5秒 */ Send(sockfd, "a", 1, MSG_OOB); printf("wrote 1 byte of OOB data \n"); Write(sockfd, buff, 1024); printf("wrote 1024 bytes of normal data\n"); exit(0); /* 我们不久将会看到接收者设置的套接口接收缓冲区大小为4096,所以发送者的这些操作保证发送TCP填满接收者的套接口缓冲区。发送者接着发送1字节的带外数据,后面跟着1024字节的普通数据,然后终止 */ }
图21.11给出了接收程序
#include "unp.h" int listenfd, connfd; void sig_urg(int); int main(int argc, char * * argv) { int size; if(argc == 2) listenfd = Tcp_listen(NULL, argv[1], NULL); else if(argc == 3) listenfd = Tcp_listen(argv[1], argv[2], NULL); else err_quit("usage: tcprecv05 [ <host> ] <port#>"); size = 4096; /* 接收进程设置监听套接口的缓冲区大小为4096,在连接建立后,这个大小会传递给已连接套接口 */ Setsockopt(listenfd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); connfd = Accept(listenfd, NULL, NULL); /* 进程accept连接,给SIGURG建立一个信号处理程序,并设置套接口的属主 */ Signal(SIGURG, sig_urg); Fcntl(connfd, F_SETOWN, getpid()); for( ; ; ) /* 主进程然后在无穷循环中调用pause */ pause(); } void sig_urg(int signo) { /* 信号处理程序调用recv来读带外数据 */ int n; char buff[2048]; printf("SIGURG received \n"); n = Recv(connfd, buff, sizeof(buff)-1, MSG_OOB); buff[n] = 0; /* null terminate */ printf('read %d OOB byte \n", n); }
但是当指定MSG_OOB标志调用recv时,带外字节不能读。解决方法是让接收者通过读入现有的普通数据,在套接口接收缓冲区中腾出空间。这将导致TCP向发送者通告一个非零的窗口,最终让发送者发送带外字节。
下面一个例子展示每个给定的TCP连接只有一个带外标记,如果在接收进程读入现有带外数据之前一个新的带外数据到达,那么先前的标记丢失。
图21.12是发送程序,与图21.8相似,附加了另一个带外数据的send和接着的普通数据的wirte
#include "unp.h" int main(int argc, char * * argv) { int sockfd; if(argc != 3) err_quit("usage: tcpsend04 <host> <port#>"); sockfd = Tcp_connect(argv[1], argv[2]); Write(sockfd, "123", 3); printf("wrote 3 bytes of normal data \n"); Send(sockfd, "4", 1, MSG_OOB); printf("wrote 1 byte of OOB data\n"); Write(sockfd, "5", 1); printf("wrote 1 byte of normal data \n"); Send(sockfd, "6", 1, MSG_OOB); printf("wrote 1 byte of OOB data\n"); Write(sockfd, "7", 1); printf("wrote 1 byte of normal data\n"); exit(0); }
在发送中没有停顿,允许所有的数据迅速发送给接收者TCP
接收程序与图21.9中相同,它在接受连接之后sleep5秒,以允许数据到达它的TCP。
21.4. TCP带外数据小结
当我们考虑可能出现的定时问题时,带外数据会变得繁杂。要考虑的第一点是带外数据的概念实际上传递给接收者三条不同的信息。
1. 发送者进入紧急模式的事实,接收进程可以用SIGURG信号或者select得到通知。这种通知在发送者发送带外数据后立即传输,因为在图21.11中我们看到即使发送数据给接收者因TCP的流控而停止了,TCP仍发送这种通知。这种通知可能导致接收者进入某种特殊处理模式,以处理收到的后继数据。
2. 带外字节的位置,也就是说相对于发送者的其余数据,它是在哪儿发出的:带外标记。
3. 带外字节的实际值。由于TCP是一个字节流协议,它不解释应用进程发送的数据,因此这可以是任何8位值
对于TCP的紧急模式,我们可以认为URG标志是通知,紧急指针是标记,数据字节是其本身。
和这种带外数据相关的问题是(a)每个连接只有一个TCP紧急指针,(b)每个连接只有一个带外标记,(c)每个连接只有一个字节的带外缓冲区(这个缓冲区只有在数据非在线读入时才是一个问题)。我们在图21.12中看到,新到来的标记覆盖进程没有处理的先前标记。如果是在线读数据,当新的带外数据到来时,先前的带外字节不会丢失,但是标记丢失了。
21.5 客户-服务器心搏函数
我们现在开发一些简单的心搏函数用于我们的回射客户和服务器程序。这些函数可以发现对方主机或对方的通信路径的早期失效。
我们将使用TCP的紧急模式来有规律地轮询对方。我们在下面的描述中假设每秒轮询一次,5秒无回应则认为对方不再存活,但这是可有应用程序改变的。图21.13展示了客户和服务器的关系。
在这个例子中,客户每隔一秒向服务器发送一个带外字节,服务器收到后向客户发回一个带外字节。双方都需要知道对方是否不存在了或者不可到达。客户和服务器每秒增加它们的变量cnt,每收到一个带外字节就将这个变量重置为0.如果计数到了5(也就是说进程5秒内没有收到对方的带外字节),就认为失败了。客户和服务器都用SIGURG信号接收带外字节到达的通知。我们在该图的中间指出:数据、回射数据和带外字节都通过一个TCP连接交换的。
图21.4给出了提供客户心搏功能的三个函数
#include "unp.h" /* 给heartbeat_cli的参数的拷贝: 套接口描述字(信号处理程序需用它来发送和接收带外数据),SIGALRM的频率,在客户认为服务器或连接死掉之前没有来子服务器的响应的SIGALRM的总数,总量nprobes记录从最近一次服务器应答以来的SIGALRM的数目 */ static int servfd; static int nsec; /* #seconds between each alarm */ static int maxnprobes; /* #probes w/no response before quit */ static int nprobes; /* #probes since last server response */ static void sig_urg(int), sig_alrm(int); void heartbeat_cli(int servfd_arg, int nsec_arg, int maxnprobes_arg) {/* heartbeat_cli函数检查并且保存参数,给SIGURG和SIGALRM建立信号处理函数,将套接口的属主设为进程ID,alarm调度一个SIGALRM */ servfd = servfd_arg; /* set globals for signal handlers */ if( (nsec = nsec_arg) < 1) nsec = 1; if( (maxnprobes = maxnprobes_arg) < nsec) maxnprobes = nsec; nprobes = 0; Signal(SIGURG, sig_urg); Fcntl(servfd, F_SETOWN, getpid() ); Signal(SIGALRM, sig_alrm); alarm(nesc); } static void sig_urg(int signo) {/* 当一个带外通知到来时,就会产生这个信号。我们试图去读带外字节,但如果还没有到(EWOULDBLOCK)也没有关系。由于系统不是在线接收带外数据,因此不会干扰客户读取它的普通数据。既然服务器仍然存活,nprobes就重置为0 */ int n; char c; if( ( n = recv(servfd, &c, 1, MSG_OOB) ) < 0 ) { if(errno != EWOULDBLOCK) err_sys("recv error"); } nprobes = 0; /* reset counter */ return; /* may interrupt client code */ } static void sig_alrm(int signo) {/* 这个信号以有规律间隔产生。计数器nprobes增1, 如果达到了maxnprobes,我们认为服务器或者崩溃或者不可达。在这个例子中,我们结束客户进程,尽管其他的设计也可以使用:可以发送给主循环一个信号,或者作为另外一个参数给heartbeat_cli提供一个客户函数,当服务器看来死掉时调用它 */ if( ++nprobes > maxnprobes) { fprintf(stderr, "server is unreachable \n"); exit(0); } Send(servfd, "1", 1, MSG_OOB); alarm(nsec); return; /* may interrupt client code */ }
图21.15给出了服务器心搏函数
#include "unp.h" static int servfd; static int nsec; /* #seconds between each alarm */ static int maxnalarms; /* #alarms w/no client probe before quit */ static int nprobes; /* #alarms since last client probe */ static void sig_urg(int), sig_alrm(int); void heartbeat_serv(int servfd_arg, int nsec_arg, int maxnalarms_arg) { servfd = servfd_arg; /* set globals for signal handlers */ if( (nsec = nsec_arg) < 1 ) nsec = 1; if( (maxnalarms = maxnalarms_arg) < nsec) maxnalarms = nsec; Signal(SIGURG, sig_urg); Fcntl(servfd, F_SETOWN, getpid()); Signal(SIGALRM, sig_alrm); alarm(nsec); } static void sig_urg(int signo) { /* 当一个带外通知收到时, 服务器试图读入它。就像客户一样,如果带外字节没有到达没有什么关系。带外字节被作为带外数据返回给客户。注意,如果recv返回EWOULDBLOCK错误,那么自动变量c碰巧是什么就送给客户什么。由于我们不用带外字节的值,所以这没有关系。重要的是发送1字节的带外数据,而不管该字节是什么。由于刚收到通知,客户仍存活,所以重置nprobes为0 */ int n; char c; if( (n = recv(servfd, &c, 1, MSG_OOB)) < 0) { if(errno != EWOULDBLOCK) err_sys("recv error"); } Send(servfd, &c, 1, MSG_OOB); /* echo back out-of-hand byte */ nprobes = 0; /* reset counter */ return; /* may interrupt server code */ } static void sig_alrm(int signo) { /* nprobes增1, 如果它到达了调用者指定的值maxnalarms,服务器进程将被终止。否则调度一下SIGALRM */ if( ++nprobes > maxnalarms) { printf("no probes from client\n"); exit(0); } alarm(nsec); return; /* may interrupt server code */ }
21.6 小结