当前位置:   article > 正文

【计算机网络】2、TCP:四次挥手与TIME_WAIT、shutdown() 优雅关闭 server、探活、拥塞窗口与 Nagle 算法、端口占用、tcp 的流式协议、可靠性_tcptimewait阶段进行中断abort

tcptimewait阶段进行中断abort

六、四次挥手 与 TIME_WAIT

四次挥手过程如下:

  • 首先,主动关闭方应用程序,调用 close() 发送一个 FIN 包,表示需要关闭连接。之后其进入 FIN_WAIT_1 状态。
  • 接着,收到这个 FIN 包的 对端 执行被动关闭。这个 FIN 由 TCP 协议栈处理(TCP 协议栈为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可通过 read() 感知此 FIN 包,注意这个 EOF 会被放在 已排队等候的其他已接收的数据 之后,这就意味着 接收端应用程序 需处理此异常情况,因为 EOF 表示在该连接上再无额外数据到达)。此时 被动关闭方 进入 CLOSE_WAIT 状态。
  • 接下来,被动关闭方 将读到这个 EOF,于是应用程序也调用 close() 关闭其套接字,这导致其 TCP 也发送一个 FIN 包。这样 被动关闭方 将进入 LAST_ACK 状态。
  • 最终,主动关闭方 收到对方的 FIN 包,并确认这个 FIN 包。主动关闭方 进入 TIME_WAIT 状态,而收到 ACK 的被动关闭方 则进入 CLOSED 状态。进过 2MSL 时间之后,主动关闭方 也进入 CLOSED 状态。

可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。当然也可用 shutdown() 执行一端到另一端的半关闭。

在这里插入图片描述

在这里插入图片描述

TCP 连接终止时,主机1 先发送 FIN 报文,主机2 进入 CLOSE_WAIT 状态,并发送一个 ACK 应答,同时主机2 通过 read() 获得 EOF,并将此结果通知给应用程序做主动关闭(发送 FIN 报文)。主机1 在接收到 FIN 报文后发送 ACK 应答,此时主机1 进入 TIME_WAIT 状态。

主机1 在 TIME_WAIT 停留持续时间是 2MSL(MSL:最长分节生命期 maximum segment lifetime)。Linux 硬编码 TCP_TIMEWAIT_LEN 为固定的 60 秒(大多数 BSD 派生的系统与此相同)。过了这个时间之后,主机1 就进入 CLOSED 状态。即只有发起连接终止的一方会进入 TIME_WAIT 状态。

#define TCP_TIMEWAIT_LEN (60*HZ) // how long to wait to destroy TIME-WAIT state, about 60 seconds
  • 1

当 socket 被关闭时,TCP 为其所在端发送一个 FIN 包。

  • 多数情况下这是由应用进程调用 close() 而发生的
  • 另外一个进程无论是正常退出(exit() 或 main 函数返回),还是非正常退出(如因 kill -9 而收到 SIGKILL 信号),所有该进程打开的描述符都会被系统关闭,这也导致 TCP 描述符对应的连接上发出一个 FIN 包。

无论是 client 还是 server 任何一端都可以发起主动关闭。当然大多数真实情况是client 执行主动关闭,你可能不会想到的是 HTTP/1.0 却是由服务器发起主动关闭的。

6.1 TIME_WAIT 的作用

为什么不直接进入 CLOSED 状态,而要停留在 TIME_WAIT 这个状态?

  • 原因一是因为 TCP 做了充分的容错性设计:比如 TCP 假设报文会出错而需要重传。例如若图中主机1 的 ACK 报文没有传输成功,那么主机2 就会重新发送 FIN 报文。
    • 若主机1 没有维护 TIME_WAIT 状态,而直接进入 CLOSED 状态,它就失去了当前状态的上下文,只能回复一个 RST 操作,从而导致被动关闭方出现错误。
    • 而现在主机1 知道自己处于 TIME_WAIT 的状态,就可以在接收到 FIN 报文之后,重新发出一个 ACK 报文,使得主机2 可以进入正常的 CLOSED 状态。
  • 原因二和连接“化身”和报文迷走有关系:是为了让旧连接的重复分节在网络中自然消失。
    • 我们知道,在网络中常会发生报文 “经过一段时间” 才能到达目的地的情况,产生的原因是多种多样的,如路由器重启、链路突然出现故障等。若迷走报文到达时,发现 TCP 连接四元组(源 IP,源端口,目的 IP,目的端口)所代表的连接不复存在,那么很简单,这个报文自然丢弃。
    • 我们考虑这样一个场景,在原连接中断后,又重新创建了一个原连接的“化身”(说是化身其实是因为这个连接和原先的连接四元组完全相同),若迷失报文经过一段时间也到达,那么这个报文会被误认为是连接“化身”的一个 TCP 分节,这样就会对 TCP 通信产生影响。
    • 所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的分组都被丢弃,使得原来连接的分组在网络中都自然消失,再出现的分组一定都是新化身所产生的。

2MSL 的时间是从 主机1 收到 FIN 后,发送 ACK 来开始计时的

  • 若在 TIME_WAIT 时间内,因为主机1 的 ACK 没有传输到主机2,主机1 又接收到了主机2 重发的 FIN 报文,那么 2MSL 时间将 重新计时。道理很简单,因为 2MSL 的时间,目的是为了让旧连接的所有报文都能自然消亡,现在主机1 重新发送了 ACK 报文,自然需要重新计时,以便防止这个 ACK 报文对新可能的连接化身造成干扰。
  • MSL 是任何 IP 数据报能够在因特网中存活的最长时间。其并不是靠计时器实现的,而是在每个数据报里都含一个名为 TTL(time to live)的 8 位字段(最大值为 255),其由源主机设置初始值,表示的是一个 IP 数据报可以经过的最大跳跃数,每经过一个路由器就相当于经过了一跳则值就减 1,当减为 0 时则所在的路由器会将其丢弃,同时发送 ICMP 报文通知源主机。Linux 设置的 MSL 是 30 秒。

6.2 TIME_WAIT 的危害和方案

有如下危害:

  • 第一是内存资源占用,目前看来不是太严重而基本可以忽略。
  • 第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地 port。而 port 资源也是有限的,一般可以开启的 port 为 32768~61000 ,也可以通过 net.ipv4.ip_local_port_range 指定,若 TIME_WAIT 状态过多,会导致无法创建新连接。

在高并发的情况下,若想优化 TIME_WAIT 来解决我们一开始提到的例子,该如何办呢?

  • net.ipv4.tcp_max_tw_buckets:默认值为 18000,可用 sysctl 调小。当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置,并只打印出警告信息。这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。
  • 调低 TCP_TIMEWAIT_LEN 并重新编译系统:这个方法有效,但缺点是需要重新编译内核,不是大多数人能接受的方式。
  • 设置 SO_LINGER(不推荐) :英文单词 “linger” 的意为停留,可通过 setsockopt() 来设置调 close() 或 shutdown() 关闭连接时的行为。设置 linger 参数有几种可能:
    • l_onoff 为 0,l_linger 的值将被忽略,这对应了默认行为,close() 或 shutdown() 立即返回。若在套接字发送缓冲区中有数据残留,系统会将试着把这些数据发送出去。
    • l_onoff 为 非0, 而 l_linger 值为 0,那么调用 close() 后,会立刻发送一个 RST 标志给对端,该 TCP 连接将 跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。这种关闭的方式称为“强行关闭”。
      • 在这种情况下,排队数据不会被发送
      • 被动关闭方也不知道对端已彻底断开。只有当被动关闭方正 阻塞在 recv() 调用上时,接受到 RST 时,会立刻得到一个“connet reset by peer”的异常。
    • l_onoff 为 非0, 且 l_linger 的值也非 0,那么调用 close() 后,调用 close() 的线程就将阻塞,直到数据被发送出去,或者设置的 l_linger 计时时间到。
struct linger {
 int  l_onoff;  // 0=off, nonzero=on
 int  l_linger; // linger time, POSIX specifies units as seconds
}
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s,SOL_SOCKET,SO_LINGER, &so_linger,sizeof(so_linger));
// int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • net.ipv4.tcp_tw_reuse:是安全可控的,可以复用处于 TIME_WAIT 的套接字为新的连接所用:
    • 只适用于连接发起方(C/S 模型中的 client );
    • 对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。
    • 使用这个选项,前提是需打开对 TCP 时间戳的支持,即 net.ipv4.tcp_timestamps=1(默认即为 1)。
// net.ipv4.tcp_tw_reuse:
Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol 
viewpoint. Default value is 0.
It should not be changed without advice/request of technical experts.
  • 1
  • 2
  • 3
  • 4

其实 TCP 协议也在与时俱进,RFC 1323 中实现了 TCP 拓展规范,以便保证 TCP 的高可用,并引入了新的 TCP 选项,两个 4 字节的时间戳字段,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。

七、server 用 shutdown() 优雅关闭

优雅关闭:client 主动发起连接的中断时,其将自己到 server 的数据流方向关闭,此时 client 不再往 server 写入数据,server 读完 client 数据后就不会再有新的报文到达。但这并不意味着 TCP 连接已完全关闭,很有可能的是,server 正在对 client 的最后报文进行处理,比如访问数据库存入一些数据;或者是计算出某个 client 需要的值;当完成这些操作之后, server 把结果通过套接字写给 client ,我们说这个套接字的状态此时是「半关闭」的。最后 server 才有条不紊地关闭剩下的半个连接,结束这一段 TCP 连接的使命。

