TCP中的keepalive机制


一、RFC1122

关于 TCP 中 keepalive 机制的描述在 RFC1122 中的 4.2.3.6 小结 ,内容不多,总结一下有下面几点:

  1. TCP 实现中 可以 包含 keepalive 这个功能,也可以不包含。
  2. 如果实现了 keepalive 这个功能,那么 必须 能为每个 TCP 连接开启或者关闭 keepalive 功能。同时,此功能默认 必须 是关闭的。
  3. 必须 在规定的时间间隔内,既没有收到包含数据包,也没有收到包含 ACK 标记的包时,才能发送 keepalive 的包。这个时间间隔 必须 是可配置的,并且默认 必须 不能小于两个小时。
  4. TCP 不能保证没有数据仅有 ACK 标记的包一定会被对端接收到。所以,如果实现了 keepalive 机制,必须 不能把任何特定探测失败的连接标记为死链接。
  5. keepalive 的实现 应该 是发送一个不包含数据的 keepalive 包。但是为了与不规范的 TCP 实现兼容,可以 配置在 keepalive 包中包含一个垃圾的字节,来避免第 4 点的情况。
  6. 一些 TCP 实现 keepalive 的方式是:发送一个 Seq 编号为 Next-1 (如果是正常的数据发送 Seq 的值应为 Next ),并且可能包含或者不包含一个垃圾字节的包给对端。因为 keepalive 包中的 Seq 值在对端 TCP 的滑动窗口之外(因为已经确认收到了),所以对端会回应一个包含有 ACK 标记的包,并据此来判断对端是否存活。如果对端网络故障或者崩溃,则会回应一个带有 RST 标记的包来重置连接。
  7. 一些不够完善的 TCP 实现可能不能正确的响应 Seq 编号为 Next-1 且不带数据的 keepalive 包,但是却可以响应带有一个字节垃圾信息的包。所以在实现 keepalive 机制时,需要判断这种情况,并据此决定在发送 keepalive 包时,是否要带上一字节的垃圾数据。
  8. TCP 的 keepalive 机制应该仅用在服务器的程序中,因为如果客户端崩溃,服务器就能及时的释放资源。

二、相关设置

1. SO_KEEPALIVE

默认情况下,新创建出来的套接字的 keepalive 功能都是关闭的。

int keepalive = 1;
setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, (const char*)&keepalive, sizeof(keepalive));

2. TCP_KEEPIDLE

这个选项用于控制经过多久后,既没有收到包含数据包,也没有收到包含 ACK 标记的包时,就发送 keepalive 的包。

int keepidle = 7200;
setsockopt(sock, SOL_TCP, TCP_KEEPIDLE, (const char*)&keepidle, sizeof(keepidle));

Windows下,代码中的 SOL_TCP 要替换成 IPPROTO_TCP,下同。

3. TCP_KEEPINTVL

这个选项用于控制一旦开始发送了 keepalive 后,经过多久时间后再发送下一个包。

int keepintvl = 75;
setsockopt(sock, SOL_TCP, TCP_KEEPINTVL, (const char*)&keepintvl, sizeof(keepintvl));

4. TCP_KEEPCNT

这个选项用于控制在经过发送了多少个 keepalive 包但是都没有收到回复后,可以认为连接已经断开了。

int keepcnt = 9;
setsockopt(sock, SOL_TCP, TCP_KEEPCNT, (const char*)&keepcnt, sizeof(keepcnt));

5. 默认值

Linux下,系统的默认值可以通过命令来查看:

cat /proc/sys/net/ipv4/tcp_keepalive_time #7200
cat /proc/sys/net/ipv4/tcp_keepalive_intvl #75
cat /proc/sys/net/ipv4/tcp_keepalive_probes #9

上面参数的意义是:在未收到包含数据或 ACK 标志的包 7200 秒后,开始发送 keepalive 包,并每隔 75 秒重发一次,如果连发 9 次都没有响应,就认为网络已断开。但是如果 9 次内的某次收到了 keepalive 包对应的 ACK 包,则又会在未收到包含数据或ACK 标志的包 7200 秒后重新开始发 keepalive 包。

