跳转至

TCP 行为

是什么 / 解决什么问题

理解 TCP 协议栈的运行时行为,帮助诊断网络性能问题、连接异常、延迟抖动等。重点不在协议规范,而在"内核 TCP 实现实际会怎么做"。

连接生命周期

三次握手

客户端                    服务端
  │                        │
  │──── SYN ──────────────►│  connect() 发起
  │                        │  (进入 SYN 队列)
  │◄─── SYN+ACK ──────────│
  │                        │
  │──── ACK ──────────────►│  (移入 Accept 队列)
  │                        │  accept() 取走
  │     连接建立            │

四次挥手

主动关闭方                  被动关闭方
  │                        │
  │──── FIN ──────────────►│  close()/shutdown(WR)
  │                        │  read() 返回 0
  │◄─── ACK ──────────────│
  │                        │
  │◄─── FIN ──────────────│  close()
  │                        │
  │──── ACK ──────────────►│
  │                        │
  │  TIME_WAIT (2*MSL)     │  CLOSED
  │     60s 后             │
  │  CLOSED                │

连接状态转换的实际影响

状态 含义 常见问题
SYN_SENT 等待 SYN+ACK connect 超时
SYN_RECV 收到 SYN,等 ACK SYN flood 攻击填满队列
ESTABLISHED 正常数据传输
FIN_WAIT_1 发了 FIN,等 ACK 对端不响应
FIN_WAIT_2 收到 ACK,等对端 FIN 对端 close 泄漏
TIME_WAIT 主动关闭后等待 2MSL 端口耗尽
CLOSE_WAIT 收到 FIN,等应用 close 常见 bug:fd 泄漏
# 查看各状态的连接数
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn

行为与语义

Nagle 算法

目的:减少网络上小包数量。

规则:如果有已发送但未确认的数据,则缓冲新的小数据直到: 1. 累积到 MSS(通常 1460 字节) 2. 收到前一个包的 ACK

发送: write("H") write("e") write("l") write("l") write("o")

有 Nagle:  [H] ──等ACK──► [ello] ──►  (两个包)
无 Nagle:  [H] [e] [l] [l] [o]        (五个包)

问题:Nagle + Delayed ACK 组合导致 40ms 延迟

客户端 (Nagle ON)           服务端 (Delayed ACK)
  │                          │
  │─── 小包1 ───────────────►│  收到,等 200ms 或等凑够一个 ACK
  │    想发小包2,但 Nagle     │
  │    说要等 ACK...          │
  │                          │  (40ms Delayed ACK 定时器到)
  │◄── ACK ─────────────────│
  │─── 小包2 ───────────────►│
  │                          │
  总延迟:40ms(无谓等待)

解决方案:

// 方案1:关闭 Nagle
int on = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));

// 方案2:用 writev 合并多个小写入为一次 syscall
struct iovec iov[2] = {
    { .iov_base = header, .iov_len = header_len },
    { .iov_base = body,   .iov_len = body_len },
};
writev(fd, iov, 2);  // 内核会作为一个 TCP 段发送

// 方案3:TCP_CORK 先塞住再放开
int on = 1;
setsockopt(fd, IPPROTO_TCP, TCP_CORK, &on, sizeof(on));
write(fd, header, header_len);
write(fd, body, body_len);
on = 0;
setsockopt(fd, IPPROTO_TCP, TCP_CORK, &on, sizeof(on)); // flush

TIME_WAIT

  • 持续 2*MSL(Linux 默认 60s,硬编码不可改)
  • 目的:确保最后的 ACK 到达对端;防止旧连接的延迟包影响新连接
  • 只有主动关闭方进入 TIME_WAIT

常见问题与解决:

# 查看 TIME_WAIT 数量
ss -tan state time-wait | wc -l

# 如果大量 TIME_WAIT 导致端口耗尽:
// 1. SO_REUSEADDR — 允许 bind 到 TIME_WAIT 地址
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

// 2. tcp_tw_reuse — 允许复用 TIME_WAIT 连接(客户端出方向)
// sysctl net.ipv4.tcp_tw_reuse = 1

// 3. 让对端主动关闭(谁主动关闭谁承担 TIME_WAIT)
// HTTP 中 server 发送 Connection: close 让 client 先关

// 4. 连接池 — 从根本上减少连接创建/关闭

Backlog 与连接排队

listen(fd, backlog);
SYN flood 保护:
  /proc/sys/net/ipv4/tcp_syncookies = 1  (默认开启)
  SYN 队列满时不丢弃,用 cookie 验证

Accept 队列满时行为:
  /proc/sys/net/ipv4/tcp_abort_on_overflow = 0 (默认)
  → 忽略客户端 ACK,客户端重传 SYN(表现为连接建立慢)
  = 1
  → 发送 RST(客户端立即收到 connection reset 错误)
# 观察 Accept 队列溢出
nstat -az | grep -i listen
# TcpExtListenOverflows: Accept 队列满次数
# TcpExtListenDrops: 因队列满丢弃的连接数

Keep-Alive

TCP 层的心跳机制(与 HTTP keep-alive 不同):

