Old-School Networking / 2026 ・ No.06
古法网络入门
第6讲 TCP ── 端口号、三次握手、重发、窗口与拥塞
发布
第 5 讲里,包靠目的地址在路由器之间传递。可是途中有时会丢,有时顺序会乱。从这一讲开始进入第 4 层,传输层。
丢了、乱了、接收方慢、路上堵,TCP 都要把申请可靠地送到。我们做一条故意丢包的“捣乱线”,逐一确认它的机制。
房间号这一层
新井上一讲 NAT 表里的 40418 和 3000,您说是房间号对吧。
古井对。靠地址能送到大楼。再往里,交给哪个房间的哪个程序,就是 TCP 的工作之一。
新井“之一”,也就是说还有别的?
古井丢包的路由器、打乱顺序的路、读得慢的服务器、拥挤的线路。IP 一概不管,全由 TCP 接手。今天把这些都故意制造一遍。
全 12 讲路线图(当前:第 6 讲)
- 端口号 ── 地址是大楼,端口是房间。一条连接由 4 个值决定
- 招呼与编号 ── 三次握手、Seq 与 Ack、被拒绝与被晾着(实测)
- 重发 ── 丢一个包、打乱顺序(实测)
- 窗口与拥塞 ── 不读数据的接收方、拥挤线路上包数怎样变化(实测)
- 道别 ── FIN、TIME-WAIT、RST
这一讲的练习网络,是在电脑(192.168.1.10)和服务器(203.0.113.20)之间夹了一个笔者写的小程序。它一边中转,一边可以延迟、限速、丢掉指定的包、打乱顺序,我们叫它“捣乱线”。如无特别说明,单程设为 20 毫秒(往返 40 毫秒)。为了集中话题,省去了第 5 讲的路由器和 NAT。
端口号
地址是大楼,端口号是房间
端口号是 16 位,从 0 到 65535 的数。服务器的程序在固定的端口监听(LISTEN),电脑一侧每次连接时借用一个空闲的端口。
# 服务器上正在监听的端口(LISTEN)
srv$ ss -tln
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 128 0.0.0.0:4000 0.0.0.0:*
LISTEN 0 511 0.0.0.0:3000 0.0.0.0:*
# 电脑一侧使用的端口范围(Linux 默认)
pc$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768 60999
服务器上,申请 API 的 Node.js 在 3000 号,为这一讲准备的“读得慢的服务器”在 4000 号等待。电脑一侧的端口,Linux 从 32768〜60999 中选(Windows 和 Mac 是 49152〜65535)。这次申请用的是 35594 号。
| 端口 | 常见用途 |
|---|---|
| 22 | SSH |
| 80 / 443 | HTTP / HTTPS(第 8 讲、第 10 讲) |
| 53 | DNS(第 9 讲,主要用 UDP) |
| 3306 / 5432 / 6379 | MySQL / PostgreSQL / Redis |
| 3000 / 5173 / 8080 | 开发中的服务器常用的端口(并非规定) |
一条连接由两端的地址和端口这 4 个值来区分。从同一台电脑开两个浏览器标签页连接同一台服务器的 3000 号,因为电脑一侧的端口不同,就是两条不同的连接。开发时遇到“Address already in use”,说明已经有程序在这个端口监听了。
从打招呼开始
TCP 在发送数据之前,先用三次招呼(三次握手)建立连接:“我想连接(SYN)”“请,我也想连接(SYN+ACK)”“明白(ACK)”。下面是一次申请的记录。
# 一次申请的往来(单程 20 ms 的线。Seq 和 Ack 与 Wireshark 一样,从 0 开始计数)
0.000 pc → srv SYN Seq=0 Ack=0 Len=0
0.040 srv → pc SYN+ACK Seq=0 Ack=1 Len=0
0.041 pc → srv ACK Seq=1 Ack=1 Len=0
0.041 pc → srv PSH+ACK Seq=1 Ack=1 Len=198 POST /api/expenses
0.081 srv → pc ACK Seq=1 Ack=199 Len=0
0.090 srv → pc PSH+ACK Seq=1 Ack=199 Len=203 201 Created
0.090 pc → srv ACK Seq=199 Ack=204 Len=0
0.090 pc → srv FIN+ACK Seq=199 Ack=204 Len=0
0.132 srv → pc FIN+ACK Seq=204 Ack=200 Len=0
0.132 pc → srv ACK Seq=200 Ack=205 Len=0
# 开头 3 个是打招呼,最后 3 个是道别
一次申请:打招呼、申请与回复、道别(实测时刻)
封面照片展开的就是最初的 SYN。SYN 里还写着希望。MSS 1460 是“一个包的内容请不要超过 1460 字节”,即第 2 讲的 MTU 1500 减去 IP 和 TCP 头部各 20 字节。此外,能否使用细致告知已到达范围的 SACK、时间戳(Timestamps)、窗口倍率(Window scale)也在这里商定。
# curl 测得的时间:连接用 1 个往返,到回复的第一个字节用 2 个往返
pc$ curl -w '…' -X POST … http://203.0.113.20:3000/api/expenses
connect 0.0407 s first byte 0.0900 s total 0.0901 s local port 35594
# 先关闭的电脑一侧,会留下 60 秒的 TIME-WAIT
pc$ ss -tano dst 203.0.113.20
TIME-WAIT 0 0 192.168.1.10:35594 203.0.113.20:3000 timer:(timewait,59sec,0)
看 curl 的数字,连接用了 1 个往返(0.041 秒),收到回复的第一个字节用了 2 个往返(0.090 秒)。光打招呼就要 1 个往返。如果对方往返 100 毫秒,光这一步就是 0.1 秒。所以,重复使用一条连接会更快。
# 两次申请:每次重新连接
0.0827 s + 0.0824 s = 0.1652 s
# 两次申请:重复使用一条连接(第 2 次 connects 0)
0.0825 s + 0.0421 s = 0.1246 s
第二次申请,复用连接是 0.042 秒,重新连接是 0.082 秒,相差正好一个往返。HTTP 的 keep-alive 和数据库连接池,就是为了省掉这次招呼(第 10 讲)。
给字节编号
新井记录里有 Seq=199、Ack=204,是第 199 个包的意思吗?
古井编号的不是包,而是字节。TCP 不把数据当成一串包,而是当成一条长长的字节流。
Seq 是“这段数据从第几号字节开始”,Ack 是“下一个想要第几号”
电脑的 POST 是 198 字节,编号 1 到 198。服务器的 Ack=199 意思是“198 号为止都收到了,下一个从 199 号开始”。服务器回复的 203 字节在服务器一侧另有编号,电脑回复 Ack=204。两个方向各有一条编号序列。
Wireshark 为了好看把开头当作 0,真正的编号从随机值开始(照片详情里的 Sequence Number (raw))。这是为了防止旧连接的包混进来,也防止别人猜中编号、插入伪造的包。
TCP 不记得分界。即使一次发送 198 字节,也不能保证接收方读一次就正好拿到 198 字节,可能被拆开,也可能和后面的数据粘在一起。HTTP 用 Content-Length 写明长度就是这个原因。自己使用套接字时,要事先约定长度或分隔符(第 7 讲)。
被拒绝,被晾着
招呼不成功有两种情况。连接没人监听的端口,对方的操作系统会回复 RST(那里没人)。而如果 SYN 在途中被丢掉,就什么也收不到。我们让捣乱线丢掉发往 3002 号的 SYN,比较一下。
# 发往关闭的端口 3001:服务器回复 RST(没人),1 个往返就被拒绝
pc$ curl http://203.0.113.20:3001/
curl: (7) Failed to connect to 203.0.113.20 port 3001 after 40 ms: Couldn't connect to server
# 发往没有回应的端口 3002(线把 SYN 丢掉):一直重发,20 秒后 curl 放弃
pc$ curl --connect-timeout 20 http://203.0.113.20:3002/
curl: (28) Failed to connect to 203.0.113.20 port 3002 after 20002 ms: Timeout was reached
# 发送 SYN 的时刻(秒)
0.0 1.0 2.0 3.0 4.1 5.1 7.1 11.3 19.5
被拒绝只需一瞬间,没有回应就得一直等
被拒绝的一方 0.041 秒,也就是 1 个往返就结束了。没有回应的一方,电脑每隔 1 秒重发 SYN,之后把间隔翻倍为 2 秒、4 秒、8 秒。这台 Linux(6.18)的设置是前几次每隔 1 秒(tcp_syn_linear_timeouts = 4),默认约 2 分钟后放弃。
Connection refused 说明已经到达对方的机器,只是那个端口上没有程序在运行。timed out 说明在途中或对方的防火墙消失了。错误信息会告诉你该去哪里查(第 12 讲)。
丢了就重发
我们模拟给申请附上 1 MB 收据图片的场景,把捣乱线限速到 10 Mbit/s,只丢掉电脑发出的第 40 个包。
# 上传 1 MB 收据图片的途中,线丢掉电脑发出的第 40 个包(10 Mbit/s,单程 20 ms)
wire: up drop data#40
# 电脑一侧的记录(编号是 Wireshark 的序号)
58 0.1282 pc → srv Seq=55169 Len=1448
123 0.1759 srv → pc Ack=55169 SACK 56617-58065
128 0.1771 srv → pc Ack=55169 (Dup ACK #1)
130 0.1783 srv → pc Ack=55169 (Dup ACK #2)
131 0.1784 pc → srv Seq=55169 Len=1448 (Fast Retransmission)
# 耗时:不丢包的 3 次,与丢包的 1 次
1.007 s 1.007 s 1.006 s / 1.008 s
同样的 Ack 接连出现,就判断为丢失并重发
Seq=55169 没有到达,所以后面的包每到一个,服务器就回复同样的 Ack“55169 起还没收到”,并用 SACK 附上已到达的范围。同样的 Ack 凑齐 3 个时,电脑不等超时,只重发了 55169(Fast Retransmission)。从丢失到重发 0.050 秒,几乎正好一个往返。总耗时 1.008 秒,与不丢包的 3 次(1.006〜1.007 秒)几乎没有差别。
一个包是 1448 字节,是 MSS 1460 减去时间戳(Timestamps)12 字节的结果。如果后面没有包,凑不齐同样的 Ack,就要等超时(RTO)再重发。Linux 的超时最短也有 0.2 秒,这种情况会明显变慢。
顺序乱了的话
# 让线把第 40 个包往后挪一个。服务器一侧的记录:
76 0.1375 pc → srv Seq=56617 Len=1448 (跳过 55169 先到)
77 0.1376 srv → pc Ack=55169 SACK 56617-58065
78 0.1388 pc → srv Seq=55169 Len=1448 (Out-Of-Order)
# 后一个包先到,服务器回复“55169 起还没收到,56617〜58065 已经有了”。紧接着 55169 到达,空洞填上
# 电脑的重发次数 0
这次不丢第 40 个包,而是往后挪一个。服务器把先到的 56617 先存进缓冲,回复“55169 还没到”,但 55169 马上就到了,空洞被填上。电脑重发 0 次。应用(Node.js)只会收到按编号重新排好的字节流,根本察觉不到顺序乱过。
接收方的情况:窗口
新井发送方拼命发,接收方跟不上,会怎样?
古井接收方每次都在回复里写着“还能接收多少”,这就是窗口。我做了一个偷懒不读数据的服务器。
# 向接收后 3 秒内不读取的服务器(接收缓冲 32 KB)发送 1 MB
48 0.163 srv → pc Win=0 Ack=40689 (ZeroWindow)
# 缓冲区满了,窗口为 0。电脑隔一段时间探问“好了吗?”
49 0.414 pc → srv Len=0 (probe)
51 0.966 pc → srv Len=0 (probe)
53 2.022 pc → srv Len=0 (probe)
# 3 秒后服务器开始读取,窗口打开
55 3.082 srv → pc Win=36200 (Window Update)
curl: total 4.188 s
接收缓冲满了,窗口就变成 0
4000 号的服务器连上后 3 秒内完全不读取。接收缓冲(32 KB)在 40,688 字节时装满,0.163 秒时窗口变成 0(ZeroWindow)。电脑停止发送,以 0.41 秒、0.97 秒、2.02 秒逐渐拉长的间隔探问“窗口开了吗?”。3.08 秒服务器开始读取,窗口打开,发送恢复。
窗口字段是 16 位,原样只能写到 64 KB。所以在 SYN 里约定倍率(封面照片的 Window scale: 10 是 1024 倍)。有了这种流量控制,快的发送方就不会把慢的接收方淹没。Node.js 的 write() 返回 false,追根溯源也和这个窗口有关。
路上的拥堵
窗口是接收方的情况。另外还有途中道路的情况。线路很细却发得太多,就会从路由器前的队列里溢出丢失。TCP 根据丢包推测道路的拥挤程度,自己调节发送量(拥塞控制)。
把捣乱线设为 10 Mbit/s、队列最多 30 个,从服务器下载 2 MB 的 CSV。下图是根据服务器一侧的记录,每 10 毫秒统计一次的“已发送但尚未确认的包数”。
发送量成倍增加,溢出后减少,再慢慢增加(实测)
# 下载 2 MB 的 CSV(10 Mbit/s,单程 20 ms,线前最多排 30 个)
# 服务器每个往返(41 ms)发出的包数
10 20 40 51 36 36 35 36 …
# 途中服务器的状态(ss -ti。cwnd 是包的个数)
cubic mss:1448 cwnd:56 ssthresh:46 rtt:66.713 ms minrtt:40.738 ms
queue drops 4 retransmissions 4 curl: 1.884 s, 8.90 Mbit/s
第一个往返发了 10 个,接着 20 个,再接着 40 个。每个往返翻一倍,这叫慢启动(最初的 10 个是 RFC 6928 的值)。当线路和队列装不下时溢出了 4 个,重发丢失的部分后,把发送量减到约七成(ssthresh 46)。之后再慢慢增加。这是 Linux 默认的 cubic 方式。
也请注意途中状态里 rtt:66.7 和 minrtt:40.7 的差。在队列里等待让往返延长了 26 毫秒。队列太深导致延迟变大的状态叫缓冲膨胀(bufferbloat)。也有像 Google 的 BBR 那样,不等丢包、而是根据往返时间的增长来判断拥塞的方式。
道别的方式
申请记录的最后 3 个就是道别。电脑先发 FIN(没有要发的了),服务器也回 FIN,最后电脑用 ACK 收尾。因为两个方向要分别关闭,本来是 4 次,这里服务器的 ACK 和 FIN 合成了一个。
先关闭的一方会留下 60 秒的 TIME-WAIT 状态(前面的 timer:(timewait,59sec))。这是为了在最后的 ACK 丢失时能重新回应,也是为了防止迟到的旧包混进使用相同端口的新连接。大量建立短连接的程序,可能会堆积几万个 TIME-WAIT,导致电脑一侧的端口不够用。这也是复用连接的理由之一。
不打招呼的强制结束是 RST。除了连接没人监听的端口(第五章),对方程序崩溃时也会收到。Connection reset by peer 就是收到了对方的 RST。
在自己的电脑上确认
| 想查的内容 | Mac | Windows | Linux |
|---|---|---|---|
| 当前的连接与状态 | netstat -an -p tcp | netstat -an | ss -tan |
| 占用该端口的程序 | lsof -i :3000 | netstat -ano | findstr :3000 | ss -tlnp |
| 能否连上该端口 | nc -vz 目的地 443 | Test-NetConnection 目的地 -Port 443 | nc -vz 目的地 443 |
| 连接耗时 | curl -o /dev/null -s -w '%{time_connect} %{time_starttransfer}\n' URL | ||
在浏览器里开着几个网站时看 netstat -an,应该会看到一排发往 443 号的 ESTABLISHED,刚关闭的则是 TIME_WAIT。
结 — 一页总结与下一讲
- 端口号是房间。一条连接由两端的地址和端口这 4 个值决定。电脑一侧的端口在 Linux 上是 32768〜60999。
- 招呼是 SYN、SYN+ACK、ACK,连接要 1 个往返。复用连接后,第二次申请从 0.082 秒变成 0.042 秒。
- Seq 和 Ack 是字节的编号。Ack 表示“下一个想要第几号”。TCP 不记得分界。
- 关闭的端口 0.041 秒就被 RST 拒绝;SYN 被丢掉时,每隔 1 秒重发,一直被晾着。
- 丢一个包,同样的 Ack 接连出现,约 1 个往返后重发,总耗时几乎不变。顺序乱了则重发 0 次。
- 不读数据的接收方会让窗口变成 0。发送方等待、探查,窗口打开后恢复。
- 发送量按 10、20、40 翻倍增加,溢出后减到约七成。队列让往返延长了 26 毫秒。
新井TCP 真是太周到了。全都用它不就好了?
古井有时周到反而碍事。视频通话里,把 1 秒前的画面重发过来也没用吧。下一讲讲既不打招呼也不重发的 UDP,以及程序使用它们的窗口:套接字。
参考
- RFC 9293 — Transmission Control Protocol (TCP)
- RFC 6335 — IANA Procedures for the Management of the Service Name and Transport Protocol Port Number Registry
- IANA — Service Name and Transport Protocol Port Number Registry
- Microsoft Learn — The default dynamic port range for TCP/IP has changed
- RFC 7323 — TCP Extensions for High Performance (Window Scale, Timestamps)
- RFC 2018 — TCP Selective Acknowledgment Options
- RFC 6298 — Computing TCP's Retransmission Timer
- RFC 6928 — Increasing TCP's Initial Window
- RFC 9438 — CUBIC for Fast and Long-Distance Networks
- Linux kernel — IP Sysctl(tcp_syn_retries, tcp_syn_linear_timeouts ほか)
- curl — man page(--write-out)
记录来自笔者在云端 Linux(Ubuntu 24.04,内核 6.18)上用网络命名空间搭建的练习网络,于 2026 年 10 月 25 日抓取。电脑和服务器之间的“捣乱线”是一个在两个接口之间中转帧的 Python 小程序(使用 AF_PACKET),可以指定延迟、带宽、队列长度、要丢的包和要打乱的包。服务器是 Node.js,读得慢的服务器是 Python,拥塞控制为 cubic,为了让记录更易读,关闭了收发分段功能(TSO/GRO)。表中的数字都是从这些记录和当时命令的输出读出的实际数值。登场人物均为虚构。
古法网络入门 第6讲 — TCP / 2026.10