UNIX网络编程卷一 学习笔记 第二十四章 带外数据

许多传输层都有带外数据(out-of-band data)的概念,它有时也称为经加速数据(expedited data)。其想法是一个连接的某端发生了重要的事情,且该端希望迅速通告其对端,这里的迅速指这种通知应该在已经排队等待发送的任何普通(有时也称带内)数据前发送,即带外数据被认为比普通数据优先级更高。带外数据并不要求在客户和服务器间再使用一个连接,而是映射到已有的连接中。

但几乎每个传输层都各自有不同的带外数据实现,而UDP作为一个极端的例子,没有实现带外数据。本章我们只关注TCP的带外数据模型,并描述了telnet、rlogin、FTP等应用是如何使用带外数据的,除了这样的远程交互应用,几乎很少有使用到带外数据的地方。

TCP并没有真正的带外数据,但提供了紧急模式。假设一个进程已经往一个TCP套接字写出N字节数据,且TCP把这些数据排队在该套接字的发送缓冲区中,等待发送到对端:
UNIX网络编程卷一 学习笔记 第二十四章 带外数据_第1张图片
该进程接着以MSG_OOB标志调用send写一个含有ASCII字符a的单字节带外数据:

// 发送的是a,而非a\0
send(fd, "a", 1, MSG_OOB);

TCP把这个数据放在该套接字发送缓冲区的下一个可用位置,并把该连接的TCP紧急指针设置成再下一个可用位置:
UNIX网络编程卷一 学习笔记 第二十四章 带外数据_第2张图片
TCP紧急指针对应一个TCP序列号,它是使用MSG_OOB标志写出的最后一个数据字节(即带外字节)对应的序列号加1,只要发送端TCP和接收端TCP在TCP紧急指针的解释上达成一致,就不会有问题。

给定上图所示的TCP套接字发送缓冲区状态,发送端TCP将为待发送的下一分节的TCP首部中设置URG标志,并把紧急偏移字段设为指向带外字节之后的字节,但该分节中可能不含我们上图中标记为OOB的那个字节,是否含有该字节取决于发送缓冲区中先于OOB的字节数、TCP准备发送给对端的分节大小(取决于对端通告本端的MSS和本端通告对端的MSS中两者较小值、对端通告的当前窗口)。

我们使用了紧急指针和紧急偏移两个术语,TCP首部中的16位值称为紧急偏移,它必须加上同一个首部中的序列号字段才能获得32位的紧急指针。只有首部中的URG标志被设置的情况下,TCP才会检查紧急偏移。从编程角度看,我们无需担心这个细节,统一指称TCP紧急指针就行。

TCP首部指出发送端已经进入紧急模式(即伴随紧急偏移的URG标志已经设置),但由紧急指针所指的实际数据字节不一定随同送出。事实上,即使发送端TCP因流量控制而暂停发送数据(接收端套接字的接收缓冲区已满,导致其TCP向发送端TCP通告了一个值为0的窗口),紧急通知仍不伴随任何数据地发送。这也是应用使用TCP紧急模式(即带外数据)的一个原因,即便数据的流动会因为TCP的流量控制而停止,紧急通知却总是无障碍地发送到对端TCP。

如果我们发送多字节的带外数据:

send(fd, "abc", 3, MSG_OOB);

紧急指针会指向最后一个字节c后面的位置,即最后那个字节c被认为是带外字节。

从接收端角度看带外数据:
1.当收到一个设置了URG标志的分节时,接收端TCP检查紧急指针,确定它是否指向新的带外数据,即判断本分节是否是首个到达的指向特定紧急数据字节的分节。发送端TCP往往会在一小段时间内发送多个含有URG标志且紧急指针指向同一个数据字节的分节,这些分节中只有第一个到达的会导致接收进程收到一个通知。

2.当有新的紧急指针到达时,接收进程被通知。首先内核给接收套接字的属主进程发送SIGURG信号(属主进程用PID标识,可调用fcntl或ioctl为套接字建立属主)。其次如果接收进程阻塞在select调用中以等待这个套接字描述符出现一个异常条件,select函数就返回。