int on = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));

// 默认参数(极其保守):
// tcp_keepalive_time  = 7200s (2小时后才开始探测)
// tcp_keepalive_intvl = 75s   (每75s探测一次)
// tcp_keepalive_probes = 9    (9次无响应才判定死亡)
// 总计:2h + 75*9 = 2h11m 才能发现死连接

// 通常需要调整为更积极的值
int idle = 60;      // 60s 空闲后开始探测
int intvl = 10;     // 每 10s 一次
int cnt = 3;        // 3 次无响应判定死亡
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
// 总计:60 + 10*3 = 90s 发现死连接

拥塞控制

内核维护 cwnd(拥塞窗口),控制发送速率:

慢启动:cwnd 从 initcwnd 开始指数增长
  → cwnd 达到 ssthresh 后进入拥塞避免
  → 拥塞避免:cwnd 线性增长
  → 丢包:cwnd 减半(具体行为看算法)

常用算法:

算法 特点 适用
cubic Linux 默认,适合高带宽长延迟 通用
bbr Google 开发,基于带宽估计而非丢包 高丢包网络
reno 经典,cwnd 恢复慢 教学
# 查看/设置拥塞算法
sysctl net.ipv4.tcp_congestion_control
# net.ipv4.tcp_congestion_control = cubic

# 改为 bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr

# 也可以 per-socket 设置
setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr", 3);
# 查看初始拥塞窗口
ip route show | grep initcwnd
# 默认 10 (10个MSS ≈ 14.6KB)

# 调大初始窗口(适合内网短连接)
ip route change default via <gw> initcwnd 32

零窗口与窗口探测

接收方缓冲区满时通告零窗口:

发送方                      接收方
  │                          │  (recv buffer 满,应用层处理慢)
  │◄── ACK, win=0 ──────────│  "我满了,别发了"
  │                          │
  │  持续定时器:每隔一段      │
  │  时间发送窗口探测包        │
  │──── 1字节探测 ──────────►│
  │◄── ACK, win=0 ──────────│  "还是满的"
  │         ...               │
  │◄── ACK, win=8192 ───────│  "有空间了"
  │──── 继续发数据 ─────────►│

如果应用层 read 慢导致频繁零窗口,说明处理能力跟不上。

性能考量

影响吞吐的关键参数

# 发送/接收缓冲区(直接影响带宽延迟积)
# BDP = bandwidth * RTT
# 100Mbps * 10ms = 125KB 缓冲区才能跑满
sysctl net.ipv4.tcp_rmem  # 最小 默认 最大
sysctl net.ipv4.tcp_wmem
# 默认: "4096 131072 6291456" (4K 128K 6M)

# 内网高带宽场景可以调大
sysctl -w net.ipv4.tcp_rmem="4096 524288 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 524288 16777216"

影响延迟的关键因素

  1. Nagle + Delayed ACK → 关闭 TCP_NODELAY
  2. 慢启动 → 连接池复用,避免新连接
  3. 拥塞控制 → BBR 在有丢包时表现更好
  4. Accept 队列满 → 增大 backlog 和 somaxconn

常见陷阱与 FAQ

1. CLOSE_WAIT 堆积

ss -tan state close-wait | wc -l
# 如果持续增长,说明应用层没有 close() 已收到 FIN 的连接
# 这是 fd 泄漏!检查代码中 read 返回 0 后是否 close

2. RST 的常见原因

  • 连接不存在(对方已关闭或重启)
  • 对方 close 时 recv buffer 中还有未读数据(强制 RST 而非 FIN)
  • SO_LINGER l_onoff=1, l_linger=0(close 时发 RST)
  • 防火墙丢包后超时

3. 小包性能问题

// 差:多次小 write
write(fd, "GET ", 4);
write(fd, path, path_len);
write(fd, " HTTP/1.1\r\n", 11);

// 好:合并后一次写
char request[4096];
int len = snprintf(request, sizeof(request), "GET %s HTTP/1.1\r\n", path);
write(fd, request, len);

// 或用 writev
struct iovec iov[3] = { ... };
writev(fd, iov, 3);

观测与调试

# TCP 连接详细信息
ss -tnpi dst :8080
# 输出含:cwnd, rtt, retrans, send buffer 等

# 查看重传
nstat -az | grep -i retrans
# TcpRetransSegs: 重传段数

# 查看 TCP 内存使用
cat /proc/net/sockstat
# TCP: inuse 1234 orphan 5 tw 678 alloc 1300 mem 890

# tcpdump 抓包分析
tcpdump -i eth0 -nn port 8080 -w dump.pcap
# 用 wireshark 打开分析

# 查看某连接的拥塞窗口变化
ss -ti dst 10.0.0.1:8080
# cubic wscale:7,7 rto:204 rtt:1.5/0.5 cwnd:10 ssthresh:7

延伸阅读

  • man 7 tcp — TCP socket 选项和行为
  • RFC 9293 — TCP 规范
  • sysctl net.ipv4.tcp_* — 内核 TCP 参数一览
  • Brendan Gregg 的 TCP/IP 性能分析文章