Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.5 TCP 连接建立与关闭

本章回答

三次握手为什么不能是两次?SYN 到了之后内核把它放在哪,accept 又是从哪取的?两个队列(半连接、全连接)各自被谁限制,溢出时丢的是什么?四次挥手为什么比三次多一次?TIME_WAIT 为什么必须等 2MSL,`tcp_tw_reuse` 到底安全不安全?

读完你会

用 tcpdump 逐行读出一次握手并解释每个 seq/ack;用 ss -lnt 的 Recv-Q 判断 accept 队列是否堆满;用 nstat 区分 ListenOverflows 与 ListenDrops;画出 TCP 11 状态机并指出 CLOSE_WAIT 堆积意味着什么。

4.4 把 TCP 讲成了「一条已经存在的可靠字节流」。可这条流是怎么来的?两端怎么知道对方的序号?谁先说话?说完了怎么体面地告别?这些问题的答案构成了 TCP 的生命周期,也是线上故障最集中的地方。

这一章的价值在于把协议状态和内核对象对上。你在 ss 里看到的 SYN-SENT、ESTAB、TIME-WAIT 不是抽象名词,每一个都对应内核里一个 struct sock 的状态字段和一组队列。搞清这层映射,才能解释「为什么 backlog 调大了还是溢出」「为什么 TIME_WAIT 堆了几万个」「为什么服务端什么都没收到就 RST 了」。

这一章的顺序按时间走:先讲握手(建立),再讲内核怎么管理「还没被 accept 的连接」,再讲挥手(关闭),最后讲两个异常场景(RST 和 SYN flood)。读完之后,4.11 讲连接队列和百万连接时,你会认出这里的每一个数字。

4.5.1 三次握手:为什么是三次

握手的目标不是「打招呼」,而是交换并确认双方的初始序号(ISN)。TCP 的可靠性建立在序号之上,所以双方必须知道「对方从哪个字节开始编号」,并且确认「对方知道我的编号」。

TCP 三次握手与两个连接队列 SYN 进入半连接队列,ACK 后移入 accept 队列,accept() 取走。 CLIENTSERVERTCP 三次握手与连接队列SYN seq=xSYN+ACK seq=y, ack=x+1ACK seq=x+1, ack=y+1SYN queue半连接:收到 SYN,已回 SYN-ACKaccept queue全连接:三次握手完成,等待 accept收到 ACKbacklog = min(somaxconn, listen(fd, n))队列满:默认丢弃 / 忽略 ACKSYN 队列长度由 tcp_max_syn_backlog 控制;accept 队列由 listen backlog 控制;ss -lnt 的 Recv-Q 是后者当前长度
读图:上半部分是报文时序,下半部分是服务端内核的两个队列。注意:listen() 的 backlog 只约束 accept 队列;SYN flood 打的是 SYN 队列(tcp_syncookies 是另一条防线)。ss -lnt 的 Recv-Q 就是 accept 队列里等待被取走的连接数。

读这张图要抓住两个队列。左边三次报文是协议层,右边两个方框是内核实现层:SYN 到达时连接还没建好,内核先把 request_sock 放进半连接队列(SYN queue);第三次 ACK 到达后,才创建完整的 struct sock 并挂到全连接队列(accept queue),等应用 accept 取走。图上「队列满:默认丢弃 / 忽略 ACK」这句是关键——溢出发生在哪一层,决定了你该调哪个参数。

为什么是三次而不是两次?假设只有两次(客户端发 SYN,服务端回 SYN-ACK 就算建立):

  • 服务端无法确认「客户端收到了我的 SYN-ACK」。如果这个包丢了,服务端已经认为连接建立并分配了资源,客户端却还在等。这就是半开连接(half-open),会白白消耗服务端资源。
  • 更严重的是旧连接的延迟 SYN。一个很久以前的 SYN 迟到,服务端回 SYN-ACK 并建立连接,然后一直等数据,而客户端根本不认这条连接。

第三次 ACK 让服务端确认「客户端确实收到了我的序号,并且愿意继续」。两次不够,四次多余——因为第三次 ACK 已经同时确认了双方的序号,第四次的确认没有新信息。

定义 4.5.1 · 半连接与全连接

半连接(half-open) 指服务端收到 SYN、回了 SYN-ACK,但还没收到第三次 ACK 的连接,内核里由 struct request_sock 表示。全连接 指三次握手完成、等待应用 accept 的连接,内核里是一个完整的 struct sock,挂在 icsk_accept_queue 上。两者分别受 tcp_max_syn_backlog 和 listen 的 backlog(再被 somaxconn 截断)限制。

4.5.2 握手报文里到底交换了什么

抓一次本机握手,逐行读:

python3 -m http.server 8080 --bind 127.0.0.1 &
sudo tcpdump -i lo -nn -S -c 3 'tcp port 8080' &
sleep 0.5
curl -sS -o /dev/null http://127.0.0.1:8080/
wait

-S 让 tcpdump 显示绝对序号(而不是相对序号),输出形如:

1) 127.0.0.1.51234 > 127.0.0.1.8080: Flags [S], seq 3123456789, win 65495,
   options [mss 65495,sackOK,TS val 111 ecr 0,nop,wscale 7], length 0
2) 127.0.0.1.8080 > 127.0.0.1.51234: Flags [S.], seq 1987654321, ack 3123456790,
   win 65483, options [mss 65495,sackOK,TS val 222 ecr 111,nop,wscale 7], length 0
3) 127.0.0.1.51234 > 127.0.0.1.8080: Flags [.], seq 3123456790, ack 1987654322, win 512, length 0

三个包各自携带的信息:

报文seqack关键选项含义
SYN客户端 ISN = 3123456789无MSS、SACK、TS、wscale「我想从 3123456789 开始编号」
SYN-ACK服务端 ISN = 19876543213123456790(客户端 ISN + 1)同上「收到,我从 1987654321 开始」
ACK31234567901987654322(服务端 ISN + 1)无「确认」

SYN 和 FIN 各占一个序号,所以确认号要 +1。这是初学者最容易算错的地方:SYN 本身不携带数据,但它消耗一个序号位置。

第三个包的 win 512 看起来很小,那是因为它还没应用窗口缩放(wscale 在 SYN 里协商,从第三个包开始生效)。实际窗口是 512 << 7 = 65536。

选项里的 TS val/ecr 是时间戳:val 是本端当前时间戳,ecr 是回显对方的时间戳。它让 RTT 测量更精确(每个 ACK 都能算,不必依赖重传),也提供 PAWS 保护。

三次握手:SYN → SYN+ACK → ACK;完成后服务端连接进入 accept 队列。开启 JavaScript 可播放。

4.5.3 listen:内核准备了什么

listen() 做的事情比名字多。它不只「开始监听」,还要分配队列、设置上限、把 socket 挂进监听哈希表。

内核路径(Linux 6.8):

__sys_listen
  → sock->ops->listen = inet_listen
    → inet_csk_listen_start
      → reqsk_queue_alloc(&icsk->icsk_accept_queue)   # 分配全连接队列
      → sk->sk_max_ack_backlog = backlog              # 记录上限
      → inet_csk_reqsk_queue_add / inet_hash_insert   # 挂进监听哈希 lhash2