非优雅关闭:当然这里描述的,是 server “优雅”地关闭了连接。但若 server 处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果很可能是 server 处理完的信息没办法正常传送给 client ,破坏了用户侧的使用场景。

7.1 close()

int close(int sockfd)
  • 1

close() 会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并会关闭 TCP 两个方向的数据流。(因套接字可被多个进程共享,当通过 fork() 产生子进程时套接字引用计数 +1,当 close() 时套接字引用计数 -1)。

close() 具体是如何关闭两个方向的数据流呢?

  • 在输入方向,系统内核 会将该 socket 设置为「不可读」,「任何读操作都会返回异常」。
  • 在输出方向,系统内核「尝试」将「发送缓冲区」的数据发送给「对端」,并最后向对端发一个 FIN 报文,接下来若再对该套接字进行「写操作则会返回异常」。
  • 若「对端」没有检测到套接字已关闭,还继续发送报文,就会收到一个 RST 报文,告诉对端:“Hi, 我已关闭了,别再给我发数据了。”

我们会发现,close() 并不能帮我们关闭连接的「一个」方向,那如何在需要时关闭一个方向呢?幸运的是设计 TCP 协议的人帮我们想好了 shutdown() 方案。

7.2 shutdown()

int shutdown(int sockfd, int howto)
  • 1

howto 是这个函数的设置选项,其设置有三个选项:

  • SHUT_RD(0):关闭连接的“读”这个方向,对该套接字进行读操作直接返回 EOF。从数据角度来看,套接字上接收缓冲区已有的数据将被丢弃,若再有新的数据流到达,会对数据进行「ACK 并悄悄地丢弃」。即对端还是会接收到 ACK,而在这种情况下根本不知道数据已被丢弃了。
  • SHUT_WR(1):关闭连接的“写”这个方向,这就是常被称为「半关闭」的连接。此时不管套接字引用计数的值是多少,都会直接关闭连接的写方向。「socket 上发送缓冲区已有的数据将被立即发送出去,并发一个 FIN 报文给对端」。应用程序若对该套接字进行写操作会报错。
  • SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。

用 SHUT_RDWR 来调用 shutdown() 和 close() 虽然都是关闭连接的读和写两个方向,但还是有区别的:

  • 释放资源:close() 会关闭连接并释放所有连接对应的资源,而 shutdown() 并不会释放掉套接字和所有的资源。
  • 使 socket 不可用:close() 因存在引用计数的概念故并不一定导致该 socket 不可用;shutdown() 则不管引用计数直接使该 socket 不可用,若有别的进程企图使用该套接字,将会受到影响。
  • 发 FIN 报文:close() 因引用计数故导致不一定会发出 FIN 结束报文,而 shutdown() 则总是会发出 FIN 结束报文,这在我们打算关闭连接通知对端的时候,是非常重要的。

7.3 对比 close() 和 shutdown()

下面,我们通过构建一组 client 和 server 程序,来进行 close() 和 shutdown() 的实验。

client 端:从 stdin 不断接收用户输入,把输入的字符串通过 socket 发给 server,同时将 server 的应答显示到 stdout 上。client 端代码如下:

  • 若用户在 client 端输入了“close”,则会调用 close() 函数关闭连接,休眠一段时间,等待 server 处理后退出;
  • 若用户在 client 端输入了“shutdown”,则会调用 shutdown() 关闭连接的写方向,注意我们不会直接退出,而是会继续等待 server 的应答,直到 server 完成自己的操作,在另一个方向上完成关闭。
  • 在这里,我们会第一次接触到 select 多路复用,这里不展开讲,你只需要记住 使用 select 使得我们可以 同时完成 对 连接socket 和 stdin 两个 I/O 对象的处理。
