Update 计算机网络 - 传输层.md
补充三次握手细节、全连接队列实测附录及 RTO 计算示例,更新 TIME_WAIT 与拥塞控制说明,并新增及重新绘制相关配图。
|
After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 222 KiB |
@@ -30,16 +30,19 @@
|
||||
* [A5 DNS 改用 TCP](#a5-dns-改用-tcp)
|
||||
* [A6 快重传与 SACK](#a6-快重传与-sack)
|
||||
* [A7 零窗口与窗口探测](#a7-零窗口与窗口探测)
|
||||
* [A8 全连接队列已满](#a8-全连接队列已满)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
|
||||
网络层只把分组发送到目的主机,但是真正通信的并不是主机而是主机中的进程。传输层提供了进程间的逻辑通信,传输层向高层用户屏蔽了下面网络层的核心细节,使应用程序看起来像是在两个传输层实体之间有一条端到端的逻辑通信信道。
|
||||
网络层只把分组送到目的主机,真正通信的却是主机中的进程。传输层提供进程之间的逻辑通信,并向上屏蔽网络层的细节,使应用程序看起来像是在两个进程之间有一条端到端的信道。
|
||||
|
||||
传输层用端口号区分同一台主机上的进程,例如 Nginx 监听 80 端口、MySQL 监听 3306 端口,报文按首部中的目的端口交给对应的进程。TCP 和 UDP 的端口互相独立,同一个端口号可以同时被一个 TCP 程序和一个 UDP 程序使用。
|
||||
|
||||
## UDP 和 TCP 的特点 🔥🔥
|
||||
|
||||
- 用户数据报协议 UDP(User Datagram Protocol)是无连接的,尽最大可能交付,没有拥塞控制,面向报文(对于应用程序传下来的报文不合并也不拆分,只是添加 UDP 首部),支持一对一、一对多、多对一和多对多的交互通信;
|
||||
- 传输控制协议 TCP(Transmission Control Protocol)是面向连接的,提供可靠交付,有流量控制,拥塞控制,提供全双工通信,面向字节流(把应用层传下来的报文看成字节流,把字节流组织成大小不等的数据块),每一条 TCP 连接只能是点对点的(一对一)。
|
||||
- 传输控制协议 TCP(Transmission Control Protocol)是面向连接的,提供可靠交付(实现方法见 [TCP 可靠传输](#tcp-可靠传输)),有流量控制,拥塞控制,提供全双工通信,面向字节流(把应用层传下来的报文看成字节流,把字节流组织成大小不等的数据块,不保留消息边界,见 [TCP 粘包与拆包](#tcp-粘包与拆包)),每一条 TCP 连接只能是点对点的(一对一)。
|
||||
|
||||
UDP 延迟低,不是因为它发得更快,而是省掉了 TCP 为可靠付出的等待:不需要握手,第一个数据报就带着数据;丢包时不等重传,后面的数据照常交给应用;没有拥塞控制,不会因为丢包而主动降低发送速率;首部只有 8 字节,TCP 至少 20 字节。需要注意的是,这也意味着网络拥塞时,UDP 应用要自己控制发送速率,否则会加重拥塞。
|
||||
|
||||
@@ -51,7 +54,7 @@ TCP 的可靠和按序是有代价的:一个报文段丢失后,后面已经
|
||||
- 数据过时就没有用、宁可丢掉的场景用 UDP,例如实时对战游戏的位置同步、视频通话(WebRTC 优先使用 UDP);
|
||||
- 只有一问一答的场景用 UDP 可以省去握手,例如 DNS 查询。
|
||||
|
||||
直播常用的 HLS 基于 HTTP,也就是 TCP,观众能接受几秒的延迟,换来画面完整。
|
||||
直播常用的 HLS 基于 HTTP 分发视频分片,通常运行在 TCP 上,观众能接受几秒的延迟,换来画面完整。
|
||||
|
||||
DNS 也不是只用 UDP。不带 EDNS 扩展时,UDP 上的 DNS 响应限制为 512 字节,超出时服务器只返回放得下的部分并设置 TC(截断)标志,客户端改用 TCP 重新查询;主从 DNS 服务器之间同步整个区域的数据(区域传送)也用 TCP。例如不带 EDNS 查询 google.com 的 TXT 记录,UDP 响应被截断,dig 改用 TCP 后收到 1176 字节的完整响应(实测,见[附录 A5](#a5-dns-改用-tcp))。RFC 7766 要求 DNS 实现同时支持 UDP 和 TCP。
|
||||
|
||||
@@ -90,16 +93,22 @@ DNS 也不是只用 UDP。不带 EDNS 扩展时,UDP 上的 DNS 响应限制为
|
||||
- 首先 B 处于 LISTEN(监听)状态,等待客户的连接请求;
|
||||
- A 向 B 发送连接请求报文,SYN=1,ACK=0,选择一个初始的序号 x,进入 SYN_SENT 状态;
|
||||
- B 收到连接请求报文,如果同意建立连接,则向 A 发送连接确认报文,SYN=1,ACK=1,确认号为 x+1,同时也选择一个初始的序号 y,进入 SYN_RCVD 状态;
|
||||
- A 收到 B 的连接确认报文后,还要向 B 发出确认,确认号为 y+1,序号为 x+1,A 进入 ESTABLISHED 状态;
|
||||
- B 收到 A 的确认后也进入 ESTABLISHED 状态,连接建立。
|
||||
- A 收到 B 的连接确认报文后,还要向 B 发出确认,确认号为 y+1,序号为 x+1,A 进入 ESTABLISHED 状态,阻塞式 connect() 此时成功返回,不等这个确认到达 B;
|
||||
- B 收到 A 的确认后也进入 ESTABLISHED 状态,连接建立,服务器的 accept() 这时才能取到这个连接。
|
||||
|
||||
初始序号不从 0 开始,而是由一个随时间递增的计数器加上四元组(双方的 IP 地址和端口)与密钥的哈希值算出。计数器让同一四元组先后建立的连接从不同的序号开始,减少旧连接残留的报文段被新连接误收;带密钥的哈希让攻击者猜不出序号,难以伪造报文段插入连接。
|
||||
|
||||
第三次握手可以携带数据,因为 A 收到 SYN+ACK 时已经确认了双方的序号,连接对 A 来说已经建立。SYN 中即使带了数据,也要等握手完成才能交给应用;TCP 快速打开(TCP Fast Open, TFO)用上一次连接时得到的 Cookie 验证客户端,SYN 中的数据才能立即交给应用。
|
||||
|
||||
### 三次握手的原因
|
||||
|
||||
双方都要把自己的初始序号告诉对方,并得到对方的确认:A 发送 x,B 确认;B 发送 y,A 确认。这本来是四个动作,B 的确认和 B 的 SYN 合并在了一个报文中,所以只需要三次。只握手两次时,B 的初始序号没有得到确认,B 无法知道 A 是否收到了它。
|
||||
|
||||
第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。
|
||||
第三次握手还可以防止已经失效的连接请求到达服务器,让服务器错误地打开连接。例如 A 发出的 SYN 在网络中滞留,A 等不到回复,connect() 超时后放弃;很久以后这个 SYN 才到达 B:
|
||||
|
||||
客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。有了第三次握手,客户端收到服务器对滞留请求的确认时,发现确认号不是自己期望的,就回复 RST,服务器收到后放弃这个连接,不会多打开一个连接。
|
||||
<div align="center"> <img src="pics/tcp-stale-syn.png" width="600px"/> </div><br>
|
||||
|
||||
B 照常回复 SYN+ACK。A 并没有在等这个连接,回复 RST(复位报文段,告诉对方这个连接不存在),B 收到后放弃这个半连接。如果只有两次握手,B 发出 SYN+ACK 就认为连接已经建立,为它分配资源,等待一个永远不会来的请求。A 已经重新发起连接时,这个确认对不上 A 正在进行的连接,A 同样回复 RST,B 不会多打开一个连接。
|
||||
|
||||
### 握手报文丢失
|
||||
|
||||
@@ -107,13 +116,23 @@ DNS 也不是只用 UDP。不带 EDNS 扩展时,UDP 上的 DNS 响应限制为
|
||||
|
||||
- 第一次握手的 SYN 丢失:客户端收不到 SYN+ACK,超时后重传 SYN。Linux 6.5 之前按 tcp_syn_retries(默认 6)重传,间隔从 1 秒起每次翻倍,约 127 秒后放弃;6.5 起前几次重传改为固定间隔 1 秒(tcp_syn_linear_timeouts,默认 4),实测共重传 10 次,134 秒后 connect() 返回超时错误;
|
||||
- 第二次握手的 SYN+ACK 丢失:客户端以为 SYN 丢了,重传 SYN;服务器收不到第三次握手,也重传 SYN+ACK(tcp_synack_retries 默认 5 次,间隔 1、2、4、8、16 秒),任何一方的重传到达,连接都能建立;
|
||||
- 第三次握手的 ACK 丢失:客户端发出 ACK 后已经进入 ESTABLISHED,服务器仍处于 SYN_RCVD,继续重传 SYN+ACK,客户端每收到一次就再回复一次 ACK。客户端发送的数据报文段也带有 ACK,服务器收到后同样会建立连接;如果客户端一直不发数据,服务器重传 5 次后放弃,之后客户端再发数据会收到 RST。
|
||||
- 第三次握手的 ACK 丢失:客户端发出 ACK 后已经进入 ESTABLISHED,服务器仍处于 SYN_RCVD,继续重传 SYN+ACK,客户端每收到一次就再回复一次 ACK。客户端发送的数据报文段也带有 ACK,服务器收到后同样会建立连接;如果这些 ACK 都丢失、客户端也一直不发数据,服务器重传 5 次后放弃,之后客户端再发数据会收到 RST。
|
||||
|
||||
<div align="center"> <img src="pics/tcp-third-ack-loss.png" width="660px"/> </div><br>
|
||||
|
||||
服务器用两个队列存放还没有被应用取走的连接:收到 SYN 后,连接进入半连接队列(SYN_RCVD);握手完成后移入全连接队列,等应用调用 accept() 取走。listen() 的 backlog 参数指的是全连接队列的长度,超过 net.core.somaxconn(Linux 5.4 起默认 4096)时按它截断。
|
||||
|
||||
全连接队列满时,Linux 默认丢弃新到的 SYN,客户端按上面的规则重传。例如 backlog 为 2、服务器不调用 accept(),依次发起 6 个连接,3 个连接建立,另外 3 个停在 SYN_SENT。ss -lnt 中 LISTEN 一行的 Recv-Q 是全连接队列中的连接数,Send-Q 是 backlog(Linux 6.10 实测,见[附录 A8](#a8-全连接队列已满)):
|
||||
|
||||
<div align="center"> <img src="pics/tcp-accept-queue.png" width="760px"/> </div><br>
|
||||
|
||||
只发 SYN、不完成握手的 SYN Flood 攻击用来占满半连接队列。Linux 默认开启 SYN Cookie:队列溢出时不保存半连接,把连接信息编码进 SYN+ACK 的序号,收到第三次握手时再还原,见 [拒绝服务攻击](攻击技术.md#四拒绝服务攻击)。
|
||||
|
||||
## TCP 的四次挥手 🔥
|
||||
|
||||
<div align="center"> <img src="pics/tcp-teardown.png" width="640px"/> </div><br>
|
||||
|
||||
以下描述不讨论序号和确认号,因为序号和确认号的规则比较简单。并且不讨论 ACK,因为 ACK 在连接建立之后都为 1。
|
||||
以下描述省略序号和确认号(数值见图),连接建立后所有报文段的 ACK 都为 1,也不再单独写出。
|
||||
|
||||
- A 发送连接释放报文,FIN=1,进入 FIN_WAIT_1 状态;
|
||||
- B 收到之后发出确认,进入 CLOSE_WAIT 状态,A 收到确认后进入 FIN_WAIT_2 状态。此时 TCP 属于半关闭状态,B 能向 A 发送数据但是 A 不能向 B 发送数据;
|
||||
@@ -125,7 +144,7 @@ FIN 和数据一样,丢失后由发送方超时重传。A 在 FIN_WAIT_2 中
|
||||
|
||||
### 四次挥手的原因
|
||||
|
||||
客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE_WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。
|
||||
TCP 是全双工的,两个方向要分别关闭。A 的 FIN 只表示 A 不再发送数据,B 可能还有数据没发完,所以 B 先回复确认,进入 CLOSE_WAIT 继续发送,等应用发完并调用 close() 后再发出自己的 FIN。握手时 B 的 SYN 和确认可以一起发出,挥手时 B 的确认和 FIN 通常隔着一段时间,只能分开发送,所以多了一次。
|
||||
|
||||
如果 B 收到 FIN 时已经没有数据要发送,应用立即调用 close(),B 的确认和 FIN 就可以合并在一个报文段中,挥手只有三次。Linux 收到 FIN 后,确认会延迟几十毫秒再发送,应用在这期间调用 close() 就会合并(Linux 6.10 实测,见[附录 A3](#a3-三次挥手)):
|
||||
|
||||
@@ -139,26 +158,36 @@ A → B ACK B → A FIN + ACK 1 秒后
|
||||
|
||||
### TIME_WAIT
|
||||
|
||||
客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由:
|
||||
A 收到 B 的 FIN 并发出确认后进入 TIME_WAIT,等待 2MSL 才进入 CLOSED。这么做有两个理由:
|
||||
|
||||
- 确保最后一个确认报文能够到达。如果 B 没收到 A 发送来的确认报文,那么就会重新发送连接释放请求报文,A 等待一段时间就是为了处理这种情况的发生。A 在 TIME_WAIT 中每收到一次重传的 FIN,就再确认一次,并重新开始计时(Linux 6.10 实测,见[附录 A4](#a4-最后的-ack-丢失));
|
||||
- 等待一段时间是为了让本连接持续时间内所产生的所有报文都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文。
|
||||
- 确保最后一个确认能够到达。这个确认丢失时,B 会重传 FIN,A 还在 TIME_WAIT 中,每收到一次就再确认一次,并重新开始计时(Linux 6.10 实测,见[附录 A4](#a4-最后的-ack-丢失))。如果 A 已经关闭,收到重传的 FIN 只能回复 RST,B 就无法正常关闭;
|
||||
- 让本连接的所有报文段都从网络中消失。之后用同一个四元组建立的新连接,就不会收到旧连接迟到的报文段。
|
||||
|
||||
2MSL 正好覆盖一个来回:A 的确认最多在网络中存活 1 MSL,B 超时重传的 FIN 最多再存活 1 MSL。Linux 中 TIME_WAIT 固定为 60 秒(TCP_TIMEWAIT_LEN)。
|
||||
<div align="center"> <img src="pics/tcp-timewait-stale.png" width="660px"/> </div><br>
|
||||
|
||||
2MSL 正好覆盖一个来回:A 的确认最多在网络中存活 1 MSL,B 超时重传的 FIN 最多再存活 1 MSL。RFC 9293 把 MSL 取为 2 分钟,Linux 中 TIME_WAIT 则固定为 60 秒(TCP_TIMEWAIT_LEN)。
|
||||
|
||||
### 大量 TIME_WAIT 与 CLOSE_WAIT
|
||||
|
||||
TIME_WAIT 出现在主动关闭的一方,使用短连接的机器会积累大量 TIME_WAIT,例如每次请求都新建连接去访问下游服务。每个 TIME_WAIT 连接占用一个本地端口 60 秒,Linux 默认的本地端口范围是 32768~60999,共 28232 个,连接同一个目标地址和端口时,每秒新建超过约 470 个连接就会耗尽端口。
|
||||
TIME_WAIT 出现在主动关闭的一方,本身是正常的状态,数量多到耗尽端口时才成为问题。使用短连接的机器会积累大量 TIME_WAIT,例如每次请求都新建连接去访问下游服务。每个 TIME_WAIT 连接占用一个本地端口 60 秒,Linux 默认的本地端口范围是 32768~60999,共 28232 个,连接同一个目标地址和端口时,每秒新建超过约 470 个连接就会耗尽端口。
|
||||
|
||||
服务器主动关闭时 TIME_WAIT 留在服务器上,例如响应后关闭的 HTTP 短连接。这些连接共用监听端口,靠客户端的地址和端口区分,不消耗本地端口,只占少量内存;总数超过 tcp_max_tw_buckets 时,新进入 TIME_WAIT 的连接会被直接销毁。Nginx 这类代理既是服务器又向上游发起连接,排查时先确认是哪个方向的连接、哪一方先关闭。
|
||||
|
||||
各状态的连接数可以这样统计:
|
||||
|
||||
```bash
|
||||
ss -tan | awk 'NR > 1 {print $1}' | sort | uniq -c
|
||||
```
|
||||
|
||||
解决方案:
|
||||
|
||||
- 使用长连接或连接池,例如 HTTP 的 keep-alive、数据库连接池;
|
||||
- 在发起连接的一方开启 net.ipv4.tcp_tw_reuse,允许新连接复用处于 TIME_WAIT 的端口,它依靠 TCP 时间戳选项识别旧连接的报文;
|
||||
- 使用长连接或连接池,例如 [HTTP 的长连接](HTTP.md#1-短连接与长连接)、数据库连接池;
|
||||
- 在发起连接的一方开启 net.ipv4.tcp_tw_reuse,允许新连接复用处于 TIME_WAIT 的端口,它依靠 TCP 时间戳选项识别旧连接的报文(Linux 默认值为 2,只对回环地址生效,设为 1 才对所有出站连接生效);
|
||||
- 扩大本地端口范围 net.ipv4.ip_local_port_range。
|
||||
|
||||
另一个参数 tcp_tw_recycle 在 NAT 环境下会丢弃正常的连接,Linux 4.12 已将其移除。
|
||||
Linux 没有提供修改 TIME_WAIT 时长的参数,缩短它也会让旧连接的报文段更容易混进新连接。另一个参数 tcp_tw_recycle 在 NAT 环境下会丢弃正常的连接,Linux 4.12 已将其移除。
|
||||
|
||||
CLOSE_WAIT 出现在被动关闭的一方,表示对方已经关闭,本方的应用还没有调用 close()。连接大量停在 CLOSE_WAIT 通常是程序的问题,例如异常分支没有关闭连接、从连接池借出的连接没有归还,需要排查代码,保证每个连接最终都会被关闭。
|
||||
CLOSE_WAIT 出现在被动关闭的一方,表示对方已经关闭,本方的应用还没有调用 close()。连接大量停在 CLOSE_WAIT 通常是程序的问题,例如异常分支没有关闭连接、从连接池借出的连接没有归还,这些连接会一直占用文件描述符,积累到上限后出现 Too many open files。排查时用 ss -tanp state close-wait 找到所属进程,再检查代码。
|
||||
|
||||
## TCP 可靠传输 🔥
|
||||
|
||||
@@ -177,7 +206,7 @@ TCP 的确认是累积的:确认号为 N 表示 N 之前的字节都已收到
|
||||
|
||||
<div align="center"> <img src="pics/tcp-sack.gif" width="760px"/> </div><br>
|
||||
|
||||
第 3 段到达时,接收方仍然只能确认到 1001,于是重复发送 ack 1001,称为重复确认。发送方收到 3 个重复确认,就知道 1001 开始的这一段丢了,不等超时立即重传,即快重传;接收方补上空洞后,一次确认到 8001。
|
||||
第 3 段到达时,接收方仍然只能确认到 1001,于是重复发送 ack 1001,称为重复确认。发送方收到 3 个重复确认,就知道 1001 开始的这一段丢了,不等超时立即重传,即快重传。要等 3 个,是因为报文段乱序到达也会产生一两个重复确认;接收方补上空洞后,一次确认到 8001。
|
||||
|
||||
SACK 是握手时协商的选项,确认中额外携带已经收到的区间。上例中 SACK 2001~8000 告诉发送方只缺 1001~2000,发送方只重传这一段;没有 SACK 时,发送方只知道 1001 之后有缺失,不知道后面哪些已经到达。
|
||||
|
||||
@@ -201,9 +230,19 @@ RTTs = (1 - a) × RTTs + a × RTT
|
||||
RTO = RTTs + 4 × RTTd
|
||||
```
|
||||
|
||||
其中 RTT<sub>d</sub> 为偏差的加权平均值。RFC 6298 建议 a 取 1/8,计算 RTT<sub>d</sub> 时的权重取 1/4,并建议 RTO 不小于 1 秒;Linux 的下限为 200 ms。
|
||||
其中 RTTd 为新测得的 RTT 与更新前的 RTTs 之差的绝对值的加权平均值,每次先更新 RTTd 再更新 RTTs;第一次测量时 RTTs 取 RTT,RTTd 取 RTT / 2。RFC 6298 建议 a 取 1/8,计算 RTTd 时的权重取 1/4,并建议 RTO 不小于 1 秒;Linux 的下限为 200 ms。
|
||||
|
||||
例如先后测得的 RTT 为 100、120、300、110 ms,按 RFC 6298 计算,RTO 依次为 300、273、452、386 ms(不计 1 秒的下限)。RTT 稳定时 RTO 逐渐向 RTT 靠拢;一次 300 ms 的抖动让偏差项变大,RTO 随之变长,避免下一次抖动时误判丢失。
|
||||
例如先后测得的 RTT 为 100、120、300、110 ms,按 RFC 6298 计算如下(单位 ms,不计 1 秒的下限,|RTTs − RTT| 用的是更新前的 RTTs):
|
||||
|
||||
```text
|
||||
RTT |RTTs − RTT| RTTd RTTs RTO
|
||||
100 — 50 100 300
|
||||
120 20 42.5 102.5 273
|
||||
300 197.5 81.3 127.2 452
|
||||
110 17.2 65.2 125.0 386
|
||||
```
|
||||
|
||||
RTT 稳定时 RTO 逐渐向 RTT 靠拢;一次 300 ms 的抖动让偏差项变大,RTO 随之变长,避免下一次抖动时误判丢失。
|
||||
|
||||
此外还有两条规则:
|
||||
|
||||
@@ -212,21 +251,21 @@ RTO = RTTs + 4 × RTTd
|
||||
|
||||
## TCP 滑动窗口
|
||||
|
||||
窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。
|
||||
如果每发出一个报文段都要等到确认再发下一个,一个 RTT 只能发送一个报文段,例如 RTT 为 100 ms、每段 1460 字节时,吞吐量只有约 117 kbit/s。滑动窗口允许发送方连续发出窗口内的多个报文段,再随着确认的到达向前滑动。
|
||||
|
||||
发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。
|
||||
窗口是一段允许发送或接收的序号范围,对应缓存中暂时存放字节流的部分。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方的窗口不超过这个值,还要受拥塞窗口的限制(见[TCP 拥塞控制](#tcp-拥塞控制))。
|
||||
|
||||
接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认,确认号为 32。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。
|
||||
发送窗口内的字节都允许发送,接收窗口内的字节都允许接收。发送窗口左端的字节收到确认后,窗口向右滑动,直到左端是第一个还没有被确认的字节;接收窗口左端的字节按序收到并确认后,接收窗口同样向右滑动。
|
||||
|
||||
接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认,确认号为 32。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。图中假设 B 的应用及时读走数据,通告的窗口一直是 10;应用不读取时窗口会缩小,见[流量控制](#tcp-流量控制)。
|
||||
|
||||
<div align="center"> <img src="pics/tcp-sliding-window.png" width="760px"/> </div><br>
|
||||
|
||||
## TCP 流量控制
|
||||
|
||||
流量控制是为了控制发送方发送速率,保证接收方来得及接收。
|
||||
流量控制是为了控制发送方的发送速率,保证接收方来得及接收。接收方在确认报文的窗口字段中通告接收缓冲区还空着的大小,发送方已发送但未确认的数据不能超过这个值。
|
||||
|
||||
接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。
|
||||
|
||||
接收方通告的窗口就是接收缓冲区中还空着的部分。应用读得慢,缓冲区逐渐填满,窗口就逐渐变小直到 0,发送方随之停下;应用读走数据后,接收方发出窗口更新,发送方继续发送。例如服务器把接收缓冲区设为 16 KB 并且暂不读取,客户端不停地写入(Linux 6.10 实测,见[附录 A7](#a7-零窗口与窗口探测)):
|
||||
应用读得慢,缓冲区逐渐填满,窗口就逐渐变小直到 0,发送方随之停下;应用读走数据后,接收方发出窗口更新,发送方继续发送。例如服务器把接收缓冲区设为 16 KB 并且暂不读取,客户端不停地写入(Linux 6.10 实测,见[附录 A7](#a7-零窗口与窗口探测)):
|
||||
|
||||
```text
|
||||
时间(秒) 服务器通告的窗口(字节) 说明
|
||||
@@ -240,12 +279,14 @@ RTO = RTTs + 4 × RTTd
|
||||
|
||||
窗口为 0 时,发送方启动持续计时器,定期发送零窗口探测报文段,间隔逐次翻倍,对方收到后必须回复当前的窗口。这是因为窗口更新报文不携带数据,丢失后不会重传,如果发送方只是等待,双方就会一直互相等待。
|
||||
|
||||
首部的窗口字段只有 16 位,最大 65535 字节。在带宽大、延迟高的网络中不够用,例如 RTT 为 100 ms 时,64 KB 的窗口最多只能达到约 5 Mbit/s,而 1 Gbit/s 的链路一个 RTT 内可以发出 12.5 MB。窗口扩大选项在握手时协商一个移位数,实际窗口为窗口字段左移这么多位,移位数最大为 14,窗口最大约 1 GB。
|
||||
首部的窗口字段只有 16 位,最大 65535 字节。在带宽大、延迟高的网络中不够用,例如 RTT 为 100 ms 时,64 KB 的窗口最多只能达到约 5 Mbit/s,而 1 Gbit/s 的链路一个 RTT 内可以发出 12.5 MB,即带宽 × RTT(带宽时延积),窗口至少要这么大才能跑满链路。窗口扩大选项在握手时协商一个移位数,实际窗口为窗口字段左移这么多位,移位数最大为 14,窗口最大约 1 GB。
|
||||
|
||||
## TCP 拥塞控制 🔥🔥
|
||||
|
||||
如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。
|
||||
|
||||
发送方通常看不到路由器的状态,下面介绍的经典算法把丢包当作拥塞的信号:发生超时,或者收到 3 个重复确认。
|
||||
|
||||
<div align="center"> <img src="pics/tcp-flow-vs-congestion.png" width="700px"/> </div><br>
|
||||
|
||||
TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。
|
||||
@@ -265,29 +306,29 @@ TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、
|
||||
|
||||
实际实现中初始拥塞窗口通常更大,RFC 6928 建议初始值为 10 个报文段。
|
||||
|
||||
注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能性也就更高。设置一个慢开始门限 ssthresh,当 cwnd \>= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。
|
||||
慢开始每个轮次都将 cwnd 加倍,增长很快,很容易造成拥塞。因此设置一个慢开始门限 ssthresh,当 cwnd \>= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。
|
||||
|
||||
如果出现了超时,则令 ssthresh = cwnd / 2,然后重新执行慢开始。
|
||||
|
||||
### 2. 快重传与快恢复
|
||||
|
||||
在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M<sub>1</sub> 和 M<sub>2</sub>,此时收到 M<sub>4</sub>,应当发送对 M<sub>2</sub> 的确认。
|
||||
接收方每收到一个失序的报文段,就重复确认最后一个按序到达的报文段;发送方收到 3 个重复确认,就立即重传下一个报文段,过程见[确认与重传](#确认与重传)。例如收到 3 个对 M<sub>2</sub> 的重复确认,说明 M<sub>3</sub> 丢失,立即重传 M<sub>3</sub>。
|
||||
|
||||
在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M<sub>2</sub>,则 M<sub>3</sub> 丢失,立即重传 M<sub>3</sub>。
|
||||
<div align="center"> <img src="pics/tcp-fast-retransmit.png" width="620px"/> </div><br>
|
||||
|
||||
在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd / 2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。
|
||||
重复确认说明后面的报文段还能到达,网络只是轻度拥塞,不必像超时那样从 1 重新开始。因此执行快恢复,令 ssthresh = cwnd / 2,cwnd = ssthresh,直接进入拥塞避免。
|
||||
|
||||
这是教材中的简化。RFC 5681 中重传后先令 cwnd = ssthresh + 3(3 个重复确认说明已有 3 个报文段离开网络),之后每收到一个重复确认 cwnd 再加 1,收到确认新数据的 ACK 后 cwnd 才回到 ssthresh,进入拥塞避免。
|
||||
|
||||
慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。
|
||||
|
||||
以上是经典的 Reno 算法(RFC 5681)。Linux、Windows 和 Apple 的协议栈默认使用的是 CUBIC(RFC 9438),它保留了慢开始和快重传,区别在拥塞避免阶段:cwnd 按距离上次丢包的时间的三次函数增长,先快速回到丢包前的大小,在附近放慢,然后再加速探测更大的带宽,在带宽大、延迟高的网络中比每轮加 1 增长得快。Google 的 BBR 则不再把丢包作为主要的拥塞信号,而是根据测得的瓶颈带宽和最小 RTT 来控制发送速率。
|
||||
|
||||
<div align="center"> <img src="pics/tcp-fast-retransmit.png" width="620px"/> </div><br>
|
||||
|
||||
## TCP 粘包与拆包
|
||||
|
||||
TCP 面向字节流,只保证字节按顺序到达,不保留应用每次写入的边界。发送方写入两次,接收方的一次读取可能读到两条消息拼在一起,称为粘包;也可能只读到一条消息的一部分,称为拆包(也叫半包)。
|
||||
|
||||
例如客户端依次写入 hello、world 和一条 200000 字节的消息,服务器等待 200 ms 后,用 1024 字节的缓冲区反复读取:
|
||||
例如客户端依次写入 hello、world 和一条 200000 字节的消息,服务器等待 200 ms 后,先用 1024 字节的缓冲区读一次,再用 300000 字节的缓冲区读完剩下的数据:
|
||||
|
||||
<div align="center"> <img src="pics/tcp-sticky.png" width="760px"/> </div><br>
|
||||
|
||||
@@ -297,15 +338,15 @@ TCP 面向字节流,只保证字节按顺序到达,不保留应用每次写
|
||||
- 发送方的 Nagle 算法会把多次写入的小数据合并成一个报文段发送;
|
||||
- 超过最大报文段长度(Maximum Segment Size, MSS)的消息会被分成多个报文段,可能分多次读到。
|
||||
|
||||
UDP 面向报文,一次读取得到的就是对方一次发送的完整数据报,不存在粘包。
|
||||
UDP 面向报文,一次读取得到的就是对方一次发送的一个数据报,不存在粘包。需要注意的是,接收缓冲区比数据报小时,多出的部分会被丢弃,不会留到下一次读取。
|
||||
|
||||
解决方法是在应用层约定消息的边界,常用以下三种:
|
||||
|
||||
- 固定长度:每条消息长度相同,不足的部分补齐,浪费空间;
|
||||
- 分隔符:用特殊字符标记消息结束,例如 Redis 协议的每一行以 `\r\n` 结束,消息中出现分隔符时需要转义;
|
||||
- 分隔符:用特殊字符标记消息结束,内容中的分隔符要转义或禁止出现,例如 Redis 协议的简单字符串以 `\r\n` 结束,内容中不能有换行;
|
||||
- 长度字段:在消息头部写明消息体的长度,接收方先读长度,再读够这么多字节。
|
||||
|
||||
HTTP/1.1 同时用了后两种:首部以一个空行结束,消息体的长度由 Content-Length 给出,或者用分块传输编码在每一块前写明长度。Netty 提供了对应的解码器 FixedLengthFrameDecoder、DelimiterBasedFrameDecoder 和 LengthFieldBasedFrameDecoder。
|
||||
HTTP/1.1 同时用了后两种:首部以一个空行结束,消息体的长度由 Content-Length 给出,或者用 [分块传输编码](HTTP.md#分块传输编码) 在每一块前写明长度。Netty 提供了对应的解码器 FixedLengthFrameDecoder、DelimiterBasedFrameDecoder 和 LengthFieldBasedFrameDecoder。
|
||||
|
||||
用长度字段收发消息的最小实现如下,按这种格式重复上面的例子,接收方依次得到 hello、world 和 200000 字节的消息:
|
||||
|
||||
@@ -334,8 +375,6 @@ static String receive(DataInputStream in) throws IOException {
|
||||
|
||||
## 在 UDP 上实现可靠传输
|
||||
|
||||
[选择 TCP 还是 UDP](#选择-tcp-还是-udp) 中比较了游戏位置更新用 TCP 和 UDP 发送的效果。
|
||||
|
||||
游戏中的位置可以丢,购买道具、释放技能这类消息却必须送达。既要 UDP 的低延迟、又有一部分数据必须可靠时,就要在应用层按 TCP 的思路自己实现:
|
||||
|
||||
- 序号:给每个数据包编号,接收方据此去重和排序;
|
||||
@@ -602,9 +641,43 @@ while True:
|
||||
|
||||
连续运行 3 次,窗口变为 0 的时间为 0.124~0.127 秒,3 次探测的时间分别为 0.34、0.77~0.79、1.65~1.69 秒,间隔约 0.2、0.4、0.9 秒。
|
||||
|
||||
### A8 全连接队列已满
|
||||
|
||||
同一个容器,服务器 listen(2) 之后不调用 accept(),客户端每隔 0.3 秒发起一个非阻塞连接,共 6 个,1.5 秒后用 ss 查看:
|
||||
|
||||
```python
|
||||
srv.bind(("127.0.0.1", 9300)); srv.listen(2)
|
||||
cs = [] # 保留所有客户端套接字,否则被回收时连接会关闭
|
||||
for i in range(6):
|
||||
c = socket.socket(); c.setblocking(False)
|
||||
try: c.connect(("127.0.0.1", 9300))
|
||||
except BlockingIOError: pass
|
||||
cs.append(c); time.sleep(0.3)
|
||||
```
|
||||
|
||||
```text
|
||||
$ ss -lnt sport = :9300
|
||||
State Recv-Q Send-Q Local Address:Port Peer Address:Port
|
||||
LISTEN 3 2 127.0.0.1:9300 0.0.0.0:*
|
||||
|
||||
$ ss -tan '( sport = :9300 or dport = :9300 )'(节选)
|
||||
ESTAB 0 0 127.0.0.1:9300 127.0.0.1:40364
|
||||
ESTAB 0 0 127.0.0.1:40364 127.0.0.1:9300
|
||||
SYN-SENT 0 1 127.0.0.1:40394 127.0.0.1:9300
|
||||
SYN-SENT 0 1 127.0.0.1:40392 127.0.0.1:9300
|
||||
SYN-SENT 0 1 127.0.0.1:40408 127.0.0.1:9300
|
||||
|
||||
net.core.somaxconn = 4096
|
||||
net.ipv4.tcp_max_syn_backlog = 512
|
||||
net.ipv4.tcp_abort_on_overflow = 0
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
```
|
||||
|
||||
连续运行 3 次,结果相同:3 个连接建立(服务器和客户端两端共 6 行 ESTAB),3 个停在 SYN-SENT。队列中的连接数比 backlog 多 1 个,是 Linux 判断队列已满时用的是“大于”。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 谢希仁. 计算机网络 [M]. 电子工业出版社, 2017.
|
||||
- 谢希仁. 计算机网络(第 7 版)[M]. 电子工业出版社, 2017.
|
||||
- [RFC 768: User Datagram Protocol](https://www.rfc-editor.org/rfc/rfc768)
|
||||
- [RFC 9293: Transmission Control Protocol (TCP)](https://www.rfc-editor.org/rfc/rfc9293)
|
||||
- [RFC 5681: TCP Congestion Control](https://www.rfc-editor.org/rfc/rfc5681)
|
||||
|
||||