这里有两个上限,容易混淆:

  • listen(fd, backlog) 的 backlog 是应用想要的值。
  • 内核实际用的是 min(backlog, net.core.somaxconn)。

所以在 somaxconn = 4096 的机器上,listen(fd, 65535) 实际只有 4096。调 backlog 必须同时调 somaxconn,这是最常见的配置错误之一。

# 看两个上限
sysctl net.core.somaxconn
ss -lnt '( sport = :8080 )'          # Recv-Q 是当前全连接队列长度,Send-Q 是上限

ss -lnt 输出里的 Send-Q 对监听 socket 有特殊含义:它显示的是 backlog 上限,不是发送缓冲区。这是 ss 的一个历史约定,很多人第一次看会误解。

半连接队列的上限由 net.ipv4.tcp_max_syn_backlog 控制(默认 1024 或按内存自动调整)。它限制的是「同时处于 SYN-RECV 状态的 request_sock 数量」。

4.5.4 connect:客户端做了什么

connect() 对阻塞 socket 会一直等到握手完成或超时。内核路径:

__sys_connect
  → sock->ops->connect = inet_stream_connect
    → tcp_v4_connect
      → 选源地址、查路由、分配临时端口
      → tcp_connect:构造 SYN,启动重传定时器
      → tcp_transmit_skb 发出

发出 SYN 后,socket 进入 TCP_SYN_SENT。此时如果收到 SYN-ACK,tcp_rcv_state_process 处理,发 ACK,状态转 TCP_ESTABLISHED,connect 返回 0。

如果 SYN 丢了,客户端会重传,间隔按指数退避:1s、2s、4s、8s……由 tcp_syn_retries(默认 6)控制,总共约 127 秒后放弃。这就是「连一个不存在的 IP 要等两分钟」的原因。

# 看 SYN 重传次数上限
sysctl net.ipv4.tcp_syn_retries
# 观察 SYN-SENT 状态(在本机连一个黑洞地址时)
ss -tn state syn-sent

一个实用技巧:用 ss -tn state syn-sent 能直接看到「有多少连接卡在握手第一阶段」。如果这个数字持续很大,说明对端不可达或 SYN 被丢,而不是「服务端慢」。

4.5.5 accept:从队列里取走

accept() 的工作是从全连接队列里取一个已经建好的 socket。如果没有,阻塞等待(或者非阻塞返回 EAGAIN)。

__sys_accept4
  → inet_accept
    → inet_csk_accept
      → 若 icsk_accept_queue 非空:取出一个 struct sock,返回新 fd
      → 若为空且阻塞:sk_wait_data 睡在 sk_sleep 上

这里有一个关键事实:三次握手由内核完成,与应用是否调用 accept 无关。只要队列没满,内核会一直替应用完成握手。所以「服务端没 accept,客户端却能连上」是正常现象——连接躺在队列里等着。

这也解释了一个常见现象:应用卡住不 accept 时,ss -lnt 的 Recv-Q 会持续增长,直到等于 backlog,然后新连接开始被丢弃(或忽略 ACK)。Recv-Q 是判断「应用 accept 太慢」的直接证据。

# 一边压连接,一边看队列
for i in $(seq 1 50); do
  (exec 3<>/dev/tcp/127.0.0.1/8080; sleep 5) &
done
watch -n1 "ss -lnt '( sport = :8080 )'"

/dev/tcp 是 bash 的内建重定向,不需要额外工具。50 个连接同时挂着不读,队列会迅速堆到 backlog。

4.5.6 队列溢出:丢的到底是什么

两个队列,两种溢出,两组计数器。分不清它们,调参就是碰运气。

溢出位置触发条件内核行为计数器
半连接队列request_sock 数超过 tcp_max_syn_backlog丢弃新 SYN(开启 syncookies 时改用 cookie 回复)ListenDrops
全连接队列accept 队列长度达到 backlog丢弃第三次 ACK(默认),客户端认为已建立ListenOverflows

全连接队列溢出的行为特别隐蔽:服务端丢了 ACK,客户端却认为连接已建立,于是发数据;服务端收到数据时没有对应的 socket,回 RST;客户端看到 RST,报 Connection reset by peer。症状是「偶发连接被重置」,而不是「连接被拒绝」。

# 两个计数器的累计值
nstat -az | grep -E 'ListenOverflows|ListenDrops'
# 或者
netstat -s | grep -i listen

如果 ListenOverflows 在增长,说明 accept 队列满了:要么 backlog 太小,要么应用 accept 太慢。如果只有 ListenDrops 增长,更可能是 SYN 队列满或 SYN flood。

调整的顺序应该是:先看应用能不能更快 accept,再看 somaxconn 和 backlog,最后才考虑 tcp_abort_on_overflow(设为 1 会让服务端直接回 RST,客户端立刻知道失败,而不是超时)。

队列相关的四个误解
  1. 「backlog 就是半连接队列大小」。错。backlog 限制的是全连接队列,半连接由 tcp_max_syn_backlog 限制。
  2. 「listen 的 backlog 写多少就是多少」。错。会被 somaxconn 截断。
  3. 「队列满了客户端就连不上」。不一定。全连接队列满时,客户端可能显示已建立,直到发数据才被 RST。
  4. 「ss -lnt 的 Send-Q 是发送缓冲」。对监听 socket,它是 backlog 上限,不是缓冲区。

4.5.7 四次挥手:为什么比握手多一次

TCP 是全双工的,两个方向独立关闭。握手能压到三次,是因为 SYN-ACK 把「确认」和「我的 SYN」合并了;挥手压不到三次,是因为 FIN 只能表示「我这个方向没数据了」,不能替对方表态。

客户端                                    服务端
  │  FIN, seq=u            ───────────▶   │  客户端说:我不发了
  │  (FIN_WAIT_1)                          │  (CLOSE_WAIT)
  │  ACK, ack=u+1          ◀───────────   │  服务端说:知道了
  │  (FIN_WAIT_2)                          │
  │                        ◀───────────   │  服务端把剩余数据发完
  │  FIN, seq=w            ◀───────────   │  服务端说:我也不发了
  │  (LAST_ACK)                            │
  │  ACK, ack=w+1          ───────────▶   │
  │  (TIME_WAIT)                           │  (CLOSED)

中间那段时间(CLOSE_WAIT)服务端还可以继续发数据,这是「半关闭」状态。shutdown(fd, SHUT_WR) 就是主动进入半关闭,只关写方向,读方向还能用。

CLOSE_WAIT 堆积是应用 bug 的典型信号:内核已经收到对方的 FIN 并回了 ACK,但应用没有调用 close(),socket 就一直停在 CLOSE_WAIT。这不是网络问题,是代码没关 fd。看到几百个 CLOSE_WAIT,直接去查应用的连接释放逻辑。

# 按状态统计连接数,一眼看出异常状态
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# 只看 CLOSE_WAIT,并显示进程
ss -tanp state close-wait | head

