TCP 三次握手到底发几个包?丢包重传动画实测一遍

1 阅读9分钟

三次握手全程只发 3 个包:SYN、SYN+ACK、ACK,客户端侧耗时 1 个 RTT。

丢第一个 SYN,客户端按 RTO 翻倍重传,1 次之后建连成功。

服务端完全不回应时,客户端重传 3 次、RTO 涨到 8 个 RTT 才放弃。

这些数字不是背下来的,是我在页面上一帧一帧数出来的。

图 1:页面首次打开,左边是五个场景开关和三个链路参数,右边统计面板显示报文总数 1、消耗 RTT 0.0,底部自检面板 18/18 断言全绿

在线案例与源码:atomgit.com/deli007/dem… 成品只有一个 index.html,双击就能用,不装任何依赖。

一、为什么值得做:这题人人会背,但很少有人把丢包跑一遍

搜「TCP 三次握手」,出来的答案几乎一样:画三个箭头,写一句「确认双方收发能力」,就结束了。

看完你还是不知道:丢一个包会发生什么,重传几次算完,服务端一直不回又是什么下场。

我把五条链路做成一条时间轴,每一步都停在那一帧上,SEQ 和 ACK、标志位、两端状态机、当前 RTO 全写在旁边。

二、准备环境:打开码道 Web,把需求写成一段话

浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source… ,登录后在对话框里写需求,就能拿到一个能跑的单文件应用。

码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。

需求我写了 7 条,事后看最值钱的是第 2 条和第 6 条。

第 2 条要求五种丢包场景,第 6 条要求页面自己有自检脚本并把结果打出来。

有了这两条,页面就不是一张静态示意图,而是一台能改参数、能自己验算的模拟器。

三、需求原文(可以照着复制)

做一个单文件网页应用 index.html(原生 HTML+CSS+JS,无外链、无 CDN、无构建、可离线打开),主题是 TCP 三次握手与四次挥手可视化实验室。

1. 时序图动画,逐步演示 SYN→SYN+ACK→ACK 三次握手与 FIN→ACK→FIN+ACK→ACK 四次挥手,每步显示方向、SEQ/ACK 序号、标志位、当前状态机(CLOSED/LISTEN/SYN_SENT/ESTABLISHED/FIN_WAIT_1/CLOSE_WAIT 等)与已消耗的 RTT 数。
2. 场景开关:正常、丢第一个 SYN、丢 SYN+ACK、丢最后的 ACK(服务端已建连、客户端仍认为未建连的半开连接)、服务端不响应(超时重传若干次后放弃),丢包要按 RTO 翻倍重传并显示当前 RTO。
3. 控制条:下一步、自动播放、暂停、重置、速度 0.5x~4x。
4. 统计面板:报文总数、消耗 RTT 数、总耗时、重传次数。
5. 点任意报文可展开头部字段(SEQ、ACK、SYN、FIN、窗口)。
6. 页面上必须有自检脚本并把每条断言结果显示出来(例如正常握手报文数=3、正常握手耗时=1 RTT、四次挥手耗时=2 RTT、丢包场景重传次数=k),改动参数后重跑自检。
7. 输入非法参数(比如重传次数填负数或 0、窗口填非数字)要给明确中文提示并保留上一次有效值。

完成后打开预览,让我直接看到运行效果。

四、页面长什么样:一条时间轴,五个场景

上半部分是场景开关、链路参数和播放控制,右上角是统计面板,中间那条时间轴就是全部剧情。

下图是正常链路点到最后一步:客户端和服务端都进了 ESTABLISHED,握手报文停在 3 个。

图 2:正常链路握手完成,时间轴上三条报文分别标注 SYN、SYN+ACK、ACK,两端状态胶囊都亮在 ESTABLISHED,统计面板显示报文总数 3、消耗 RTT 1.5

点任意一条报文,会弹出它的头部字段:方向、SEQ、ACK、SYN 与 FIN 标志位、窗口大小、链路是否送达。

五、实测:三次握手就是 3 个包,客户端 1 个 RTT

正常链路下,握手阶段恰好 3 个报文,客户端在收到 SYN+ACK 的那一刻就认为自己建连成功,此时耗时 1.0 RTT。

服务端要晚半拍,它得等第三个 ACK 飞完那 0.5 RTT 才进 ESTABLISHED。

所以截图里那个「消耗 RTT 1.5」不是笔误,是两端对「什么时候算连上了」的理解本来就差了半个往返。

挥手阶段是 4 个报文、2 个 RTT,整个正常场景从头跑到尾是 4.5 个 RTT。

六、丢一个包,多的不只是一个包

五种场景我都跑到最后一帧,数字列在下面。

场景报文总数重传次数客户端 RTO 变化握手耗时结果
正常链路701 个 RTT,不变1.0 RTT成功
丢第一个 SYN811 → 22.0 RTT成功
丢 SYN+ACK911 → 22.0 RTT成功
丢最后 ACK91,服务端发起服务端 1 → 22.5 RTT成功
服务端不响应431 → 2 → 4 → 8放弃失败

丢 SYN+ACK 那一场我停在客户端收到重传后的 SYN+ACK 这一帧,时间轴上那个红色叉号就是被丢掉的包。

图 3:丢 SYN+ACK 场景,第一条 SYN+ACK 用红色虚线画到一半并标了丢包,RTO 从 1 翻倍到 2 的标记打在客户端一侧,重传后握手恢复

服务端一直不回是最极端的一种,客户端把 RTO 从 1 一路翻倍到 8,重传 3 次后放弃。