一旦有新的紧急指针到达,不论紧急指针指向的字节是否已经到达接收端TCP,以上两个通知手段都会发生。

只有一个OOB标记,如果新的OOB字节在旧的OOB字节被读取前就到达,旧的OOB字节会被废弃。

3.当由紧急指针指向的实际数据字节到达接收端TCP时,该数据字节可能被拉出带外,也可能留在带内(即inline留存)。SO_OOBINLINE套接字选项默认是关闭的,此时,紧急数据字节不放入套接字接收缓冲区,而是放入该连接的一个独立的单字节带外缓冲区。接收进程从这个单字节缓冲区读入数据的唯一方法是指定MSG_OOB标志调用recv、recvfrom、recvmsg。如果新的OOB字节在旧的OOB字节被读取前就到达,旧的OOB字节会被丢弃。

如果接收进程开启了SO_OOBINLINE套接字选项,则由紧急指针指向的实际数据字节将被留在套接字接收缓冲区中,此时,接收进程不能指定MSG_OOB标志读入该字节,而是检查该连接的带外标记以获悉何时访问到这个数据字节。

接收带外数据时可能会发生一些错误:
1.默认,如果接收进程通过指定MSG_OOB标志请求读入带外数据,但对端没有发送过紧急数据(即我们未被通知过有紧急数据),读入操作将返回EINVAL。

2.接收进程已被告知对端发送了一个带外字节(通过SIGURG或select函数)的前提下,如果接收进程试图读入该字节,但该字节尚未到达,读入操作将返回EWOULDBLOCK。接收进程此时能做的仅仅是从套接字接收缓冲区读入数据(如果没有存放这些数据的空间,可能还要丢弃读入的数据),以便在接收缓冲区中腾出空间,继而允许对端TCP发送那个带外字节。

3.如果接收进程试图多次读入同一带外字节,读入操作将返回EINVAL。

4.如果接收进程已经开启了SO_OOBINLINE套接字选项,再试图通过MSG_OOB标志读入带外数据时,读入操作将返回EINVAL。

发送带外数据的程序:

#include "unp.h"

int main(int argc, char **argv) {
    int sockfd;

    if (argc != 3) {
        err_quit("usage: tcpsend01  ");
    }

    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分节在本端发送,运行它:
UNIX网络编程卷一 学习笔记 第二十四章 带外数据_第3张图片
以下是紧急数据的接收程序:

#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 [  ] ");
    }

    connfd = Accept(listenfd, NULL, NULL);

    // 建立SIGURG的信号处理函数,此处我们在accept函数返回后才建立信号处理函数
    // 这么做可能错过一些带外数据,它们在TCP完成三路握手后,但在accept函数返回前到达
    // 但如果我们在调用accept前建立信号处理函数并设置监听套接字的属主(属主会传递给已连接套接字)
    // 那么如果带外数据在accept函数返回前到达,我们的信号处理函数将没有可读的connfd值
    // 如果这种情形对应用来说很重要,我们可以把connfd初始化为-1,在信号处理函数中检查该值是否为-1
    // 如果是-1,则设置一个标志,供accept函数返回后的主循环中检查
    // 另一种处理方式是阻塞accept函数附近的信号递送
    Signal(SIGURG, sig_urg);
    // 设置已连接套接字的属主,如果此处不设置套接字属主,将不会递送SIGURG信号
    Fcntl(connfd, F_SETOWN, getpid());

    for (; ; ) {
        if ((n = Read(connfd, buff, sizeof(buff) - 1)) == 0) {
            printf("recvived 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,此处这么做这是为了查看程序在干什么
    printf("read %d OOB byte: %s\n", n, buff);
}

先运行接收程序,再运行发送程序得到的输出:
UNIX网络编程卷一 学习笔记 第二十四章 带外数据_第4张图片
结果与预期一致,发送进程每次发送带外数据都会产生一个SIGURG信号给接收进程,之后接收进程读入单个带外字节。

如果以上接收进程中删掉设置套接字属主的代码,会得到这样的输出:
在这里插入图片描述
现在改用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 [  ] ");
    }

    connfd = Accept(listenfd, NULL, NULL);

    FD_ZERO(&rset);
    FD_ZERO(&xset);
    for (; ; ) {
        FD_SET(connfd, &rset);
        FD_SET(connfd, &xset);

        // 等待普通数据(读集合rset)和带外数据(异常集合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函数每次调用都会指示一个异常条件,同一个带外数据不能读入多次,因为首次读入后,内核就清空这个单字节缓冲区,再次指定MSG_OOB标志调用recv时,它将返回EINVAL。

对于以上问题,应该是一个bug,我在以下机器上运行以上程序是可以正常运行的:

Linux rh 2.6.39-400.17.1.el6uek.x86_64 #1 SMP Fri Feb 22 18:16:18 PST 2013 x86_64 x86_64 x86_64 GNU/Linux

输出与使用SIGURG信号的版本一致。

对于以上问题的解决方法,可以只在读入普通数据后才select异常条件:

#include "unp.h"

int main(int argc, char **argv) {
    int listenfd, connfd, n, justreadoob = 0;
    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 [  ] ");
    }

    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 byte: %s\n", n, buff);
            justreadoob = 1;
            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;
        }
    }
}

