Wireshark&Packetdrill|TCP慢启动(5)

admin 2026-02-03 01:10:06 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文利用packetdrill探究Linux内核TCP慢启动机制,分析ABC与StretchedACK对拥塞窗口的影响。实验表明Linux仅在完整确认数据包后增加cwnd,并解析了内核tcp_slow_start函数逻辑,揭示了其基于完整包确认而非单纯ACK计数的实现细节,有助于深入理解协议行为。 综合评分: 88 文章分类: 网络安全,安全工具,实战经验


cover_image

Wireshark & Packetdrill | TCP 慢启动(5)

原创

7ACE 7ACE

Echo Reply

2026年2月2日 08:08 江苏

持续思索,探究根本原因

实验目的

基于 packetdrill TCP 三次握手脚本,通过构造模拟服务器端场景,继续研究测试 TCP 慢启动现象。

基础脚本

# cat tcp_tcp_slow_start_000.pkt 0   socket(..., SOCK_STREAM, IPPROTO_TCP) = 3+0  setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0+0  bind(3, ..., ...) = 0+0  listen(3, 1) = 0
+0 < S 0:0(0) win 10000 <mss 1460>+0 > S. 0:0(0) ack 1 <...>+0.01 < . 1:1(0) ack 1 win 10000+0 accept(3, ..., ...) = 4#

#

慢启动

TCP 慢启动是 TCP 拥塞控制的一种初始机制,其核心目的是在连接建立或重传超时后,快速而谨慎地探测网络的可用带宽。发送方通过维护一个拥塞窗口(cwnd)来控制未确认数据量,在慢启动阶段,每收到一个确认数据包(ACK),cwnd 就增加一个最大报文段长度(MSS)。这使得在一个往返时延(RTT)内,能发送的数据量大约会翻倍,从而实现窗口的指数级增长,直至达到慢启动阈值(ssthresh)或检测到数据包丢失,此后连接将转入线性增长的拥塞避免阶段。

在 Linux 实现中,慢启动过程会在三种典型场景下触发:连接建立时(默认 cwnd=10、ssthresh 为极大值),确保必然进入慢启动(除非路由表配置覆盖);RTO 超时重传时将 cwnd 重置为1,同时根据拥塞控制算法重新计算 ssthresh 后重新启动慢启动;连接空闲超过 RTO 时间时也会重新初始化拥塞窗口,通过慢启动重新探测网络状态。这三种机制共同保障 TCP 在不同场景下都能合理适配网络容量。

注:TCP 协议极其复杂,在具体实现上也有很多变化,因此有些概念阐述得比较难理解,它是有一定前提条件和背景的。

基础测试

ABC

在 RFC 3465 中所描述的“Appropriate Byte Counting”(ABC)是对传统 TCP 拥塞窗口增长机制的一项关键改进,它规定发送方在慢启动和拥塞避免阶段,应根据接收方确认(ACK)报文段中实际确认的新数据字节数来增加拥塞窗口(cwnd),而不是机械地根据接收到的 ACK 报文数量来增加。通过以字节为单位对已成功传输的数据进行计量,ABC 能够更加准确地反映网络对数据的真实交付能力。

这种基于字节计数的窗口调整方式有效消除了 ACK 行为(如延迟确认、ACK 合并或部分确认)对拥塞控制算法的非本质影响,使 cwnd 的增长速率与实际成功传输的数据量保持一致。因而,ABC 既可以避免在延迟 ACK 等场景下出现不必要的窗口增长过慢,也能够抑制由于 ACK 压缩或异常确认模式导致的增长过快,从而提升 TCP 拥塞控制在复杂网络环境下的正确性与稳定性。

在目前 Linux 系统上的实现(测试服务器 5.19.17 内核版本),是基于 acked 值来增加的,再往前回溯,它是来自于这个 ACK 数据包所 Acked 或 Sacked 完整确认的数据包数量,相较于 RFC 3465 有些许区别。

