漫谈TCP-AIMD/BBR的公平性以及buffer bloat

周三的时候,我发了一则朋友圈:

aimd是公平的这个事实很容易从数学上证明,但是朴素的aimd会带来全局同步。
解决reno家族全局同步的策略就是概率性随机丢弃,避免所有流同时md,然而bbr却没法从数学上证明这种策略是公平的,至少我是证明不了。。。

周六早上3,4点钟自然醒,写篇意识流。


了解TCP Reno/CUBIC的都应该知道,基于AIMD的Reno家族的窗口/时间曲线是一个锯齿状。

锯齿看似是固有的,不可消除的,因为包括CUBIC在内的Reno家族CC(congestion control)算法只有没有检测到丢包,均是无条件AI(加性增窗)的,如此一来拥塞窗口总是会超过实际带宽的限制。至少看起来是这样。

然而,继续分析之前,我们必须回答一个问题:

  • 主机真的有能力发送任意多的数据包吗?

意思是说,如果CC算法提供了大小为W的拥塞窗口,主机一定有能力发送W个数据包吗?并不是!

然而,继续分析之前,我们必须了解一个现实:

  • 顾名思义,拥塞控制是控制拥塞的,独享带宽,没有拥塞,不需要拥塞控制!

我们首先要理解什么是 带宽。

理想情况下,如果我们把网络看作是一根粗细均匀管道,那么带宽就是该管道的横截面积,它一方面表示主机的发包能力,另一方面也表示网络的吞吐率。

我们常说的1Gbits/Sec千兆以太网卡,意思就是一台主机通过该网卡每秒可以发送1000Mbits的数据,这就是主机的发包能力。以一个包1500字节计算,1Gbits差不多就是89478个包,此时即便你提供了10000000的拥塞窗口,主机也只能发89478个包!这种情况下,如果网络带宽被一条TCP流独占,那么Reno的拥塞窗口理论上会无限AI涨下去,不会出现锯齿,而是一条直线!

然而,无限增长拥塞窗口有意义吗?

答案很明确,不但没有意义,还会出问题!

Reno并不理解带宽的概念,它只知道拥塞窗口。假如同一个网络管道中又出现一条TCP流,那么二者势必要分享带宽。需要分享出让带宽的信号就是拥塞丢包,按照AIMD窗口控制理论,当发生拥塞时,二者均会丢包,二者均要执行MD(乘性减窗),然而如果第一个TCP流的窗口已经增长的过大,即便它执行了MD,也依然远大于实际可用带宽,拥塞将持续,丢包将加剧,这就是问题!

因此,拥塞窗口必须维持在 刚刚够用 的状态!我们可以从Linux 2.6.13中新添加的两行代码中看到这个处理:

void tcp_reno_cong_avoid(struct tcp_sock *tp, u32 ack, u32 rtt, u32 in_flight,
			 int flag)
{
     
	// 如果在途包无法填满拥塞窗口(很可能已经达到了最大的发包能力),说明没有增加窗口的必要,直接返回!
	if (in_flight < tp->snd_cwnd)
		return;

        if (tp->snd_cwnd <= tp->snd_ssthresh) {
     
                /* In "safe" area, increase. */
		if (tp->snd_cwnd < tp->snd_cwnd_clamp)
			tp->snd_cwnd++;
	} else {
     
                /* In dangerous area, increase slowly.
		 * In theory this is tp->snd_cwnd += 1 / tp->snd_cwnd
		 */
		if (tp->snd_cwnd_cnt >= tp->snd_cwnd) {
     
			if (tp->snd_cwnd < tp->snd_cwnd_clamp)
				tp->snd_cwnd++;
			tp->snd_cwnd_cnt = 0;
		} else
			tp->snd_cwnd_cnt++;
	}
}

如果你真的去看代码,会发现在Linux 2.6.13以前的Reno算法中,拥塞窗口真的就是无限增长的…

OK,我们可以确认,在单流独享带宽的情况下,拥塞不会发生,拥塞窗口最终会稳定在BDP不再继续增长,这种情况下,不会出现锯齿。