// https://github.com/datager/yolanda/blob/master/chap-11/graceclient.c
# include "lib/common.h"
# define    MAXLINE     4096
 
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: graceclient <IPaddress>");
    }
    // 创建一个 TCP 套接字
    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);
 	// 设置了连接的目标服务器 IPv4 地址,绑定到了指定的 IP 和 port
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
 	// 使用创建的套接字,向目标 IPv4 地址发起连接请求
    socklen_t server_len = sizeof(server_addr);
    int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }
 
    char send_line[MAXLINE], recv_line[MAXLINE + 1];
    int n;
 
    fd_set readmask;
    fd_set allreads;
 	// 为使用 select 做准备,初始化描述字集合
    FD_ZERO(&allreads);
    FD_SET(0, &allreads);
    FD_SET(socket_fd, &allreads);
	// 程序主体部分:用 select 多路复用, 观测在 socket_fd 和 stdin 上的 I/O 事件
    for (;;) {
        readmask = allreads;
        int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
        if (rc <= 0)
            error(1, errno, "select failed");
        if (FD_ISSET(socket_fd, &readmask)) { // 当 socket_fd 上有数据可读,将数据读入到程序缓冲区中	
            n = read(socket_fd, recv_line, MAXLINE);
            if (n < 0) {
                error(1, errno, "read error"); // 若有异常则报错退出
            } else if (n == 0) {
                error(1, 0, "server terminated \n"); // 若读到 server 发送的 EOF 则正常退出
            }
            recv_line[n] = 0;
            fputs(recv_line, stdout);
            fputs("\n", stdout);
        }
        if (FD_ISSET(0, &readmask)) { // 当 stdin 上有数据可读,读入后进行判断
            if (fgets(send_line, MAXLINE, stdin) != NULL) {
                if (strncmp(send_line, "shutdown", 8) == 0) { // 若输入的是“shutdown”,则关闭 stdin 的 I/O 事件感知,并调用 shutdown() 关闭写方向
                    FD_CLR(0, &allreads);
                    if (shutdown(socket_fd, 1)) { 
                        error(1, errno, "shutdown failed");
                    }
                } else if (strncmp(send_line, "close", 5) == 0) { // 若输入的是”close“,则调用 close() 关闭连接
                    FD_CLR(0, &allreads);
                    if (close(socket_fd)) {
                        error(1, errno, "close failed");
                    }
                    sleep(6);
                    exit(0);
                } else { // 处理正常的输入,将回车符截掉,调 write() 通过 socket_fd 将数据发送给 server
                    int i = strlen(send_line);
                    if (send_line[i - 1] == '\n') {
                        send_line[i - 1] = 0;
                    }
 
                    printf("now sending %s\n", send_line);
                    size_t rt = write(socket_fd, send_line, strlen(send_line));
                    if (rt < 0) {
                        error(1, errno, "write failed ");
                    }
                    printf("send bytes: %zu \n", rt);
                }
            }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81

server 端,连接建立之后,打印出接收的字节,并重新格式化后发送给 client。代码如下:

// https://github.com/datager/yolanda/blob/master/chap-11/graceserver.c
#include "lib/common.h"
 
static int count;
 
static void sig_int(int signo) {
    printf("\nreceived %d datagrams\n", count);
    exit(0); // exit后,操作系统内核协议栈会接管后续的处理: 即发送 FIN 报文
}
 
int main(int argc, char **argv) {
	// 创建一个 TCP 套接字
    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
 	// 设置了本地服务器 IPv4 地址,绑定到了 ANY 地址和指定的端口
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);
 	// 使用创建的套接字,以此执行 bind、listen 和 accept 操作,完成连接建立
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }
 
    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }
 
    signal(SIGINT, sig_int);
    signal(SIGPIPE, SIG_IGN);
 
    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
 
    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "bind failed ");
    }
 
    char message[MAXLINE];
    count = 0;

 	// 程序的主体: 通过 read() 获取 client 传送来的数据流,并回送给 client
    for (;;) {
        int n = read(connfd, message, MAXLINE);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
        message[n] = 0;
        printf("received %d bytes: %s\n", n, message); // 显示收到的字符串
        count++;
 
        char send_line[MAXLINE];
        sprintf(send_line, "Hi, %s", message); // 对原字符串进行重新格式化
 
        sleep(5); // 发送之前,让 server 程序休眠了 5 秒,以模拟 server 处理的时间
 
        int write_nc = send(connfd, send_line, strlen(send_line), 0); // 调用 send() 将数据发送给 client
        printf("send bytes: %zu \n", write_nc);
        if (write_nc < 0) {
            error(1, errno, "error write");
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69

7.3.1 close()

效果如下:我们启动 server ,再启动 client,依次在 stdin 上输入 “data1”、“data2” 和 “close”,观察一段时间后可看到:client 依次发送了 data1 和 data2,server 也正常接收到 data1 和 data2。在 client 端 close() 掉整个连接之后,server 接收到 SIGPIPE 信号并直接退出。client 并没有收到 server 的应答数据。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

下图详细解释了 client 和 server 交互的时序图:

  • 因为 client 调用 close() 关闭了整个连接,当 server 发送的 “Hi, data1” 分组到达时,client 给回送一个 RST 分组;
  • server 再次尝试发送 “Hi, data2” 第二个应答分组时,系统内核通知 SIGPIPE 信号。这是因为「在 RST 的套接字进行写操作,会直接触发 SIGPIPE 信号从而让程序终止」。
    • 我们可用 signal() 注册一个信号处理函数,对 SIGPIPE 信号进行处理,避免程序莫名退出:
    static void sig_pipe(int signo) {
        printf("\nreceived %d datagrams\n", count);
        exit(0);
    }
    signal(SIGINT, sig_pipe);
    
    • 1
    • 2
    • 3
    • 4
    • 5

在这里插入图片描述

7.3.2 shutdown()

接下来,再次启动 server,再启动 client,依次在标准输入上输入 “data1”、“data2” 和 “shutdown”,观察一段时间后可看到:

  • 和前面的结果不同,server 输出了 data1、data2;
  • client 也输出了 “Hi,data1” 和 “Hi,data2”
  • client 和 server 各自完成了自己的工作后,正常退出。

在这里插入图片描述

我们再看下 client 和 server 交互的时序图(见本文 7.3.1 节)。

  • 因为 client 调用 shutdown() 「只是关闭连接的一个方向」,所以「server 到 client 的这个方向」还可以继续进行数据的发送和接收,所以 “Hi,data1” 和 “Hi,data2” 都可正常传送;
  • 「当 server 读到 EOF 时立即向 client 发送了 FIN 报文」,client 在 read() 中感知了 EOF 也进行了正常退出。

close() 关闭连接有如下两个特点,因此「当期望关闭连接其中一个方向时应该用 shutdown()」。

  • close() 只是把套接字引用计数减 1,未必会立即关闭连接;
  • close() 若在套接字引用计数达到 0 时,立即终止读和写两个方向的数据传送。

八、探活

之前我们讲到了如何使用 close() 和 shutdown() 来关闭连接,多数情况下我们会优选 shutdown() 来关闭连接的一个方向,待对端处理完之后,再关闭另外一个方向。

在很多情况下,有探活的需求:即「连接的一端要一直感知连接的状态」,若连接无效了,应用程序可能需要报错或重新发起连接等。

  • 案例 1:例如打游戏时,朋友的电脑突然蓝屏死机,朋友的角色还残留于游戏中,所以 server 为了判定他是否真的存活还是需要一个心跳包,隔了一段时间过后把朋友角色踢下线。
  • 案例 2:做过一个基于 NATS 消息系统的项目,多个消息的提供者 (pub)和订阅者(sub)都连到 NATS 消息系统,通过这个系统来完成消息的投递和订阅处理。
    • 突然有一天,线上报了一个故障,一个流程不能正常处理。经排查发现消息已正确地投递到了 NATS server,但是消息订阅者没有收到该消息,也没能做出处理,导致流程没能进行下去。
    • 通过观察消息订阅者后发现,消息订阅者到 NATS 服务端的连接虽然显示是“正常”的,但实际上这个「连接已是无效」的了。为什么呢?这是因为 NATS 服务器崩溃过,NATS 服务器和消息订阅者之间的连接中断 FIN 包,由于异常情况而没能正常到达消息订阅者,这样造成的结果就是消息订阅者一直维护着一个 「过时的连接」从而不会收到 NATS 服务器发送来的消息。
    • 这个故障的根本原因在于,作为 NATS 服务器的 client,消息订阅者没有及时对 连接的有效性进行检测。
  • udp 因不需要连接,故没必要为了连接而保活,当然若为了探测对端是否正常工作而做 ping-pong 也是可行的。

8.1 TCP 层:Keep-Alive 探活

TCP 有叫做 Keep-Alive 的探活机制:其定义一个时间段,若在此时间段内无任何连接相关的活动,TCP 保活机制会开始作用,其每隔一段时间发一个探测报文(包含的数据非常少),若连续几个探测报文都无响应,则认为当前的 TCP 连接已死亡,系统内核则将错误信息通知给上层应用程序。

有三个控制变量,均可用 sysctl 修改:

  • 保活时间长度 net.ipv4.tcp_keepalive_time,默认 7200 秒(2 小时)
  • 保活时间间隔 net.ipv4.tcp_keepalive_intvl,默认 75 秒
  • 保活探测次数 net.ipv4.tcp_keepalive_probes,默认 9 次探测

TCP 保活有以下几种情况:

  • 若对端程序是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
  • 若对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已被重置。
  • 若对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海没有响应,连续几次达到保活探测次数后,TCP 会报告该 TCP 连接已死亡。

TCP 保活机制默认是关闭的,当我们打开时,既可分别在连接的两个方向上开启,也可只在一个方向上开启。

  • 若开启 server 到 client 的检测,就可在 client 非正常断连的情况下,清除在 server 保留的“脏数据”
  • 而开启 client 到 server 的检测,就可在 server 无响应的情况下,重新发起连接。

8.2 应用层:自行探活

若使用 TCP 自身的 keep-Alive 机制,Linux 至少需经 2 小时 11 分 15 秒才可以发现一个“死亡”连接(其实是 2 小时,加上 75 秒乘以 9 的总和)。这在很多时延敏感的系统是不可接受的。(为什么 TCP 不提供一个频率很好的保活机制呢?我的理解是早期的网络带宽非常有限,若提供一个频率很高的保活机制,对有限的带宽是一个比较严重的浪费。)

我们可在「应用程序」中模拟 TCP Keep-Alive 机制来在应用层探活。即可设计一个 PING-PONG 的机制:即需要保活的一方(如 client)在探活时间达到后发起 PING,若 server 有回应则重置探活时间,否则对探测次数进行计数,当到达探活次数阈值后则认为连接已无效。

这里有两个关键点:第一个是需用定时器(可通过 I/O 复用机制实现);第二个是需设计一个 PING-PONG 协议。下面我们尝试来完成这样的一个设计。

首先定义消息对象结构体,其前 4 个字节标识了消息类型,其含 MSG_PING、MSG_PONG、MSG_TYPE 1 和 MSG_TYPE 2 四种消息类型。

// https://github.com/datager/yolanda/blob/master/chap-12/message_objecte.h
typedef struct {
    u_int32_t type;
    char data[1024];
} messageObject;
#define MSG_PING          1
#define MSG_PONG          2
#define MSG_TYPE1        11
#define MSG_TYPE2        21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

client 端完全模拟 TCP Keep-Alive 的机制:当探活时间达到后,探活次数增加 1,并向 server 发 PING;此后以预设的保活时间间隔持续向 server 发 PING。若能收到 server 的应答则结束探活并将探活时间重置为 0。(这里用 select I/O 复用函数自带的定时器),代码如下:

// https://github.com/datager/yolanda/blob/master/chap-12/pingclient.c
#include "lib/common.h"
#include "message_objecte.h"
 
#define    MAXLINE     4096
#define    KEEP_ALIVE_TIME  10
#define    KEEP_ALIVE_INTERVAL  3
#define    KEEP_ALIVE_PROBETIMES  3
 

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: tcpclient <IPaddress>");
    }
 
    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);
 
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
 
    socklen_t server_len = sizeof(server_addr);
    int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }
 
    char recv_line[MAXLINE + 1];
    int n;
 
    fd_set readmask;
    fd_set allreads;
 
    struct timeval tv;
    int heartbeats = 0;
 
    tv.tv_sec = KEEP_ALIVE_TIME; // 设置了超时时间为 KEEP_ALIVE_TIME,这相当于保活时间
    tv.tv_usec = 0;
 
    messageObject messageObject;
 
    FD_ZERO(&allreads); // 初始化 select() 的套接字
    FD_SET(socket_fd, &allreads);
    for (;;) {
        readmask = allreads;
        int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv); // 调 select() 感知 I/O 事件。这里的 I/O 事件,除了套接字上的读操作之外,还有在 39-40 行设置的超时事件。当 KEEP_ALIVE_TIME 这段时间到达之后,select() 会返回 0,于是进入 53-63 行的处理
        if (rc < 0) {
            error(1, errno, "select failed");
        }
        if (rc == 0) { // client 已在 KEEP_ALIVE_TIME 这段时间内没有收到任何对当前连接的反馈,于是发起 PING 消息,尝试问server :”喂,你还活着吗?“这里我们通过传送一个类型为 MSG_PING 的消息对象来完成 PING 操作,之后我们会看到 server 如何响应这个 PING 操作
            if (++heartbeats > KEEP_ALIVE_PROBETIMES) {
                error(1, 0, "connection dead\n");
            }
            printf("sending heartbeat #%d\n", heartbeats);
            messageObject.type = htonl(MSG_PING);
            rc = send(socket_fd, (char *) &messageObject, sizeof(messageObject), 0);
            if (rc < 0) {
                error(1, errno, "send failure");
            }
            tv.tv_sec = KEEP_ALIVE_INTERVAL;
            continue;
        }
        if (FD_ISSET(socket_fd, &readmask)) { // client 接收到 server 响应后的处理。实际工作中这里需对报文进行解析后处理的:即只有是 PONG 类型的响应才认为是 PING 探活的结果(这里为了简单这里就没有再进行报文格式的转换和分析,认为只要收到 server 的报文则连接就是正常的),则会对探活计数器和探活时间都置零,等待下一次探活时间的来临。
            n = read(socket_fd, recv_line, MAXLINE);
            if (n < 0) {
                error(1, errno, "read error");
            } else if (n == 0) {
                error(1, 0, "server terminated \n");
            }
            printf("received heartbeat, make heartbeats to 0 \n");
            heartbeats = 0;
            tv.tv_sec = KEEP_ALIVE_TIME;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78

server 端设计如下:其接受一个参数(此参数设置的比较大,可模拟连接无响应的情况)。

  • server 在接收到 client 发送来的各种消息后做处理,其中若发现是 PING 消息,则在休眠一段时间后回复 PONG 消息,告诉client :”嗯,我还活着。”
  • 当然,若这个休眠时间很长的话 client 就无法快速知道 server 是否存活,这是我们模拟连接无响应的一个手段而已,实际情况可能是系统崩溃或网络异常。
// https://github.com/datager/yolanda/blob/master/chap-12/pingserver.c
#include "lib/common.h"
#include "message_objecte.h"
 
static int count;
 
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: tcpsever <sleepingtime>");
    }
 
    int sleepingTime = atoi(argv[1]);
 
    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
 
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);
 
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }
 
    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }
 
    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
 
    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "bind failed ");
    }
 
    messageObject message;
    count = 0;
 
    for (;;) {
        int n = read(connfd, (char *) &message, sizeof(messageObject));
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
 
        printf("received %d bytes\n", n);
        count++;
 
        switch (ntohl(message.type)) {
            case MSG_TYPE1 :
                printf("process  MSG_TYPE1 \n");
                break;
 
            case MSG_TYPE2 :
                printf("process  MSG_TYPE2 \n");
                break;
 
            case MSG_PING: { // 处理 MSG_PING 类型的消息。通过休眠来模拟响应是否及时,然后调用 send() 发一个 PONG 报文向 client 表示”还活着“的意思
                messageObject pong_message;
                pong_message.type = MSG_PONG;
                sleep(sleepingTime);
                ssize_t rc = send(connfd, (char *) &pong_message, sizeof(pong_message), 0);
                if (rc < 0)
                    error(1, errno, "send failure");
                break;
            }
 
            default :
                error(1, 0, "unknown message type (%d)\n", ntohl(message.type));
        }
    } 
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78

