TCP 带外数据(out-of-band data)和紧急指针

带外数据就是在普通数据之外的数据,通常是为了通告另一端重要事情而设置了“以最快速度发送”这样的标记。也就是说,带外数据有更高的优先级,而且几乎每个传输层协议都实现了该特性,因为它非常重要。而 UDP 是一个极端的例子,它没有带外数据。严格意义上,TCP 没有真正的带外数据,只是提供了被称为“紧急指针”的机制。

在 Linux 网络编程中,可以使用 MSG_OOB 标记来发送带外数据:

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

这个字节被放置在发送缓冲区的下一个可用位置,并设置 TCP 首部中的 16bit 紧急指针字段的值为该位置。然后,发送端 TCP 将为待发送的下一个分节设置首部中的 URG 标记。

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

发送多个字节的带外数据时,只有最后一个字节,即’3’,才会被标记为 OOB。

当收到一个含 URG 标记的分节时,TCP 检查紧急指针,确定是否指向新的带外数据。因为 TCP 经常在很短时间内发送多个含 URG 且紧急指针指向同一个数据字节的分节,而只有第一个到达的 URG 才会导致接收进程被通知“新的带外数据到达”。Linux 的 TCP 有一个单字节缓冲区,收到的带外数据就被放到该缓冲区,而普通的 read 或最后一个参数为 0 的 recv 函数都不能读到该字节,只有给 recv 设置 MSG_OOB 才可以。

读操作总是停在带外标记 OOB 上。假如在带外标记前只有 3 个普通数据字节,那么一个请求 100bytes 的读操作只能返回前 3 个字节。

如果设置了 SO_OOBINLINE 套接字选项,那么 OOB 字节就会被留在“带内”,也就是和普通数据都放在套接字接收缓冲区里,然而这时 recv 就不能使用 MSG_OOB 来读取该字节了,因为会返回 EINVAL 错误,在读取普通数据和带外数据时还有可能发生各种各样的错误,man 文档里有相当全的资料,读者可以自己尝试。

如果进程已为接收套接字设置了 owner,当新的紧急指针到达,接收进程被通知 SIGURG 信号,该信号的默认处理方式是 ignore,关于套接字的 owner,可以参看man 2 fcntl中的 F_SETOWN,默认情况下,socket() 返回的套接字是没有 owner 的,而 connect(listenfd) 返回的套接字通常都是继承 listenfd 的属性和套接字选项。

sockatmark()

说到带外数据,就不可能不说 sockatmark 函数,它的作用是检查缓冲区下一个待读取位置是否是带外标记。即使使用了 SO_OOBINLINE 选项,该函数依然能正确判断带外标记的位置,所以就可以不用信号处理函数而轻易处理带外数据。

特性

(1) 如果新的 OOB 字节在旧的 OOB 被读取之前就到达,旧的 OOB 就会被丢弃,现在来验证这一点:

#include "all.h"

int main()
{
    int fd;

    fd = tcp_connect("localhost", "33333");

    sleep(1);
    send(fd, "123", 3, 0);
    sleep(1);
    send(fd, "abc", 3, MSG_OOB);
    sleep(1);
    send(fd, "def", 3, 0);
    sleep(1);
    send(fd, "ghi", 3, MSG_OOB);
    sleep(1);

    return 0;
}

这是发送端程序,tcp_connect 类似 UNP 中的代码,可以看我的 github,第一个 sleep 是为了让接收端在 accept 之后能有时间设置 sigaction,其他的是为了让 TCP 能分开发送几次数据。接收程序如下:

#include "all.h"

#define MAX 100
int listenfd, connfd;

void sig_urg(int signo)
{
    int n;
    char buf[MAX];

    printf("SIGURG received\n");
    n = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
    buf[n] = 0;
    printf("read %d OOB byte: %s\n", n, buf);
    puts("--------------");
}

int main()
{
    struct sigaction sa;

    listenfd = tcp_listen("localhost", PORT, NULL);
    connfd = accept(listenfd, NULL, NULL);

    sa.sa_handler = sig_urg;
    sa.sa_flags = 0;
    sigaction(SIGURG, &sa, NULL);
    fcntl(connfd, F_SETOWN, getpid());

    while (1)
        pause();

    return 0;
}

运行结果:

SIGURG received
read 1 OOB byte: c
--------------
SIGURG received
read 1 OOB byte: i
--------------

预料之中。然后将发送程序改成:

int main()
{
    int fd;

    fd = tcp_connect("localhost", "33333");

    sleep(1);
    send(fd, "123", 3, 0);
    send(fd, "abc", 3, MSG_OOB);
    send(fd, "def", 3, 0);
    send(fd, "ghi", 3, MSG_OOB);

    return 0;
}

结果就变成了:

SIGURG received
read 1 OOB byte: i
--------------

可以看到,只收到一个字节,因为前一个 OOB 字节被覆盖了。经过测试,也有可能出现这种情况:

SIGURG received
read 1 OOB byte: i
--------------
SIGURG received
read 16777215 OOB byte: i
--------------