正如本文开始时所说的,顾名思义,拥塞控制算法是控制拥塞的,如果没有拥塞,显然它们不应该起作用!显然,均匀带宽理想情况下,当一个网络只有一条流通过时,不可能会有拥塞,因此拥塞控制毫无意义,也就不必纠结这个时候为什么不会出现锯齿了。

然而,在现实中,网络并不是一根粗细均匀的管道,因此对于端到端主机而言,带宽特指该主机的可用带宽,就是这个网络管道中最细位置的截面积,即瓶颈带宽(Bottleneck)。瓶颈带宽的传输能力很可能小于主机的发包能力,这个时候就需要拥塞控制来应对了。

换句话说,在现实的不均匀网络中,拥塞控制的目标之一就是探测瓶颈带宽,控制主机发包速率使流量恰好通过瓶颈带宽。显然,BBR正是围绕这一目标被设计的。

回到我们的理想情况,理想单流假设的前提下,主机的发包能力决定了拥塞窗口的上限,这个过程无需任何外在干预,单流完全可以自适应网络带宽,Reno算法只是单纯的将拥塞窗口慢启动或者AI到BDP而已(在此我们忽略随机噪声丢包)。理想单流的情况,网络就是一条单纯的管道,其中不需要buffer!

然而如果多条流复用同一个网络,网络管道必然会过载,此时需要需要引入buffer来暂存过载的数据包,buffer部署在交换节点,如果没有buffer,网络将退化到CSMA/CD总线,丢弃所有过载的数据包。详见:
https://blog.csdn.net/dog250/article/details/90446782

当多个流复用一个部署了buffer的网络,在端到端尚未感知到网络过载之前,buffer的作用就是暂存过载的数据包,然后等待端主机在感知到拥塞的时候自行执行MD从而排空buffer,这就是现代分组交换网络拥塞控制的运作机制。

现在你可能会问两个问题:

  • 为什么MD就能排空buffer?
  • AIMD能保证对所有流都公平吗?

好吧,我来简单的数学证明一下。

假设一种初始状态,一共 N N N条流复用同一个网络,它们在网络过载的时刻拥塞窗口分别为 W 1 W_1 W1 W 2 W_2 W2 W 3 W_3 W3,… W N W_N WN,而整个网络可容纳的数据包量为 W W W,即 W W W是单流独享网络时的最大拥塞窗口,此时有:

W = W 1 + W 2 + W 3 + . . . W N W=W_1+W_2+W_3+...W_N W=W1+W2+W3+...WN

随即它们均感知到了丢包,它们均执行MD,我们跟踪第一条流的窗口值变化,其它的流与此一致。

发生了MD之后,第一条流的窗口变为 W 1 2 \dfrac{W_1}{2} 2W1,此外,如前所述,如果交换节点发生拥塞,那肯定是针对所有流的,即所有流都会感知到拥塞,都会丢包,都会执行MD下降一半的窗口以排空网络,所以说此时网络的总空闲窗口变为为 W 2 \dfrac{W}{2} 2W,接下来大家执行AI,均分这么多的空闲窗口,即每个流增加 W 2 N \dfrac{\frac{W}{2}}{N} N2W个窗口。

第一条流在接下来感知到丢包时的窗口分别为:

第2次感知到丢包: W t 1 _ 1 = W 1 2 + W 2 × N W_{t1\_1}=\dfrac{W_1}{2}+\dfrac{W}{2\times N} Wt1_1=2W1+2×NW

第3次感知到丢包: W t 1 _ 2 = W t 1 _ 1 2 + W 2 × N W_{t1\_2}=\dfrac{W_{t1\_1}}{2}+\dfrac{W}{2\times N} Wt1_2=2Wt1_1+2×NW

第4次感知到丢包: W t 1 _ 3 = W t 1 _ 2 2 + W 2 × N W_{t1\_3}=\dfrac{W_{t1\_2}}{2}+\dfrac{W}{2\times N} Wt1_3=2Wt1_2+2×NW