基于上面的程序设计做两个不同的实验:

第一次实验,server 休眠时间为 60 秒。我们看到,client 在发送了三次心跳检测报文 PING 报文后,判断出连接无效,直接退出了。之所以造成这样的结果,是因为在这段时间内没有接收到来自 server 的任何 PONG 报文。当然实际工作的程序可能需要不一样的处理,比如重新发起连接。
在这里插入图片描述

第二次实验,让 server 休眠时间为 5 秒。我们看到,由于这一次 server 在心跳检测过程中及时地进行了响应,client 一直都会认为连接是正常的。(如下图:当 client 收到 PONG 后则重置变量 heartbeats 为 0,使得再发送的 PING 仍从 sending heartbeat #1 开始)
在这里插入图片描述

九、tcp 的动态数据传输

本文将通俗易懂的解释发送窗口、接收窗口、拥塞窗口的含义。

应用程序用 write() 或 send() 发送数据流,用这些接口并不意味着数据被真正发送到网络上,其实这些数据只是从应用程序中被拷贝到了系统内核的套接字缓冲区中等待协议栈的处理。至于这些数据是什么时候被发送出去对应用程序来说是无法预知的。对这件事情真正负责的是运行于操作系统内核的 TCP 协议栈实现模块。

9.1 流量控制、生产者-消费者模型

可以把理想中的 TCP 协议可以想象成一队运输货物的货车,运送的货物就是 TCP 数据包,这些货车将数据包从发送端运送到接收端,就这样不断周而复始。

我们仔细想一下,货物达到接收端之后,是需要卸货处理、登记入库的,接收端限于自己的处理能力和仓库规模,是不可能让这队货车以不可控的速度发货的。接收端肯定会和发送端不断地进行信息同步,比如接收端通知发送端:“后面那 20 车你给我等等,等我这里腾出地方你再继续发货。”

其实这就是发送窗口和接收窗口的本质,我管这个叫做“TCP 的生产者 - 消费者”模型:发送窗口和接收窗口是 TCP 连接的双方,一个作为生产者,一个作为消费者,为了达到一致协同的生产消费速率的算法。

说白了,作为 TCP 发送端,即生产者,不能忽略 TCP 的接收端,也就是消费者的实际状况,不管不顾地把数据包都传送过来。若都传送过来,消费者来不及消费,必然会丢弃;而丢弃反过使得生产者又重传,发送更多的数据包,最后导致网络崩溃。

理解了“TCP 的生产者-消费者”模型,再反过来看发送窗口和接收窗口的设计目的和方式,就会恍然大悟了。

9.1.1 发送窗口、拥塞窗口

TCP 的生产者 - 消费者模型,只是在考虑单个连接的数据传递,但 TCP 数据包需经过网卡、交换机、核心路由器等一系列的网络设备,网络设备本身的能力也是有限的,当多个连接的数据包同时在网络上传送时,势必会发生带宽争抢、数据丢失等,这样 TCP 就必须考虑多个连接共享在有限的带宽上,兼顾效率和公平性的控制,这就是拥塞控制的本质。

我们可以把网络设备形成的网络信息高速公路和生活中实际的高速公路做个对比。

  • 有一个货车行驶在半夜三点的大路上,这样的场景是断然不需要拥塞控制的。
  • 正是因为有多个 TCP 连接,形成了高速公路上的多队运送货车,高速公路上开始变得熙熙攘攘,此时就需要拥塞控制了。

在 TCP 协议中,拥塞控制是通过拥塞窗口来完成的,拥塞窗口的大小会随着网络状况实时调整。

  • 拥塞控制常用的算法有“慢启动”,它通过一定的规则,慢慢地将网络发送数据的速率增加到一个阈值。
  • 超过这个阈值之后,慢启动就结束了,另一个叫做“拥塞避免”的算法登场,在这个阶段,TCP 会不断地探测网络状况,并随之不断调整拥塞窗口的大小。

发送窗口和拥塞窗口的区别:

  • 发送窗口:反应了作为单 TCP 连接、点对点之间的流量控制模型,它需和接收端一起共同协调来调整大小;
  • 拥塞窗口:则是反应了作为多个 TCP 连接共享带宽的拥塞控制模型,它是发送端独立地根据网络状况来动态调整的。

何一时刻,TCP 发送缓冲区的数据是否能真正发送出去,至少取决于两个因素,一个是当前的发送窗口大小,另一个是拥塞窗口大小,而 TCP 协议中总是取两者中最小值作为判断依据。比如当前发送的字节为 100,发送窗口的大小是 200,拥塞窗口的大小是 80,那么取 200 和 80 中的最小值 80,那么当前发送的字节数显然是大于拥塞窗口的则不能发出去。

9.2 有效利用网络带宽

注意我在前面的表述中,提到了在任何一个时刻里,TCP 发送缓冲区的数据是否能真正发送出去,用了“至少两个因素”这个说法,细心的你有没有想过这个问题,除了之前引入的发送窗口、拥塞窗口之外,还有什么其他因素吗?

我们考虑以下几个有趣的场景:

  • 第一个场景,接收端处理得急不可待,比如刚刚读入了 100 个字节,就告诉发送端:“喂,我已读走 100 个字节了,你继续发”,在这种情况下,你觉得发送端应该怎么做呢?
  • 第二个场景是所谓的“交互式”场景,如用 telnet 登录到一台服务器上,或用 SSH 和远程的服务器交互,这种情况下,我们在屏幕上敲打了一个命令,等待服务器返回结果,这个过程需要不断和server 传输数据。这里最大的问题是,每次传输的数据可能都非常小,比如敲打的命令“pwd”,仅仅三个字符。这意味着什么?这就好比,每次叫了一辆大货车,只送了一个小水壶。在这种情况下,你又觉得发送端该怎么做才合理呢?
  • 第三个场景是从接收端来说的。我们知道,接收端需要对每个接收到的 TCP 分组进行确认,也就是发送 ACK 报文,但是 ACK 报文本身是不带数据的分段,若一直这样发送大量的 ACK 报文,就会消耗大量的带宽。之所以会这样,是因为 TCP 报文、IP 报文固有的消息头是不可或缺的,比如两端的地址、端口号、时间戳、序列号等信息, 在这种情形下,你觉得合理的做法是什么?

TCP 之所以复杂,就是因为 TCP 需要考虑的因素较多。像以上这几个场景,都是 TCP 需要考虑的情况,一句话概况就是如何有效地利用网络带宽。

  • 第一个场景也被叫做糊涂窗口综合症,这个场景需要在接收端进行优化。即接收端不能在接收缓冲区空出一个很小的部分之后,就急吼吼地向发送端发送窗口更新通知,而是需要在自己的缓冲区大到一个合理的值之后,再向发送端发送窗口更新通知。这个合理的值,由对应的 RFC 规范定义。
  • 第二个场景需要在发送端用 Nagle 算法做优化,Nagle 算法的本质其实就是限制大批量的小数据包同时发送,为此它提出任一个时刻,未被确认的小数据包不能超过一个。这里的小数据包指长度小于最大报文段长度 MSS 的 TCP 分组。这样发送端就可把接下来连续的几个小数据包存储起来,等待接收到前一个小数据包的 ACK 分组之后,再将数据一次性发送出去。
  • 第三个场景,需在接收端进行优化,这个优化的算法叫做延时 ACK。延时 ACK 在收到数据后并不马上回复,而是累计需发送的 ACK 报文,等到有数据需要发送给对端时,将累计的 ACK 捎带一并发送出去。当然延时 ACK 机制不能无限地延时下去,否则发送端误认为数据包没有发送成功而引起重传反而会占用额外的网络带宽。

9.3 禁用 Nagle 算法

Nagle 算法 + 延时 ACK 是很奇怪的组合。举一个例子来体会一下。

比如,client 分两次将一个请求发送出去,由于请求的第一部分的报文未被确认,Nagle 算法开始起作用;同时延时 ACK 在server 起作用,假设延时时间为 200ms,则 server 等待 200ms 后对请求的第一部分进行确认;接下来 client 收到了确认后,Nagle 算法解除请求第二部分的阻止,让第二部分得以发送出去,server 收到后处理应答并同时将第二部分的 ACK 捎带发送出去。

在这里插入图片描述

从上图可知,Nagle 算法 + 延时确认的组合增大了处理时延,实际上两个优化彼此在阻止对方。

因此有些情况 Nagle 算法并不适用(如时延敏感的应用)

幸运的是,我们可以通过对套接字的修改来关闭 Nagle 算法。

int on = 1; 
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&on, sizeof(on));
  • 1
  • 2