Windows下,不同系统版本默认值不同,一般是7200秒,1秒,10次。具体的可以参考:https://docs.microsoft.com/en-us/windows/win32/winsock/sio-keepalive-vals#remarks

三、平台实现

1. 测试代码

写一段代码简单的测试一下:

// file: keepalive.c
// gcc -o keepalive keepalive.c

#ifdef _WIN32
#include <WinSock2.h>
#include <Ws2tcpip.h>
#include <stdio.h>
#include <stdlib.h>
#pragma comment(lib, "ws2_32.lib")
#define socklen_t int
#define SOL_TCP IPPROTO_TCP
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define SOCKET int
#endif

#include <stdio.h>
#include <string.h>

#define IP "192.168.104.106"
#define PORT 9876

void print_sock_opt(const char* name, SOCKET sockfd, int level, int optname)
{
    int optval = -1;
    socklen_t optlen = sizeof(optval);
    if (getsockopt(sockfd, level, optname, (char*)&optval, &optlen) != 0) {
        perror("getsockopt failed!");
    }
    printf("%s: %d\n", name, optval);
}

void print_sock_keepalive(SOCKET sockfd)
{
    print_sock_opt("keepalive", sockfd, SOL_SOCKET, SO_KEEPALIVE);
    print_sock_opt("keepidle", sockfd, SOL_TCP, TCP_KEEPIDLE);
    print_sock_opt("keepintvl", sockfd, SOL_TCP, TCP_KEEPINTVL);
    print_sock_opt("keepcnt", sockfd, SOL_TCP, TCP_KEEPCNT);
    puts("");
}

void set_sock_opt(SOCKET sockfd, int level, int optname, int optval)
{
    if (setsockopt(sockfd, level, optname, (const char*)&optval, sizeof(optval)) != 0) {
        perror("setsockopt failed!");
    }
}

void set_sock_keepalive(SOCKET sockfd, int keepalive, int idle, int intvl, int cnt)
{
    set_sock_opt(sockfd, SOL_SOCKET, SO_KEEPALIVE, keepalive);
    set_sock_opt(sockfd, SOL_TCP, TCP_KEEPIDLE, idle);
    set_sock_opt(sockfd, SOL_TCP, TCP_KEEPINTVL, intvl);
    set_sock_opt(sockfd, SOL_TCP, TCP_KEEPCNT, cnt);
}

int main(int argc, char** argv)
{
#ifdef _WIN32
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);
#endif

    SOCKET sockfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = inet_addr(IP);

    if (connect(sockfd, (const struct sockaddr*)&addr, sizeof(addr)) != 0) {
        perror("connect failed!");
        return -1;
    }

    print_sock_keepalive(sockfd);
    set_sock_keepalive(sockfd, 1, 10, 15, 8);
    print_sock_keepalive(sockfd);

    while (1) {
        char buf[1024] = {};
        int n = recv(sockfd, buf, sizeof(buf), 0);
        if (n <= 0) {
            break;
        }
        buf[n] = '\0';
        printf("receive: %s\n", buf);
        send(sockfd, buf, n, 0);
    }

    return 0;
}

2. 服务器

在 Linux 上安装软件 socat,并运行下面的命令来监听端口9876:

socat tcp-listen:9876,reuseaddr -

服务器的 IP 地址为:192.168.104.106。

3. Linux 下 keepalive 的实现

Linux 的 IP 地址为:192.168.102.63
Linux 的内核为:4.15.0-147-generic

首先安装 tcpdump 并运行下面的命令来抓包:

sudo tcpdump -pni enp0s31f6 -vvv -S "tcp port 9876"

其中 enp0s31f6 是网卡的名字,9876 为端口号。

然后运行测试代码编译出来的程序,tcpdump 输出(删除了一些不必要的信息):