i + 1 i+1 i+1次感知到丢包: W t 1 _ i = W t 1 _ ( i − 1 ) 2 + W 2 × N W_{t1\_i}=\dfrac{W_{t1\_{(i-1)}}}{2}+\dfrac{W}{2\times N} Wt1_i=2Wt1_(i1)+2×NW

将上面的式子整理,递推式子分别带入,这其实就是一个等差数列,最终初始窗口 W 1 W_1 W1的作用削减到无所谓:

W 1 _ f i n a l = W 1 2 j + 1 + W 2 j + 1 × N + W 2 j × N + W 2 j − 1 × N + . . . W 2 × N W_{1\_final}=\dfrac{W_1}{2^{j+1}}+\dfrac{W}{2^{j+1}\times N}+\dfrac{W}{2^{j}\times N}+\dfrac{W}{2^{j-1}\times N}+...\dfrac{W}{2\times N} W1_final=2j+1W1+2j+1×NW+2j×NW+2j1×NW+...2×NW

忽略掉无穷小的量 W 1 2 j + 1 \dfrac{W_1}{2^{j+1}} 2j+1W1,这个约等于:

W N \dfrac{W}{N} NW

好家伙,这就是均分带宽啊! N N N条流,每一条流分得 W N \dfrac{W}{N} NW的带宽!这就是公平!

我们已经知道,当全部TCP流启用Reno家族算法的时候,最终它们将收敛到公平!然而公平归公平,带宽利用率高吗?

上面我们的讨论均基于一种buffer管理策略,即 buffer满载后丢弃后来到达的数据包。 这会带来一个问题,即 全局同步:

  • 所有共享带宽的TCP流同时遭遇丢包。
  • 所有共享带宽的TCP流同时执行MD。
  • 所有共享带宽的TCP流同时MD后带宽大量闲置。

解决这个问题的方法和 避免桥梁垮塌 的方法一致,军队渡桥的时候要用碎步,TCP过buffer的时候要随机丢包,让所有的TCP流不要在同一时刻执行MD即可。

全局同步不是本文的核心话题,就此别过…


我们已经充分理解为什么Reno家族的CC在网络拥塞时为什么必须需要buffer,接下来就聊聊 buffer bloat ,它的成因和现状。在此稍后,我会通过buffer bloat引向BBR公平性的讨论。

buffer bloat很大的成因在于Reno家族(包括BIC/CUBIC)的CC算法看待网络的方式。 Reno的本质问题在于它将网络看成了一个容器而忽略了带宽。 举个现实的例子,Reno将网络看作一个停车场而非一条高速公路。

我们可以从Reno家族的拥塞控制变量cwnd中看出来,cwnd的单位是数据包的个数,而带宽的单位则是单位时间内的数据包的个数。这里可以看出,Reno家族是buffer友好的,却不是带宽友好的。

BBR正好与此相反,我们看到BBR的拥塞控制变量pacing rate的单位就是带宽的单位。因此BBR是带宽友好的,却不是buffer友好的。

Reno的行为促使路由器厂商倾向于在设备中配置越来越大的buffer ,因为这样会让使用Reno家族CC(显然,很长一段时间,Reno家族遍布全世界)的用户觉得他们可以向网络发送更多的数据包,这会显示出自己设备处理能力的强大,因此,设备buffer的大小往往是衡量设备性能的一个重要指标,在Reno看来,这个指标当然是越大越好。

现在让我们想象一个理想的BBR算法实现,并且整个网络均使用这个BBR实现进行拥塞控制。在这种情况下,路由器的buffer将不再有大用,因为BBR总是可以通过不断测量瓶颈带宽和最小RTT(任何排队都会让RTT增加),最终收敛到所有的流均不在buffer中排队。如果整个网络均切换到BBR进行拥塞控制,这势必会促使路由器厂商逐渐放弃将buffer大小作为重要性能指标的策略,从而缓解整个网络的buffer bloat。

