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 的可靠性建立在序号之上,所以双方必须知道「对方从哪个字节开始编号」,并且确认「对方知道我的编号」。
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 已经同时确认了双方的序号,第四次的确认没有新信息。
半连接(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
三个包各自携带的信息:
| 报文 | seq | ack | 关键选项 | 含义 |
|---|---|---|---|---|
| SYN | 客户端 ISN = 3123456789 | 无 | MSS、SACK、TS、wscale | 「我想从 3123456789 开始编号」 |
| SYN-ACK | 服务端 ISN = 1987654321 | 3123456790(客户端 ISN + 1) | 同上 | 「收到,我从 1987654321 开始」 |
| ACK | 3123456790 | 1987654322(服务端 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,客户端立刻知道失败,而不是超时)。
- 「backlog 就是半连接队列大小」。错。backlog 限制的是全连接队列,半连接由
tcp_max_syn_backlog限制。 - 「listen 的 backlog 写多少就是多少」。错。会被
somaxconn截断。 - 「队列满了客户端就连不上」。不一定。全连接队列满时,客户端可能显示已建立,直到发数据才被 RST。
- 「
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 秒)。这个等待有两个目的:
- 保证最后一个 ACK 能到达。如果 ACK 丢了,对方会重传 FIN;处于
TIME_WAIT的一方还能回 ACK。如果立刻关闭,对方重传的 FIN 会收到 RST,导致对方异常终止。 - 让旧连接的延迟包消失。同一个四元组在短时间内重用,旧包可能还在网络里。等 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 当敌人,想尽办法消灭它。但从协议角度,它是保护:没有它,最后一个 ACK 丢失会导致对端异常,旧包会导致数据错乱。工程上的正确做法不是「消灭 TIME_WAIT」,而是「让连接活得久一点」——用连接池、用长连接、用 HTTP/2 多路复用。这三条都比调内核参数更根本。
4.5.9 RST:三种「不体面」的结束
RST 是 TCP 的紧急刹车。收到 RST 的一方立刻释放连接,不经过四次挥手,缓冲区里的数据直接丢弃。三种常见场景:
- 连一个没监听的端口。内核回 RST,客户端立刻得到
Connection refused。这是最快的失败,比超时好得多。 - 往一个已关闭的连接写数据。对端已经释放,收到数据后回 RST。客户端报
Connection reset by peer。 - 应用主动 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)——后者会让连接一直躺在半开状态,直到超时。
- 「TFO 一定更快」。首次连接没有 cookie,仍然要完整握手;只有重复连接才省 RTT。而且失败重试会带来额外延迟。
- 「
TCP_DEFER_ACCEPT减少连接数」。它不减少连接,只是推迟 accept 的时机。连接数由 backlog 和 fd 限制决定。 - 「握手优化能解决高并发」。握手只占短连接延迟的一部分,真正的高并发瓶颈在 accept 队列、fd 和内存(4.11 会算)。
4.5.13 握手相关参数速查
把这一章涉及的参数集中在一张表里,方便对照。默认值以 Linux 6.8 的常见发行版配置为准,不同发行版会有差异。
| 参数 | 默认值 | 作用 | 什么时候调 |
|---|---|---|---|
net.core.somaxconn | 4096 | 全连接队列上限的全局封顶 | 高并发服务调到 8192–65535 |
net.ipv4.tcp_max_syn_backlog | 1024/自动 | 半连接队列上限 | SYN flood 风险时调大 |
net.ipv4.tcp_syn_retries | 6 | 客户端 SYN 重传次数 | 内网可调小到 3,加快失败 |
net.ipv4.tcp_synack_retries | 5 | 服务端 SYN-ACK 重传次数 | 通常不动 |
net.ipv4.tcp_syncookies | 1 | SYN cookie 开关 | 保持 1 |
net.ipv4.tcp_abort_on_overflow | 0 | 队列满时是否回 RST | 想快速失败时设 1 |
net.ipv4.tcp_fin_timeout | 60 | FIN_WAIT_2 超时 | 通常不动 |
net.ipv4.tcp_tw_reuse | 0/2 | 复用 TIME_WAIT 端口 | 短连接客户端设 1 |
net.ipv4.ip_local_port_range | 32768–60999 | 临时端口范围 | 高并发客户端扩大 |
net.ipv4.tcp_fastopen | 1 | TFO 位掩码 | 内网服务可设 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」的代码路径。
- 立刻失败 +
Connection refused:对端没监听,收到 RST。 - 连接建立后首次读写失败 +
Connection reset by peer:对端 socket 已消失,常见于队列溢出或进程崩溃。 - 连接一直挂着 +
CLOSE_WAIT:本端应用没关 fd,查代码不查网络。
4.5.15 两个队列的数据结构:从 request_sock 到 accept 链表
4.5.3 说「内核分配队列、记录上限」,这一节把队列拆开。两个队列不是两个数组,而是两种不同的对象、挂在同一个 inet_connection_sock 上:
| 结构体 | 头文件 | 代表什么 | 生命周期 |
|---|---|---|---|
struct request_sock | include/net/request_sock.h | 一次「还没完成的握手」 | SYN 到达时创建,第三次 ACK 或超时后释放 |
struct request_sock_queue | 同上 | 监听 socket 上的两个队列 | listen() 时初始化,close() 时销毁 |
struct inet_connection_sock | include/net/inet_connection_sock.h | 监听 socket 的「连接子层」 | 与 struct sock 同生命周期 |
request_sock 里最关键的字段:
| 字段 | 类型 | 含义 | 谁写 |
|---|---|---|---|
rsk_listener | struct sock * | 指回监听 socket | tcp_conn_request |
sk | struct sock * | 第三次 ACK 后创建的子 socket | tcp_check_req |
rsk_timer | struct timer_list | SYN-ACK 重传定时器 | inet_csk_reset_xmit_timer |
rsk_ops | const struct request_sock_ops * | 协议操作表(TCP 是 tcp_request_sock_ops) | 分配时 |
mss | u16 | 客户端通告的 MSS | 解析 SYN 选项 |
ts_recent | u32 | 客户端时间戳 | 解析 TS 选项 |
num_retrans | u8 | SYN-ACK 已重传次数 | 定时器回调 |
cookie_ts | u8 | 是否为 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_tail | sk_max_ack_backlog | 全连接队列,FIFO 链表 |
rskq_defer_accept | TCP_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 backlog | somaxconn | tcp_max_syn_backlog | 计算过程 | 半连接队列 |
|---|---|---|---|---|
| 5 | 128 | 8192 | min(5,128)=5 → min(5,8192)=5 → max(5,8)=8 → roundup(9)=16 | 16 |
| 512 | 128 | 8192 | min(512,128)=128 → min(128,8192)=128 → roundup(129)=256 | 256 |
| 4096 | 4096 | 1024 | min=1024 → roundup(1025)=2048 | 2048 |
一句话总结:半连接队列 ≈ 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。
假设每条 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 命中监听 socket | tcp_v4_conn_request → tcp_conn_request | 分配 request_sock,挂进 SYN 表 | TCP_SYN_RECV(request 层面) |
| 发 SYN-ACK | tcp_conn_request 尾部 | tcp_transmit_skb | 启动 rsk_timer | 等 ACK |
| 第三次 ACK | tcp_v4_rcv 查 ehash 未命中,回到监听 socket | tcp_check_req | syn_recv_sock 创建子 struct sock | 子 socket 初始 TCP_SYN_RECV |
| 入 accept 队列 | tcp_v4_hnd_req | inet_csk_complete_hashdance → inet_csk_reqsk_queue_add | req->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;
}
两个边界值得记住:
accept()可能返回一个已经收到 RST 的连接。客户端在握手后立刻断开,子 socket 可能已经在CLOSE状态;accept仍会把它交出来,应用第一次read返回 0 或ECONNRESET。这不是 bug,是「连接建立」与「连接可用」的差别。O_NONBLOCK与非阻塞 accept:队列空时返回EAGAIN;但如果是「队列空且监听 socket 被 shutdown」,返回EINVAL。多线程 accept 时这两个错误要分开处理。
握手路径上的丢弃计数:
| 计数 | 位置 | 含义 |
|---|---|---|
ListenDrops | tcp_conn_request 的 drop 标签 | SYN 被丢(队列满、内存不足、校验失败等) |
ListenOverflows | syn_recv_sock 返回 NULL | 全连接队列满,第三次 ACK 被丢 |
TCPReqQFullDrop | tcp_conn_request 中 SYN 队列满 | 半连接队列溢出(与 syncookie 互斥) |
TCPReqQFullDoCookies | 同上 | 队列满但用了 cookie |
SyncookiesSent | tcp_v4_send_synack 的 cookie 分支 | 已进入无状态防御 |
TCPAbortOnSynDrop | SYN 重传超限 | 半开连接被清理 |
ListenOverflows 与 ListenDrops 的区别是排障的第一道分叉:前者一定是 accept 慢,后者要先排除 SYN 队列满和攻击。
ListenOverflows不在 SYN 到达时增加,而在第三次 ACK 创建子 socket 失败时增加。所以它增长时,客户端往往已经「连上了」。ss -lnt的Recv-Q是 accept 队列当前长度,包含已握手但未 accept 的连接;它不等于ListenOverflows的累计值。tcp_abort_on_overflow=1并不能让队列变大,只是把静默丢弃换成 RST;根因仍是 accept 慢。tcp_max_syn_backlog不是listen()的 backlog,两者限制不同队列;调一个不会影响另一个。- 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 在丢包时要等 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 本章陷阱清单
- 把
listen()的 backlog 当半连接队列:backlog 管全连接队列,半连接由tcp_max_syn_backlog管。 - 以为 backlog 写多少就是多少:会被
somaxconn截断;ss -lnt的Send-Q才是实际值。 - 用
min(backlog, somaxconn)算半连接队列:还要与tcp_max_syn_backlog取 min,再+1向上取整到 2 的幂。 - 看到
ListenOverflows就调大 backlog:先确认应用 accept 速度;队列只是缓冲,不是产能。 - 以为队列满时客户端连不上:默认丢第三次 ACK,客户端显示已建立,首次读写才失败。
- 把
tcp_abort_on_overflow=1当修复:它只把静默丢弃换成 RST,让失败更快暴露。 - 在 syncookie 模式下看
TCPReqQFullDrop:cookie 不保存半连接状态,计数不会增长,要看SyncookiesSent。 - 认为
accept()返回的连接一定可用:对端可能在握手后立刻 RST,accept 仍会交出 fd。 - 把
EAGAIN与EINVAL混为一谈:非阻塞 accept 队列空是EAGAIN;监听 socket 已关闭是EINVAL。 - 服务端主动关闭也等 TIME_WAIT:只有主动关闭方进入 TIME_WAIT;被动关闭方进
CLOSE_WAIT,等应用 close。 - 给服务端开
tcp_tw_reuse想复用端口:它只影响主动发起连接的端口选择,对监听端口无效。 - 用
tcp_fin_timeout清理CLOSE_WAIT:它只管FIN_WAIT_2;CLOSE_WAIT只能靠应用 close。
4.5.21 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 解释为什么握手是三次 | 能说出两次会导致半开连接 | 4.5.1 |
| 逐行读一次握手抓包 | 能算出 ack = seq + 1 | 4.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_two | 4.5.15 |
| 说出一次握手里创建的两个对象 | 知道 accept 队列放的是 request_sock | 4.5.15、4.5.16 |
| 复现并解释「假成功」 | 能说明丢第三次 ACK 的后果 | 4.5.17 |
| 用非阻塞 connect 判定三种结局 | 会读 EINPROGRESS 与 SO_ERROR | 4.5.18 |
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。