14:21:17.008786 192.168.102.63.34882 > 192.168.104.106.9876: Flags [S],  seq 3365757301, length 0
14:21:17.009112 192.168.104.106.9876 > 192.168.102.63.34882: Flags [S.], seq 3674364464, ack 3365757302, length 0
14:21:17.009152 192.168.102.63.34882 > 192.168.104.106.9876: Flags [.],  seq 3365757302, ack 3674364465, length 0

14:21:27.142639 192.168.102.63.34882 > 192.168.104.106.9876: Flags [.],  seq 3365757301, ack 3674364465, length 0
14:21:27.142904 192.168.104.106.9876 > 192.168.102.63.34882: Flags [.],  seq 3674364465, ack 3365757302, length 0

14:21:37.382671 192.168.102.63.34882 > 192.168.104.106.9876: Flags [.],  seq 3365757301, ack 3674364465, length 0
14:21:37.383214 192.168.104.106.9876 > 192.168.102.63.34882: Flags [.],  seq 3674364465, ack 3365757302, length 0

14:21:47.622639 192.168.102.63.34882 > 192.168.104.106.9876: Flags [.],  seq 3365757301, ack 3674364465, length 0
14:21:47.623224 192.168.104.106.9876 > 192.168.102.63.34882: Flags [.],  seq 3674364465, ack 3365757302, length 0

14:21:50.787206 192.168.102.63.34882 > 192.168.104.106.9876: Flags [F.], seq 3365757302, ack 3674364465, length 0
14:21:50.791349 192.168.104.106.9876 > 192.168.102.63.34882: Flags [.],  seq 3674364465, ack 3365757303, length 0
14:21:51.288252 192.168.104.106.9876 > 192.168.102.63.34882: Flags [F.], seq 3674364465, ack 3365757303, length 0
14:21:51.288293 192.168.102.63.34882 > 192.168.104.106.9876: Flags [.],  seq 3365757303, ack 3674364466, length 0

从输出中可以看出:

  1. 在 TCP 三次握手成功后的 10 秒后,开始发送 keepalive 包。
  2. Linux 发出的 keepalive 包中,Seq 的值确实是 Next-1 ,且 keepalive 包中并未包含有一个字节的垃圾数据。

4. Windows

Windows 的 IP 地址为:192.168.104.134
Windows 的版本为:Version 21H2(OS Build 22000.51)

首先安装 wireshark 并使用下面的条件来嗅探网络包:

tcp.port == 9876

然后使用 VS2017 编译并运行测试代码, wireshark 捕获到:

10.902771 192.168.104.134 192.168.104.106 2389 → 9876 [SYN]      Seq=0 Len=0
10.903375 192.168.104.106 192.168.104.134 9876 → 2389 [SYN, ACK] Seq=0 Ack=1 Len=0
10.903458 192.168.104.134 192.168.104.106 2389 → 9876 [ACK]      Seq=1 Ack=1 Len=0

20.906729 192.168.104.134 192.168.104.106 2389 → 9876 [ACK]      Seq=0 Ack=1 Len=1
20.907444 192.168.104.106 192.168.104.134 9876 → 2389 [ACK]      Seq=1 Ack=1 Len=0

30.907197 192.168.104.134 192.168.104.106 2389 → 9876 [ACK]      Seq=0 Ack=1 Len=1
30.907740 192.168.104.106 192.168.104.134 9876 → 2389 [ACK]      Seq=1 Ack=1 Len=0

40.914386 192.168.104.134 192.168.104.106 2389 → 9876 [ACK]      Seq=0 Ack=1 Len=1
40.915088 192.168.104.106 192.168.104.134 9876 → 2389 [ACK]      Seq=1 Ack=1 Len=0

42.105023 192.168.104.134 192.168.104.106 2389 → 9876 [RST, ACK] Seq=1 Ack=1 Len=0

从输出中可以看出:

  1. 在 TCP 三次握手成功后的 10 秒后,开始发送 keepalive 包。
  2. Windows 发出的 keepalive 包中,Seq 的值确实是 Next-1 ,但 keepalive 包中包含有一个字节的垃圾数据。

文章作者: Kiba Amor
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 Kiba Amor !
  目录