FIN_WAIT_2 也可能堆积,但原因不同:主动关闭方发了 FIN 并收到 ACK,进入 FIN_WAIT_2 等对方的 FIN。如果对方一直不发(应用不 close),这个状态会挂很久,由 tcp_fin_timeout(默认 60 秒)兜底回收。

4.5.8 TIME_WAIT:为什么必须等 2MSL

主动关闭方在发完最后一个 ACK 后进入 TIME_WAIT,等待 2 倍报文最大生存时间(2MSL,Linux 实现为 60 秒)。这个等待有两个目的:

  1. 保证最后一个 ACK 能到达。如果 ACK 丢了,对方会重传 FIN;处于 TIME_WAIT 的一方还能回 ACK。如果立刻关闭,对方重传的 FIN 会收到 RST,导致对方异常终止。
  2. 让旧连接的延迟包消失。同一个四元组在短时间内重用,旧包可能还在网络里。等 2MSL 确保这些包已经过期,不会被新连接误收。

代价是端口占用:每条主动关闭的连接会占用一个临时端口 60 秒。高并发短连接场景(比如反向代理)会出现几万个 TIME_WAIT,把临时端口耗尽,报 Cannot assign requested address。

# 看 TIME_WAIT 数量
ss -tan state time-wait | wc -l
# 看相关参数
sysctl net.ipv4.tcp_tw_reuse net.ipv4.tcp_fin_timeout net.ipv4.ip_local_port_range

常见的三种应对:

方法原理代价
调大 ip_local_port_range更多端口可用只推迟问题,且端口不是无限的
tcp_tw_reuse=1允许复用 TIME_WAIT 的端口(需时间戳)只对主动发起的连接有效,且有极小概率误收旧包
用长连接 / 连接池减少连接建立次数需要应用改造

tcp_tw_recycle 在 Linux 4.12 被移除,因为它依赖「每台主机的时间戳单调递增」,在 NAT 环境下会导致大量连接被丢弃。看到老文档让你开 tcp_tw_recycle,直接忽略。

TIME_WAIT 是负担还是保护

很多人把 TIME_WAIT 当敌人,想尽办法消灭它。但从协议角度,它是保护:没有它,最后一个 ACK 丢失会导致对端异常,旧包会导致数据错乱。工程上的正确做法不是「消灭 TIME_WAIT」,而是「让连接活得久一点」——用连接池、用长连接、用 HTTP/2 多路复用。这三条都比调内核参数更根本。

4.5.9 RST:三种「不体面」的结束

RST 是 TCP 的紧急刹车。收到 RST 的一方立刻释放连接,不经过四次挥手,缓冲区里的数据直接丢弃。三种常见场景:

  1. 连一个没监听的端口。内核回 RST,客户端立刻得到 Connection refused。这是最快的失败,比超时好得多。
  2. 往一个已关闭的连接写数据。对端已经释放,收到数据后回 RST。客户端报 Connection reset by peer。
  3. 应用主动 abort。设置 SO_LINGER 且 l_onoff=1, l_linger=0,close() 会直接发 RST 而不是 FIN。常用于「我知道对端已经没用了,不想走四次挥手」。

还有一种隐蔽的情况:服务端 accept 队列满时丢弃 ACK,随后收到数据回 RST。这就是 4.5.6 说的「偶发连接重置」。

# 看 RST 相关计数
nstat -az | grep -E 'TcpOutRsts|TcpInErrs|TcpAttemptFails'
# 抓 RST 包
sudo tcpdump -i any -nn 'tcp[tcpflags] & tcp-rst != 0' -c 5

TcpOutRsts 增长说明本机在主动拒绝连接,通常意味着「有流量打到了没有 socket 的端口」。在 NAT 网关或负载均衡上,这个数字能反映后端失联的规模。

4.5.10 SYN flood 与 syncookies

SYN flood 的原理很简单:攻击者伪造源 IP 发大量 SYN,服务端为每个 SYN 分配 request_sock 并回 SYN-ACK,但永远等不到第三次 ACK。半连接队列被占满,正常用户连不进来。

防御的核心思路是不保存状态。SYN cookie 把「连接信息」编码进 SYN-ACK 的初始序号里:

ISN = 哈希(源 IP, 源端口, 目的 IP, 目的端口, 时间) + 时间基数

服务端不回存任何东西。第三次 ACK 到达时,用同样的输入重算哈希,与 ack-1 比对,对得上就认为合法,直接建连接。代价是 SYN-ACK 里不能带完整的选项(MSS、窗口缩放等会被压缩),所以开启 syncookies 后性能会略降。

# 0=关闭,1=队列满时才用,2=总是用
sysctl net.ipv4.tcp_syncookies
# SYN-ACK 重传次数
sysctl net.ipv4.tcp_synack_retries
# 看是否触发了 cookie
nstat -az | grep -i Syncookies

SyncookiesSent 增长说明已经进入防御模式,SyncookiesRecv/SyncookiesFailed 帮助判断是否有人在伪造。生产环境通常设 tcp_syncookies=1,让它在队列满时自动启用。

4.5.11 状态机全景与观测

TCP 有 11 个状态,全部转换可以画成一张图。不必背,但要能回答「这个状态是谁在等什么」。

CLOSED ──listen──▶ LISTEN ──收 SYN──▶ SYN-RECV ──收 ACK──▶ ESTABLISHED
CLOSED ──connect─▶ SYN-SENT ──收 SYN+ACK──▶ ESTABLISHED
ESTABLISHED ──收 FIN──▶ CLOSE-WAIT ──应用 close──▶ LAST-ACK ──收 ACK──▶ CLOSED
ESTABLISHED ──应用 close──▶ FIN-WAIT-1 ──收 ACK──▶ FIN-WAIT-2 ──收 FIN──▶ TIME-WAIT ──2MSL──▶ CLOSED
FIN-WAIT-1 ──同时收 FIN──▶ CLOSING ──收 ACK──▶ TIME-WAIT

读这张图的方法是找「谁在等谁」。FIN_WAIT_1 在等对方的 ACK,FIN_WAIT_2 在等对方的 FIN,CLOSE_WAIT 在等本应用调用 close,LAST_ACK 在等最后的 ACK。四种等待对应四类问题:网络丢包、对端不发、应用不关、ACK 丢失。

# 全状态分布
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# 只看某个状态的连接与进程
ss -tanp state established | head
# 每秒刷新,观察状态迁移
watch -n1 "ss -tan | awk 'NR>1 {print \$1}' | sort | uniq -c"

真实排障时,这张状态分布表比任何单个连接的信息都有用。TIME_WAIT 多说明短连接多,CLOSE_WAIT 多说明应用漏关,SYN-RECV 多说明有人在扫或 SYN flood,FIN_WAIT_2 多说明对端应用不关连接。

4.5.12 TCP Fast Open 与 TCP_DEFER_ACCEPT

握手有一个绕不开的成本:一个 RTT。对短连接(HTTP/1.0、RPC 调用)来说,这个 RTT 占总延迟的比例很高。内核提供了两个机制来削减它,思路完全不同。