修改后可以按预期的方式工作了。

将以上程序改为使用poll函数的:

#include "unp.h"

int main(int argc, char **argv) {
    int listenfd, connfd, n, justreadoob = 0;
    char buff[100];
    struct pollfd pollfd[1];

    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: tcprecv03p [  ] ");
    }

    connfd = Accept(listenfd, NULL, NULL);

    pollfd[0].fd = connfd;
    pollfd[0].events = POLLRDNORM;
    for (; ; ) {
        if (justreadoob == 0) {
            pollfd[0].events |= POLLRDBAND;
        }

        Poll(pollfd, 1, INFTIM);

        if (pollfd[0].revents & POLLRDBAND) {
            n = Recv(connfd, buff, sizeof(buff) - 1, MSG_OOB);
            buff[n] = 0;    /* null terminate */
            printf("read %d OOB byte: %s\n", n, buff);
            justreadoob = 1;
            pollfd[0].events &= ~POLLRDBAND;    /* turn bit off */
        }

        if (pollfd[0].revents & POLLRDNORM) {
            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;
        }
    }
}

每当收到一个带外数据,就有一个与之关联的带外标记,带外标记是发送进程发送带外字节时该字节在发送端普通数据流中的位置,接收进程可调用sockatmark确定当前是否处于带外标记:
在这里插入图片描述
sockatmark函数是POSIX创造的,POSIX正在把许多ioctl函数请求替换成单独的函数。

以下是sockatmark函数的一个实现,它以SIOCATMARK为参数调用iotcl:

#include "unp.h"

int sockatmark(int fd) {
    int flag;

    if (ioctl(fd, SIOCATMARK, &flag) < 0) {
        return -1;
    }    
    return flag != 0;
}

不管接收进程在线(开启SO_OOBINLINE套接字选项)还是带外接收带外数据,带外标记都适用。

带外标记的两个特性:
1.带外标记总是指向普通数据最后一个字节的后一个位置。如果带外数据在线接收,如果下一个待读入字节是使用MSG_OOB标志发送的,sockatmark函数就返回真;如果SO_OOBINLINE套接字选项没有开启,如果下一个待读入字节是跟在带外数据后发送的第一个字节,sockatmark函数就返回真。

2.读操作总是停在带外标记上。如果套接字接收缓冲区中有100个字节,但在带外标记之前只有5个字节,而进程执行一个请求100字节的read调用,那么返回的是带外标记前的5个字节。

以下是我们的另一个发送带外数据的程序:

#include "unp.h"

int main(int argc, char **argv) {
    int sockfd;

    if (argc != 3) {
        err_quit("usage tcpsend04  ");
    }

    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);
}

以上程序发送3字节普通数据,然后发送1字节带外数据,再跟1字节普通数据,每个输出操作间没有停顿。

以下是以上发送程序的接收程序:

#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 [  ] ");
    }

    // 我们希望在线接收带外数据,如果我们在accept函数返回后再在已连接套接字上开启SO_OOBINLINE
    // 那时三路握手已经完成,带外数据也可能已经到达,因此我们必须在监听套接字上开启此选项
    // 此套接字选项会从监听套接字传承给已连接套接字
    Setsockopt(listenfd, SOL_SOCKET, SO_OOBINLINE, &on, sizeof(on));

    connfd = Accept(listenfd, NULL, NULL);
    // 接受连接后,接收进程sleep一段时间以接收所有来自发送进程的数据
    // 这样做可以展示read函数会停在带外标记上,即使套接字接收缓冲区中还有额外数据
    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);
    }
}

运行以上程序:
UNIX网络编程卷一 学习笔记 第二十四章 带外数据_第5张图片
可见首次调用read因遇到带外标记仅返回了5个字节中的前3个,下一个读入的字节为4,它是带外字节,因为我们已经开启了SO_OOBINLINE套接字选项。

我们给出另一个例子,以展示带外数据的另2个属性:
1.即使因为流量控制而停止发送数据了,TCP仍发送带外数据的通知。

2.在带外数据到达前,接收进程就能被通知有带外数据,如果此时接收进程指定MSG_OOB调用recv,而带外数据尚未到达,recv函数将返回EWOULDBLOCK错误。

以下是发送程序:

#include "unp.h"

int main(int argc, char **argv) {
    int sockfd, size;
    char buff[16384];

    if (argc != 3) {
        err_quit("usage: tcpsend05  ");
    }

    sockfd = Tcp_connect(argv[1], argv[2]);
    
    size = 32768;
    Setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));

    // 接收进程的套接字接收缓冲区大小为4096字节,此处的write函数会填满接收端套接字接收缓冲区
    Write(sockfd, buff, 16384);
    printf("wrote 16384 bytes of normal data\n");
    sleep(5);

    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);
}

以下是以上发送程序的接收程序:

#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 [  ] ");
    }

    // 把监听套接字缓冲区设为4096,连接建立后,这个大小将传承给已连接套接字
    size = 4096;
    Setsockopt(listenfd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));

    connfd = Accept(listenfd, NULL, NULL);

    Signal(SIGURG, sig_urg);
    Fcntl(connfd, F_SETOWN, getpid());

    for (; ; ) {
        pause();
    }
}

void sig_urg(int signo) {
    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);
}

先运行发送程序:
在这里插入图片描述
以下是接收进程的输出:
在这里插入图片描述
由err_sys函数显式的出错消息传对应EAGAIN,EAGAIN等同于FreeBSD中的EWOULDBLOCK。发送端TCP向接收端TCP发送了带外通知,由此产生了递交给接收进程的SIGURG信号,但当接收进程指定MSG_OOB标志调用recv时,相应带外字节不能读入。

解决方法是让接收进程读已排队的普通数据,腾出套接字接收缓冲区中的空间,这将导致接收端TCP向发送端通告一个非0窗口,最终允许发送端发送带外字节。

源自Berkeley的实现中,即使套接字发送缓冲区已满,内核也会接受将要发送到对端的一个带外字节,当发送进程调用send发送带外字节时,一个含有紧急通知的TCP分节被立刻发送,所有正常的TCP输出检查(Nagle算法、糊涂窗口避免等)都被略过。

下例展示了一个给定TCP连接只能有一个带外标记,如果接收进程读入某个现有带外数据前有新带外数据到达,先前的标记会丢失:

#include "unp.h"