buffer对于BBR唯一的作用在于暂存其probe bandwidth阶段超发的数据包 ,这些数据包随即会在接下来的Drain阶段中排空。除此之外,buffer对于BBR(请注意,这里的BBR是理想实现,并且没有Reno家族与其共存)再无用处。

然而,在现实网络中,必然是Reno家族和BBR共存的状态,那么Reno家族和BBR可以相安无事吗?

我们设想一个拥有无穷大buffer的网络,Reno家族和BBR共享该网络的资源。Reno家族倾向于占满尽可能多的buffer,这个行为会导致队列不断变长,测量得到的RTT不断增加,这会将BBR不断逼入probe rtt阶段的超慢速传输。虽然现实网络中不可能拥有无穷大的buffer,但可以确定的是, 即便是有限的buffer,由于空闲buffer耗尽丢包导致的Reno家族MD以及重传行为也仅仅是缓解了上述BBR的窘境,而决非解决。

我常听人们说,在deep buffer场景中,Reno家族的表现比BBR好,在shallow buffer场景中,BBR的表现要更优秀。这其实并不正确!正确的说法应该是,不管那种buffer,只要是buffer,对BBR均不友好,buffer是为Reno家族准备的。说shallow buffer中BBR表现优秀是因为Reno家族在该场景中表现更差而相对的优秀,但绝非真正的优秀!

得到shallow buffer场景中BBR表现比其在deep buffer中表现更好这个结论,完全是因为Reno家族与BBR的共存,且Reno家族在shallow buffer场景中比deep buffer场景中表现更差而产生的错觉。对于Reno家族而言,buffer当然是越deep越好,而对于BBR,无论是shallow buffer还是deep buffer,只要不与Reno家族共存,BBR在两种场景中的表现将趋于一致。

BBR只需要足以应对probe bandwidth的buffer就够了。

虽然BBR有效治愈了buffer bloat,或者至少说是有效缓解了buffer bloat,但遗憾的是,没有方法证明BBR是公平的。在BBR背后是一个静态的成本收益模型,而不是一个AIMD类似的动态反馈模型。我曾经分析过这个成本收益模型:
https://blog.csdn.net/dog250/article/details/82892267

我们想象两条BBR流共享一个100Mbits/Sec的网络,它们恰好同时都处在probe bandwidth状态,它们中的一个pacing rate是90Mbits/Sec,另一个的pacing rate是10Mbits/Sec,总带宽利用率恰好100%. 它们在probe up阶段将使得各自的inflight徒劳地增长1.25倍,然后在drain down阶段落到各自的初始值,即90Mbits/Sec和10Mbits/Sec,如此反复下去,显然这也是一种全局同步,显然这是一种不公平的全局同步!BBR并没有一种显而易见的方式让所有共享带宽的流收敛到公平。

解释为什么Reno家族可以收敛到公平看起来很简单,因为丢包是在buffer中实施的,而buffer是全局的,虽然共享网络的各条TCP流分别执行AIMD,但本质上仍然是由路由器集中控制的,是路由器的丢包促使了各条流AIMD状态的变化。

然而以上的解释对于BBR而言则略显苍白。BBR更像是通过一种主动的测量行为来计算pacing rate的,这种测量行为是在各个端到端主机上分别进行的,这种控制并非集中式的,测量的过程网络侧(指交换机,路由器等交换节点)不加任何干预,很难想象各条独立的TCP流会形成一张相同一致的的带宽分布视图,指导它们收敛到公平。

buffer正是Reno家族的统一控制中心,buffer是一张全局视图,而BBR则没有这个控制中心,没有这张全局视图。BBR的公平性必须由每条流自身来保证,而在这些相互独立的流之间,如何传递公平性信号,这是一个难题!

  • 当有新的空余带宽出让(比如有流退出了网络)时,如何保证争抢的公平性。

  • 当没有空余带宽出让而仅仅维持稳态(持续共享100%带宽利用率)时,如何收敛到公平。