TCP Fast Open(TFO) 让客户端在 SYN 里就带数据,省掉「握手完成后才能发请求」的等待。它的做法是:客户端第一次连接时正常握手,服务端在 SYN-ACK 里下发一个加密的 cookie;客户端缓存 cookie,下次连接时在 SYN 里带上 cookie 和请求数据,服务端验证 cookie 后直接把数据交给应用,同时回 SYN-ACK。

# 位掩码:1=客户端启用,2=服务端启用,4=客户端无 cookie 也可发数据
sysctl net.ipv4.tcp_fastopen
# 服务端在 listen 时设置 TCP_FASTOPEN 队列长度,这个值同时是开关
# setsockopt(fd, IPPROTO_TCP, TCP_FASTOPEN, &qlen, sizeof qlen);
# 客户端用 sendto 带 MSG_FASTOPEN 发起
# sendto(fd, buf, len, MSG_FASTOPEN, &addr, addrlen);
# 看 TFO 统计
nstat -az | grep -i FastOpen

TFO 的坑在于中间设备。有些防火墙会把带数据的 SYN 当成异常包丢掉,导致连接失败;有些 NAT 会改写 IP 但 cookie 绑定了 IP,验证失败后退化成普通握手。所以 TFO 通常只在可控的数据中心内部启用,公网服务要谨慎。

TCP_DEFER_ACCEPT 解决的是另一个问题:服务端 accept 之后往往要等数据才干活,但连接建立时就会被唤醒一次,读完数据又要唤醒一次。TCP_DEFER_ACCEPT 让内核等到「有数据到达」才把连接放进 accept 队列,省掉一次唤醒。

# 设一个 socket 的延迟 accept 超时(秒)
# setsockopt(fd, IPPROTO_TCP, TCP_DEFER_ACCEPT, &secs, sizeof secs);
sysctl net.ipv4.tcp_defer_accept

它适合「客户端连上就先发请求」的协议(HTTP、Redis),不适合「服务端先说话」的协议(SMTP、MySQL)——后者会让连接一直躺在半开状态,直到超时。

握手优化的三个误区
  1. 「TFO 一定更快」。首次连接没有 cookie,仍然要完整握手;只有重复连接才省 RTT。而且失败重试会带来额外延迟。
  2. 「TCP_DEFER_ACCEPT 减少连接数」。它不减少连接,只是推迟 accept 的时机。连接数由 backlog 和 fd 限制决定。
  3. 「握手优化能解决高并发」。握手只占短连接延迟的一部分,真正的高并发瓶颈在 accept 队列、fd 和内存(4.11 会算)。

4.5.13 握手相关参数速查

把这一章涉及的参数集中在一张表里,方便对照。默认值以 Linux 6.8 的常见发行版配置为准,不同发行版会有差异。

参数默认值作用什么时候调
net.core.somaxconn4096全连接队列上限的全局封顶高并发服务调到 8192–65535
net.ipv4.tcp_max_syn_backlog1024/自动半连接队列上限SYN flood 风险时调大
net.ipv4.tcp_syn_retries6客户端 SYN 重传次数内网可调小到 3,加快失败
net.ipv4.tcp_synack_retries5服务端 SYN-ACK 重传次数通常不动
net.ipv4.tcp_syncookies1SYN cookie 开关保持 1
net.ipv4.tcp_abort_on_overflow0队列满时是否回 RST想快速失败时设 1
net.ipv4.tcp_fin_timeout60FIN_WAIT_2 超时通常不动
net.ipv4.tcp_tw_reuse0/2复用 TIME_WAIT 端口短连接客户端设 1
net.ipv4.ip_local_port_range32768–60999临时端口范围高并发客户端扩大
net.ipv4.tcp_fastopen1TFO 位掩码内网服务可设 3

这张表的用法是按症状查,不是「全部调大」。调参前先确认瓶颈在哪一层:ListenOverflows 指向应用 accept 慢,SyncookiesSent 指向 SYN 攻击,TIME_WAIT 堆积指向短连接过多。参数只放大容量,不解决根因。

4.5.14 实验:复现三种连接异常

这三种现象在线上都能看到,但在本机几分钟就能复现。复现一次,以后看到日志里的错误码就知道对应哪个环节。

第一种:连接被拒绝(RST)。 连一个没有监听的端口,内核立刻回 RST,客户端拿到 ECONNREFUSED:

nc -zv 127.0.0.1 9 ; echo "退出码=$?"
# 输出:nc: connect to 127.0.0.1 port 9 (tcp) failed: Connection refused

注意它是立刻失败,不是超时。这正是 RST 的价值:快速失败,不用等重传。

第二种:accept 队列溢出。 起一个只 listen 不 accept 的服务端,backlog 设为 1,然后并发压连接:

python3 - <<'PY' &
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 8081))
s.listen(1)          # 全连接队列上限 1
time.sleep(30)       # 故意不 accept
PY
sleep 0.5
for i in $(seq 10); do (exec 3<>/dev/tcp/127.0.0.1/8081; sleep 20) & done
sleep 2
ss -lnt '( sport = :8081 )'      # Recv-Q 卡在 1,不再增长
nstat -az | grep -E 'ListenOverflows|ListenDrops'

Recv-Q 停在 1 就是队列满的证据。此时第 3 个之后的连接,客户端可能显示已建立,但一发数据就收到 RST——这就是 4.5.6 说的「偶发重置」。

第三种:CLOSE_WAIT 堆积。 服务端收到 FIN 但不关闭 socket:

nc -l 127.0.0.1 8082 &            # 只收,不主动关
sleep 0.5
printf 'x' | nc -N 127.0.0.1 8082 # 客户端发完就关,服务端收到 FIN
sleep 1
ss -tan state close-wait | head

nc -l 收到 FIN 后不会退出(它还在等更多输入),socket 就停在 CLOSE_WAIT。真实应用里,这对应「读到了 EOF 却没调用 close」的代码路径。

三种异常的判据
  1. 立刻失败 + Connection refused:对端没监听,收到 RST。
  2. 连接建立后首次读写失败 + Connection reset by peer:对端 socket 已消失,常见于队列溢出或进程崩溃。
  3. 连接一直挂着 + CLOSE_WAIT:本端应用没关 fd,查代码不查网络。

4.5.15 两个队列的数据结构:从 request_sock 到 accept 链表

4.5.3 说「内核分配队列、记录上限」,这一节把队列拆开。两个队列不是两个数组,而是两种不同的对象、挂在同一个 inet_connection_sock 上:

结构体头文件代表什么生命周期
struct request_sockinclude/net/request_sock.h一次「还没完成的握手」SYN 到达时创建,第三次 ACK 或超时后释放
struct request_sock_queue同上监听 socket 上的两个队列listen() 时初始化,close() 时销毁
struct inet_connection_sockinclude/net/inet_connection_sock.h监听 socket 的「连接子层」与 struct sock 同生命周期

request_sock 里最关键的字段:

字段类型含义谁写
rsk_listenerstruct sock *指回监听 sockettcp_conn_request
skstruct sock *第三次 ACK 后创建的子 sockettcp_check_req
rsk_timerstruct timer_listSYN-ACK 重传定时器inet_csk_reset_xmit_timer
rsk_opsconst struct request_sock_ops *协议操作表(TCP 是 tcp_request_sock_ops)分配时
mssu16客户端通告的 MSS解析 SYN 选项
ts_recentu32客户端时间戳解析 TS 选项
num_retransu8SYN-ACK 已重传次数定时器回调
cookie_tsu8是否为 syncookie 连接tcp_conn_request

一个容易误解的点:全连接队列里放的也是 request_sock,不是 struct sock。第三次 ACK 到达后,内核创建子 struct sock,把它挂到 req->sk,再把 req 挂进 accept 链表。accept() 取出 req,返回 req->sk 对应的新 fd,然后释放 req。所以「队列长度」数的是 request_sock 的个数,不是 socket 的个数。

request_sock_queue 的字段与两个上限的对应关系:

字段对应上限说明
rskq_accept_head / rskq_accept_tailsk_max_ack_backlog全连接队列,FIFO 链表
rskq_defer_acceptTCP_DEFER_ACCEPT延迟 accept 的秒数

6.8 的 request_sock_queue 字段比旧内核少:max_qlen_log/nr_table_entries/syn_wait_lock 已不存在,SYN 表由 struct inet_hashinfo 的 lhash2/ehash 管理。判断队列长度用 inet_csk_reqsk_queue_len(),判断 accept 队列满用 sk_acceptq_is_full();不要照旧内核的字段名写代码。

半连接队列的长度有一串容易算错的步骤。6.8 的 reqsk_queue_alloc() 只接收 queue,桶数在函数内按 tcp_max_syn_backlog/backlog 计算;下面按它的语义(min → max(8) → +1 → roundup_pow_of_two)手算:

/* net/core/request_sock.c,6.8 简化 */
int reqsk_queue_alloc(struct request_sock_queue *queue);

按这个语义手算三组:

listen backlogsomaxconntcp_max_syn_backlog计算过程半连接队列
51288192min(5,128)=5 → min(5,8192)=5 → max(5,8)=8 → roundup(9)=1616
5121288192min(512,128)=128 → min(128,8192)=128 → roundup(129)=256256
409640961024min=1024 → roundup(1025)=20482048

一句话总结:半连接队列 ≈ min(backlog, somaxconn, tcp_max_syn_backlog) + 1 向上取整到 2 的幂,且不小于 16。

SYN 入队走 inet_csk_reqsk_queue_hash_add()(SYN 表);第三次 ACK 后创建子 socket,才走 inet_csk_reqsk_queue_add() 挂 accept 链表。两者不要混。所以「我把 backlog 调到 65535 了,为什么 SYN 还是被丢」的答案往往是 tcp_max_syn_backlog 还停在 1024。

例 4.5.1 · 两个队列的内存账

假设每条 request_sock 约 256 字节(含 struct request_sock 与 TCP 私有部分),全连接队列 4096、半连接队列 2048:

  • 半连接队列的哈希桶:2048 × 8 字节指针 ≈ 16 KB,加上最多 2048 个 request_sock ≈ 512 KB。
  • 全连接队列本身只是链表,但每个条目背后有一个完整的子 struct sock,约 2 KB(4.11 会细算)。4096 个 ≈ 8 MB。

所以「把 backlog 调到几十万」不是免费的:队列长度直接乘以单连接内存。调参前先算这笔账。

4.5.16 内核路径:一次握手创建了两次对象

4.5.1 的图是协议视角,这一节是对象视角。一次成功的握手,内核会创建两个对象:request_sock(半连接)和子 struct sock(全连接),分界点是第三次 ACK。

时刻触发函数创建/修改状态
SYN 到达tcp_v4_rcv 查 lhash2 命中监听 sockettcp_v4_conn_request → tcp_conn_request分配 request_sock,挂进 SYN 表TCP_SYN_RECV(request 层面)
发 SYN-ACKtcp_conn_request 尾部tcp_transmit_skb启动 rsk_timer等 ACK
第三次 ACKtcp_v4_rcv 查 ehash 未命中,回到监听 sockettcp_check_reqsyn_recv_sock 创建子 struct sock子 socket 初始 TCP_SYN_RECV
入 accept 队列tcp_v4_hnd_reqinet_csk_complete_hashdance → inet_csk_reqsk_queue_addreq->sk = child,挂链表子 socket TCP_ESTABLISHED
accept()应用调用inet_csk_accept → reqsk_queue_remove取出 req,返回 req->sk连接交给应用

tcp_conn_request 的主干在 net/ipv4/tcp_input.c,它的判断顺序决定了「SYN 被丢还是被回」:

/* net/ipv4/tcp_input.c,简化 */
int tcp_conn_request(struct request_sock_ops *rsk_ops, const struct tcp_request_sock_ops *af_ops,
                     struct sock *sk, struct sk_buff *skb)
{
    struct request_sock *req;
    ...
    if (inet_csk_reqsk_queue_is_full(sk)) {          /* 半连接队列满 */
        if (sysctl_tcp_syncookies == 2 || ...)       /* 总是用 cookie */
            goto send_cookie;
        goto drop;                                   /* 记 ListenDrops */
    }
    if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
        ...                                          /* 全连接队列满:可能直接丢 */
        goto drop;
    }
    req = inet_reqsk_alloc(rsk_ops, sk, false);
    if (!req)
        goto drop;
    ...
    if (tcp_parse_options(sock_net(sk), skb, &req->opt, ...)) ...
    if (tcp_synq_overflow(sk)) ...                    /* SYN 洪水保护 */
    ...
    if (cookie) {
send_cookie:
        ...
        tcp_v4_send_synack(sk, req, ...);             /* 无状态 SYN-ACK */
    } else {
        inet_csk_reqsk_queue_add(sk, req, NULL);      /* 先挂 SYN 表 */
        tcp_v4_send_synack(sk, req, ...);
    }
    return 0;
drop:
    __NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENDROPS);
    return 0;
}

注意 inet_csk_reqsk_queue_add(sk, req, NULL) 在这条路径上挂的是 SYN 表(第三个参数是 struct sock *child,此处为 NULL);第三次 ACK 时同一个函数再被调用一次,这次带 child,挂的是 accept 链表。同名函数、两个队列,靠参数区分——读源码时最容易看错的地方。

第三次 ACK 的处理在 tcp_check_req():

/* net/ipv4/tcp_minisocks.c,简化 */
struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb, struct request_sock *req,
                           bool fastopen, bool *req_stolen)
{
    ...
    if (th->syn) { ... }                       /* 重传的 SYN:重发 SYN-ACK */
    if (th->rst) { ... }                       /* RST:删 request_sock */
    if (!th->ack || th->seq != ntohl(req->rcv_isn) + 1) ...
    child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL, req, &own_req);
    if (!child)
        goto listen_overflow;
    if (own_req) {
        inet_csk_reqsk_queue_drop(sk, req);
        ...
    }
    return child;
listen_overflow:
    if (!sysctl_tcp_abort_on_overflow) {
        inet_rsk(req)->acked = 1;
        return NULL;                            /* 丢弃 ACK,客户端不知道 */
    }
    ...
}

