bigywater.tech
网络协议 · 56

TCP keepalive:探测空闲连接是否还活着

TCP 连接空闲时不会自己发现对端已经没了。keepalive 就是内核替你定期问一句“你还在吗”。这篇在 macOS 上看默认参数有多保守、探测包长什么样、连续几次没回应才判死、公网服务器会不会回应探测。其中“对端真的消失”这一步造不出真黑洞,我用 50% 丢包替代,并会写明。

7800 s
按默认值(2 小时空闲 + 75 s × 8 次)对端消失后最长发现时间(公式)
5.0 s
30 条 idle=2、intvl=1、cnt=3 的连接在 50% 丢包下最短判死时间,等于 idle + intvl × cnt
227 ms
对 example.com 的 keepalive 探测到回应的延迟

默认值

实测 · 本机执行输出
  net.inet.tcp.keepidle: 7200000
  net.inet.tcp.keepintvl: 75000
  net.inet.tcp.keepcnt: 8
  net.inet.tcp.always_keepalive: 0
  单位:keepidle、keepintvl 是毫秒。默认:空闲 7200000 ms = 2 小时才发第一个探测,之后每 75 s 一个,8 次无回应才判死
  按默认值,一条空闲连接的对端消失后,最长要 7200 + 75 × 8 = 7800 秒(2.17 小时)才会报错(公式计算,下面第四节用缩短的参数实测)

keepalive 默认关闭;打开后空闲 7200 秒才探测、间隔 75 秒、8 次判死。这三个参数可以用 TCP_KEEPALIVE、TCP_KEEPINTVL、TCP_KEEPCNT 针对单个套接字修改。

套接字选项

实测 · 本机执行输出
  新连接上 SO_KEEPALIVE = 0
  socket 模块的常量:TCP_KEEPALIVE=True(值 16),TCP_KEEPINTVL=True(值 257),TCP_KEEPCNT=True(值 258),TCP_KEEPIDLE=False
  不设置时读回:TCP_KEEPALIVE=7200 TCP_KEEPINTVL=75 TCP_KEEPCNT=8
  设成 (idle=2, intvl=1, cnt=3) 后读回:SO_KEEPALIVE=8 TCP_KEEPALIVE=2 TCP_KEEPINTVL=1 TCP_KEEPCNT=3

macOS 的名字是 TCP_KEEPALIVE(相当于 Linux 的 TCP_KEEPIDLE);SO_KEEPALIVE 读回的是内部标志位 8,不是 1。

探测包

实测 · 本机执行输出
  9 秒内线上共 8 个包(设置选项前连接已有的数据已不在内):
    +  1.79 s  客户端→服务端  载荷 0 字节  seq(相对)=10576  ack(相对)=18968  标志 0x10
    +  1.79 s  服务端→客户端  载荷 0 字节  seq(相对)=18968  ack(相对)=10577  标志 0x10
    +  3.79 s  客户端→服务端  载荷 0 字节  seq(相对)=10576  ack(相对)=18968  标志 0x10
    +  3.79 s  服务端→客户端  载荷 0 字节  seq(相对)=18968  ack(相对)=10577  标志 0x10
    +  5.79 s  客户端→服务端  载荷 0 字节  seq(相对)=10576  ack(相对)=18968  标志 0x10
    +  5.79 s  服务端→客户端  载荷 0 字节  seq(相对)=18968  ack(相对)=10577  标志 0x10
    +  7.79 s  客户端→服务端  载荷 0 字节  seq(相对)=10576  ack(相对)=18968  标志 0x10
    +  7.79 s  服务端→客户端  载荷 0 字节  seq(相对)=18968  ack(相对)=10577  标志 0x10
  探测包个数 4,间隔(s):[2.0, 2.0, 2.0]

0 字节载荷的纯 ACK,序号比对端期望的小 1,对端回一个纯 ACK。对端一直回应时,探测每个 idle 周期一个。

判死规则

实测 · 本机执行输出
  25 秒内被内核判死的连接:30 / 30;其余 0 条一直有探测得到回应(在丢包下运气好),仍然连着
  判死的时间(秒,从放进丢包环境算起):最短 5.0 / 中位 7.5 / 最长 22.0;recv 的错误:['ETIMEDOUT Operation timed out']
  规则下限:idle + intvl × cnt = 2 + 1 × 3 = 5 秒(第一个探测在空闲 2 秒后,之后每 1 秒一个,连续 3 次探测都没有回应,再过 1 个间隔才判死,具体边界以最短值为准)

连续 cnt 次没有回应才断开,recv 得到 ETIMEDOUT;最短时间等于 idle + intvl × cnt。注意本篇用 50% 丢包代替黑洞。

公网路径

实测 · 本机执行输出
    + -0.23 s  本机→服务器  载荷 0 字节  标志 0xc2
    + -0.00 s  服务器→本机  载荷 0 字节  标志 0x52
    + -0.00 s  本机→服务器  载荷 0 字节  标志 0x10
    +  2.00 s  本机→服务器  载荷 0 字节  标志 0x10
    +  2.23 s  服务器→本机  载荷 0 字节  标志 0x10
    +  4.23 s  本机→服务器  载荷 0 字节  标志 0x10
    +  4.46 s  服务器→本机  载荷 0 字节  标志 0x10
    +  6.46 s  本机→服务器  载荷 0 字节  标志 0x10
    +  6.69 s  服务器→本机  载荷 0 字节  标志 0x10
  握手之后(第 1 秒起)本机发出的探测 3 个,服务器回的包 3 个;相邻探测的间隔(s):[2.23, 2.23]
  探测到回应的延迟(ms):[227, 226, 230]

example.com 会回应探测;下一个探测周期是从收到回应开始数的,所以间隔是 idle 加一个 RTT。

分步演示

keepalive
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第二、三、四节在 lo0 上,第四节用 netlab 丢包 50% 代替真正的黑洞(netlab 丢包上限 50%);第五节对 example.com:80 的 en0;参数都缩短为 idle=2、intvl=1、cnt=3 以便观察
    • 没有验证:真正黑洞下(100% 丢包)的判死时间;默认参数下的实际等待(2 小时);always_keepalive 的作用;对端重启(收到 RST)时的行为;应用层心跳与 keepalive 的比较;NAT 超时对 keepalive 间隔的影响;最后一个探测之后内核发 RST 的细节(没有在正式输出里统计)
    京ICP备2026067502号-1京公网安备11010502063487号