这是因为收到了两个 SIGURG,但是 ‘c’ 已经被 ‘i’ 覆盖,而第二次读取时,单字节缓冲区为空,recv 发生了错误,返回的其实是 -1。

(2) 即使因为对端接收缓冲区满而通告了一个 0 的窗口,发送端无法继续发送数据了,但 TCP 仍然继续发送带外数据的通知,也就是说,发送 length = 0 的分节。

我设置了发送端缓冲区 32768,以及接收端缓冲区 4096。然后发送端为:

fd = tcp_connect("localhost", PORT);
size = 32768;
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));

writen(fd, buf, 16384);
sleep(5);

send(fd, "a", 1, MSG_OOB);
writen(fd, buf, 1024);

结果 tcpdump 出现了如下情形:

12:45:36.994449 IP localhost.53596 > localhost.33333: Flags [S], seq 1434797463, win 43690, options [mss 65495,sackOK,TS val 3824426 ecr 0,nop,wscale 7], length 0
12:45:36.994479 IP localhost.33333 > localhost.53596: Flags [S.], seq 3440905176, ack 1434797464, win 4096, options [mss 65495,sackOK,TS val 3824427 ecr 3824426,nop,wscale 0], length 0
12:45:36.994504 IP localhost.53596 > localhost.33333: Flags [.], ack 1, win 342, options [nop,nop,TS val 3824427 ecr 3824427], length 0
12:45:38.994682 IP localhost.53596 > localhost.33333: Flags [.], seq 1:2049, ack 1, win 342, options [nop,nop,TS val 3824927 ecr 3824427], length 2048
12:45:38.994697 IP localhost.33333 > localhost.53596: Flags [.], ack 2049, win 2048, options [nop,nop,TS val 3824927 ecr 3824927], length 0
12:45:38.994706 IP localhost.53596 > localhost.33333: Flags [P.], seq 2049:4097, ack 1, win 342, options [nop,nop,TS val 3824927 ecr 3824427], length 2048
12:45:39.034553 IP localhost.33333 > localhost.53596: Flags [.], ack 4097, win 0, options [nop,nop,TS val 3824937 ecr 3824927], length 0
12:45:39.242542 IP localhost.53596 > localhost.33333: Flags [.], ack 1, win 342, options [nop,nop,TS val 3824989 ecr 3824937], length 0
12:45:39.242552 IP localhost.33333 > localhost.53596: Flags [.], ack 4097, win 0, options [nop,nop,TS val 3824989 ecr 3824927], length 0
12:45:39.658538 IP localhost.53596 > localhost.33333: Flags [.], ack 1, win 342, options [nop,nop,TS val 3825093 ecr 3824989], length 0
12:45:40.490567 IP localhost.53596 > localhost.33333: Flags [.], ack 1, win 342, options [nop,nop,TS val 3825301 ecr 3824989], length 0
12:45:40.490577 IP localhost.33333 > localhost.53596: Flags [.], ack 4097, win 0, options [nop,nop,TS val 3825301 ecr 3824927], length 0
12:45:42.158550 IP localhost.53596 > localhost.33333: Flags [.], ack 1, win 342, options [nop,nop,TS val 3825718 ecr 3825301], length 0
12:45:42.158571 IP localhost.33333 > localhost.53596: Flags [.], ack 4097, win 0, options [nop,nop,TS val 3825718 ecr 3824927], length 0
12:45:45.494620 IP localhost.53596 > localhost.33333: Flags [.U], ack 1, win 342, urg 12289, options [nop,nop,TS val 3826552 ecr 3825718], length 0
12:45:45.494641 IP localhost.53596 > localhost.33333: Flags [.U], ack 1, win 342, urg 12290, options [nop,nop,TS val 3826552 ecr 3825718], length 0
12:45:45.494651 IP localhost.33333 > localhost.53596: Flags [.], ack 4097, win 0, options [nop,nop,TS val 3826552 ecr 3826552], length 0
12:45:45.495025 IP localhost.33333 > localhost.53596: Flags [R.], seq 1, ack 4097, win 4096, options [nop,nop,TS val 3826552 ecr 3826552], length 0

发送端试图发送一个 16384 bytes 的分节,但是接收端缓冲区只有 4096 bytes,所以在发送端多次尝试无果后,发送了 OOB 字节,接收端收到 SIGURG 信号,然而 recv 使用 MSG_OOB 时,相应的带外字节不能被读入,因为是 length = 0 的分节。所以产生了 EAGAIN or EWOULDBLOCK 错误:

SIGURG received
./serv: error: Resource temporarily unavailable

(3) 带外数据还有一种特性,就是,接收端有可能收到 SIGURG 信号,然后马上使用带 MSG_OOB 的 recv,但是此时带外数据仍未到达,那么 recv 也将返回 EAGAIN or EWOULDBLOCK 错误。

带外数据的应用

带外数据通常可以作为一个标记,接收端可以根据该标记分别特殊处理标记前后的数据。

TCP 带外数据还可以用于心搏函数。