listen_overflow 这个标签就是「全连接队列满」的落点。默认 tcp_abort_on_overflow=0:内核丢掉第三次 ACK,不回 RST。客户端在收到 SYN-ACK 时就已经进入 ESTABLISHED,以为自己连上了;服务端却没有这条连接。等客户端发数据,服务端查不到 socket,回 RST——这就是 4.5.6 描述的「偶发连接重置」的完整机制。

如果设 tcp_abort_on_overflow=1,这里改成回 RST,客户端立刻收到 ECONNREFUSED。快速失败还是静默丢弃,是一个产品决策:前者让客户端重试,后者让客户端在第一次请求时才失败。多数服务选择默认值,因为 RST 会暴露「服务端过载」这个信息。

inet_csk_accept() 是应用侧的出口:

/* net/ipv4/inet_connection_sock.c,简化 */
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern)
{
    struct request_sock_queue *queue = &icsk->icsk_accept_queue;
    struct request_sock *req;
    struct sock *newsk;

    if (sk->sk_state != TCP_LISTEN)
        goto out_err;
    if (reqsk_queue_empty(queue)) {
        long timeout = sock_rcvtimeo(sk, flags & O_NONBLOCK);
        ...
        if (!timeout) { *err = -EAGAIN; goto out_err; }
        ...
        inet_csk_wait_for_connect(sk, timeo);      /* 睡在 sk_wq 上 */
    }
    req = reqsk_queue_remove(queue, sk);
    newsk = req->sk;
    ...
    return newsk;
}

两个边界值得记住:

  1. accept() 可能返回一个已经收到 RST 的连接。客户端在握手后立刻断开,子 socket 可能已经在 CLOSE 状态;accept 仍会把它交出来,应用第一次 read 返回 0 或 ECONNRESET。这不是 bug,是「连接建立」与「连接可用」的差别。
  2. O_NONBLOCK 与非阻塞 accept:队列空时返回 EAGAIN;但如果是「队列空且监听 socket 被 shutdown」,返回 EINVAL。多线程 accept 时这两个错误要分开处理。

握手路径上的丢弃计数:

计数位置含义
ListenDropstcp_conn_request 的 drop 标签SYN 被丢(队列满、内存不足、校验失败等)
ListenOverflowssyn_recv_sock 返回 NULL全连接队列满,第三次 ACK 被丢
TCPReqQFullDroptcp_conn_request 中 SYN 队列满半连接队列溢出(与 syncookie 互斥)
TCPReqQFullDoCookies同上队列满但用了 cookie
SyncookiesSenttcp_v4_send_synack 的 cookie 分支已进入无状态防御
TCPAbortOnSynDropSYN 重传超限半开连接被清理

ListenOverflows 与 ListenDrops 的区别是排障的第一道分叉:前者一定是 accept 慢,后者要先排除 SYN 队列满和攻击。

队列语义的五个细节
  1. ListenOverflows 不在 SYN 到达时增加,而在第三次 ACK 创建子 socket 失败时增加。所以它增长时,客户端往往已经「连上了」。
  2. ss -lnt 的 Recv-Q 是 accept 队列当前长度,包含已握手但未 accept 的连接;它不等于 ListenOverflows 的累计值。
  3. tcp_abort_on_overflow=1 并不能让队列变大,只是把静默丢弃换成 RST;根因仍是 accept 慢。
  4. tcp_max_syn_backlog 不是 listen() 的 backlog,两者限制不同队列;调一个不会影响另一个。
  5. syncookie 开启时半连接队列不保存状态,所以「SYN 队列满」的计数不会增长,但 SyncookiesSent 会;这时看队列长度会误判为「没满」。

4.5.17 程序一:把 accept 队列压到溢出

这段程序起一个 listen(fd, 1) 的服务端,故意不调用 accept,然后连 4 个客户端。它验证三件事:connect() 在队列满时仍然成功、ss -lnt 的 Recv-Q 停在上限、后续写入会收到 RST。

/* backlog_overflow.c:观察全连接队列溢出时的「假成功」 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define NCLIENT 4

static void msleep(long ms)
{
    struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L };
    nanosleep(&ts, NULL);
}

static int make_listener(int backlog, unsigned short *port)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1;
    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = 0 };
    socklen_t len = sizeof a;

    if (fd < 0)
        return -1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
    a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    if (bind(fd, (struct sockaddr *)&a, sizeof a) < 0 ||
        listen(fd, backlog) < 0 ||
        getsockname(fd, (struct sockaddr *)&a, &len) < 0) {
        perror("listen");
        close(fd);
        return -1;
    }
    *port = ntohs(a.sin_port);
    return fd;
}

static int connect_to(unsigned short port)
{
    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(port) };
    int fd = socket(AF_INET, SOCK_STREAM, 0);

    if (fd < 0)
        return -1;
    a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) {
        close(fd);
        return -1;
    }
    return fd;
}

int main(void)
{
    unsigned short port = 0;
    int lfd = make_listener(1, &port);
    int cfd[NCLIENT];

    if (lfd < 0)
        return 1;
    signal(SIGPIPE, SIG_IGN);
    printf("listen(backlog=1) on 127.0.0.1:%u,服务端不会 accept\n", port);

    pid_t pid = fork();
    if (pid == 0) {                 /* 子进程:只持有监听 fd,睡 3 秒后退出 */
        close(lfd);
        msleep(3000);
        _exit(0);
    }

    for (int i = 0; i < NCLIENT; i++) {
        cfd[i] = connect_to(port);
        printf("client %d connect -> %s\n", i, cfd[i] < 0 ? strerror(errno) : "ok");
        msleep(100);
    }

    msleep(1000);                   /* 等内核把队列填满、多余的 ACK 丢掉 */
    for (int i = 0; i < NCLIENT; i++) {
        if (cfd[i] < 0)
            continue;
        ssize_t n = write(cfd[i], "ping", 4);
        char buf[16];
        ssize_t r = recv(cfd[i], buf, sizeof buf, MSG_DONTWAIT);
        printf("client %d write=%zd read=%zd errno=%s\n",
               i, n, r, r < 0 ? strerror(errno) : "-");
        close(cfd[i]);
    }
    close(lfd);
    waitpid(pid, NULL, 0);
    return 0;
}

编译运行,在另一个终端同时看队列:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 backlog_overflow.c -o backlog_overflow
./backlog_overflow &
watch -n0.5 "ss -lnt '( sport = :<端口> )'; nstat -az | grep -E 'ListenOverflows|ListenDrops'"

典型输出(Linux):

listen(backlog=1) on 127.0.0.1:41234,服务端不会 accept
client 0 connect -> ok
client 1 connect -> ok
client 2 connect -> ok
client 3 connect -> ok
client 0 write=4 read=-1 errno=Resource temporarily unavailable
client 1 write=4 read=-1 errno=Connection reset by peer
client 2 write=4 read=-1 errno=Connection reset by peer
client 3 write=4 read=-1 errno=Connection reset by peer