值得注意的是,除非我们对此有十足的把握,否则不要轻易改变默认的 TCP Nagle 算法。因为在现代操作系统中,针对 Nagle 算法和延时 ACK 的优化已非常成熟了,有可能在禁用 Nagle 算法之后,性能问题反而更加严重。

9.4 用 writev() 合并发送

其实前面的例子里,若我们能将一个请求一次性发送过去,而不是分开两部分独立发送,结果会好很多。所以,在写数据之前,将数据合并到缓冲区,批量发送出去,这是一个比较好的做法。不过,有时候数据会存储在两个不同的缓存中,对此,我们可以使用如下的方法来进行数据的读写操作,从而避免 Nagle 算法引发的副作用。

ssize_t writev(int filedes, const struct iovec *iov, int iovcnt)
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
  • 1
  • 2

这两个函数的第二个参数都是指向某个 iovec 结构数组的一个指针,其中 iovec 结构定义如下:

struct iovec {
	void *iov_base; /* starting address of buffer */
	size_t iov_len; /* size of buffer */
};
  • 1
  • 2
  • 3
  • 4

下面的程序展示了集中写的方式:

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: tcpclient <IPaddress>");
    }
 
    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);
 
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
 
    socklen_t server_len = sizeof(server_addr);
    int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }
 
    char buf[128];
    struct iovec iov[2];
 
    char *send_one = "hello,"; // 24-33 行,使用了 iovec 数组,分别写入了两个不同的字符串,一个是“hello,”,另一个通过标准输入读入。
    iov[0].iov_base = send_one; // 数组第一项
    iov[0].iov_len = strlen(send_one);
    iov[1].iov_base = buf; // 数组第二项
    while (fgets(buf, sizeof(buf), stdin) != NULL) {
        iov[1].iov_len = strlen(buf);
        int n = htonl(iov[1].iov_len);
        if (writev(socket_fd, iov, 2) < 0) // 用 writev() 合并发送 iov数组的两项
            error(1, errno, "writev failure");
    }
    exit(0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

先启动 server,再启动上文的 client, 并在 client 依次输入“world”和“network”,随后 server 收到了 iovec 组成的新的字符串。原理其实就是调用 writev() 时,会自动把几个数组的输入合并成一个有序的字节流,然后发送给对端。
在这里插入图片描述

总结:

  • 发送窗口用来控制发送和接收端的流量;阻塞窗口用来控制多条连接公平使用的有限带宽。
  • 小数据包加剧了网络带宽的浪费,为了解决此问题引入了如 Nagle 算法、延时 ACK 等机制。
  • 在程序设计层面,不要多次频繁地发送小报文,若有则可用 writev() 批量发送。

十、用「SO_REUSEADDR 套接字选项」避免「Address already in use」错误

server 重启时,总是碰到 “Address in use” 的报错使 server 不能很快地重启。那么这个问题是如何产生的?我们又该如何避免呢?为了引入讨论,我们从之前讲过的一个 TCP server 开始说起:

// https://github.com/datager/yolanda/blob/master/chap-15/addressused.c
static int count;
 
static void sig_int(int signo) {
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}
 
int main(int argc, char **argv) {
    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
 
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);
 
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }
 
    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }
 
    signal(SIGPIPE, SIG_IGN);
 
    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
 
    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "bind failed ");
    }
 
    char message[MAXLINE];
    count = 0;
 
    for (;;) {
        int n = read(connfd, message, MAXLINE);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
        message[n] = 0;
        printf("received %d bytes: %s\n", n, message);
        count++;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53

这个 server 绑定到一个本地 port,用的是通配地址 ANY,当连接建立后从该连接读取输入的字符流。启动 server 后,再用 telnet 向此 server 输入字符。和期望相同,server 打印出 client 的输入,在 Telnet client 端关闭连接之后,server 接收到 EOF 也顺利地关闭了连接。server 也可以很快重启,等待新的连接到来。

root@server:/home/yolanda/build/bin# ./addressused
received 3 bytes: a
received 3 bytes: b
received 3 bytes: c
client closed
  • 1
  • 2
  • 3
  • 4
  • 5
root@server:/home/yolanda/build/bin# netstat -nultp | grep addressused
tcp        0      0 0.0.0.0:43211           0.0.0.0:*               LISTEN      16053/addressused

root@client:/home/yolanda/build/bin# telnet -h
telnet: invalid option -- 'h'
Usage: telnet [-4] [-6] [-8] [-E] [-L] [-a] [-d] [-e char] [-l user]
	[-n tracefile] [ -b addr ] [-r] [host-name [port]]

root@client:/home/yolanda/build/bin# telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
a
b
c
telnet> q # 用 ctrl+]然后再输入 q 来退出 telnet
Connection closed.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

接下来,我们改变一下连接的关闭顺序。和前面的过程一样,先启动服务器,再使用 Telnet 作为client 登录到服务器,在屏幕上输入一些字符。注意接下来的不同,我不会在 Telnet 端关闭连接,而是直接使用 Ctrl+C 的方式在server 关闭连接。

root@k8s-master-163:/home/yolanda/build/bin# ./addressused
received 3 bytes: a
received 3 bytes: b
received 3 bytes: c

^C
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
root@client:/home/yolanda/build/bin# telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
a
b
c
Connection closed by foreign host.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

我们看到,连接已被关闭,telnet client也感知连接关闭并退出了。接下来,我们尝试重启server 程序。你会发现,此时server 重启失败,报错信息为:bind failed: Address already in use。

root@k8s-master-163:/home/yolanda/build/bin# ./addressused
received 3 bytes: a
received 3 bytes: b
received 3 bytes: c

^C

root@k8s-master-163:/home/yolanda/build/bin# ./addressused
bind failed : Address already in use (98)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

在这里插入图片描述

这个错误是由于 TIME_WAIT 造成的:当连接的一方主动关闭连接,在接收到对端的 FIN 报文之后,主动关闭连接的一方会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。

在这里插入图片描述

若此时用 netstat 去查看 server 所在主机的 TIME_WAIT 的状态连接,会发现有一个 server 生成的 TCP 连接,当前正处于 TIME_WAIT 状态。这里 9527 是本地监听端口,36650 是 telnet client 端口。当然 Telnet client 端口每次也会不尽相同。

# 启动 server:
root@server:/home/yolanda/build/bin# ./addressused

# 启动 server 后,网络状态如下。都是 LISTEN 的:
root@server:~# netstat -alepn | grep 43211
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode       PID/Program name
tcp        0      0 0.0.0.0:43211           0.0.0.0:*               LISTEN      0          1177104853  39821/addressused

# 启动 telnet 作为 client,连接 server:
root@server:~# netstat -alepn | grep 43211
tcp        0      0 0.0.0.0:43211           0.0.0.0:*               LISTEN      0          1177104853  39821/addressused
tcp        0      0 127.0.0.1:43211         127.0.0.1:42510         ESTABLISHED 0          1177104854  39821/addressused
tcp        0      0 127.0.0.1:42510         127.0.0.1:43211         ESTABLISHED 0          1177100853  40842/telnet


# 关闭 server 后,再启动会报错 Address in used 错误,同时网络状态有 TIME_WAIT
root@server:/home/yolanda/build/bin# ./addressused
bind failed : Address already in use (98)
root@server:~# netstat -alepn | grep 43211
tcp        0      0 127.0.0.1:43211         127.0.0.1:42510         TIME_WAIT   0          0           - 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

在这里插入图片描述

server 发起的关闭连接操作,引起了一个已有的 TCP 连接处于 TME_WAIT 状态,正是这个 TIME_WAIT 的连接,使得服务器重启时,继续绑定在 127.0.0.1 地址和 9527 端口上的操作,返回了 Address already in use 的错误。

10.1 设置 SO_REUSEADDR 套接字选项

10.1.1 防止 Address already in use 错误

我们知道,一个 TCP 连接是通过四元组(源地址、源端口、目的地址、目的端口)来唯一确定的,若每次 telnet client 使用的本地端口都不同,就不会和已有的四元组冲突,也就不会有 TIME_WAIT 的新旧连接化身冲突的问题。

即使在很小的概率下,telnet client 用了相同的端口,从而造成新连接和旧连接的四元组相同,在现代 Linux 操作系统下也不会有什么大的问题,原因是现代 Linux 操作系统对此进行了一些优化。

  • 第一种优化是新连接 SYN 告知的初始序列号,一定比 TIME_WAIT 老连接的末序列号大,这样通过序列号就可以区别出新老连接。
  • 第二种优化是开启了 tcp_timestamps,使得新连接的时间戳比老连接的时间戳大,这样通过时间戳也可以区别出新老连接。

在这样的优化之下,一个 TIME_WAIT 的 TCP 连接可以忽略掉旧连接,重新被新的连接所使用。

这就是重用套接字选项,通过给套接字配置可重用属性,告诉操作系统内核,这样的 TCP 连接完全可以复用 TIME_WAIT 状态的连接。代码如下:

int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
  • 1
  • 2

SO_REUSEADDR 套接字选项,允许启动绑定在一个端口,即使之前存在一个和该端口一样的连接。前面的例子已表明,在默认情况下,server 历经创建 socket、bind 和 listen 重启时,若试图绑定到一个现有连接上的端口,bind 操作会失败,但是若我们在创建 socket 和 bind 之间,使用上面的代码片段设置 SO_REUSEADDR 套接字选项,情况就会不同。

下面对原来的 server 代码升级,升级的部分主要在 11-12 行,在 bind() 监听套接字之前调 setsockopt() 设置重用套接字选项:

// https://github.com/datager/yolanda/blob/master/chap-15/addressused02.c
int main(int argc, char **argv) {
    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
 
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);
 
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); // SO_REUSEADDR
 
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }
 
    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }
 
    signal(SIGPIPE, SIG_IGN);
 
    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
 
    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "bind failed ");
    }
 
    char message[MAXLINE];
    count = 0;
 
    for (;;) {
        int n = read(connfd, message, MAXLINE);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
        message[n] = 0;
        printf("received %d bytes: %s\n", n, message);
        count++;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49

重新编译过后,重复上面那个实验步骤,先启动 server,再用 telnet client 登录到服务器,在屏幕上输入一些字符,使用 Ctrl+C 的方式在 server 关闭连接。马上尝试重启 server,此时发现 server 正常启动,没再出现 Address already in use 的错误。说明修改已生效。
在这里插入图片描述

10.1.2 不同地址上,用相同的端口提供服务

SO_REUSEADDR 套接字选项还有一个作用,那就是本机 server 若有多个地址,可以在不同地址上使用相同的 port 提供服务。

比如,一台 server 有 192.168.1.101 和 10.10.2.102 两个地址,我们可以在这台机器上启动三个不同的 HTTP 服务

  • 第一个以本地通配地址 ANY 和端口 80 启动
  • 第二个以 192.168.1.101 和端口 80 启动
  • 第三个以 10.10.2.102 和端口 80 启动

这样配置后效果如下:

  • 目的地址为 192.168.1.101,目的端口为 80 的连接请求,被发往第二个服务;
  • 目的地址为 10.10.2.102,目的端口为 80 的连接请求,被发往第三个服务;
  • 目的端口为 80 的所有其他连接请求,被发往第一个服务。

我们必须给这三个 server 都设置 SO_REUSEADDR 套接字选项,否则第二个和第三个服务调用 bind() 绑定到 80 端口时会出错。

10.1.3 最佳实践

最佳实践: server 都应该设置 SO_REUSEADDR 套接字选项,以便其在极短时间内复用同一个 port 启动。

有些人可能觉得这不是安全的。但其实单独重用一个套接字不会有任何问题:

  • 首先 TCP 机制绝不允许在相同的地址和 port 上绑定不同的 server,即使我们设置 SO_REUSEADDR 套接字选项,也不可能在 ANY 通配符地址下 和 端口 9527 上 重复启动两个 server 实例。(若我们启动第二个 server 实例,不出所料会得到 Address already in use 的报错,即使当前还没有任何一条有效 TCP 连接产生。)
  • 而且前面已讲过,TCP 连接是通过四元组唯一区分的,只要 client 不用相同的源端口,连接 server 是没有问题的,即使使用了相同的 port,根据序列号或者时间戳,也是可以区分出新旧连接的。

注意:tcp_tw_reuse 的内核配置选项,和 SO_REUSEADDR 套接字选择,这两个东西一点关系也没有。

  • tcp_tw_reuse 是内核选项:主要用在连接的发起方。TIME_WAIT 状态的连接创建时间超过 1 秒后,新的连接才可以被复用,注意,这里是连接的「发起方」;tcp_tw_reuse 是为了缩短 TIME_WAIT 的时间,避免出现大量的 TIME_WAIT 链接而占用系统资源,解决的是 accept() 后的问题。
  • SO_REUSEADDR 是用户态选项:SO_REUSEADDR 选项用来告诉操作系统内核,若端口已被占用,但是 TCP 连接状态位于 TIME_WAIT,可以重用端口。若端口忙,而 TCP 处于其他状态,重用端口时依旧得到 “Address already in use” 的错误信息。注意这里一般都是连接的「服务方」。SO_REUSEADDR 是为了解决 TIME_WAIT 状态带来的端口占用问题,以及支持同一个 port 对应多个 ip,解决的是bind() 时的问题。

十一、tcp 的流式协议

前文我们讲的都是单个 client - server的例子,可能会给你造成一种错觉,好像 TCP 是一种应答形式的数据传输过程,比如发送端一次发送 network 和 program 这样的报文,在前面的例子中,我们看到的结果基本是这样的:

  • 发送端:network ----> 接收端回应:Hi, network
  • 发送端:program -----> 接收端回应:Hi, program

这其实是一个假象,之所以会这样,是因为网络条件比较好,而且发送的数据也比较少。为了让大家理解 TCP 数据是流式的这个特性,我们分别从发送端和接收端来阐述。

  • 在发送端,当我们调用 send() 完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中,至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。即我们不能假设每次 send() 发送的数据,都会作为一个整体完整地被发送出去。若我们考虑实际网络传输过程中的各种影响,假设发送端陆续调用 send() 先后发送 network 和 program 报文,那么实际的发送很有可能是这个样子的。
第一种情况,一次性将 network 和 program 在一个 TCP 分组中发送出去,像这样:
...xxxnetworkprogramxxx...

第二种情况,program 的部分随 network 在一个 TCP 分组中发送出去,像这样:
TCP 分组 1...xxxxxnetworkpro
TCP 分组 2:
gramxxxxxxxxxx...

第三种情况,network 的一部分随 TCP 分组被发送出去,另一部分和 program 一起随另一个 TCP 分组发送出去,像这样。
TCP 分组 1...xxxxxxxxxxxnet
TCP 分组 2:
workprogramxxx...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 实际上类似的组合可以枚举出无数种。不管是哪一种,核心的问题是我们不知道 network 和 program 这两个报文是如何进行 TCP 分组传输的。换言之发数据时不应假设 “数据流和 TCP 分组是一种映射关系”。就好像在前面我们似乎觉得 network 这个报文一定对应一个 TCP 分组,这是完全不正确的。

  • 若我们再来看 client,数据流的特征更明显。接收端缓冲区保留了没有被取走的数据,随着应用程序不断从接收端缓冲区读出数据,接收端缓冲区就可以容纳更多新的数据。若我们使用 recv() 从接收端缓冲区读取数据,发送端缓冲区的数据是以字节流的方式存在的,无论发送端如何构造 TCP 分组,接收端最终受到的字节流总是像下面这样:

xxxxxxxxxxxxxxxxxnetworkprogramxxxxxxxxxxxx
  • 1

关于接收端字节流,有两点需要注意:

  • 第一,这里 netwrok 和 program 的「肯定会保持顺序」,也就是说先调用 send() 发送的字节,总在后调用 send() 发送字节的前面,这是由 TCP 严格保证的;
  • 第二,若发送过程中有 TCP 分组丢失,但是其后续分组陆续到达,那么 TCP 协议栈会「缓存后续分组」,直到前面丢失的分组到达,最终形成可以被应用程序读取的数据流。

11.1 网络的大小端字节序

计算机最终保存和传输,用的都是 0101 这样的二进制数据,字节流在网络上的传输,也是通过二进制来完成的。

从二进制到字节是通过编码完成的,比如著名的 ASCII 编码,通过一个字节 8 个比特对常用的西方字母进行了编码。

这里有一个有趣的问题,若需要传输数字,比如 0x0201,对应的二进制为 00000010000000001,那么两个字节的数据到底是先传 0x01,还是相反?

在这里插入图片描述

在计算机发展的历史上,对于如何存储这个数据没有形成标准。比如这里讲到的问题,不同的系统就会有两种存法

  • 一种是将 0x02 高字节存放在起始地址,称之为大端字节序(Big-Endian)
  • 另一种将 0x01 低字节存放在起始地址,称之为小端字节序(Little-Endian)。

但是在网络传输中,必须保证双方都用同一种标准来表达,这就好比我们打电话时说的是同一种语言,否则双方不能顺畅地沟通。网络协议用的是大端字节序,我个人觉得大端字节序比较符合人类的思维习惯,你可想象手写一个多位数字,从开始往小位写,自然会先写大位,比如写 12, 1234 这个样子。

POSIX 标准提供了如下的转换函数来保证网络字节序的一致:

uint16_t htons (uint16_t hostshort)
uint16_t ntohs (uint16_t netshort)
uint32_t htonl (uint32_t hostlong)
uint32_t ntohl (uint32_t netlong)
  • 1
  • 2
  • 3
  • 4

这里函数中的 n 代表的就是 network,h 代表的是 host,s 表示的是 short,l 表示的是 long,分别表示 16 位和 32 位的整数。这些函数可帮我们在主机(host)和网络(network)的格式间灵活转换。用这些函数时并不需要关心主机到底是什么样的字节顺序,只要使用函数给定值进行网络字节序和主机字节序的转换就可以了。

你可以想象,若碰巧我们的系统本身是大端字节序,和网络字节序一样,那么使用上述所有的函数进行转换的时候,结果都仅仅是一个空实现,直接返回。比如这样:

# if __BYTE_ORDER == __BIG_ENDIAN
/* The host byte order is the same as network byte order,
   so these functions are all just identity.  */
# define ntohl(x) (x)
# define ntohs(x) (x)
# define htonl(x) (x)
# define htons(x) (x)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

11.2 报文编码格式

报文既然是以字节流的形式呈现给应用程序的,那么应用程序如何解读字节流呢?这就要说到报文格式和解析了。报文格式实际上定义了字节的组织形式,发送端和接收端都按照统一的报文格式进行数据传输和解析,这样就可以保证彼此能够完成交流。

报文格式最重要的是如何确定报文的边界。常见的报文格式有两种方法:

  • 一种是发送端把要发送的报文长度预先通过报文告知给接收端
  • 另一种是通过一些特殊的字符来进行边界的划分

11.2.1 显式编码报文长度

这个报文的格式很简单,首先 4 个字节大小的消息长度,其目的是将真正发送的字节流的大小显式通过报文告知接收端,接下来是 4 个字节大小的消息类型,而真正需要发送的数据则紧随其后。
在这里插入图片描述

client 如下:

// https://github.com/datager/yolanda/blob/master/chap-16/streamclient.c
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: tcpclient <IPaddress>");
    }
 
    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);
 
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
 
    socklen_t server_len = sizeof(server_addr);
    int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }
 
 	// 图示的报文格式转化为结构体
    struct {
        u_int32_t message_length;
        u_int32_t message_type;
        char buf[128];
    } message;
 
    int n;
 
    while (fgets(message.buf, sizeof(message.buf), stdin) != NULL) {
        n = strlen(message.buf);
        message.message_length = htonl(n);
        message.message_type = 1;
        // 实际发送的字节流大小为消息长度 4 字节,加上消息类型 4 字节,以及标准输入的字符串大小
        if (send(socket_fd, (char *) &message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) < 0)
            error(1, errno, "send failure");
 
    }
    exit(0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

server 如下:

// https://github.com/datager/yolanda/blob/master/chap-16/streamserver.c
static int count;

static void sig_int(int signo) {
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}
 
int main(int argc, char **argv) {
    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
 
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);
 
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
 
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }
 
    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }
 
    signal(SIGPIPE, SIG_IGN);
 
    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
 
    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "bind failed ");
    }
 
    char buf[128];
    count = 0;
 
    while (1) {
        int n = read_message(connfd, buf, sizeof(buf));
        if (n < 0) {
            error(1, errno, "error read message");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
        buf[n] = 0;
        printf("received %d bytes: %s\n", n, buf);
        count++;
    } 
    exit(0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57

其中解析报文的read_message() 和 readn() 函数:

// 第 6 行通过调用 readn 函数获取 4 个字节的消息长度数据,紧接着,第 11 行通过调用 readn 函数获取 4 个字节的消息类型数据。第 15 行判断消息的长度是不是太大,若大到本地缓冲区不能容纳,则直接返回错误;第 19 行调用 readn 一次性读取已知长度的消息体。
size_t read_message(int fd, char *buffer, size_t length) {
    u_int32_t msg_length;
    u_int32_t msg_type;
    int rc;
 
    rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
    if (rc != sizeof(u_int32_t))
        return rc < 0 ? -1 : 0;
    msg_length = ntohl(msg_length);
 
    rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
    if (rc != sizeof(u_int32_t))
        return rc < 0 ? -1 : 0;
 
    if (msg_length > length) {
        return -1;
    }
 
    rc = readn(fd, buffer, msg_length);
    if (rc != msg_length)
        return rc < 0 ? -1 : 0;
    return rc;
}


// 读取报文预设大小的字节,readn 调用会一直循环,尝试读取预设大小的字节,若接收缓冲区数据空,readn 函数会阻塞在那里,直到有数据到达。
// readn 函数中使用 count 来表示还需要读取的字符数,若 count 一直大于 0,说明还没有满足预设的字符大小,循环就会继续。第 9 行通过 read 函数来服务最多 count 个字符。11-17 行针对返回值进行出错判断,其中返回值为 0 的情形是 EOF,表示对方连接终止。19-20 行要读取的字符数减去这次读到的字符数,同时移动缓冲区指针,这样做的目的是为了确认字符数是否已读取完毕。
size_t readn(int fd, void *buffer, size_t length) {
    size_t count;
    ssize_t nread;
    char *ptr;
 
    ptr = buffer;
    count = length;
    while (count > 0) {
        nread = read(fd, ptr, count);
 
        if (nread < 0) {
            if (errno == EINTR)
                continue;
            else
                return (-1);
        } else if (nread == 0)
            break;                /* EOF */
 
        count -= nread;
        ptr += nread;
    }
    return (length - count);        /* return >= 0 */
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

实验如下:我们依次启动作为报文解析的服务器一端,以及作为报文发送的client 。我们看到,每次client 发送的报文都可以被server 解析出来,在标准输出上的结果验证了这一点。

在这里插入图片描述

11.2.2 特殊字符作为边界

HTTP 是一个非常好的例子。HTTP 通过设置回车符、换行符做为 HTTP 报文协议的边界。

在这里插入图片描述

因 windows 和 mac linux 的换行不一样。\r 或\r\n,所以 HTTP 报文要同时处理 \r 或\r\n。

下面的 read_line 函数就是在尝试读取一行数据,也就是读到回车符 \r,或者读到回车换行符 \r\n 为止。这个函数每次尝试读取一个字节。

int read_line(int fd, char *buf, int size) {
    int i = 0;
    char c = '\0';
    int n;
 
    while ((i < size - 1) && (c != '\n')) {
        n = recv(fd, &c, 1, 0);
        if (n > 0) {
            if (c == '\r') { // 第 9 行若读到了回车符 \r
                n = recv(fd, &c, 1, MSG_PEEK);
                if ((n > 0) && (c == '\n')) // 接下来在 11 行的“观察”下看有没有换行符 \n
                    recv(fd, &c, 1, 0); // 若有就在第 12 行读取这个换行符 \n
                else
                    c = '\n';
            }
            buf[i] = c; // 若没有读到回车符 \r,就在第 16-17 行将字符放到缓冲区,并移动指针。
            i++;
        } else
            c = '\n';
    }
    buf[i] = '\0';
 
    return (i);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

十二、tcp 并不完全可靠

你可能会认为,TCP 是一种可靠的协议,这种可靠体现在端到端的通信上。这似乎给我们带来了一种错觉,从发送端来看,应用程序通过调用 send() 发送的数据流总能可靠地到达接收端;而从接收端来看,总是可以把对端发送的数据流完整无损地传递给应用程序来处理。事实上若对 TCP 传输环节进行详细的分析,就会沮丧地发现上述论断是不正确的。

  • 发送端调 send() 后,数据流并没有马上通过网络传输出去,而是存储在套接字的发送缓冲区中,由网络协议栈决定何时发、如何发。当对应的数据发送给接收端,接收端回应 ACK,存储在发送缓冲区的这部分数据就可以删除了,但发送端并无法获取对应数据流的 ACK 情况,即发送端无法判断对端的接收方是否已接收发送的数据流,若需要知道这部分信息,就必须在应用层自己添加处理逻辑,例如显式的报文确认机制。
  • 接收端也无法保证 ACK 过的数据部分可以被应用程序处理,因为数据需被接收端程序从接收缓冲区中拷贝走,可能出现的状况是,已 ACK 的数据保存在接收端缓冲区中,接收端处理程序突然崩溃了,这部分数据就无法被应用程序继续处理。

你有没有发现,TCP 协议实现并没有提供给上层应用程序过多的异常处理细节,即 TCP 协议反映链路异常的能力偏弱,这其实是有原因的。要知道 TCP 诞生之初,是为美国防部服务的,考虑到军事作战的实际需要 TCP 不希望暴露更多的异常细节,而是能够以无人值守、自我恢复的方式运作。

TCP 连接建立之后,能感知 TCP 链路的方式是有限的:

  • 一种是以 read() 为核心的读操作
  • 另一种是以 write() 为核心的写操作

接下来,我们就看下如何通过读写操作来感知异常情况,以及对应的处理方式。实际情景有的异常情况主要有如下两大类,这两大类情况又可以根据应用程序的场景细分:

  • 第一类,对端无 FIN 包发送出来,需通过巡检或超时来发现。
  • 第二类,对端有 FIN 包发送出来,需通过增强 read() 或 write() 的异常处理来发现。

在这里插入图片描述

12.1 网络中断造成的:对端无 FIN 包

很多原因都会造成网络中断,在这种情况下,TCP 程序并不能及时感知到异常信息。除非网络中的其他设备,如路由器发出一条 ICMP 报文,说明目的网络或主机不可达,此时 read() 或 write() 就会返回 Unreachable 的错误。

  • 可惜多数时候并不是如此,在没有 ICMP 报文的情况下,TCP 程序并不能理解感应到连接异常。若程序是阻塞在 read() 上,那么很不幸,程序无法从异常中恢复。这显然是非常不合理的,不过我们可以通过「给 read() 设置超时」来解决。
  • 若程序先调 write() 发了一段数据流,接下来阻塞在 read() 上,结果会非常不同。Linux 的 TCP 协议栈会不断尝试将发送缓冲区的数据发出去,大概在重传 12 次、合计时间约为 9 分钟之后,协议栈会标识该连接异常,此时阻塞的 read() 会返回一条 TIMEOUT 的错误信息。若此时程序还执着地往这条连接写数据,写操作会立即失败,返回一个 SIGPIPE 信号给应用程序。

12.2 系统崩溃造成的:对端无 FIN 包

当系统突然崩溃,如断电时,网络连接来不及发出任何东西。这里和通过系统调用杀死应用程序非常不同的是,没有任何 FIN 包被发送出来。

这种情况和网络中断造成的结果非常类似,在无 ICMP 报文的情况下,TCP 程序只能通过 read() 和 write() 得到网络连接异常的信息,超时错误是一个常见的结果。

不过还有一种情况需要考虑,那就是系统在崩溃之后又重启,当重传的 TCP 分组到达重启后的系统,由于系统中没有该 TCP 分组对应的连接数据,系统会返回一个 RST 重置分节,TCP 程序通过 read() 和 write() 可分别对 RST 进行错误处理。

  • 若是阻塞的 read() ,会立即返回一个错误,错误信息为连接重置(Connection Resest)。
  • 若是一次 write() ,也会立即失败,应用程序会被返回一个 SIGPIPE 信号。

12.3 对端有 FIN 包发出

对端若有 FIN 包发出,可能的场景是对端调用了 close() 或 shutdown() 显式地关闭了连接,也可能是对端应用程序崩溃而操作系统内核代为清理所发出的。从应用程序角度上看无法区分是哪种情形。

阻塞的 read() 在完成正常接收数据的读取之后,FIN 包会通过返回一个 EOF 来完成通知,此时 read() 返回值为 0。

  • 注意收到 FIN 包之后 read() 不会立即返回。(因为收到 FIN 包相当于往接收缓冲区里放置了一个 EOF 符号,之前已在接收缓冲区的有效数据不会受到影响。)

12.4 实验

为了展示这些特性,我分别编写了 server 和 client 如下:

// https://github.com/datager/yolanda/blob/master/chap-17/reliable_server01.c
// server : 是一个简单的应答程序,在收到数据流之后回显给client ,在此之前,休眠 5 秒,以便完成后面的实验验证。
int main(int argc, char **argv) {
    int connfd;
    char buf[1024];
 
    connfd = tcp_server(SERV_PORT);
 
    for (;;) {
        int n = read(connfd, buf, 1024);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
 
        sleep(5);
 
        int write_nc = send(connfd, buf, n, 0);
        printf("send bytes: %zu \n", write_nc);
        if (write_nc < 0) {
            error(1, errno, "error write");
        }
    }
 
    exit(0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
// https://github.com/datager/yolanda/blob/master/chap-17/reliable_client01.c
// client : 从标准输入读入,将读入的字符串传输给server 
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: reliable_client01 <IPaddress>");
    }
 
    int socket_fd = tcp_client(argv[1], SERV_PORT);
    char buf[128];
    int len;
    int rc;
 
    while (fgets(buf, sizeof(buf), stdin) != NULL) {
        len = strlen(buf);
        rc = send(socket_fd, buf, len, 0);
        if (rc < 0)
            error(1, errno, "write failed");
        rc = read(socket_fd, buf, sizeof(buf));
        if (rc < 0)
            error(1, errno, "read failed");
        else if (rc == 0)
            error(1, 0, "peer connection closed\n");
        else
            fputs(buf, stdout);
    }
    exit(0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

12.4.1 read() 直接感知 FIN 包

我们依次启动 server 和 client,在 client 输入 “good” 字符后,迅速结束掉 server,这里需赶在 server 从睡眠中苏醒之前杀死 server。屏幕上打印出:peer connection closed。client 正常退出。

在这里插入图片描述

这说明 client 通过 read() ,感知到了 server 发的 FIN 包,于是正常退出了client 。

在这里插入图片描述

注意若我们的速度不够快,导致 server 从睡眠中苏醒,并成功将报文发送出来后,client 会正常显示,此时我们停留等待 stdin。若不继续通过 read 或 write() 对套接字进行读写,是无法感知 server 已关闭套接字这个事实的。

在这里插入图片描述

12.4.2 先通过 write() 收到对端发出的 RST,再通过 read() 感知 RST

这一次,我们仍然依次启动 server 和client ,在client 输入 bad 字符之后,等待一段时间,直到client 正确显示了服务端的回应“bad”字符之后,再杀死服务器程序。client 再次输入 bad2,这时屏幕上打印出”peer connection closed“。

在这里插入图片描述

时序图如下:

在这里插入图片描述

在很多书籍和文章中,对这个程序的解读是,收到 FIN 包的 client 继续合法地向 server 发送数据,server 在无法定位该 TCP 连接信息的情况下发送了 RST 信息,client 收到 RST 并当 client 调 read() 时内核会将 RST 错误信息通知给应用程序。这是一个典型的 client 的 write() 造成异常,再通过 client 的 read() 来感知异常的样例。

  • 不过我在 Linux 4.4 内核上实验这个程序,多次的结果都是,内核正常将 EOF 信息通知给应用程序,而不是 RST 错误信息。
  • 我又在 Max OS 10.13.6 上尝试这个程序, read() 可以返回 RST 异常信息。输出和时序图也已给出。
# macos 
$./reliable_client01 127.0.0.1
$bad
$bad
$bad2
$read failed: Connection reset by peer (54)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

12.4.3 向一个已关闭连接连续写,最终导致 SIGPIPE

为了模拟这个过程,对 server 和 client 都做了如下修改:

// https://github.com/datager/yolanda/blob/master/chap-17/reliable_server02.c
// server 每次读取 1K 数据后休眠 1 秒,以模拟处理数据的过程。
int main(int argc, char **argv) {
    int connfd;
    char buf[1024];
    int time = 0;
 
    connfd = tcp_server(SERV_PORT);
 
    while (1) {
        int n = read(connfd, buf, 1024);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
 
        time++;
        fprintf(stdout, "1K read for %d \n", time);
        usleep(1000); // 休眠一秒
    }
 
    exit(0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
// https://github.com/datager/yolanda/blob/master/chap-17/reliable_client02.c
// client 第 8 行注册了 SIGPIPE 的信号处理程序,第 14-22 行 client 一直循环发数据流。
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: reliable_client02 <IPaddress>");
    }
 
    int socket_fd = tcp_client(argv[1], SERV_PORT);
 
    signal(SIGPIPE, SIG_IGN); // 注册信号处理程序
 
    char *msg = "network programming";
    ssize_t n_written;
 
    int count = 10000000;
    while (count > 0) { // 第 14-22 行 client 一直循环发数据流
        n_written = send(socket_fd, msg, strlen(msg), 0);
        fprintf(stdout, "send into buffer %ld \n", n_written);
        if (n_written <= 0) {
            error(1, errno, "send error");
            return -1;
        }
        count--;
    }
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

若在 server 读数并处理据的过程中突然被 kill,我们会看到 client 很快也会退出,并在屏幕上打印出 “Connection reset by peer” 的提示。
在这里插入图片描述

这是因为server 被 kill 之后,操作系统内核会做一些清理的事情,为这个套接字发送一个 FIN 包,但是 client 在收到 FIN 包之后,没有 read() ,还是会继续往这个套接字写入数据。这是因为根据 TCP 协议,连接是双向的,收到对方的 FIN 包只意味着对方不会再发送任何消息。 在一个双方正常关闭的流程中,收到 FIN 包的一端将剩余数据发送给对面(通过一次或多次 write),然后关闭套接字。

当 client 的数据到达 server 时,server 操作系统内核发现这是一个指向关闭的套接字,该 server 会再次向 client 发送一个 RST 包,对于 client 而言若此时再执行 write() ,立即会返回一个 RST 错误信息。时序图如下:

在这里插入图片描述

在很多书籍和文章中,对这个实验的期望结果不是这样的。大部分的教程是这样说的:在第二次 write() 时,由于 server 无法查到对应的 TCP 连接信息,于是发了一个 RST 包给 client ,client 第二次操作时应用程序会收到一个 SIGPIPE 信号。若不捕捉这个信号则应用程序会在毫无征兆的情况下直接退出。

我在 Max OS 10.13.6 上尝试这个程序,得到的结果确实如此。你可以看到屏幕显示和时序图。

#send into buffer 19 
#send into buffer -1 
#send error: Broken pipe (32)
  • 1
  • 2
  • 3

这说明,Linux4.4 的实现和类 BSD 的实现已非常不一样了。限于时间的关系,我没有仔细对比其他版本的 Linux,还不清楚是新的内核特性。但有一点是可以肯定的,「需给 SIGPIPE 注册处理函数」,通过 write() 感知 RST 的错误信息,这样可保证应用程序在 Linux 4.4 和 Mac OS 上都能正常处理异常。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/552848
推荐阅读
相关标签
  

闽ICP备14008679号