int main(int argc, char **argv) {
    int sockfd;

    if (argc != 3) {
        err_quit("usage: tcpsend06  ");
    }

    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("worte 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);
}

使用tcprecv04接收进程,它在线接收带外数据,且在接受连接后睡眠5秒,以允许来自发送端的数据到达接收端TCP:
在这里插入图片描述
可见第二个带外字节(6)的到来覆写了第一个带外字节(4)的带外标记。(我测试时,如果接收进程非在线接收带外数据,4也会输出,紧急字节本身不会被覆写)

带外数据的概念实际向接收端传达三个信息:
1.发送端进入紧急模式。接收端得到这一信息的手段只有SIGURG信号或select调用。发送进程调用send发送带外字节后发送端TCP立即发送一个TCP分节通知接收端,即使往接收端的数据发送因流量控制而停止了。本通知可能导致接收端进入某种特殊处理模式,以处理后续数据的接收。

2.带外字节的位置。

3.带外字节的实际值。

对于TCP紧急模式,我们可以认为URG标志是上述信息1,紧急指针时上述信息2,数据字节是上述信息3。

与带外数据概念相关的问题有:
1.每个连接只有一个TCP紧急指针。

2.每个连接只有一个带外标记。

3.每个连接只有一个单字节的带外缓冲区(非在线读入时才用带外缓冲区)。新到达带外标记会覆写先前未接收的带外标记,如果带外数据是在线读入的,当新的带外数据到达时,先前的带外字节并未丢失,但它们的标记却被取代而丢失了(我测试时,如果带外数据是非在线读入,先前的带外字节同样不会丢失,这可能只是我所用系统的特性)。

带外数据的一个常见用途体现在rlogin程序中。当客户中断运行在服务器主机上的程序时,服务器需要告知客户丢弃所有被中断程序的输出,服务器此时会给客户发送一个带外数据,客户收到由带外数据引发的SIGURG信号后,就从套接字中读,直到碰到带外标记,并丢弃到标记之前的所有数据。这种情况下即使服务器连续快速发送多个带外字节客户也不受影响,因为客户只是读到最后一个标记为止,并丢弃所有读入的数据。

如果带外数据的目的只是上例一样告知对端丢弃直到标记处的普通数据,那么丢失一个中间带外字节及其标记不会有不良后果。但如果不丢失带外字节本身很重要,则必须在线接收这些数据。带外字节应区别于普通数据,因为新的标记到达时,先前的标记将被覆写,从而把先前的带外字节混杂在普通数据中,例如,telnet在客户和服务器之间的数据流中发送telnet命令,手段是把值为255的字节作为telnet命令的前置字节,值为255的普通数据流需要发送2个相继的值为255的字节,这么做使得telnet能区分其命令和普通用户数据,但要求客户进程和服务器进程处理每个数据字节以寻找命令。

现在为先前开发的回射客户和服务器程序开发一些简单的心博函数,这些心博函数可发现对端主机或到对端的通信路径过早失效。

TCP的保持存活特性(SO_KEEPALIVE套接字选项)也能提供类似功能,但TCP得在连接已经闲置2小时后才发送一个保持存活探测段,时间过长,如果我们想把2小时缩小为一个小得多的值,如秒钟量级,以便更快地检测到失效,我们就需要系统的支持,实际上许多系统上都可以这么做,但这些参数通常是按内核而非套接字维护的,改动它们将影响所有开启该选项的套接字。保持存活选项的用意不是高频率地轮询。

TCP一开始就设计成能够对付临时断连,而源自Berkeley的TCP实现将重传8~10分钟才放弃某个连接。较新的IP路由协议(如OSPF)能发现链接的失效,且可能在短时间内(秒钟量级上)启用候选路径。应用开发人员需要考虑引入心博机制的具体应用,确定在没有对端应答持续超过5~10秒后终止相应连接是好事还是坏事,有些系统需要终止连接,但大多数不需要。

我们将使用TCP紧急模式周期地轮询对端,下例中我们1秒钟轮询一次,若持续5秒没有听到对端应答则认为对端已不再存活,但此值可由应用改动。
UNIX网络编程卷一 学习笔记 第二十四章 带外数据_第6张图片
如上图,客户每隔1秒向服务器发送一个带外字节,服务器收取该字节并向客户发送回一个带外字节。每端都需要知道对端是否不复存在或不再可达。客户和服务器每1秒递增它们的cnt变量一次,每收到一个带外字节就把该变量重置为0,如果计数器达到5(即本进程5秒没有收到来自对端的带外字节),就认定连接失效。有带外字节到达时,客户和服务器都使用SIGURG信号得到通知。我们在上图中间指出,普通数据和带外字节都通过单个TCP连接交换。

我们的回射客户main函数来自第五章:

#include "unp.h"

int main(int argc, char **argv) {
    int sockfd;
    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));
    
    /* do it all,完成客户剩余部分的处理工作 */
    str_cli(stdin, sockfd);    
    
    exit(0);
}