四行 connect -> ok 是 Linux 上的重点:队列只有 1,四个客户端却都连上了。在 macOS/BSD 上实测,第 2 个客户端可能在 connect() 阶段就收到 ECONNRESET——队列满时是「丢 ACK」还是「回 RST」由内核实现决定,协议没有规定。因为客户端在收到 SYN-ACK 时就进入 ESTABLISHED,服务端丢的是第三次 ACK,不通知客户端。随后写数据,服务端查不到 socket,回 RST,客户端读到 ECONNRESET。

ss -lnt 的 Recv-Q 会停在 1(队列上限),ListenOverflows 增加 3。如果只看到 ListenDrops 增长而没有 ListenOverflows,说明丢的是 SYN 而不是 ACK,方向要转到半连接队列。

4.5.18 程序二:非阻塞 connect 的三种结局

这段程序把 connect() 设成非阻塞,用 poll() 等结果,分别连「有监听」「没监听」「黑洞地址」三类目标。它验证 EINPROGRESS、SO_ERROR 和超时三者的区别,也是写连接池/健康检查的基础。

/* nconnect.c:非阻塞 connect + poll + SO_ERROR */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>

static long now_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L;
}

int main(int argc, char **argv)
{
    if (argc != 4) {
        fprintf(stderr, "用法: %s <host> <port> <timeout_ms>\n", argv[0]);
        return 2;
    }
    struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM };
    struct addrinfo *res = NULL;
    int rc = getaddrinfo(argv[1], argv[2], &hints, &res);
    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return 2;
    }

    int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (fd < 0) {
        perror("socket");
        freeaddrinfo(res);
        return 1;
    }
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) {
        perror("fcntl");
        close(fd);
        freeaddrinfo(res);
        return 1;
    }

    long t0 = now_ms();
    rc = connect(fd, res->ai_addr, res->ai_addrlen);
    if (rc == 0) {
        printf("connect 立刻成功,耗时 %ld ms\n", now_ms() - t0);
        close(fd);
        freeaddrinfo(res);
        return 0;
    }
    if (errno != EINPROGRESS) {
        printf("connect 立刻失败: %s(%ld ms)\n", strerror(errno), now_ms() - t0);
        close(fd);
        freeaddrinfo(res);
        return 1;
    }

    struct pollfd pfd = { .fd = fd, .events = POLLOUT };
    rc = poll(&pfd, 1, atoi(argv[3]));
    if (rc == 0) {
        printf("poll 超时 %s ms,连接仍在 SYN-SENT(ss -tn state syn-sent 可查)\n", argv[3]);
        close(fd);
        freeaddrinfo(res);
        return 1;
    }
    if (rc < 0) {
        perror("poll");
        close(fd);
        freeaddrinfo(res);
        return 1;
    }

    int err = 0;
    socklen_t elen = sizeof err;
    if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen) < 0) {
        perror("getsockopt(SO_ERROR)");
        close(fd);
        freeaddrinfo(res);
        return 1;
    }
    printf("poll 就绪,耗时 %ld ms,SO_ERROR=%d (%s)\n",
           now_ms() - t0, err, err == 0 ? "连接建立" : strerror(err));
    close(fd);
    freeaddrinfo(res);
    return err == 0 ? 0 : 1;
}

三次运行覆盖三种结局:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 nconnect.c -o nconnect
# 1) 没监听:poll 立刻就绪,SO_ERROR=ECONNREFUSED
./nconnect 127.0.0.1 9 1000
# 2) 有监听:poll 就绪,SO_ERROR=0
python3 -m http.server 18081 --bind 127.0.0.1 &
./nconnect 127.0.0.1 18081 1000
# 3) 黑洞地址:poll 超时,连接卡在 SYN-SENT
./nconnect 10.255.255.1 81 1000
connect 立刻失败: Connection refused(0 ms)
poll 就绪,耗时 0 ms,SO_ERROR=0 (连接建立)
poll 超时 1000 ms,连接仍在 SYN-SENT(ss -tn state syn-sent 可查)

三个关键点:

  • EINPROGRESS 不是错误,它表示「握手已发起,结果稍后通过 fd 可写通知」。
  • poll() 就绪不等于连接成功,必须用 SO_ERROR 取真实结果。直接 write() 会在失败时返回 ECONNREFUSED,但错误可能被后续调用吞掉。
  • 超时后要 close(),否则 fd 会一直占着,SYN 重传还在后台跑(tcp_syn_retries 决定多久放弃)。
为什么连接池偏爱非阻塞 connect

阻塞 connect 在丢包时要等 1s、2s、4s 的重传,一个坏后端就能拖住整个线程。非阻塞 connect 把等待交给 poll/epoll,一个线程能同时发起几百个连接,并按「先完成先使用」的顺序取结果。Go 的 net.Dialer、Nginx 的 upstream 都走这条路。

4.5.19 实验:用 ss、nstat 与 bpftrace 定位队列溢出

实验一验证「队列长度与溢出计数」,实验二验证「握手在内核里走了哪条分支」。两个实验都用 4.5.17 的程序做负载。

实验一:队列与计数器。

# 终端 1:压队列,端口在输出里
./backlog_overflow
# 终端 2:每 0.5 秒看一次监听队列
watch -n0.5 "ss -lnt '( sport = :41234 )'; nstat -az | grep -E 'ListenOverflows|ListenDrops|TCPReqQFull'"
# 终端 3:抓握手与 RST(-c 20 防止刷屏)
sudo tcpdump -i lo -nn -S -c 20 'tcp port 41234'

ss -lnt 的输出要这样读:

State  Recv-Q Send-Q Local Address:Port  Peer Address:Port
LISTEN 1      1      127.0.0.1:41234     0.0.0.0:*
  • Recv-Q = 1:accept 队列里有 1 条已握手连接,等于 Send-Q(backlog 上限)。
  • Send-Q = 1:min(listen(1), somaxconn) 的结果,不是发送缓冲。
  • 如果 Recv-Q 一直是 0 而 ListenOverflows 在涨,说明连接根本没进队列——查 tcp_abort_on_overflow 或 SYN 队列。

nstat 的前后差值:

TcpExtListenOverflows        3      0.0
TcpExtListenDrops            3      0.0

两个数一起涨,说明丢的是第三次 ACK;只有 ListenDrops 涨,说明 SYN 阶段就被丢。

实验二:握手路径的 bpftrace。

sudo bpftrace -e '
kprobe:tcp_conn_request { @syn[comm] = count(); }
kprobe:inet_csk_reqsk_queue_add { @add[comm] = count(); }
kprobe:inet_csk_accept { @t[tid] = nsecs; }
kretprobe:inet_csk_accept /@t[tid]/ { @accept_ns = hist(nsecs - @t[tid]); delete(@t[tid]); }
interval:s:5 { print(@syn); print(@add); print(@accept_ns); clear(@syn); clear(@add); clear(@accept_ns); exit(); }'

读法:

现象含义
@syn 远大于 @add大量 SYN 没走到建连(队列满/攻击/半开)
@add 与 @syn 接近正常,SYN 都变成了连接
@accept_ns 长尾到毫秒级accept 路径被锁或调度延迟拖慢
@accept_ns 为空应用根本没调 accept

