第35章 socket 与 TCP 状态机:半关闭、超时、连接生命周期
学习目标
- 理解代理必须同时管理两套独立 TCP 状态机,及它们如何耦合
- 吃透半关闭:
shutdown(SHUT_WR)vsclose,及玩具代理为何会截断响应 - 看懂
CLOSE_WAIT/TIME_WAIT的来历,及它们如何导致连接泄漏与端口耗尽 - 把超时分级(第31章)落到状态机的具体位置
前置知识
原理
代理 = 两套 TCP 状态机的协调者
一个代理连接其实是两条独立 TCP 连接(第01章),各有完整的状态机。代理的职责是把两套状态机的生命周期事件正确地传递:一端建连、关闭、半关闭、异常,要恰当地反映到另一端。做错了,就是截断、泄漏、或端口耗尽。
半关闭:玩具代理最容易错的地方
TCP 是全双工的,两个方向独立关闭。这对应两个系统调用:
shutdown(fd, SHUT_WR) 只关"写"方向 → 发 FIN,但仍能读 = "我说完了,但还听着"
shutdown(fd, SHUT_RD) 只关"读"方向
close(fd) 关整个 socket(双向)+ 释放 fd
为什么代理必须支持半关闭? 看一个真实场景(HTTP 上传、或任何"请求发完等响应"的协议):
客户端发完请求体 → shutdown(SHUT_WR) → 给代理发 FIN("我发完了")
但客户端仍在等响应(读方向还开着)
代理收到客户端 FIN(客户端→源站方向 EOF)
✗ 错误:代理 close() 到源站的整条连接
→ 源站那边收到的是"双向都关了" → 源站还没发完的响应被截断!
✓ 正确:代理只 shutdown(SHUT_WR) 到源站(转发这个"发完了"的信号)
→ 源站继续发响应 → 经代理回到客户端 → 那个方向也 EOF 时才真正关闭
一句话:一个方向的 EOF 只应该关"对应方向的写",不能关掉整条连接。 否则你把另一个还在传输的方向腰斩了。
揭穿 第21章 的半关闭缺陷
第21章 的转发是这样:
func transfer(dst, src net.Conn) {
defer dst.Close() // ← 一个方向 EOF 就 Close 两端
defer src.Close()
io.Copy(dst, src)
}
// go transfer(dst, client); transfer(client, dst)
当 io.Copy(dst, src) 因为 src 发来 FIN 而返回时,defer 把 dst 和 src 双向 Close 了。对纯隧道(CONNECT,双向大致同时结束)通常无碍,但对半关闭语义的协议会截断另一方向。正确写法是传播半关闭:
func transfer(dst, src net.Conn) {
io.Copy(dst, src)
// src 读到了 EOF:只关 dst 的【写】方向,转发这个 FIN
if c, ok := dst.(interface{ CloseWrite() error }); ok {
c.CloseWrite() // = shutdown(SHUT_WR),发 FIN,不动读方向
}
}
// 两个方向各自跑;两个方向都 EOF 后,连接自然完全关闭
net.TCPConn.CloseWrite() 正是 shutdown(SHUT_WR)。生产级代理(含 第38章 capstone)都用半关闭传播,而非一刀 Close。
TCP 关闭状态机(代理视角)
四次挥手的状态转移,代理天天打交道:
主动关闭方(先发 FIN 的一方):
ESTABLISHED ─发FIN→ FIN_WAIT_1 ─收ACK→ FIN_WAIT_2 ─收对方FIN→ TIME_WAIT ─2MSL→ CLOSED
被动关闭方:
ESTABLISHED ─收FIN→ CLOSE_WAIT ─应用调close→ LAST_ACK ─收ACK→ CLOSED
两个状态是代理排错的高频主角:
CLOSE_WAIT = "收到了对方的 FIN,但我(应用)还没调用 close"。它停在这里,说明应用代码没正确关闭连接——这是连接/fd 泄漏的铁证。代理大量 CLOSE_WAIT 堆积 = 转发逻辑漏关了连接(呼应 第34章 的状态机要完整收尾)。
TIME_WAIT = 主动关闭方发完最后的 ACK 后等 2×MSL(防止旧报文串到新连接、确保对方收到 ACK)。问题在于:正向代理/做大量出向短连接的代理,会积累海量 TIME_WAIT,每个占一个本地端口(第31章 的端口耗尽)。这就是为什么代理偏爱连接池 + 长连接(少关少建,少 TIME_WAIT)。
超时分级落到状态机的哪里(呼应第31章)
第31章 列了超时种类,这里给它们在状态机上定位:
| 超时 | 卡在哪个状态 | 触发动作 |
|---|---|---|
| connect timeout | 发了 SYN 等 SYN-ACK(SYN_SENT) | 放弃连接,快速失败(→ 502) |
| idle/keepalive timeout | ESTABLISHED 上长期无数据 | 主动关闭,回收僵尸连接 |
| read/server timeout | ESTABLISHED 等对端数据 | 关闭,→ 504(第03章) |
注意区分两种 keepalive:
- TCP keepalive(
SO_KEEPALIVE):内核级,默认 2 小时才探,太慢,且只能发现"对端死机",不能管业务超时 - 应用层 idle timeout:代理自己计时,更短更可控——生产代理依赖应用层超时,TCP keepalive 仅作兜底
异常关闭:RST 与 SO_LINGER
- RST(reset):异常终止,不走四次挥手,立即丢弃连接、对端收到"连接被重置"。代理在出错、超时、或要快速回收时可能发 RST。
SO_LINGER:控制close行为。l_onoff=1, l_linger=0→close立即发 RST 而非 FIN(abortive close),避免进入 TIME_WAIT,但会丢未发数据。某些高并发代理用它主动避免 TIME_WAIT 堆积,是把双刃剑。
连接的资源账(呼应第31章)
每条连接的成本:1 个 fd + 读缓冲 + 写缓冲(各可达数十 KB 到 MB)+ 应用层状态。所以代理的并发连接上限由 fd 上限(ulimit -n)和内存共同约束。连接池里的空闲连接处于 ESTABLISHED,但可能已被对端/中间设备静默断开(坏死连接),复用前需探活——否则复用一个死连接会失败重试。
️ 实现 / 命令
实验一:用 ss 看连接状态与定时器
# -o 显示定时器(TIME_WAIT 倒计时、keepalive、重传)
ss -tano
# State ... Timer
# ESTAB ... timer:(keepalive,118min,0)
# TIME-WAIT ... timer:(timewait,52s,0) ← 2MSL 倒计时
# CLOSE-WAIT ... ← 没有计时器 = 等应用 close(泄漏信号)
ss -tan state close-wait | wc -l # CLOSE_WAIT 数量(持续增长 = 连接泄漏)
ss -tan state time-wait | wc -l # TIME_WAIT 数量(过多 = 端口压力)
实验二:复现半关闭截断
# 用一个"发完即半关闭、然后等响应"的客户端打代理
# nc -N 在发完 stdin 后会 shutdown(SHUT_WR)(发 FIN 但继续读)
printf 'GET /slow-response HTTP/1.0\r\n\r\n' | nc -N 代理 8080
# 玩具代理(双向 Close):收到客户端 FIN 就关了到源站的连接 → 响应被截断/不完整
# 修正代理(CloseWrite 传播):只转发 FIN,源站照常发完响应
实验三:观察 CLOSE_WAIT 泄漏
# 一个不正确关闭连接的代理,压一批请求后:
ss -tan state close-wait dst :后端端口 | wc -l
# 数字只增不减 = 代理收到后端 FIN 却没 close → fd 缓慢耗尽,最终 "too many open files"
lsof -p $(pgrep 代理) | grep -c CLOSE_WAIT
实验四:缓解 TIME_WAIT 端口耗尽
# 出向短连接多导致 TIME_WAIT 堆积、本地端口耗尽(第31章)
sysctl net.ipv4.tcp_tw_reuse=1 # 允许复用 TIME_WAIT 端口(出向安全)
sysctl net.ipv4.ip_local_port_range="1024 65535"
# 根治:用连接池 + 长连接,从源头减少关闭/新建(少 TIME_WAIT)
排错
| 现象 | 根因 | 解决 |
|---|---|---|
| 响应被截断/不完整 | 一个方向 EOF 就 close 了整条连接 | 改用 CloseWrite()/shutdown(SHUT_WR) 传播半关闭 |
CLOSE_WAIT 持续增长、fd 耗尽 | 收到 FIN 没 close(连接泄漏) | 转发收尾确保两端都正确关闭 |
TIME_WAIT 海量、端口耗尽 | 出向短连接多 | 连接池 + 长连接;tw_reuse;必要时 SO_LINGER |
| 复用连接池连接偶发失败 | 池里有坏死的 ESTABLISHED 连接 | 复用前探活 / 设最大空闲时间 |
| 连不上后端但很久才报错 | 没设 connect timeout(卡在 SYN_SENT) | 配 connect 超时快速失败 |
| 僵尸连接长期不释放 | 只靠 TCP keepalive(2 小时) | 加应用层 idle timeout |
本章小结
- 代理协调两套独立 TCP 状态机,必须正确传递建连/关闭/半关闭/异常事件。
- 半关闭是玩具代理头号坑:一个方向 EOF 只应
shutdown(SHUT_WR)(CloseWrite)传播 FIN,不能close整条连接,否则截断另一方向。 CLOSE_WAIT堆积 = 连接泄漏(没 close);TIME_WAIT堆积 = 出向短连接多(端口耗尽,靠连接池根治)。- 超时要落到状态机:connect(SYN_SENT)、idle(ESTABLISHED)、read(等数据→504);靠应用层超时而非 TCP keepalive。
下一章 第36章 HTTP/2 帧与流控、HPACK,把 第07章 的"多路复用很难"展开到帧、两级流控窗口、HPACK 上下文、h2↔h1 翻译的机制级。