拥塞控制浅谈
前段时间业余有空把 gatewaysocks 基本重写了,主要有以下几个变化:
- 代码逻辑全部改成 async/await 方式
- 抽象了 gateway 中的 TcpStream 和 UdpSocket
- 为 TCP 实现了 CUBIC 拥塞控制算法,结合 pacing 控制数据发送
第二版实现没有改动 TCP 协议的代码,保留实现了一个最小的可用子集,测试了 iPadOS 26 和 Nintendo Switch 的常见场景,参考的 RFC 主要有:
- RFC 793 基础 TCP 协议
- RFC 2525 基础 TCP 协议的一些问题
- RFC 6298 RTT 计算
- RFC 7323 Window Scale 和 Timestamps
- RFC 8312 CUBIC 拥塞控制算法
CUBIC 拥塞控制算法看起来简单,实现起来还是有一定的复杂度,实现的过程中参考了几个 QUIC 库的实现,包括 Rust 的 s2n-quic/quinn/quiche,也有 Golang 的 quic-go 等,主要参考的是 s2n-quic,其他库都是带着问题去找对应的代码做参考。粗略看下来发现这些库的拥塞控制有些实现是有问题的,比如 quinn 的 CUBIC 和 BBR 实现,BBR 的代码实现中甚至可以看到无效代码。quinn 的维护者也表示拥塞控制未来会作为可选项让用户自己提供而不是自带,估计是参考了 s2n-quic 提供了拥塞控制的 provider 能力让用户可以自定义拥塞控制器。类似的,WebRTC 的协议实现 pion 采用了 interceptor 这种方式来支持拥塞控制,也给了用户自定义拥塞控制器的可能性(pion 默认有一套 interceptor 可用)。
CUBIC 拥塞控制
AIMD(additive-increase/multiplicative-decrease) 是 Reno/New-Reno/CUBIC 拥塞控制的基本逻辑,简单说就是,没拥塞就增加窗口,拥塞了就减少窗口,本质是在调整窗口大小,不对带宽大小做评估。我们不展开讨论 CUBIC 的拥塞控制的详细逻辑,请参考 CUBIC 的 RFC,这里主要记录一下实现 CUBIC 过程中值得注意的点。
拥塞控制算法在数据量发送比较少时不会启用,也就是说当应用层没有多少数据要发送的时候不会触发 AIMD,应用层业务数据发送的不足时叫做 application limited,相反的,应用层有大量数据要发送时叫做 congestion limited,也就是拥塞控制算法需要运行的状态。这就有一个重要的问题要明确,如何判断当前发送的数据是不是足够多,需要启用拥塞控制。一般情况下,拥塞控制器需要跟踪所有数据包的发送以及 ack,从而统计出 in-flight 的数据总量大小,即一个 RTT 内正在传输的数据大小,当 in-flight 数据发送足够多时,就会把 congestion window(cwnd) 占满。因此,比较一下 in-flight 和 cwnd 从而判断当前是否处在 congestion limited 状态,理想情况下 in-flight 等于 cwnd 表示占满,但是一般接近满时就认为处在拥塞控制中,接近满可以是 cwnd 与 in-flight 的差小于一个 MTU,也可以适当的放宽一点,两个或三个 MTU。
当拥塞控制生效时,cwnd 按 additive-increase 开始增长了,这个过程大家应该都知道叫 slow start。如果持续的发送大量数据,那么 cwnd 会一直增长,发送的数据速度就会越来越快,直到拥塞事件发生,即丢包或者 ECN(explicit congestion notification) 触发,然后就触发了 multiplicative-decrease 来减少 cwnd 进入 congestion avoidance 状态,在这个状态中 cwnd 的增长就进入了 CUBIC 公式描述的方式缓慢增长,而且这个增长会根据以进入当前 congestion avoidance 状态的时间作为基准计算的持续时间 duration 来计算 cwnd 的目标值,详细计算逻辑请参考 RFC。
从上面讨论我们可以得知 cwnd 的变化一定要处于 congestion limited 状态,如果发送的数据一直都不够多,那么 cwnd 就不会变化,也就是拥塞控制不会启用。如果一条 TCP 连接的整个生命周期发送的数据都很小,那么它可能自始至终都不会启用拥塞控制。更多的情况是,一条 TCP 连接一段时间数据发送较多,一段时间数据发送较少,发送较多数据时触发拥塞控制调整 cwnd 大小,发送数据较少的时候不再调整 cwnd 大小,如果之后再发送较大数据时可能还会再次进入拥塞控制,如果之后再也不发送较大数据时可能再也不会进入拥塞控制。也就是有可能会间歇性的进入拥塞控制状态,如果拥塞控制处于 congestion avoidance 时,这个状态下的 cwnd 目标值计算会跟进入此状态的持续时间 duration 相关,因此计算 duration 需要排除处于 application limited 的时间段,不然会把目标的 cwnd 计算错误导致很大。这也是 CUBIC RFC 中有明确指出需要注意的问题,不过从 quinn 的实现来看,它没有排除 application limited 的时间段。这个问题甚至在 linux 内核 2015 年还有涉及修复,请参考这里。
在 CUBIC 的计算公式中涉及了 RTT,RFC 建议 RTT 使用 SRTT,即平滑的 RTT,但是 Linux 中采用了 min-RTT,从实际上来看 min-RTT 确实可能会比 SRTT 更加稳定,从而能让 cwnd 在 CUBIC curve 增长过程会更加平滑。CUBIC 算法会考虑 Reno-Friendly 阶段采用 Eq. 4 计算 cwnd 值,而在非 Reno-Friendly 阶段则采用 Eq. 1 计算 cwnd 的增长值,计算过程中 t 是否取值为 t+RTT 也是一个容易出错的问题,根据 RFC 的描述,应该在比较 Eq. 1 和 Eq. 4 的时候 t 即取值为 t,而在非 Reno-Friendly 下计算 cwnd 的增长值时,t 取值为 t+RTT。
W_cubic(t) = C*(t-K)^3 + W_max (Eq. 1)
K = cubic_root(W_max*(1-beta_cubic)/C) (Eq. 2)
W_est(t) = W_max*beta_cubic +
[3*(1-beta_cubic)/(1+beta_cubic)] * (t/RTT) (Eq. 4)
在 RFC 4.6 Fast Convergence 描述的多条连接相互释放带宽的逻辑引入其中时,Eq. 2 计算 K 值时也不能简单的套用公式了,而是要把真实的 start cwnd 带入其中去计算 K 值。因为 W_max 值可能已经被调整的更小了,所以不能简单的采用 W_max * 0.3(即 1 - beta_cubic)了,应该采用 cubic_root((W_max-cwnd)/C) 计算,理解了这个就充分理解了 K 是什么了。
另外一点值得注意,pacing 是一定要有的,只要突发发送的数据足够多,就算是内网不 pacing 也会丢包。
如开头所说,我觉得不少 QUIC 库的拥塞控制实现可能有一些问题。自然的,gatewaysocks 中实现的 CUBIC 很可能也是有问题的,只是大部分情况测试都还算符合预期。
BBR 拥塞控制
BBR 相较于 CUBIC 这类拥塞控制算法核心区别是不再简单的基于丢包来调整拥塞窗口,一次丢包不再是拥塞的标志,而是计算出丢包率,并结合间隔检测计算最大网络带宽和 RTT 去构建网络模型以评估最新的拥塞窗口。
丢包在跨境的网络环境中是一种普遍存在的现象,尤其是国内跨境连北美的常态丢包率可能达到 10~20%,如果把这固有的丢包考虑进去,实际上连接北美服务器网络带宽并不低,当然也可能是通过不公平的抢带宽得到的。
BBR 的介绍资料很多,我没有实现过 BBR,这里也不展开讨论,等以后有时间有兴趣充分理解了 BBR 之后再来记录。
WebRTC 的拥塞控制
WebRTC 是实时音视频的传输协议栈,也需要使用拥塞控制算法去评估网络带宽,从而实时的调整音视频数据的发送码率。实际上,WebRTC 唯一实装的拥塞控制算法是 GCC(Google Congestion Control),并且依赖 RTP/RTCP 的扩展来获取必要的信息以评估网络带宽,这个扩展叫做 TWCC(Transport-Wide Congestion Control),由于是扩展,因此需要在 SDP 协商的时候启用。
GCC 会基于两个因素来评估网络带宽,一个基于丢包的控制器,一个基于延迟的控制器。基于丢包的控制器与 BBR 类似,不再简单的将一次丢包视为拥塞的标志,而是计算丢包率用来评估带宽。基于延迟的控制器会根据接受端的收包间隔相较于发送端的发送间隔是否变大来评估网络是否拥塞,这种方式更加适合延迟敏感的实时音视频场景。
理论上来说,只要 TWCC 扩展的信息足够新的拥塞控制算法使用,那么完全可以定义一套新的拥塞控制算法出来,这也是我认为 pion 将这些能力作为 interceptor 的重要原因,不需要将 GCC 内置实现在 WebRTC 协议栈中,方便应用层微调 GCC。实际上,不管是 GCC 还是 TWCC 都是 RFC draft,一直没有正式定稿,而为拥塞控制添加的更通用的 RTCP feedback 机制已经在 RFC 8888 定稿。
结语
拥塞控制是一个很复杂的问题,从粗浅了解到实际实现之间会有不少细节问题需要注意,发明一个好的拥塞控制算法更加困难。