inet_csk_reqsk_queue_add 在同一次握手里会被调用两次(SYN 表、accept 链表),所以 @add 大约是连接数的两倍;如果不是,说明有连接在中间被丢弃。

4.5.20 本章陷阱清单

12 条高频错误
  1. 把 listen() 的 backlog 当半连接队列:backlog 管全连接队列,半连接由 tcp_max_syn_backlog 管。
  2. 以为 backlog 写多少就是多少:会被 somaxconn 截断;ss -lnt 的 Send-Q 才是实际值。
  3. 用 min(backlog, somaxconn) 算半连接队列:还要与 tcp_max_syn_backlog 取 min,再 +1 向上取整到 2 的幂。
  4. 看到 ListenOverflows 就调大 backlog:先确认应用 accept 速度;队列只是缓冲,不是产能。
  5. 以为队列满时客户端连不上:默认丢第三次 ACK,客户端显示已建立,首次读写才失败。
  6. 把 tcp_abort_on_overflow=1 当修复:它只把静默丢弃换成 RST,让失败更快暴露。
  7. 在 syncookie 模式下看 TCPReqQFullDrop:cookie 不保存半连接状态,计数不会增长,要看 SyncookiesSent。
  8. 认为 accept() 返回的连接一定可用:对端可能在握手后立刻 RST,accept 仍会交出 fd。
  9. 把 EAGAIN 与 EINVAL 混为一谈:非阻塞 accept 队列空是 EAGAIN;监听 socket 已关闭是 EINVAL。
  10. 服务端主动关闭也等 TIME_WAIT:只有主动关闭方进入 TIME_WAIT;被动关闭方进 CLOSE_WAIT,等应用 close。
  11. 给服务端开 tcp_tw_reuse 想复用端口:它只影响主动发起连接的端口选择,对监听端口无效。
  12. 用 tcp_fin_timeout 清理 CLOSE_WAIT:它只管 FIN_WAIT_2;CLOSE_WAIT 只能靠应用 close。

4.5.21 本章出口检查

检查项达标线没达标去哪
解释为什么握手是三次能说出两次会导致半开连接4.5.1
逐行读一次握手抓包能算出 ack = seq + 14.5.2
说清两个队列与两个上限知道 backlog 与 tcp_max_syn_backlog 各管什么4.5.3、4.5.6
用 Recv-Q 判断 accept 队列能区分「队列满」与「应用慢」4.5.5
画出四次挥手与状态迁移能指出 CLOSE_WAIT 在等谁4.5.7、4.5.11
解释 TIME_WAIT 的两个理由知道 2MSL 保护什么4.5.8
说清 syncookies 的代价知道它为什么丢失部分选项4.5.10
算出一个 backlog 对应的半连接队列会用 min/roundup_pow_of_two4.5.15
说出一次握手里创建的两个对象知道 accept 队列放的是 request_sock4.5.15、4.5.16
复现并解释「假成功」能说明丢第三次 ACK 的后果4.5.17
用非阻塞 connect 判定三种结局会读 EINPROGRESS 与 SO_ERROR4.5.18
即时自测 · 4.5

Q1 客户端的 ISN 是 1000,服务端的 ISN 是 5000。三次握手里三个包的 seq 和 ack 分别是多少(假设不带数据)?

Q2 listen(fd, 128),somaxconn = 4096。全连接队列最多能排多少个?如果应用一直不 accept,第 129 个连接会怎样?

Q3 服务端出现大量 CLOSE_WAIT,可能的原因是什么?该改内核参数还是改代码?

Q4 为什么 tcp_tw_recycle 被移除了?tcp_tw_reuse 为什么还在?

Q5 收到 SYN 后服务端回了 SYN-ACK,但客户端没回 ACK。服务端会等多久?由什么参数控制?

Q6 一台机器上 ListenOverflows 持续增长,但 CPU 和内存都不高。最可能的原因是什么?

答案

A1 第一个包 seq=1000, ack=0(SYN 不带 ack);第二个包 seq=5000, ack=1001;第三个包 seq=1001, ack=5001。SYN 占用一个序号,所以确认号是 ISN + 1。

A2 队列上限是 min(128, 4096) = 128。第 129 个连接的三次握手仍然会被内核完成(在半连接阶段),但第三次 ACK 到达时队列已满,内核默认丢弃这个 ACK。客户端认为连接已建立,发数据后被 RST,表现为「偶发连接重置」。

A3 应用收到 FIN 后没有调用 close()。CLOSE_WAIT 是内核已经回了 ACK、等应用关闭的状态,与内核参数无关。改代码:检查异常路径是否漏关 fd、连接池是否回收、是否用了 shutdown 却忘了 close。

A4 tcp_tw_recycle 依赖「同一主机的 TIME_WAIT 快速回收」,在 NAT 后面多台主机共享一个公网 IP 时,时间戳不单调,会导致正常连接被丢弃。tcp_tw_reuse 只对主动发起的连接生效,且要求开启时间戳(PAWS 保护),风险可控,所以保留。

A5 服务端处于 SYN-RECV,会重传 SYN-ACK,由 tcp_synack_retries(默认 5)控制,总时长约 63 秒(1+2+4+8+16+32 的退避)。超时后释放 request_sock。

A6 应用 accept 太慢。ListenOverflows 增长说明全连接队列满了,而 CPU/内存不高说明不是资源瓶颈,是应用没有及时调用 accept(可能卡在别的逻辑上,或者 accept 循环被阻塞)。

母本对应

母本章节本书落点程度
N(深入理解 Linux 网络)第 6 章 6.2–6.6(listen、connect、完整建立、异常、队列溢出)4.5.1–4.5.6、4.5.11完整:两个队列的内核实现与溢出观测
W(谢希仁)第 5 章 5.5–5.9(TCP 连接管理、状态机、TIME_WAIT)4.5.1、4.5.7、4.5.8完整:三次/四次与状态机
K(Kerrisk)第 61 章 61.6(TCP 报文格式、状态机、建立、终止、TIME_WAIT)4.5.2、4.5.7–4.5.9完整:系统编程视角的连接生命周期
K(Kerrisk)第 56 章 56.5(流 socket:listen、accept、connect、close)4.5.3–4.5.5精选:API 语义与失败模式
C(CSAPP)第 11 章 11.4(套接字接口)4.5.5背景:accept 返回新 fd 的语义
N(深入理解 Linux 网络)第 6 章 6.2.5(半连接队列长度计算,PDF p180–184)、6.3(connect,PDF p164–172)4.5.15–4.5.18补充:队列算术与非阻塞连接
K(Kerrisk)第 61 章 61.6.4–61.6.5(连接建立与终止,PDF p1087–1089)4.5.16、4.5.17完整:状态迁移的编程视角
W(谢希仁)第 5 章 5.9(TCP 运输连接管理,PDF p222–270)4.5.15、4.5.16补充:队列满时的协议行为

下一站:连接建立起来了,但「可靠」要靠重传,「不压垮网络」要靠拥塞控制。进入 4.6 TCP 可靠传输与拥塞控制,看序号、RTO、快重传和 CUBIC/BBR。