【计算基础|网络09】TCP(上):三次握手与四次挥手
面试被问"讲一下三次握手",十个人里八个能背出 SYN → SYN+ACK → ACK,但再往下问一句"为什么不是两次""ISN 为什么要随机""TIME_WAIT 为什么是 2MSL""服务端 CLOSE_WAIT 一堆是哪出了问题",就开始卡壳。这篇不背流程,把 TCP 连接的建立与拆除彻底讲透:报文首部每个字段干嘛的、握手挥手每一步序列号怎么变、状态机怎么跑、SYN 攻击怎么防、TIME_WAIT 多了怎么救。可靠传输(重传/滑动窗口)放中篇,拥塞控制与内核调优放下篇。
环境:Python 3.14 跑 socket 实验,macOS(Darwin arm64)用
lsof看连接状态;Linux 对应命令一并给出(ss -tnp、netstat -anp tcp)。内核参数以 Linuxman 7 tcp为准,macOS 等价参数在文中括注。
一、先厘清:TCP 到底是个什么协议
结论先给:TCP 是面向连接、可靠、面向字节流、全双工的传输层协议。这四个词每个都对应一篇内容,先建立对比表,心里有数。
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接,通信前要握手建连 | 无连接,发就完事 |
| 可靠性 | 可靠:确认、重传、排序、去重 | 不保证,丢了就丢了 |
| 数据形态 | 面向字节流,没有消息边界 | 面向报文,保留报文边界 |
| 传输方式 | 全双工,一条通道两个方向同时收发 | 全双工 |
| 首部开销 | 20 字节起(含选项可达 60) | 仅 8 字节 |
| 流量/拥塞控制 | 有 | 无 |
| 典型场景 | HTTP、数据库、SSH、文件传输 | DNS、视频直播、游戏、QUIC 底层 |
🔴 关键认知:TCP 把不可靠的 IP 网络,封装成了一条"看起来像可靠管道"的字节流 。它在应用和 IP 之间加了一整套机制:建连、编号、确认、重传、排序、流控、拥塞控制。你用 socket.send() 写进去的字节,TCP 帮你按顺序、不丢、不重、不重复地送到对面的 recv()。
本篇聚焦其中两件事:怎么把这条管道建起来(握手)、怎么把它安全地拆掉(挥手)。
二、TCP 报文段首部:每个字段都要知道干嘛的
TCP 传输的数据单元叫报文段(segment)。首部固定 20 字节,加上选项最多 60 字节。理解首部 = 理解后面所有握手挥手的把戏。
2.1 首部结构速览
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 源端口 (16) | 目的端口 (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 序列号 Sequence (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 确认号 Acknowledgment (32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 数据偏移(4)| 保留(3)|NS| CWR|ECE|URG|ACK|PSH|RST|SYN|FIN | <- 标志位
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 窗口大小 Window (16) | 校验和 Checksum (16) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 紧急指针 Urgent (16) | 选项 Options (变长) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
2.2 逐字段说明
| 字段 | 长度 | 作用 |
|---|---|---|
| 源端口 / 目的端口 | 各 16 bit | 配合 IP 首部的 IP 地址,四元组(src_ip, src_port, dst_ip, dst_port)唯一确定一条 TCP 连接 |
| 序列号 SEQ | 32 bit | 本报文段第一个字节在整条字节流里的编号 |
| 确认号 ACK | 32 bit | 表示"这个序号之前的字节我都收到了",即期望收到的下一个字节序号 |
| 数据偏移 | 4 bit | 首部长度,单位是 32 位字(4 字节),所以首部 = 数据偏移 × 4 字节 |
| 标志位 | 6~9 bit | 见下表,握手挥手全靠这几个开关 |
| 窗口大小 | 16 bit | 接收方告诉发送方"我还能收多少字节",流量控制的核心 |
| 校验和 | 16 bit | 对首部+数据+伪首部做校验,出错就丢弃 |
| 紧急指针 | 16 bit | URG 置 1 时,指向紧急数据末尾,基本不用 |
| 选项 | 变长 | MSS、窗口扩大、SACK、时间戳等,见 2.3 |
2.3 六个标志位(重点)
| 标志 | 名字 | 什么时候置 1 |
|---|---|---|
| SYN | 同步 | 建连时同步序列号,握手第一、二步 |
| ACK | 确认 | 确认号有效。除了最初的 SYN,后面所有报文都带 ACK |
| FIN | 结束 | 主动关闭方表示"我没数据要发了",挥手第一、三步 |
| RST | 复位 | 异常断开:连一个没监听的端口、强行中止连接 |
| PSH | 推送 | 告诉接收方别攒着,赶紧把数据交给应用(send 时用) |
| URG | 紧急 | 紧急指针有效,几乎不用 |
🔴 记住:SYN 和 FIN 虽然不携带数据,但它们各自要消耗一个序列号。这就是为什么握手时 ACK 号总是 SYN 序号 +1------中篇讲序列号时还会用到。
2.4 常见选项
| 选项 | Kind | 作用 |
|---|---|---|
| MSS(最大报文段长度) | 2 | 握手时双方通告"我单个段最多收多少字节数据",避免 IP 分片 |
| 窗口扩大 WScale | 3 | 窗口大小 16 bit 最大 65535,乘上一个移位因子(0~14)放大窗口,跑高速链路必须 |
| SACK(选择性确认) | 4/5 | 允许接收方精确告诉发送方"哪些块收到了",只重传真正丢的段(中篇讲) |
| 时间戳 Timestamp | 8 | 两个时间戳字段,防回绕(PAWS),也是 tcp_tw_reuse 能安全复用 TIME_WAIT 的前提 |
三、三次握手:为什么是三次,不是两次
3.1 完整过程
假设客户端主动 connect(),服务端已经 listen()。
服务端 (LISTEN) 客户端 (CLOSED) 服务端 (LISTEN) 客户端 (CLOSED) #mermaid-svg-KDNRkCo6Fm3P0Nfb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .error-icon{fill:#552222;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .marker.cross{stroke:#333333;}#mermaid-svg-KDNRkCo6Fm3P0Nfb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KDNRkCo6Fm3P0Nfb p{margin:0;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KDNRkCo6Fm3P0Nfb text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KDNRkCo6Fm3P0Nfb .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-KDNRkCo6Fm3P0Nfb #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .sequenceNumber{fill:white;}#mermaid-svg-KDNRkCo6Fm3P0Nfb #sequencenumber{fill:#333;}#mermaid-svg-KDNRkCo6Fm3P0Nfb #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .messageText{fill:#333;stroke:none;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .labelText,#mermaid-svg-KDNRkCo6Fm3P0Nfb .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .loopText,#mermaid-svg-KDNRkCo6Fm3P0Nfb .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KDNRkCo6Fm3P0Nfb .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .noteText,#mermaid-svg-KDNRkCo6Fm3P0Nfb .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .actorPopupMenu{position:absolute;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-KDNRkCo6Fm3P0Nfb .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KDNRkCo6Fm3P0Nfb .actor-man circle,#mermaid-svg-KDNRkCo6Fm3P0Nfb line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-KDNRkCo6Fm3P0Nfb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 客户端发 SYN SYN_SENT ② 服务端回 SYN+ACK SYN_RCVD ③ 客户端回 ACK ESTABLISHED ESTABLISHED SYN, seq=x (ISNc)SYN+ACK, seq=y (ISNs), ack=x+1ACK, ack=y+1
每一步的序列号变化:
- 客户端发
SYN,seq = x(x 是客户端的初始序列号 ISNc),此时客户端进入SYN_SENT。 - 服务端收到后,回
SYN+ACK:seq = y(服务端自己的 ISNs),ack = x+1(确认收到了 SYN 这一个字节)。服务端进入SYN_RCVD。 - 客户端再回
ACK:ack = y+1。这一步客户端进入ESTABLISHED;服务端收到这个 ACK 后也进入ESTABLISHED。连接建立完毕。
🔴 注意一个容易错的点:第三步 ACK 到达服务端后,服务端才进入 ESTABLISHED。也就是说客户端在发完第三步就认为连接通了,服务端要晚一步。
3.2 为什么不能是两次
两次握手 = 客户端发 SYN,服务端回 SYN+ACK 就完事。问题出在服务端没法确认客户端收到了自己的 SYN,而且会被"历史失效的连接请求"坑:
客户端的第一个 SYN 因为网络堵了没到,客户端超时重发了一个 SYN,成功建连、传完数据、断开。结果那个迷路的旧 SYN 这时才晃晃悠悠到了服务端。
- 如果是两次握手 :服务端一看 SYN,直接回 SYN+ACK 并认为连接建好了,然后就一直傻等客户端发数据------客户端根本没打算连,服务端的资源就这么白白挂着,这就是资源浪费。
- 如果是三次握手 :服务端回了 SYN+ACK 后等第三步 ACK;客户端收到这个莫名其妙的 SYN+ACK,发现"我没要连你啊",于是回一个 RST,服务端收到 RST 就把这个半连接扔掉,不会一直等。
一句话:三次握手是为了让双方都确认"对方能收、能发"。两个方向各要一次 SYN 来同步序列号,再加一次 ACK 收尾,三次刚好。
3.3 为什么不能是四次
三次已经够双向确认了,再来一次纯属浪费。RFC 793 里把服务端的 SYN 和 ACK 合并在一个报文里发,就是为了省一次往返。
3.4 ISN 为什么要随机
初始序列号 ISN 不是从 0 开始,而是随机生成的(RFC 6528)。两个原因:
- 防序列号预测攻击:如果 ISN 可预测,攻击者可以伪造源 IP 抢先"猜"中序列号,冒名顶替注入数据(经典的 TCP 会话劫持)。随机化让攻击者猜不到下一个字节的序号。
- 防旧连接数据干扰新连接:四元组(IP+端口)可能在短时间内被复用。如果 ISN 固定递增,上一条连接迷路的旧报文可能被新连接当成有效数据;随机化 + 时间戳能避免这种混淆。
⚠️ 这也是为什么你写代码时不用关心序列号------内核帮你随机挑 ISN,应用层完全无感。
3.5 半连接队列与全连接队列,SYN 攻击
服务端内核里其实有两个队列:
| 队列 | 名字 | 装的是什么 | 对应内核参数(Linux) |
|---|---|---|---|
| 半连接队列 | SYN Queue | 收到 SYN、回了 SYN+ACK,还在等第三步 ACK 的连接 | net.ipv4.tcp_max_syn_backlog |
| 全连接队列 | Accept Queue | 三次握手完成、等应用调用 accept() 取走的连接 |
net.core.somaxconn(受 listen() 的 backlog 参数限制) |
🔴 SYN 洪水攻击 :攻击者伪造大量源 IP 向服务端发 SYN,服务端每个都回 SYN+ACK 并在半连接队列挂着,永远等不到第三步 ACK(因为那些源 IP 根本不存在)。队列被塞满后,正常用户的 SYN 也进不来,服务被拒绝。
防护手段:
- syncookies :开启后(
net.ipv4.tcp_syncookies=1),服务端收到 SYN 不分配半连接资源,而是把连接信息编码成一个 cookie 放在 ISN 里发出去;等第三步 ACK 回来时从 ACK 号里还原出 cookie 验证,验证通过才建连。这样半连接队列永远不会被占满。 - 调大
tcp_max_syn_backlog、缩短 SYN+ACK 重传时间(tcp_synack_retries)。
四、四次挥手:为什么是四次
4.1 为什么比握手多一次
TCP 是全双工 ,两个方向可以同时收发。关闭时必须每个方向各关一次:A 说"我不发了",但 A 还能收 B 的;B 收到后回个 ACK,等 B 也发完数据,B 再说"我也不发了",A 再回个 ACK。两个方向的 FIN 不能合并,所以是四次(中间的 ACK 和下一个 FIN 偶尔能捎带合并,见 4.5)。
4.2 完整过程
被动关闭方 (服务端) 主动关闭方 (客户端) 被动关闭方 (服务端) 主动关闭方 (客户端) #mermaid-svg-QaHusyRBfsqh8MaA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QaHusyRBfsqh8MaA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QaHusyRBfsqh8MaA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QaHusyRBfsqh8MaA .error-icon{fill:#552222;}#mermaid-svg-QaHusyRBfsqh8MaA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QaHusyRBfsqh8MaA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QaHusyRBfsqh8MaA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QaHusyRBfsqh8MaA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QaHusyRBfsqh8MaA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QaHusyRBfsqh8MaA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QaHusyRBfsqh8MaA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QaHusyRBfsqh8MaA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QaHusyRBfsqh8MaA .marker.cross{stroke:#333333;}#mermaid-svg-QaHusyRBfsqh8MaA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QaHusyRBfsqh8MaA p{margin:0;}#mermaid-svg-QaHusyRBfsqh8MaA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QaHusyRBfsqh8MaA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QaHusyRBfsqh8MaA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QaHusyRBfsqh8MaA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QaHusyRBfsqh8MaA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QaHusyRBfsqh8MaA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QaHusyRBfsqh8MaA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QaHusyRBfsqh8MaA .sequenceNumber{fill:white;}#mermaid-svg-QaHusyRBfsqh8MaA #sequencenumber{fill:#333;}#mermaid-svg-QaHusyRBfsqh8MaA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QaHusyRBfsqh8MaA .messageText{fill:#333;stroke:none;}#mermaid-svg-QaHusyRBfsqh8MaA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QaHusyRBfsqh8MaA .labelText,#mermaid-svg-QaHusyRBfsqh8MaA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QaHusyRBfsqh8MaA .loopText,#mermaid-svg-QaHusyRBfsqh8MaA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QaHusyRBfsqh8MaA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QaHusyRBfsqh8MaA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QaHusyRBfsqh8MaA .noteText,#mermaid-svg-QaHusyRBfsqh8MaA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QaHusyRBfsqh8MaA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QaHusyRBfsqh8MaA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QaHusyRBfsqh8MaA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QaHusyRBfsqh8MaA .actorPopupMenu{position:absolute;}#mermaid-svg-QaHusyRBfsqh8MaA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QaHusyRBfsqh8MaA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QaHusyRBfsqh8MaA .actor-man circle,#mermaid-svg-QaHusyRBfsqh8MaA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QaHusyRBfsqh8MaA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 主动方发 FIN FIN_WAIT_1 ② 被动方回 ACK FIN_WAIT_2 CLOSE_WAIT (服务端把剩余数据发完) ③ 被动方发 FIN LAST_ACK TIME_WAIT ④ 主动方回 ACK CLOSED 等 2MSL 后 CLOSED FIN, seq=uACK, ack=u+1FIN, seq=w, ack=u+1ACK, ack=w+1
逐句拆解:
- 主动关闭方调用
close(),发FIN,进入FIN_WAIT_1。 - 被动方收到 FIN,回
ACK,进入CLOSE_WAIT;主动方收到 ACK 进入FIN_WAIT_2。 - 被动方把剩下没发完的数据发完后,自己也
close(),发FIN,进入LAST_ACK。 - 主动方收到 FIN 后回
ACK,进入TIME_WAIT;被动方收到这个 ACK 立刻CLOSED。主动方在 TIME_WAIT 等 2MSL 后才CLOSED。
🔴 一个生产事故信号 :如果你 netstat 看到服务端有大量 CLOSE_WAIT 状态的连接,说明被动方应用忘了调用 close() ------它收到了对方的 FIN 并回了 ACK,但自己一直不关,连接就卡在 CLOSE_WAIT。这是典型的应用层 bug(资源泄漏),不是网络问题。反过来,大量 FIN_WAIT_2 说明对方迟迟不发 FIN。
五、TCP 状态机:把上面的流程串成图
11 个状态,别硬背,跟着"谁主动打开、谁主动关闭"两条线走一遍就记住了。
#mermaid-svg-oEfg8rcUo7NEgijb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oEfg8rcUo7NEgijb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oEfg8rcUo7NEgijb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oEfg8rcUo7NEgijb .error-icon{fill:#552222;}#mermaid-svg-oEfg8rcUo7NEgijb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oEfg8rcUo7NEgijb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oEfg8rcUo7NEgijb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oEfg8rcUo7NEgijb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oEfg8rcUo7NEgijb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oEfg8rcUo7NEgijb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oEfg8rcUo7NEgijb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oEfg8rcUo7NEgijb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oEfg8rcUo7NEgijb .marker.cross{stroke:#333333;}#mermaid-svg-oEfg8rcUo7NEgijb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oEfg8rcUo7NEgijb p{margin:0;}#mermaid-svg-oEfg8rcUo7NEgijb defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-oEfg8rcUo7NEgijb g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-oEfg8rcUo7NEgijb g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-oEfg8rcUo7NEgijb g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-oEfg8rcUo7NEgijb g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-oEfg8rcUo7NEgijb g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-oEfg8rcUo7NEgijb .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-oEfg8rcUo7NEgijb .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-oEfg8rcUo7NEgijb .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-oEfg8rcUo7NEgijb .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-oEfg8rcUo7NEgijb .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-oEfg8rcUo7NEgijb .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-oEfg8rcUo7NEgijb .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-oEfg8rcUo7NEgijb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oEfg8rcUo7NEgijb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oEfg8rcUo7NEgijb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oEfg8rcUo7NEgijb .edgeLabel .label text{fill:#333;}#mermaid-svg-oEfg8rcUo7NEgijb .label div .edgeLabel{color:#333;}#mermaid-svg-oEfg8rcUo7NEgijb .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-oEfg8rcUo7NEgijb .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-oEfg8rcUo7NEgijb .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-oEfg8rcUo7NEgijb .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-oEfg8rcUo7NEgijb .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-oEfg8rcUo7NEgijb .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oEfg8rcUo7NEgijb .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oEfg8rcUo7NEgijb #statediagram-barbEnd{fill:#333333;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oEfg8rcUo7NEgijb .cluster-label,#mermaid-svg-oEfg8rcUo7NEgijb .nodeLabel{color:#131300;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-oEfg8rcUo7NEgijb .note-edge{stroke-dasharray:5;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-note text{fill:black;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram-note .nodeLabel{color:black;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagram .edgeLabel{color:red;}#mermaid-svg-oEfg8rcUo7NEgijb #dependencyStart,#mermaid-svg-oEfg8rcUo7NEgijb #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-oEfg8rcUo7NEgijb .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oEfg8rcUo7NEgijb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 被动打开(服务端 listen)
主动打开(客户端 connect)
收到 SYN, 回 SYN+ACK
收到 SYN+ACK, 回 ACK
收到 ACK
收到 SYN+ACK(同时打开)
主动 close(), 发 FIN
收到 FIN, 回 ACK
收到 ACK
被动方 close(), 发 FIN
收到 ACK
收到被动方 FIN, 回 ACK
2MSL 超时
CLOSED
LISTEN
SYN_SENT
SYN_RCVD
ESTABLISHED
FIN_WAIT_1
CLOSE_WAIT
FIN_WAIT_2
LAST_ACK
TIME_WAIT
两条主线:
- 主动关闭方 :
ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED。 - 被动关闭方 :
ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED。
六、TIME_WAIT:最常被问的那个状态
6.1 为什么要 TIME_WAIT,而且是 2MSL
主动关闭方发完最后一个 ACK 后,不直接 CLOSED,而是进 TIME_WAIT 等 2 倍 MSL,原因有两个:
- 确保最后一个 ACK 能到达对方。如果这个 ACK 丢了,被动方会重发 FIN;主动方还在 TIME_WAIT,就能重新补一个 ACK。如果直接 CLOSED 了,收到重传的 FIN 只能回 RST,对方就报错了。
- 让本条连接的旧报文在网络里自然消失。等 2MSL,保证这条连接(四元组)上所有迷路的报文都过期了,防止它们出现在下一条复用同样四元组的新连接里。
MSL(Maximum Segment Lifetime):报文段在网络里最长能活多久。RFC 793 建议 2 分钟。各系统实现:
| 系统 | 参数 | 默认值 |
|---|---|---|
| Linux | net.ipv4.tcp_fin_timeout |
60 秒(Linux 的 TIME_WAIT 实际由它控制,不是严格 2×MSL) |
| macOS | net.inet.tcp.msl |
15000(本机实测值) |
| Linux(旧认知) | 2×MSL | 教科书说法,实际内核已简化 |
⚠️ 别被教科书"2MSL=4 分钟"吓到------现代 Linux 默认 TIME_WAIT 就是 60 秒 ,以 sysctl net.ipv4.tcp_fin_timeout 实测为准。
6.2 TIME_WAIT 太多的危害与解法
高并发短连接服务器(比如每秒建几万次 HTTP 短连接)上,主动关闭方会堆积大量 TIME_WAIT。每个 TIME_WAIT 占一点内存和一个四元组,多了会耗尽本地端口、拖垮性能。解法:
| 手段 | Linux 参数 | 说明 |
|---|---|---|
| 复用 TIME_WAIT 连接 | net.ipv4.tcp_tw_reuse=1 |
只对主动发起 的新连接生效;前提是开启了时间戳 tcp_timestamps=1(靠时间戳单调递增保证复用安全) |
| 调大本地端口范围 | net.ipv4.ip_local_port_range |
默认 32768 60999,可改成 1024 65535 |
| 用长连接 | 应用层 | 最根本:能复用连接就别短连接(HTTP keep-alive、连接池) |
⚠️ 避坑 :网上老教程让你开 tcp_tw_recycle,这个参数在 Linux 4.12 已经被彻底移除 (它依赖时间戳,在 NAT 网关后会导致大量连接被丢弃),别再开。tcp_tw_reuse 本身仍在,但行为比老文章描述的严格------必须开时间戳、且只对出站连接生效。
七、同时打开 / 同时关闭 / RST 复位
7.1 同时打开
双方同时调用 connect() 向对方发 SYN,也能建连:各自发 SYN、各自收到对方 SYN 后回 SYN+ACK、各自再收到对方的 ACK。结果是双方都从 SYN_SENT 走到 ESTABLISHED,需要四个报文而不是三个。实际很少见,了解即可。
7.2 同时关闭
双方同时 close():各自发 FIN、各自收到对方 FIN 后回 ACK,两边都会走到 TIME_WAIT。比单边关闭对称。
7.3 RST:什么时候该复位
RST 是"我不认这条连接"的快速信号,常见三种场景:
- 连一个没监听的端口 :你
connect()到一台主机的某端口,但没人在listen(),内核直接回 RST,你connect()立刻报"Connection refused"。 - 向一条已关闭的连接发数据:对方已经 RST 了你还发,内核回 RST。
- 异常中止连接 :设置
SO_LINGER且linger=0时,close()不发 FIN、不等数据,直接发 RST 把连接掐断。这叫"中断式关闭",不保证已发数据送达。
🔴 FIN 是"礼貌告别"(我发完了,你慢慢收尾),RST 是"直接拔线"(不说了,结束)。写代码时 close() 默认发 FIN;想立刻断用 SO_LINGER。
八、实测:用 Python 把连接状态真跑一遍
光看图不够,我们直接起一对本地客户端/服务端,用 lsof 观察真实状态。
服务端挂着不主动关,客户端先 connect() 再 close():
python
# tcp_state_demo.py
import socket, threading, time, subprocess
PORT = 18099
hold = threading.Event()
def server():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", PORT))
srv.listen(1)
conn, _ = srv.accept()
hold.wait(timeout=15) # 故意不关,让连接挂着
conn.close(); srv.close()
threading.Thread(target=server, daemon=True).start()
time.sleep(0.4)
cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli.connect(("127.0.0.1", PORT)) # 三次握手在这里完成
time.sleep(0.3)
print(subprocess.run(["lsof","-nP","-i",f":{PORT}"],
capture_output=True,text=True).stdout)
cli.close() # 客户端主动四次挥手
time.sleep(0.2)
print("--- 客户端关闭后 ---")
print(subprocess.run(["lsof","-nP","-i",f":{PORT}"],
capture_output=True,text=True).stdout)
hold.set()
连接中(ESTABLISHED)阶段的真实输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3.1 53584 lvyizhuo 3u IPv4 0xe1228a734ef9ac60 0t0 TCP 127.0.0.1:18099 (LISTEN)
python3.1 53584 lvyizhuo 4u IPv4 0xe70365c72f37efc1 0t0 TCP 127.0.0.1:55229->127.0.0.1:18099 (ESTABLISHED)
python3.1 53584 7u IPv4 0x1af7f8252e40ce43 0t0 TCP 127.0.0.1:18099->127.0.0.1:55229 (ESTABLISHED)
三行一目了然:服务端在 LISTEN,客户端和服务端之间两条方向相反的记录都是 ESTABLISHED。
客户端 close() 之后的真实输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3.1 53584 lvyizhuo 3u IPv4 0xe1228a734ef9ac60 0t0 TCP 127.0.0.1:18099 (LISTEN)
python3.1 53584 7u IPv4 0x1af7f8252e40ce43 0t0 TCP 127.0.0.1:18099->127.0.0.1:55229 (CLOSE_WAIT)
客户端那条 ESTABLISHED 没了(fd 已释放),服务端那条从 ESTABLISHED 变成了 CLOSE_WAIT ------正好验证了第四节说的:服务端收到 FIN 回了 ACK,但自己没 close(),就卡在 CLOSE_WAIT。如果服务端这时候再调 close(),就会发 FIN 走到 LAST_ACK → CLOSED。
ℹ️ 说明:本机这版 macOS 的
netstat -an已不再列出 TCP 连接表,fd 关闭后的 TIME_WAIT 控制块也不在lsof里显示。在 Linux 上对应命令更全:
- 看所有已建立连接:
ss -tn state established或netstat -anp tcp | grep ESTABLISHED- 专门数 TIME_WAIT:
ss -tn state time-wait | wc -l- 看某个进程的连接:
lsof -nP -iTCP -p <pid>
顺手看一眼本机 macOS 的关键 TCP 默认值(sysctl 实测):
bash
$ sysctl net.inet.tcp.msl net.inet.tcp.keepidle net.inet.ip.portrange.first net.inet.ip.portrange.last
net.inet.tcp.msl: 15000
net.inet.tcp.keepidle: 7200000
net.inet.ip.portrange.first: 49152
net.inet.ip.portrange.last: 65535
对照 Linux:net.ipv4.tcp_fin_timeout(对应 TIME_WAIT 时长)、net.ipv4.tcp_keepalive_time(对应 keepidle,默认 7200 秒)、net.ipv4.ip_local_port_range(默认 32768~60999)。下一篇会把这些参数和拥塞控制一起细讲。
总结:你真正需要记住的 8 件事
- TCP 四特征:面向连接、可靠、面向字节流、全双工;UDP 正好相反(无连接、不可靠、面向报文)。
- 握手三次:
SYN → SYN+ACK → ACK;多一次是为了双向确认,少一次会被历史失效 SYN 坑到服务端资源。 - ISN 必须随机,防序列号预测攻击 + 防旧连接数据串到新连接。
- 挥手四次:
FIN → ACK → FIN → ACK;因为全双工,两个方向各关一次。 - 记住两条状态线:主动关闭方
FIN_WAIT_1→FIN_WAIT_2→TIME_WAIT,被动关闭方CLOSE_WAIT→LAST_ACK。 - 大量 CLOSE_WAIT = 应用忘了
close();大量 TIME_WAIT = 短连接太多,靠tcp_tw_reuse(需开时间戳)+ 调大端口范围 + 长连接解决。 - TIME_WAIT 等 2MSL 是为了补最后一个 ACK 和让旧报文过期 ;Linux 默认 60 秒,
tcp_tw_recycle已废弃别再用。 - RST 是"直接拔线":连未监听端口、向死连接发数据、
SO_LINGER异常关闭都会发 RST。
验证清单
- 能默画三次握手时序,并标出每一步的 seq/ack 变化
- 能说清"为什么不是两次"(历史 SYN 迷惑 + 双向确认)
- 能说出 SYN 队列和 Accept 队列分别装什么、对应哪两个内核参数
- 能默画四次挥手,并指出主动/被动方各自走哪条状态线
- 看到一堆 CLOSE_WAIT 能反应过来是应用没 close
- 能解释 TIME_WAIT 的两个作用和 Linux 默认时长
- 知道
tcp_tw_reuse的前提(时间戳、只对出站生效)和tcp_tw_recycle已废弃 - 能用
lsof -nP -iTCP:端口或ss -tn state established看到真实连接状态
参考资源
- RFC 9293(TCP 现行标准,取代 RFC 793):https://www.rfc-editor.org/rfc/rfc9293
- RFC 793(TCP 原始定义,状态机与 MSL 出处):https://www.rfc-editor.org/rfc/rfc793
- RFC 6528(TCP ISN 随机化):https://www.rfc-editor.org/rfc/rfc6528
- Linux man 手册
man 7 tcp(内核参数权威说明):https://man7.org/linux/man-pages/man7/tcp.7.html - Linux man 手册
man 7 socket(somaxconn、SO_LINGER):https://man7.org/linux/man-pages/man7/socket.7.html
下一篇《TCP(中):可靠传输》讲:序列号与累积确认怎么算、超时重传 RTO 怎么动态估算、为什么收到 3 个重复 ACK 就要快速重传、滑动窗口怎么控制发送速率、流量控制与"糊涂窗口综合征",以及用 tcpdump 抓包看真实的 seq/ack。