以上函数中调用的str_cli函数是第六章中的:

void str_cli(FILE *fp, int sockfd) {
    int maxfdp1, stdineof;
    fd_set rset;
    char buf[MAXLINE];
    int n;

    stdineof = 0;    // 只要该标志为0,我们就在主循环内用select函数检查标准输入的可读性
    FD_ZERO(&rset);
    for (; ; ) {
        if (stdineof == 0) {
		    FD_SET(fileno(fp), &rset);
		}
		FD_SET(sockfd, &rset);
		maxfdp1 = max(fileno(fp), sockfd) + 1;
		Select(maxfdp1, &rset, NULL, NULL, NULL);
	
		if (FD_ISSET(sockfd, &rset)) {    /* socket is readable */
		    if ((n = Read(sockfd, buf, MAXLINE)) == 0) {
		        // 当我们在套接字上读到EOF时,如果我们已在标准输入上遇到EOF,那就是正常终止,否则就是服务器过早终止
		        if (stdineof == 1) {
				    return;    /* normal termination */
				} else {
				    err_quit("strcli: server terminated prematurely");
				}
		    }
		    Write(fileno(stdout), buf, n);
		}
	
		if (FD_ISSET(fileno(fp), &rset)) {    /* input is readable */
		    if ((n = Read(fileno(fp), buf, MAXLINE)) == 0) {
		        stdineof = 1;
				Shutdown(sockfd, SHUT_WR);    /* send FIN */
				FD_CLR(fileno(fp), &rset);
				continue;
		    }
		    Writen(sockfd, buf, n);
		}
    }
}

为增加心博特性,我们只需对str_cli函数做以下改动:
1.在进入for循环前,调用我们的heartbeat_cli函数设置客户的心博特性:

// 以1秒为频率轮询,如果持续5此轮询无响应,则放弃当前连接
heartbeat_cli(sockfd, 1, 5);

2.如果select函数返回EINTR错误,我们continue重新循环并再次调用select。客户现多处理两个信号:SIGALRM用于定时轮询,SIGURG用于接收带外数据,因此我们需要处理被中断的系统调用。

3.我们调用writen往标准输出写服务器发回来的文本行,因为有些版本的标准IO函数库没有正确处理被中断的系统调用。

以下是为客户程序提供心博功能的3个函数:

#include "unp.h"

static int servfd;
static int nsec;    /* #seconds between each alarm */
static int maxnprobes;    /* #probes w/no response before quit,w/no是with no的缩写 */
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) {
    servfd = servfd_arg;    /* set globals for signal handlers */
    if ((nsec = nsec_arg) < 1) {
        nsec = 1;
    }
    if ((maxnprobes = maxnprobes_arg) < nsec) {
        maxnprobes = nsec;
    }
    nprobes = nsec;

    Signal(SIGURG, sig_urg);
    // 设置套接字属主
    Fcntl(servfd, F_SETOWN, getpid());

    Signal(SIGALRM, sig_alrm);
    // 启动一个定时器以调度第一个SIGALRM
    alarm(nsec);
}

static void sig_urg(int signo) {
    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) {
    if (++nprobes > maxnprobes) {
        fprintf(stderr, "server is unreachable\n");
        // 如果认定连接失效,简单地结束客户进程
        // 此处也可采用其他设计,如给主控制循环设置一个标志或调用其他处理函数
        exit(0);
    }
    // 发送1字节带外数据,发送的值没有意义
    Send(servfd, "1", 1, MSG_OOB);
    alarm(nsec);
    return;    /* may interrupt client code */
}

我们的回射服务器main函数和第五章中的相同:

#include "unp.h"