/* Slow start is used when congestion window is no greater than the slow start&nbsp;* threshold. We base on RFC2581 and also handle stretch ACKs properly.&nbsp;* We do not implement RFC3465 Appropriate Byte Counting (ABC) per se but&nbsp;* something better;) a packet is only considered (s)acked in its entirety to&nbsp;* defend the ACK attacks described in the RFC. Slow start processes a stretch&nbsp;* ACK of degree N as if N acks of degree 1 are received back to back except&nbsp;* ABC caps N to 2. Slow start exits when cwnd grows over ssthresh and&nbsp;* returns the leftover acks to adjust cwnd in congestion avoidance mode.&nbsp;*/u32&nbsp;tcp_slow_start(struct&nbsp;tcp_sock *tp, u32 acked){&nbsp; u32 cwnd = min(tcp_snd_cwnd(tp) + acked, tp->snd_ssthresh);
&nbsp; acked -= cwnd - tcp_snd_cwnd(tp);&nbsp; tcp_snd_cwnd_set(tp, min(cwnd, tp->snd_cwnd_clamp));
&nbsp;&nbsp;return&nbsp;acked;}EXPORT_SYMBOL_GPL(tcp_slow_start);
...
/* Returns the number of packets newly acked or sacked by the current ACK */static&nbsp;u32&nbsp;tcp_newly_delivered(struct&nbsp;sock *sk, u32 prior_delivered,&nbsp;int&nbsp;flag){&nbsp;&nbsp;const&nbsp;struct&nbsp;net *net = sock_net(sk);&nbsp;&nbsp;struct&nbsp;tcp_sock *tp = tcp_sk(sk);&nbsp; u32 delivered;
&nbsp; delivered = tp->delivered - prior_delivered;&nbsp; NET_ADD_STATS(net, LINUX_MIB_TCPDELIVERED, delivered);&nbsp;&nbsp;if&nbsp;(flag & FLAG_ECE)&nbsp; &nbsp; NET_ADD_STATS(net, LINUX_MIB_TCPDELIVEREDCE, delivered);
&nbsp;&nbsp;return&nbsp;delivered;}

在 5.19.17 内核版本的服务器上进行相关测试,将一个完整数据段的 ACK 数据包分拆,脚本如下。

# cat tcp_slow_start_1_014.pkt&nbsp;0 &nbsp; socket(..., SOCK_STREAM, IPPROTO_TCP) = 3+0 &nbsp;setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0+0 &nbsp;bind(3, ..., ...) = 0+0 &nbsp;listen(3, 1) = 0
+0 < S 0:0(0) win 10000 <mss 1000,nop,nop,sackOK>+0 > S. 0:0(0) ack 1 <...>+0.01 < . 1:1(0) ack 1 win 10000+0 accept(3, ..., ...) = 4
+0.01 %{print (tcpi_snd_cwnd, tcpi_snd_ssthresh)}%
+0.01 write(4,...,6000) = 6000+0 %{print (tcpi_snd_cwnd)}%
+0.01 <. 1:1(0) ack 401 win 10000+0 %{print (tcpi_snd_cwnd)}%
+0 <. 1:1(0) ack 801 win 10000+0 %{print (tcpi_snd_cwnd)}%
+0 <. 1:1(0) ack 1001 win 10000+0 %{print (tcpi_snd_cwnd)}%#

运行脚本如下,可以看到 cwnd 的增长变化与之前实验有所不同。对于一个完整的 Seq 1:1001 的数据段,分三次进行 ACK 确认,分别是 401、801 和 1001,直到最后一次完整确认后,cwnd 才从 10 增长到 11。

# packetdrill tcp_slow_start_1_014.pkt&nbsp;10 214748364710101011#

