UNIX网络编程——带外数据
许多传输层有带外数据的概念,它有时也称为经加速数据。其想法是一个连接的某端发生了重要的事情,而且该端希望迅速通告其对端。这里“迅速”意味着这种通知应该在已排队等待发送的任何“普通”(有时称为“带内”)数据之前发送。也就是说,带外数据被认为具有比普通数据更高的优先级。带外数据并不需要在客户和服务器之间再使用一个连接,而是被映射到已有的连接中。
不幸的是,一旦超越普通概念光临现实世界,我们发现几乎每个传输层都各自有不同的带外数据实现。而UDP作为一个极端的例子,没有实现带外数据。
1.TCP带外数据
TCP并没有真正的带外数据,不过提供了我们接着要说的紧急模式。假设一个进程已经往一个TCP套接字写出N字节数据,而且TCP把这些数据排队在该套接字的发送缓冲区中,等着发送到对端。如图展示了这样的套接字发送缓冲区,并且标记了从1到N的数据字节。
该进程接着以MSG_OOB标志调用send函数写出一个含有ASCII字符a的单字节带外数据:
send(fd,"a",1,MSG_OOB);TCP把这个数据放置在该套接字发送缓冲区的下一个可用位置,并把该连接的TCP紧急指针设置成再下一个可用位置。如图展示了此时的套接字发送缓冲区,并且把带外字节标记为:“OOB”。
TCP紧急指针对应一个TCP序列号,它是使用MSG_OOB标志写出的最后一个数据字节(即带外字节)对应的序列号加1。
给定的上图所示的TCP套接字发送缓冲区状态,发送端TCP将为待发送的下一个分节在TCP首部中设置URG标志,并把紧急偏移字段设置为指向带外字节之后的字节,不过该分节可能含也可能不含我们标记的OOB的那个字节。OOB字节是否发送取决于在套接字发送缓冲区中先于它的字节数,TCP准备发送给对端的分节大小以及对端通告的当前窗口。
这是TCP紧急模式的一个重要特点:TCP首部指出发送端已经进入紧急模式(即伴随紧急偏移的URG标志已经设置),但是由紧急指针所指的实际数据字节却不一定随同送出。事实上即使发送端TCP因流量控制而暂停发送数据(接收端的套接字接收缓冲区已满,导致其TCP想发送端TCP通告了一个值为0 的窗口),紧急通知照样不伴随任何数据的发送。这也是应用进程使用TCP紧急模式(即带外数据)的一个原因:即便数据的流动会因为TCP的流量控制而停止,紧急通知却总是无障碍的发送到对端TCP。
如果我们发送多个字节的带外数据,情况又会任何呢?例如:
send(fd,"abc",3,MSG_OOB);
在这个例子中,TCP的紧急指针指向最后那个字节紧后的位置,也就是说最后那个字节(字母c)被认为是带外字节,注意仅仅是一个字节。
至此我们已经讲述了带外数据的发送,下面从接收端的角度查看一下:
(1)当收到一个设置了URG标志的分节时,接收端TCP检查紧急指针,确实它是否指向新的带外数据,也就是判断本分节是不是首个到达的引用从发送端到接收端的数据流中特定字节的紧急模式分布。发送端TCP往往发送多个含有URG标志且紧急指针指向同一个数据字节的分节(通常是在已小段时间内)。这些分节中只有第一个到达的会导致通知接收进程有新的带外数据到达。
(2)当有新的紧急指针到达时,接收进程被通知到。首先,内核给接收套接字的属主进程发送SIGURG信号,前提是接收进程(或其他进程)曾调用fcntl或ioctl为这个套接字建立了属主,而且该属主进程已为这个信号建立了信号处理函数。其次,如果接收进程阻塞在select调用中以等待这个套接字描述符出现一个异常条件,select调用就返回。
一旦有新的紧急指针到达,不论由紧急指针指向的实际数据字节是否已经到达接收端TCP,这两个潜在通知接收进程的手段就发生动作。
只有一个OOB标记,如果新的OOB字节在旧的OOB字节被读取之前就到达,旧的OOB字节会被丢弃。
(3)当由紧急指针指向的实际数据字节到达接收端TCP,该数据字节既可能被拉出带外,也可能被留在带内,即在线留存。SO_OOBINLINE套接字选项默认情况下是禁止的,对于这样的接收端套接字,该数据字节并不放入套接字接收缓冲区,而是被放入该连接的一个独立的单字节带外缓冲区。接收进程从这个单字节缓冲区读入数据的唯一方法是制定MSG_OOB标志调用recv,recvfrom或recvmsg。如果新的OOB字节在旧的OOB字节被读取之前就到达,旧的OOB字节会被丢弃。
然而如果接收进程开启了SO_OOBINLINE套接字选项,那么由TCP紧急指针指向的实际数据字节将被留在通常的套接字接收缓冲区中。这种情况下,接收进程不能指定MSG_OOB标志读入该数据字节。相反,接收进程通过检查该连接的带外标记以获悉何时访问到这个数据字节。
发生一些错误是可能的:
- 如果接收进程请求读入带外数据(通过指定MSG_OOB标志),但是对端尚未发送任何带外数据,读入操作将返回EINVAL。
- 在接收进程已被告知对端发送了一个带外字节(通过SIGURG或select手段)的前提下,如果接收进程读入该字节,但是该字节尚未到达,读入操作将返回EWOULDBLOCK。接收进程此时能做的仅仅是从套接字接收缓冲区读入数据(要是没有存放这些数据的空间,可能还得丢弃他们),以便在该缓冲区中腾出空间,继而允许对端TCP发送出那个带外字节。
- 如果接收进程试图多次读入同一个带外字节,读入操作将返回EINAVL。(后面有说明:select改进)
- 如果接收进程已经开启SO_OOBINLINE套接字选项,后来试图通过指定的MSG_OOB标志读入带外数据,读入操作将返回EINVAL。
2.使用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个字节,每个输出操作之间有一个1S的sleep。停顿的目的是让每个write或send的数据作为单个TCP分节在本端发送并在对端接收。程序运行结果:
wrote 3 bytes of normal date wrote 1 bytes of OOB data wrote 2 bytes of normal date wrote 1 bytes of OOB data wrote 2 bytes of normal date
下面是接收程序:
#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); }
建立信号处理函数和套接字属主
15-16 建立SIGURG的信号处理函数,使用fcntl设置已连接套接字的属主。
(注意,我们直到accept返回之后才建立信号处理函数,这么做会错过一些以小概率出现的带外数据,他们在TCP完成三次握手之后但在
accept返回之前到达。然而如果我们在调用accept之前信号处理函数并设置监听套接字的属主(本属性将传承给已连接套接字),那么如果
带外数据在accept之前到达,我们的信号处理函数将没有真正的connfd值可用。如果这种情形对于应用程序确实重要,它就应该把connfd初
始化为-1,在信号处理函数中检查该值是否为-1,若为真则简单的设置一个标志,供主循环在accept返回之后检查。另一方面,这可能阻塞
accept调用周围的信号)。
17-25 本进程从套接字中读,显示由read返回的每个字符串。发送进程终止连接后,接收进程随后终止。
SIGURG处理函数
28-36 我们的信号处理函数调用printf,通过指定MSG_OOB标志读入带外字节,然后显示返回的数据。注意,我们在recv调用中请求最多100个字节。
运行结果:
read 3 bytes:123 SIGURG received read 1 OOB byte:4 read 2 bytes:56 SIGURG received read 1 OOB byte:7 read 2 bytes:89 received EOF结果与我们预期的一致。发送进程带外数据的每次发送产生递交给接收进程的SIGURG信号,后者接着读入单个带外字节。
3.使用select的简单例子
我们现在改用select代替SIGURG信号重新编写带外接收程序。
有问题的:
#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); } } }
19-25 调用select等待普通数据(读集合rset)或带外数据(异常集合xset)。每种情况下都显示接收的数据。
我先运行本程序,接着运行早先的发送程序,结果碰到如下错误:
read 3 bytes:123 read 1 OOB byte:4 recv error:Invalid argument
问题是select一直指示一个异常条件,直到进程的读入越过带外数据。同一个带外数据不能读入多次,因为首先读入之后,内核就清空这个单字节的缓冲区。再次指定MSG_OOB标志调用recv时,它将返回EINVAL。
解决办法是只在读入普通数据之后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; } } }
4 声明一个名为justreadoob的变量,用于指示我们是否刚刚读过带外数据。这个标志决定是否select异常条件。
29-30 当设置justreadoob标志时,我们还得在异常描述符集中清除已连接套接字描述符对应的位。