先说个可能不合时宜的观点:面试爱问三次握手,但三次握手其实是这一章里最不值得花时间的部分,它只是入场券。可靠传输真正的灵魂在滑动窗口和拥塞控制,那是把一个不可靠、会丢包、会拥堵的网络,硬生生整理成有序字节流的全部工程。这一章也是我重读次数最多的一章,每次重读都能多看懂一层。
在 Wireshark 里对任何网站发起连接,前三个包永远长这样(序号显示为相对值,Wireshark 默认把随机初始序号折算成 0 起步):
1 0.000000 192.168.1.103 → 93.184.216.34 TCP 66 51776 → 80 [SYN] Seq=0 Win=64240 Len=0 MSS=1460
2 0.034123 93.184.216.34 → 192.168.1.103 TCP 66 80 → 51776 [SYN, ACK] Seq=0 Ack=1 Win=65535 Len=0 MSS=1460
3 0.034201 192.168.1.103 → 93.184.216.34 TCP 54 51776 → 80 [ACK] Seq=1 Ack=1 Len=0
三次的意图:客户端声明「我要连」,服务器确认「我听到了,我也要连你」,客户端再确认「我听到了」。核心问题是,为什么两下收不住?
设想只有两次。服务器收到 SYN、回了 SYN+ACK 之后,连接就算建立。这时两个坏事会发生。其一,服务器根本不知道自己的 SYN+ACK 有没有被收到,它只能一厢情愿地把连接挂着等数据,等不到也只能干等。其二,更经典的场景:一个很早以前网络里迷路的旧 SYN(连接早就撤销了,报文还在某条慢链路上晃)终于抵达,服务器两次握手直接建立连接、分配缓冲区,然后傻等一个永远不来的数据。而三次握手下,客户端收到这个迟到的 SYN+ACK 会回 RST,服务器立刻死心,历史连接根本建不起来。
为什么又不用四次?服务器的「我收到你的 SYN 了」(ACK)和「我也要连你」(SYN)可以塞进同一个报文,四次被合并成三次。能省的往返没人会浪费。
还有一个细节:初始序号(ISN)两端各自随机生成。一方面,序号得随机,旧连接的残留报文才不容易撞进新连接的序号窗口;另一方面,序号可预测的连接,攻击者伪造起来太容易。Wireshark 显示的 Seq=0 是相对值,真实值每次连接都不同。
TCP 全双工,两个方向独立关闭,各来一次 FIN 加 ACK:
客户端 服务器
│ ──── FIN ────→ │ ① 我这边没有数据了
│ ←──── ACK ──── │ ② 收到(但你→我的方向还开着)
│ ←──── FIN ──── │ ③ 我这边也发完了
│ ──── ACK ────→ │ ④ 收到,两侧关闭
注意 ② 和 ③ 之间可以隔很久:客户端说「我说完了」,服务器可以继续把自己的数据发完(这叫半关闭),然后才轮到它说 FIN。这也是为什么四次合不成三次,除非恰好两边同时没话说。
主动关闭的一方,发完最后一个 ACK 后进入 TIME_WAIT,停 2MSL(报文最大生存时间的两倍,Linux 上 MSL 30 秒,也就是 60 秒)才彻底释放。代价不小,但省不得:其一,最后一个 ACK 可能丢,对方会重发 FIN,只有你还守在原地,才能补一个 ACK,否则对方收到 RST,视为出错;其二,等 2MSL,这条连接(相同四元组)上所有迷路报文必然死光,复用该端口的下一条新连接才不会被旧报文污染。
TIME_WAIT 挂在主动关闭方。高并发短连接服务上,主动 close 的服务器会积压大量 TIME_WAIT,乍看吓人,其实是正常生命周期,不等它跑完是没用的(第 9 章会讲 SO_REUSEADDR 如何让重启不被它卡住)。
最朴素的可靠传输是停等协议:发一个包,停下来等确认,收到再发下一个。可靠是可靠了,但算一笔账就知道它有多亏。
设链路 10 Mbps,往返时延 RTT 100 ms,每包携带 1000 字节。停等协议一个 RTT 只能发 1000 字节,吞吐 = 1000 字节 ÷ 0.1 秒 = 80 kbps。链路明明有 10 Mbps,利用率只有 0.8%。问题出在管道是空的:发出一个包后,这一个包在路上飞,发送方却闲着。
解法是把管道灌满。RTT 100 ms 的 10 Mbps 管道,里面可以同时容纳 10 Mbps × 0.1 s = 1,000,000 bit ≈ 125 KB,即 125 个 1000 字节的包。发送方不等确认,连发 125 个,这时第一个包的确认刚好回来,接着发第 126 个,管道始终满的,吞吐打满链路。
TCP 的滑动窗口就是这个思路的工程化:发送方维护一个「已发送但未确认 + 还能继续发」的字节区间,收到确认,窗口向右滑,新的字节获得发送资格。接收方也有一个窗口,声明「我能缓存的字节范围」,并把这个数写在每个 ACK 的窗口字段里告诉对方。
这里还要钉死一个概念:TCP 的确认是累计确认。ACK 号的含义是「期望收到的下一个字节序号」,收到 ack=3001,意味着 1 到 3000 号字节全部到手,哪怕中间的确认报文丢了也不要紧,后面的确认会替它把话说完。确认报文本身丢了不需要重传,这是累计确认送的红利。
丢包的发现靠两条路。
第一条是超时重传。发送方给每个已发送的段计时,超过 RTO(重传超时)没等到确认,就重发。RTO 不能拍脑袋定:设小了,网络稍慢就疯狂重传添乱;设大了,真丢了干等。TCP 持续采样 RTT,做加权平均(还要估计波动幅度),让 RTO 始终略大于当前 RTT。这套自适应让 TCP 能在 1 ms 的机房和 200 ms 的跨洋链路上都工作。顺带一提,你 curl 一个无回应的地址,要卡一两分钟才报 timeout,就是 SYN 按 RTO 指数退避重试六次的累计效果(Linux 默认约 127 秒)。
第二条是快速重传。超时太慢了:丢一个包,要干等一个 RTO(可能一秒起步)。接收方收到失序的段时,会重复确认「我还在等第 N 号」,于是发送方一连收到三个重复 ACK,就知道 N 号八成丢了,立刻重传,不用等超时。这在长肥管道上能省下巨量的等待时间。Linux 还支持 SACK 选项:接收方在 ACK 里附加「我手里还攥着哪些不连续的块」,发送方一次就能补齐缺口,不至于重传一堆对方早已收到的段。
两条路的后果不同,这个区别在 4.6 节拥塞控制里是关键。
可靠只管「包到了」,还得管「接收方吃得下」。接收方应用若读得慢,内核缓冲区会被塞满,这时它在 ACK 的窗口字段里把可用空间写小,甚至写成 0,发送方就得停。发送窗口取两者较小值:发多快,同时受制于接收方的胃口(接收窗口 rwnd)和网络的路况(拥塞窗口 cwnd,下一节),即 min(rwnd, cwnd)。
零窗口有个死锁隐患:发送方停了,等接收方腾地方;接收方腾了地方,发窗口更新报文;这个报文若丢了,发送方永远不知道,两边互相等待到天荒地老。TCP 的解法是:收到零窗口后,发送方启动坚持计时器,定时发一个字节的探测报文去问「现在能收了吗」,逼对方把窗口状态重新说一遍。这类「报文丢了怎么办」的补丁在 TCP 里到处都是,读协议时多问一句「如果这个报文丢了会怎样」,很多设计就自然通了。
流量控制管的是单个接收方,拥塞控制管的是整个网络:路由器队列有限,大家一拥而上,谁都别想痛快走。可怕的是拥塞会自我强化:队列满 → 丢包 → 重传 → 队列更满。TCP 的答案是让每个发送方维护一个拥塞窗口 cwnd,自己探测网络能吃多少,探测方式分四个阶段。
慢启动:连接建立时 cwnd 很小(现代 Linux 初始 10 个 MSS),每收到一个 ACK,cwnd 加 1 个 MSS,等效于每个 RTT 翻倍:
RTT 1: 发 1 个段 cwnd = 1
RTT 2: 发 2 个段 cwnd = 2
RTT 3: 发 4 个段 cwnd = 4
RTT 4: 发 8 个段 cwnd = 8
指数增长,十几轮就能冲到很大的窗口。「慢启动」这个名字纯属历史误会,慢的参照物是早年一上来就按接收窗口全速猛冲的做法。
拥塞避免:cwnd 达到慢启动阈值 ssthresh 后,指数改线性,每个 RTT 只加 1 个 MSS,小心翼翼往上蹭,试探网络的承载上限。
丢包发生时,按严重程度走两条路:
超时(严重,连 ACK 都没了,网络大概真堵死了):
ssthresh = cwnd / 2
cwnd = 1 MSS,重新慢启动
三个重复 ACK(轻微,路还通,只是个别包丢了):
ssthresh = cwnd / 2
cwnd = ssthresh,直接进拥塞避免(快恢复)
第二种处理配合快速重传,就是 TCP Reno。老版本 Tahoe 对两种丢包都一律归 1,重来的代价高得多;快恢复的意义在于丢个包不至于推倒重来。
正常时期「线性加、丢包砍半」的组合叫 AIMD(加性增、乘性减)。为什么要乘性减?因为拥塞时必须快速让路,慢慢降速等于继续踩;为什么敢线性加?因为小幅试探,挤到了就退,大家都在同一个节奏里加减,带宽最终会分得大致公平。
说一点自己的体会。拥塞控制最妙的在于:网络核心的路由器从头到尾没有参与协商,没有任何「我堵了」的广播,每个发送方只靠丢包这一个信号,自己猜、自己调。这是端到端原则的教科书案例。它也有代价:丢包在无线网络里常常只是信号差,被误判成拥塞就会白白刹车,所以后来才有了 Google 的 BBR(改用带宽和 RTT 建模)。Linux 现在默认的算法是 cubic,自己机器上 sysctl net.ipv4.tcp_congestion_control 一看便知。
几件真实会撞上的事,都附上我当时或别人的误判:
ss -tan | awk '{print $1}' | sort | uniq -c 一看,几百条 CLOSE_WAIT。第一反应是内核抽风,真相是客户端断开后我的代码没调 close(),内核替我攥着这些半死连接。TIME_WAIT 多是正常现象,CLOSE_WAIT 多是应用 bug,看到别认反。TCP_NODELAY 关掉 Nagle。诊断口诀:网络抓包里出现「请求与 ACK 之间整齐的 40ms 空档」,先怀疑这对算法在打架。ss -tan state time-wait | wc -l 看看你机器上有多少 TIME_WAIT,挑一台你频繁刷新过的网站再试一次。