图 4:服务端不响应场景跑到结束,时间轴上 4 个 SYN 越排越开,右下角橙色横幅写着连接超时失败·已放弃(重传 3 次),客户端 RTO 停在 8.0 RTT

那一帧里客户端一直停在 SYN_SENT,服务端一直停在 LISTEN,两端谁也没往前一步。

七、核心那段代码:RTO 翻倍和状态迁移写在一起

不响应场景的重传循环是整页里最像协议的一段,去掉旁白之后是这样。

function timeoutEvt(side, rtoOld, rtoNew, from, to){
  var who = side==='C' ? C : S;
  who.rto = rtoNew;
}

var ddl = R0;
var i = 0;
while(i < MR){
  var curFrom = t;
  if(ddl > t){ wait(ddl - t, '等待 RTO 超时'); }
  var rtoOld = C.rto, rtoNew = Math.round(rtoOld*2*100)/100;
  timeoutEvt('C', rtoOld, rtoNew, curFrom, t);
  i++;
  retransCount();
  var sendAt = t;
  fly({from:'C', to:'S', flags:['SYN'], seq:ISN_C, ack:null, win:W, retrans:i, rto:rtoNew});
  ddl = sendAt + C.rto;
}

每次超时都先把 RTO 乘 2 写回本地时钟,再发一个重传的 SYN,序号保持不变。

循环的出口是 i 撞上 MR,也就是界面上的「最大重传次数」,所以重传上限是参数化的。

八、验证与踩坑

页面加载后自己跑一遍 18 条断言,内置参数下 18/18 通过。

前 14 条盯住五个场景的报文数、耗时、重传次数和 RTO 倍数,后 4 条专门验参数校验函数。

断言写的是关系式而不是常量。比如「丢第一个 SYN:握手耗时 = 前 RTO + 1 RTT」,我把初始 RTO 从 1 改成 0.5,这条仍然成立,实测 1.5 RTT。

我又用页面自带的 simulate() 跑了几组参数做交叉验证。

窗口大小填 1024 和填 65535,报文数、总耗时、内部步数完全一样,都是 7 个报文、4.5 RTT、39 步。

也就是说这个参数只写进了报文头部,没有参与任何计算。

把最大重传次数改成 5,不响应场景变成 6 个 SYN、RTO 涨到 32、31.5 RTT 后才放弃。

第二个边界是时间模型,单向传输固定按 0.5 RTT 算,真实链路里 SYN 和 SYN+ACK 的时延并不相等。

第三个边界是数据的口径,报文计数把丢包的也算「发出」:丢 SYN+ACK 场景最终是 9 个报文,其中途丢掉的 SYN+ACK 照样算一次发送。

所以统计面板上的「报文总数」是发送动作数,不是到达数。

第四个边界是 RTO 只涨不落。真实实现按 SRTT 和 RTTVAR 估算 RTO,超时后退避,拿到新的 RTT 样本再收敛回来;这个页面从初始值一路翻倍,成功建连之后也不复原。

失败路径我也点了一遍,把最大重传次数填成 0。

图 5:最大重传次数填 0 之后,顶部弹出中文提示「最大重传次数无效:必须为 1 ~ 6 之间的整数,已恢复为上一次有效值 3」,三个输入框同时标红

页面给的原话是「最大重传次数无效:必须为 1 ~ 6 之间的整数,已恢复为上一次有效值 3」,输入框随即变回 3,时间轴也没有被清空。

九、实测数据汇总

项目实测值
正常链路报文数7,握手 3 加挥手 4
正常链路握手耗时客户端 1.0 RTT,服务端进 ESTABLISHED 是 1.5 RTT
正常链路全场景耗时4.5 RTT
服务端不响应4 个 SYN、3 次重传、RTO 到 8、7.5 RTT 后放弃
页面自检18/18 通过
页面网络请求0 条
单个 HTML 体积43875 个字符,无外链

「网络请求 0 条」是用浏览器的性能接口复核的,打开页面到把五个场景点完,资源列表一直是空的。

十、本地复现

双击 index.html 就能用,不需要装任何依赖。

想走静态服务:python -m http.server 8000 ,然后访问 http://localhost:8000 。

想看它有没有偷偷联网,按 F12 打开网络面板再切几次场景,列表里不会多出请求。

十一、说点不足

窗口大小只显示、不参与计算,真实 TCP 的流控和窗口缩放都没做。

只实现了超时重传,快速重传和 SACK 都没有。

挥手进了 TIME_WAIT 之后直接进 CLOSED,没有真正等 2MSL。

ESTABLISHED 之后的数据交互用一段 0.5 RTT 的空档代替,没有画数据报文。

直接下载试玩

这个 Demo 已经完整开源,源代码、本文档和全部截图都在公开仓库里,可以直接下载来玩:

打开上面的链接,进入 codearts-tcp-handshake-lab 文件夹,把 index.html 下载到本地,用浏览器打开即可运行。

十二、用码道做这个页面的体会

第一条是需求里先写清楚期望值。我直接把「正常握手报文数=3」「四次挥手耗时=2 RTT」写进需求,它就把这些变成了页面上的断言。

第二条是自检要写成关系式。关系式在改参数之后仍然成立,写死的常量只能证明一份数据对。

第三条是失败路径单独交代。我写了非法参数要给中文提示并保留上一次有效值,交回来的就是输入框变回 3、时间轴不清空。

第四条是统计面板真的有用。报文总数、重传次数、当前 RTO 这三格,是整篇文章所有数字的出处。

第五条是生成物要自己验。五条场景我都在浏览器里点到底,窗口参数、重传上限、初始 RTO 又各换了一组跑过,才敢把表格贴上来。