int main(int argc, char **argv) {
    int listenfd, connfd;
    pid_t childpid;
    socklen_t clilen;
    struct sockaddr_in cliaddr, servaddr;
    
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);
    
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    // 捆绑通配地址,告诉系统,如果系统是多宿主机,我们接受目的地址为任何本地接口的地址
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);    
    // 在头文件unp.h中,SERV_PORT的值定义为9877
    servaddr.sin_port = htons(SERV_PORT);    

    Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
    
    Listen(listenfd, LISTENQ);
 
    Signal(SIGCHLD, sig_chld);
   
    for (; ; ) {
        clilen = sizeof(cliaddr);
        if ((connfd = accept(listenfd, (SA *)&cliaddr, &clilen)) < 0) {
            if (errno = EINTR) {
                continue;    /* back to for() */
            } else {
                err_sys("accept error");
            }
        }
        
        if ((childpid = Fork()) == 0) {    /* child process */
            Close(listenfd);    /* close listening socket */
            str_echo(connfd);    /* process the request */
            exit(0);
        }
        Close(connfd);    /* parent closes connected socket */
    }
}

我们使用的str_echo函数也是第五章中的:

void str_echo(int sockfd) {
    ssize_t n;
    char buf[MAXLINE];
    
again:
    while ((n = read(sockfd, buf, MAXLINE)) > 0) {
        Writen(sockfd, buf, n);
    }
    
    if (n < 0 && errno == EINTR) {
        goto again;
    } else if (n < 0) {
        err_sys("str_echo: read error\n");
    }
}

我们只需修改str_echo函数,只需要一处改动,即在循环前加入为服务器初始化心博函数的行:

heartbeat_serv(sockfd, 1, 5);

以下是服务器程序的心博函数:

#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) {
    int n;
    char c;

    if ((n = recv(servfd, &c, 1, MSG_OOB)) < 0) {
        if (errno != EWOULDBLOCK) {
            err_sys("recv error");
        }
    }
    // 如果recv函数返回EWOULDBLOCK错误,那么自动变量c是什么就回送什么
    Send(servfd, &c, 1, MSG_OOB);    /* echo back out-of-band byte */

    nprobes = 0;    /* reset counter */
    return;    /* may interrupt server code */
}

static void sig_alrm(int signo) {
    if (++nprobes > maxnalarms) {
        printf("no probes from client\n");
        exit(0);
    }
    alarm(nsec);
    return;    /* may interrupt server code */
}

TCP没有真正的带外数据,但提供紧急模式和紧急指针。一旦发送端进入紧急模式,紧急指针就出现在发送到对端的分节的TCP首部中。连接的对端接收该指针后会告知接收进程发送端已进入紧急模式,但所有数据的发送仍受TCP正常的流量控制。紧急指针指向紧急数据的后一个字节。

套接字API把TCP的紧急模式映射成所谓的带外数据。发送进程通过指定MSG_OOB标志调用send让发送端进入紧急模式,该调用中的最后一个字节被认为是带外字节。接收端TCP收到金的紧急指针后,或通过发送SIGURG信号,或通过由select函数返回套接字有异常条件待处理的指示,让接收进程得到通知。默认,接收端TCP把带外字节从普通数据流中取出存放到自己的单字节带外缓冲区,供接收进程通过指定MSG_OOB标志调用recv读取;接收进程也可以开启SO_OOBINLINE套接字选项,此时,带外字节被留在普通数据流中。不管接收进程使用哪种方法读取带外字节,套接字层都在数据流中只维护一个带外标记,且不允许单个读操作读过这个标记,接收进程调用sockatmark确定它是否已经到达该标记。

带外数据未广泛使用,telnetl、rlogin、FTP使用它,它们使用带外数据是为了通知远端有异常情况发生,且服务器丢弃带外标记前接收的所有输入。

有以下两种调用:

send(fd, "ab", 2, MSG_OOB);

send(fd, "a", 1, MSG_OOB);
send(fd, "b", 1, MSG_OOB);

这两种调用是有区别的,第一种中的2个字节是随单个紧急指针发送的,紧急指针指向b后面的字节;第二种首先发送的是a和指向a之后字节的紧急指针,接着以另一个TCP分节发送b和指向b之后字节的紧急指针。

你可能感兴趣的:(unix,网络,学习)