# tcpdump -i any -nn port 8080tcpdump: data link type LINUX_SLL2tcpdump: verbose output suppressed, use -v[v]... for full protocol decodelistening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes10:16:35.128072 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52741 > 192.168.58.65.8080: Flags [S], seq 0, win 10000, options [mss 1000,nop,nop,sackOK], length 010:16:35.128156 tun0 &nbsp;Out IP 192.168.58.65.8080 > 192.0.2.1.52741: Flags [S.], seq 3786356793, ack 1, win 64240, options [mss 1460,nop,nop,sackOK], length 010:16:35.138435 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52741 > 192.168.58.65.8080: Flags [.], ack 1, win 10000, length 010:16:35.158693 tun0 &nbsp;Out IP 192.168.58.65.8080 > 192.0.2.1.52741: Flags [P.], seq 1:2001, ack 1, win 64240, length 2000: HTTP10:16:35.158712 tun0 &nbsp;Out IP 192.168.58.65.8080 > 192.0.2.1.52741: Flags [P.], seq 2001:4001, ack 1, win 64240, length 2000: HTTP10:16:35.158714 tun0 &nbsp;Out IP 192.168.58.65.8080 > 192.0.2.1.52741: Flags [P.], seq 4001:6001, ack 1, win 64240, length 2000: HTTP10:16:35.168758 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52741 > 192.168.58.65.8080: Flags [.], ack 401, win 10000, length 010:16:35.168798 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52741 > 192.168.58.65.8080: Flags [.], ack 801, win 10000, length 010:16:35.168807 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52741 > 192.168.58.65.8080: Flags [.], ack 1001, win 10000, length 010:16:35.195651 ? &nbsp; &nbsp; Out IP 192.168.58.65.8080 > 192.0.2.1.52741: Flags [P.], seq 5001:6001, ack 1, win 64240, length 1000: HTTP10:16:35.273396 ? &nbsp; &nbsp; Out IP 192.168.58.65.8080 > 192.0.2.1.52741: Flags [F.], seq 6001, ack 1, win 64240, length 010:16:35.273454 ? &nbsp; &nbsp; In &nbsp;IP 192.0.2.1.52741 > 192.168.58.65.8080: Flags [R.], seq 1, ack 1001, win 10000, length 0#

当然这个完整被确认的说法,也不一定非是要 MSS 大小的数据包。 继续修改下脚本,将一个非 MSS 大小的数据段,对应的 ACK 数据包分拆,脚本如下。

# cat tcp_slow_start_1_015.pkt&nbsp;`ip route change 192.0.2.0/24 dev tun0 initcwnd 1&nbsp;ethtool -K tun0 tso off&nbsp;ethtool -K tun0 gso off`
0 &nbsp; socket(..., SOCK_STREAM, IPPROTO_TCP) = 3+0 &nbsp;setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0+0 &nbsp;bind(3, ..., ...) = 0+0 &nbsp;listen(3, 1) = 0
+0 < S 0:0(0) win 10000 <mss 1460,nop,nop,sackOK>+0 > S. 0:0(0) ack 1 <...>+0.01 < . 1:1(0) ack 1 win 10000+0 accept(3, ..., ...) = 4
+0.01 %{print (tcpi_snd_cwnd, tcpi_snd_ssthresh)}%
+0.01 write(4,...,1000) = 1000+0 %{print (tcpi_snd_cwnd)}%
+0.01 <. 1:1(0) ack 401 win 10000+0 %{print (tcpi_snd_cwnd)}%
+0 <. 1:1(0) ack 801 win 10000+0 %{print (tcpi_snd_cwnd)}%
+0 <. 1:1(0) ack 1001 win 10000+0 %{print (tcpi_snd_cwnd)}%#

运行脚本如下,对于一个完整的 Seq 1:1001 的数据段(非 MSS 1460 大小),分三次进行 ACK 确认,分别是 401、801 和 1001,直到最后一次完整确认后,cwnd 才从 1 增长到 2。

# packetdrill tcp_slow_start_1_015.pkt&nbsp;1 21474836471112#

# tcpdump -i any -nn port 8080tcpdump: data link type LINUX_SLL2tcpdump: verbose output suppressed, use -v[v]... for full protocol decodelistening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes21:58:00.287909 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52159 > 192.168.23.154.8080: Flags [S], seq 0, win 10000, options [mss 1460,nop,nop,sackOK], length 021:58:00.287954 tun0 &nbsp;Out IP 192.168.23.154.8080 > 192.0.2.1.52159: Flags [S.], seq 1563504199, ack 1, win 64240, options [mss 1460,nop,nop,sackOK], length 021:58:00.298059 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52159 > 192.168.23.154.8080: Flags [.], ack 1, win 10000, length 021:58:00.318419 tun0 &nbsp;Out IP 192.168.23.154.8080 > 192.0.2.1.52159: Flags [P.], seq 1:1001, ack 1, win 64240, length 1000: HTTP21:58:00.328494 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52159 > 192.168.23.154.8080: Flags [.], ack 401, win 10000, length 021:58:00.328531 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52159 > 192.168.23.154.8080: Flags [.], ack 801, win 10000, length 021:58:00.328541 tun0 &nbsp;In &nbsp;IP 192.0.2.1.52159 > 192.168.23.154.8080: Flags [.], ack 1001, win 10000, length 021:58:00.410583 ? &nbsp; &nbsp; Out IP 192.168.23.154.8080 > 192.0.2.1.52159: Flags [F.], seq 1001, ack 1, win 64240, length 021:58:00.410628 ? &nbsp; &nbsp; In &nbsp;IP 192.0.2.1.52159 > 192.168.23.154.8080: Flags [R.], seq 1, ack 1001, win 10000, length 0#