以上两种情况,均要求 带宽越低,probe up系数越大,drain down系数越小。 这显然是一个合理的负反馈模型,可以试着用pacing rate的绝对值来计算系数,从而替代固定的 5 4 \dfrac{5}{4} 45 3 4 \dfrac{3}{4} 43,设两条流的pacing rate分别为 p 1 p_1 p1 p 2 p_2 p2,则probe bandwidth阶段它们的probe up系数和drain down系数分别为:

probe up系数 drain down系数
流1 p 1 + 1 p 1 \dfrac{p_1+1}{p_1} p1p1+1 p 2 − 1 p 2 \dfrac{p_2-1}{p_2} p2p21
流2 p 2 + 1 p 2 \dfrac{p_2+1}{p_2} p2p2+1 p 1 − 1 p 1 \dfrac{p_1-1}{p_1} p1p11

说白了就是让各条流的probe up和drain down的系数彼此交错,以实现奖惩。

如此一来,无论在任何情况,各条流的pacing rate均会向公平中心收敛:

  • 如果一个流的pacing rate很大,那么它将缓慢地probe up并且快速地drain down。
  • 如果一个流的pacing rate很小,那么它将快速地probe up并且缓慢地drain down。

无奈的是,当我大半夜着手改代码的时候,却不知道如何实现pacing rate的归一计算,毕竟它可是要做分母的啊!同时我也不善于用大数模拟浮点计算,于是我只能退而求其次,试图写死一个数组,依然失败。

由于BBR是各自独立测量pacing rate的,因此在第一条流里拿不到 p 2 p_2 p2,在第二条流里也拿不到 p 1 p_1 p1,这便使上面的理论变成了空谈,但还是可以给出一些行得通的策略来的。

下面的这个修改实现了 在有空闲带宽出让时的公平性

 static int bbr_pacing_gain[] = {
     
-	BBR_UNIT * 5 / 4,	/* probe for more available bw */
-	BBR_UNIT * 3 / 4,	/* drain queue and/or yield bw to other flows */
+	BBR_UNIT,	/* probe for more available bw */
+	BBR_UNIT,	/* drain queue and/or yield bw to other flows */
 	BBR_UNIT, BBR_UNIT, BBR_UNIT,	/* cruise at 1.0*bw to utilize pipe, */
 	BBR_UNIT, BBR_UNIT, BBR_UNIT	/* without creating excess queue... */
 };
+static int bbr_pacing_gain_rnd[5][8] = {
     
+	{
     22 / 16, 10 / 16, 1, 1, 1, 1, 1, 1},
+	{
     21 / 16, 11 / 16, 1, 1, 1, 1, 1, 1},
+	{
     20 / 16, 12 / 16, 1, 1, 1, 1, 1, 1},
+	{
     19 / 16, 13 / 16, 1, 1, 1, 1, 1, 1},
+	{
     18 / 16, 14 / 16, 1, 1, 1, 1, 1, 1}
+};
+static int bbr_probe_rnd = 4;
...
+		if (bbr->cycle_idx == 0) // 所有周期开始的时候,均应该重新随机
+			bbr->rnd_idx = prandom_u32_max(bbr_probe_rnd);
...
 	case BBR_PROBE_BW:
 		bbr->pacing_gain = (bbr->lt_use_bw ?
 				    BBR_UNIT :
-				    bbr->params.pacing_gain[bbr->cycle_idx]);
+				    BBR_UNIT)*bbr_pacing_gain_rnd[bbr->rnd_idx][bbr->cycle_idx];

这个修改并没有实现多个流共享100%带宽利用率时的公平性,至于如何是好,我还没有修改好。但理还是那个理,既然测量独立进行,何不用自己的pacing rate反着计算呢:

probe up系数 drain down系数
i i i P ( p ) = p + 1 p P(p)=\dfrac{p+1}{p} P(p)=pp+1 D ( p ) D(p) D(p)(单调递增, p p p越大增长越慢, y = 1 y=1 y=1为渐近线,上凸)

值得注意的是,这种策略只能在probe bandwidth阶段使用。

经理的领带是纯金的。


浙江温州皮鞋湿,下雨进水不会胖。

你可能感兴趣的:(BBR,AIMD,公平性,buffer,bloat)