TCP 建连要 1 个 RTT,关闭要 2 个 RTT,主动关的一端还要等 2MSL。流媒体的首屏延迟里,有一块就是这些往返叠出来的。
1.1 说了流媒体要「一直在线」,在线就意味着要有连接。这一节拆开 TCP 的连接是怎么建的、怎么关的,以及这些开销为什么值得反复复用。
一、握手三个包,各自在确认什么
三次握手的三个包,不是「打招呼」,是在对齐两件事:双方的序号起点、以及双方都还在。
① 客户端发 SYN(seq=x):我要开始发数据了,我从 x 开始编号。顺便告诉你我还能收多少(窗口)。 ② 服务端回 SYN+ACK(seq=y, ack=x+1):收到你的 x 了,我从 y 开始编号。 ③ 客户端回 ACK(ack=y+1):收到你的 y 了。
三个包只用掉 1 个 RTT——因为第 ③ 个包发出去之后客户端就直接开始发数据了,不等回复。 这一点很关键:建连的成本是「一个往返」,不是「三次」。
二、为什么不能只握手两次
两次看起来够用:客户端说「我要连」,服务端说「好」。 但有个坑:服务端无法确认「客户端收到了我的同意」。
如果客户端的 SYN 在网络上滞留了很久才到,服务端会以为这是一次新连接,分配资源、等着收数据——可客户端早就不认这个连接了。 这些资源就一直挂着,直到超时。 第三次握手的作用就是「让服务端确信客户端是清醒的」。 客户端能准确回出 ack=y+1,说明它确实收到了服务端的序号。
这也是 SYN 洪泛攻击的原理:只发第一个包不发第三个,服务端就得为每个半开连接留一份资源。 现在的系统用 SYN Cookie 对付它——先不分配,把状态编进序号里。
三、挥手为什么要四次
关闭是双向的,两个方向要各关一次,而 TCP 是全双工的。
① 主动方发 FIN:我这边没有数据要发了。 ② 对方回 ACK:知道了。但此时它可能还有数据要发。 ③ 对方数据发完,发 FIN:我这边也没有了。 ④ 主动方回 ACK:知道了。
中间那两步不能合并,就是因为「对方可能还有话说」。 这也是为什么挥手比握手多一个 RTT——握手时双方都没话说,关闭时不一定。
还有一步容易被忽略:主动关闭的一方要进入 TIME_WAIT,等 2MSL(Linux 上通常 60 秒)。目的是让最后一个 ACK 万一丢了,对方重发的 FIN 还能被接住。 代价是端口被占着——高并发短连接的服务端,经常被这个状态拖垮。
四、这些 RTT 对流媒体意味着什么
一个 RTT 在环回上是几十微秒,跨省是几十毫秒,跨国是几百毫秒。建连加关闭,最轻的也要 3 个 RTT。
流媒体对此有三条应对: ① 长连接:RTMP 一次连上就一直用,播一小时也只握手一次。 ② 连接复用:HTTP/2、HTTP/3 让多个请求共用一条连接,HLS 拉几十个 ts 片段不用建几十次连。 ③ 提前建连:播放器在用户点开之前就预连,把 RTT 藏进「用户还在犹豫」的那几百毫秒里。
反过来,TCP 有个天生的毛病对流媒体很不友好:队头阻塞。 一个包丢了,后面的包即使到了也要等它重传。 HLS 就靠「切片 + 多连接」绕开它——一片卡住不影响下一片开新连接。
五、动手 Demo:真连一次,再看报文长什么样
先按 TCP 头的真实排布拼一个 SYN 包,把字段拆开看; 再在本地真连 20 次,量一下「环回上的建连」到底花多久。
import socket, struct, threading, time
# ① 拼一个 SYN:源端口、目的端口、seq、ack、偏移+标志、窗口……
syn = struct.pack("!HHIIBBHHH", 40000, 80, 1000, 0, 0x50, 0x02, 65535, 0, 0)
f = struct.unpack("!HHIIBBHHH", syn)
print("源端口 %d 目的端口 %d seq %d 标志 0x%02X 窗口 %d"
% (f[0], f[1], f[2], f[5], f[6]))
# ② 真连 20 次,量建连耗时
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(16)
port = srv.getsockname()[1]
def acc():
for _ in range(24):
try:
c, _ = srv.accept()
except OSError:
return
c.close()
threading.Thread(target=acc, daemon=True).start()
ts = []
for _ in range(20):
t0 = time.perf_counter()
s = socket.create_connection(("127.0.0.1", port))
ts.append((time.perf_counter() - t0) * 1e6)
s.close()
print("建连最小 %.0f 微秒 / 中位数 %.0f 微秒" % (min(ts), sorted(ts)[10]))
srv.close()
实跑输出:
--------------------------------------------------------------------
TCP 的三次握手与四次挥手——时间花在【等对面】上
--------------------------------------------------------------------
① 真连一次,先看报文长什么样
**握手包不是「一句话」,是 20 字节固定头加选项。下面按 TCP 头的真实排布拼一个 SYN。**
字段 值 意思
------------------------------------------------------
源端口 40000 客户端临时端口
目的端口 80 服务端在听的那个
序号 seq 1000 我这边从哪个字节开始数
标志位 0x02 bit1 = SYN,所以这是 SYN 包
窗口 65535 我还能收多少字节
② 真测本地建连要多久
指标 数值 说明
-----------------------------------------------------
20 次最小值 121 微秒 环回,机器内部走一圈
20 次中位数 242 微秒 含调度抖动
**环回上没有真正的「等对面」**——两端在同一台机器,包不用出门。
**所以 121 微秒这个数,量的是内核处理成本,不是网络。**
③ 换成真实网络,账就变了
**握手要 1 个 RTT:SYN 过去、SYN+ACK 回来、ACK 过去(第三个包不等人)。**
**挥手要 2 个 RTT:FIN、ACK 一轮,对面 FIN、ACK 又一轮。**
**而且主动关的一端还要等 2MSL(通常 60 秒)才真正释放端口。**
网络 RTT 建连 关闭 光开场就花
----------------------------------------------------------------------
同机房 0.5 毫秒 0.5 毫秒 1.0 毫秒 1.5 毫秒
同城 5.0 毫秒 5.0 毫秒 10.0 毫秒 15.0 毫秒
跨省 30.0 毫秒 30.0 毫秒 60.0 毫秒 90.0 毫秒
跨国 200.0 毫秒 200.0 毫秒 400.0 毫秒 600.0 毫秒
**跨省一次建连加一次关闭就要 90 毫秒,还没传一个字节。**
**这就是为什么流媒体协议都把「建连」做成一次长连接、反复复用**,而不是每个请求重连一次。
**直播里那 1~3 秒的首屏延迟,有一大块就是这些 RTT 叠出来的。**
环回上量到的几十微秒是「内核处理」的成本,不是网络。 两端在同一台机器,包根本不用出门。 换成跨省(RTT 30 毫秒),同样的建连要 30 毫秒,差三个数量级。 所以线上排查「连接慢」的时候,先分清是协议本身慢,还是物理距离慢。
30 秒实验
- 把
create_connection换成socket.socket().connect()再试,看差别——前者会做 DNS 解析与地址遍历,后者不会。 - 在 PowerShell 里敲
netstat -an | findstr TIME_WAIT | measure,数一下本机现在挂了多少个等 2MSL 的端口。 - 用
curl -w "%{time_connect}" -o NUL https://www.baidu.com,量一次真实网络的建连耗时。
常见误区
- 「三次握手要三个 RTT」——错。第三个包发出去就开始传数据,建连只花 1 个 RTT。
- 「挥手也是 1 个 RTT」——错。中间多一步「等我数据发完」,所以是 2 个 RTT。
- 「TIME_WAIT 是 bug」——不是。它是防最后一个 ACK 丢失的保险,只是在高并发短连接场景下代价太大,才用连接池绕开。
自测三问(点开对答案)
1. 为什么握手的第三个包不能省? —— **让服务端确信客户端收到了它的序号,避免滞后的 SYN 造成半开连接。** 2. 挥手中间两步为什么不能合并成一步? —— **因为被动关闭方可能还有数据没发完,ACK 先回、FIN 后发。** 3. 跨省 RTT 30 毫秒,一次完整的建连加关闭要多久? —— **约 90 毫秒**——建连 1 个 RTT,关闭 2 个 RTT。- ☐ (本节完,下一节 1.2.2 视频流协议与编解码器)