Stretched ACK

Stretched ACK 是指一个 TCP ACK 数据包一次性确认了超过通常数量(譬如延迟 ACK 不能超过两个完整的 MSS 数据包)的数据包,从而把本应 N 个 ACK 的确认“拉伸”成 1 个 ACK,这种做法减少了 ACK 的数量和流量,但在慢启动阶段会让发送端看到的 ACK 数量远少于实际发出的数据段,若仍按“每 ACK 固定 +1 MSS”增长 cwnd 的话,就会导致拥塞窗口增涨过慢、利用率下降。在 RFC 3465 规定的 TCP 实现中,慢启动过程会将这个“拉伸”的 ACK 视为依次接收到 N 个 ACK,但这个 N 会限制为 2,这种选择在保守(1 个)和潜在的非常激进之间取得平衡。此外,2 个的说法也正好平衡了延迟 ACK 的负面影响。

Stretached ACK 原因有多种,像是 GRO 这种硬件优化特性就会将多个数据包聚合后产生少量的 ACK,当然也有 ACK 数据包丢失的情况。

在目前 Linux 系统上的实现(测试服务器 5.19.17 内核版本),是基于 acked 值来增加的,再往前回溯,它是来自于这个 ACK 数据包所 Acked 或 Sacked 完整确认的数据包数量,相较于 RFC 3465 有些许区别。

仍在 5.19.17 内核版本的服务器上进行相关测试,脚本如下,合并成两个 ACK 数据包,分别确认了 2 个和 4 个数据段。

# cat tcp_slow_start_1_016.pkt&nbsp;`ip route change 192.0.2.0/24 dev tun0 initcwnd 3&nbsp;ethtool -K tun0 tso off&nbsp;ethtool -K tun0 gso off`
0 &nbsp; socket(..., SOCK_STREAM, IPPROTO_TCP) = 3+0 &nbsp;setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0+0 &nbsp;bind(3, ..., ...) = 0+0 &nbsp;listen(3, 1) = 0
+0 < S 0:0(0) win 10000 <mss 1000,nop,nop,sackOK>+0 > S. 0:0(0) ack 1 <...>+0.01 < . 1:1(0) ack 1 win 10000+0 accept(3, ..., ...) = 4
+0.01 %{print (tcpi_snd_cwnd, tcpi_snd_ssthresh)}%
+0.01 write(4,...,6000) = 6000+0 %{print (tcpi_snd_cwnd)}%
+0.01 <. 1:1(0) ack 2001 win 10000+0 %{print (tcpi_snd_cwnd)}%
+0 <. 1:1(0) ack 6001 win 10000+0 %{print (tcpi_snd_cwnd)}%#

运行脚本如下,可以看到 cwnd 增长变化,收到第一个 ACK 时(模拟延迟 ACK,确认 2 个数据段的情形),cwnd 增长到 5 ,而收到第二个 ACK 时(即 Stretched ACK,模拟 GRO 聚合或是 ACK 丢失,确认了 4 个数据段的情形),cwnd 增长到 9。

# packetdrill tcp_slow_start_1_016.pkt&nbsp;3 2147483647359#

# tcpdump -i any -nn port 8080tcpdump: data link type LINUX_SLL2tcpdump: verbose output suppressed, use -v[v]... for full protocol decodelistening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes22:21:36.076085 tun0 &nbsp;In &nbsp;IP 192.0.2.1.33213 > 192.168.220.105.8080: Flags [S], seq 0, win 10000, options [mss 1000,nop,nop,sackOK], length 022:21:36.076155 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [S.], seq 4029826937, ack 1, win 64240, options [mss 1460,nop,nop,sackOK], length 022:21:36.086446 tun0 &nbsp;In &nbsp;IP 192.0.2.1.33213 > 192.168.220.105.8080: Flags [.], ack 1, win 10000, length 022:21:36.106846 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [.], seq 1:1001, ack 1, win 64240, length 1000: HTTP22:21:36.106891 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [.], seq 1001:2001, ack 1, win 64240, length 1000: HTTP22:21:36.106896 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [.], seq 2001:3001, ack 1, win 64240, length 1000: HTTP22:21:36.116960 tun0 &nbsp;In &nbsp;IP 192.0.2.1.33213 > 192.168.220.105.8080: Flags [.], ack 2001, win 10000, length 022:21:36.116988 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [.], seq 3001:4001, ack 1, win 64240, length 1000: HTTP22:21:36.116990 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [P.], seq 4001:5001, ack 1, win 64240, length 1000: HTTP22:21:36.116993 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [P.], seq 5001:6001, ack 1, win 64240, length 1000: HTTP22:21:36.117014 tun0 &nbsp;In &nbsp;IP 192.0.2.1.33213 > 192.168.220.105.8080: Flags [.], ack 6001, win 10000, length 022:21:36.691278 tun0 &nbsp;Out IP 192.168.220.105.8080 > 192.0.2.1.33213: Flags [F.], seq 6001, ack 1, win 64240, length 022:21:36.691370 tun0 &nbsp;In &nbsp;IP 192.0.2.1.33213 > 192.168.220.105.8080: Flags [R.], seq 1, ack 6001, win 10000, length 0#

当然类似的实验稍微改改脚本,得到的结果也会不一样,就算收到了 Stretched ACK,cwnd 也并不一定次次会发生变化。

感兴趣的同学,可以移步前几次慢启动的文章,找找答案。🤔

# cat tcp_slow_start_1_017.pkt&nbsp;0 &nbsp; socket(..., SOCK_STREAM, IPPROTO_TCP) = 3+0 &nbsp;setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0+0 &nbsp;bind(3, ..., ...) = 0+0 &nbsp;listen(3, 1) = 0
+0 < S 0:0(0) win 10000 <mss 1000,nop,nop,sackOK>+0 > S. 0:0(0) ack 1 <...>+0.01 < . 1:1(0) ack 1 win 10000+0 accept(3, ..., ...) = 4
+0.01 %{print (tcpi_snd_cwnd, tcpi_snd_ssthresh)}%
+0.01 write(4,...,6000) = 6000+0 %{print (tcpi_snd_cwnd)}%
+0.01 <. 1:1(0) ack 2001 win 10000+0 %{print (tcpi_snd_cwnd)}%
+0 <. 1:1(0) ack 6001 win 10000+0 %{print (tcpi_snd_cwnd)}%#

# packetdrill tcp_slow_start_1_017.pkt&nbsp;10 2147483647101212#
# cat tcp_slow_start_1_018.pkt&nbsp;0 &nbsp; socket(..., SOCK_STREAM, IPPROTO_TCP) = 3+0 &nbsp;setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0+0 &nbsp;bind(3, ..., ...) = 0+0 &nbsp;listen(3, 1) = 0
+0 < S 0:0(0) win 10000 <mss 1000,nop,nop,sackOK>+0 > S. 0:0(0) ack 1 <...>+0.01 < . 1:1(0) ack 1 win 10000+0 accept(3, ..., ...) = 4
+0.01 %{print (tcpi_snd_cwnd, tcpi_snd_ssthresh)}%
+0.01 write(4,...,8000) = 8000+0 %{print (tcpi_snd_cwnd)}%
+0.01 <. 1:1(0) ack 2001 win 10000+0 %{print (tcpi_snd_cwnd)}%
+0 <. 1:1(0) ack 8001 win 10000+0 %{print (tcpi_snd_cwnd)}%#

# packetdrill tcp_slow_start_1_018.pkt&nbsp;10 2147483647101218#

往期推荐

1. Wireshark 提示和技巧 | 捕获点之 TCP 三次握手

2. Wireshark 提示和技巧 | a == ${a} 显示过滤宏

3. Wireshark TS | 防火墙空闲会话超时问题

4. Wireshark TS | 超时重传时间不翻倍

5. 网络设备 MTU MSS Jumboframe 全解

后台回复「TT」获取 Wireshark 提示和技巧系列 合集

后台回复「TS」获取 Wireshark Troubleshooting 系列 合集

如需交流或加技术群,可后台直接留言,我会在第一时间回复,谢谢!


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Echo Reply 7ACE 7ACE《Wireshark & Packetdrill | TCP 慢启动(5)》

评论:0   参与:  0