一文解释清楚Google BBR拥塞控制算法原理

BBR算法显著提升了TCP性能,相比CUBIC,它减少了往返时延,提高了吞吐量,尤其是在高丢包率环境下表现更佳。BBR通过监测RTprop和BtlBw,在网络拥塞早期进行控制,避免了瓶颈路由器队列积压。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

BBR对TCP性能的提升是巨大的,它能更有效的使用当下网络环境,Youtube应用后在吞吐量上有平均4%提升(对于日本这样的网络环境有14%以上的提升):

报文的往返时延RTT降低了33%,这样如视频这样的大文件传输更快,用户体验更好:

不像CUBIC这种基于丢包做拥塞控制,常导致瓶颈路由器大量报文丢失,所以重新缓存的平均间隔时间也有了11%提升:

在Linux4.19内核中已经将拥塞控制算法从CUBIC(该算法从2.6.19内核就引入Linux了)改为BBR,而即将面世的基于UDP的HTTP3也使用此算法。许多做应用开发的同学可能并不清楚什么是拥塞控制,BBR算法到底在做什么,我在《Web协议详解与抓包实战》这门课程中用了6节课在讲相关内容,这里我尝试下用一篇图片比文字还多的文章把这个事说清楚。

TCP协议是面向字符流的协议,它允许应用层基于read/write方法来发送、读取任意长的字符流:

但TCP之下的IP层是基于块状的Packet报文来分片发送的,因此,TCP协议需要将应用交付给它的字符流拆分成多个Packet(在TCP传输层被称为Segment)发送,由于网速有变化且接收主机的处理性能有限,TCP还要决定何时发送这些Segment。TCP滑动窗口解决了Client、Server这两台主机的问题,但没有去管连接中大量路由器、交换机转发IP报文的问题,因此当瓶颈路由器的输入流大于其输出流时,便会发生拥塞:

这虽然是IP网络层的事,但如果TCP基于分层原则不去管,互联网上大量主机的TCP程序便会造成网络恶性拥堵。上图中瓶颈路由器已经造成了网速下降,但如果发送方不管不顾,那么瓶颈路由器的缓冲队列填满后便会发生大量丢包,且此时RTT(报文往返时间)由于存在长队列而极高。

如上图,最好的状态是没有队列,此时RTT最低,而State2中RTT升高,但没有丢包,到State 3队列满时开始发生丢包。

TCP的拥塞控制便用于解决这个问题。在BBR出现前,拥塞控制分为四个部分:慢启动、拥塞避免、快速重传、快速恢复:

慢启动在BBR中仍然保留,它的意义是在不知道连接的瓶颈带宽时,以起始较低的发送速率,以每RTT两倍的速度快速增加发送速率,直到到达一个阈值,对应上图中0-4秒。到该阈值后,进入线性提高发送速率的阶段,该阶段叫做拥塞避免,直到发生丢包,对应上图中8-11秒。丢包后,发速速率大幅下降,针对丢包使用快速重传算法重送发送,同时也使用快速恢复算法把发送速率尽量平滑的升上来。

如果瓶颈路由器的缓存特别大,那么这种以丢包作为探测依据的拥塞算法将会导致严重问题:TCP链路上长时间RTT变大,但吞吐量维持不变。

事实上,我们的传输速度在3个阶段被不同的因素限制:1、应用程序限制阶段,此时RTT不变,随着应用程序开始发送大文件,速率直线上升;2、BDP限制阶段,此时RTT开始不断上升,但吞吐量不变,因为此时瓶颈路由器已经达到上限,缓冲队列正在不断增加;3、瓶颈路由器缓冲队列限制阶段,此时开始大量丢包。如下所示:

如CUBIC这样基于丢包的拥塞控制算法在第2条灰色竖线发生作用,这已经太晚了,更好的作用点是BDP上限开始发挥作用时,也就是第1条灰色竖线。

什么叫做BDP呢?它叫做带宽时延积,例如一条链路的带宽是100Mbps,而RTT是40ms,那么

BDP=100Mbps*0.04s=4Mb=0.5MB

即平均每秒飞行中的报文应当是0.5MB。因此Linux的接收窗口缓存常参考此设置:

第1条灰色竖线,是瓶颈路由器的缓冲队列刚刚开始积压时的节点。随着内存的不断降价,路由器设备的缓冲队列也会越来越大,CUBIC算法会造成更大的RTT时延!

而BBR通过检测RTprop和BtlBw来实现拥塞控制。什么是RTprop呢?这是链路的物理时延,因为RTT里含有报文在路由器队列里的排队时间、ACK的延迟确认时间等。什么叫延迟确认呢?TCP每个报文必须被确认,确认动作是通过接收端发送ACK报文实现的,但由于TCP和IP头部有40个字节,如果不携带数据只为发送ACK网络效率过低,所以会让独立的ACK报文等一等,看看有没有数据发的时候顺便带给对方,或者等等看多个ACK一起发。所以,可以用下列公式表示RTT与RTprop的差别:

RTT我们可以测量得出,RTprop呢,我们只需要找到瓶颈路由器队列为空时多次RTT测量的最小值即可:

而BtlBw全称是bottleneck bandwith,即瓶颈带宽,我们可以通过测量已发送但未ACK确认的飞行中字节除以飞行时间deliveryRate来测量:

早在1979年Leonard Kleinrock就提出了第1条竖线是最好的拥塞控制点,但被Jeffrey M. Jaffe证明不可能实现,因为没有办法判断RTT变化到底是不是因为链路变化了,从而不同的设备瓶颈导致的,还是瓶颈路由器上的其他TCP连接的流量发生了大的变化。但我们有了RTprop和BtlBw后,当RTprop升高时我们便得到了BtlBw,这便找到第1条灰色竖线最好的拥塞控制点,也有了后续发送速率的依据。

基于BBR算法,由于瓶颈路由器的队列为空,最直接的影响就是RTT大幅下降,可以看到下图中CUBIC红色线条的RTT比BBR要高很多:

而因为没有丢包,BBR传输速率也会有大幅提升,下图中插入的图为CDF累积概率分布函数,从CDF中可以很清晰的看到CUBIC下大部分连接的吞吐量都更低:

如果链路发生了切换,新的瓶颈带宽升大或者变小怎么办呢?BBR会尝试周期性的探测新的瓶颈带宽,这个周期值为1.25、0.75、1、1、1、1,如下所示:

1.25会使得BBR尝试发送更多的飞行中报文,而如果产生了队列积压,0.75则会释放队列。下图中是先以10Mbps的链路传输TCP,在第20秒网络切换到了更快的40Mbps链路,由于1.25的存在BBR很快发现了更大的带宽,而第40秒又切换回了10Mbps链路,2秒内由于RTT的快速增加BBR调低了发送速率,可以看到由于有了pacing_gain周期变换BBR工作得很好。

pacing_gain周期还有个优点,就是可以使多条初始速度不同的TCP链路快速的平均分享带宽,如下图所示,后启动的连接由于过高估计BDP产生队列积压,早先连接的BBR便会在数个周期内快速降低发送速率,最终由于不产生队列积压下RTT是一致的,故平衡时5条链路均分了带宽:

我们再来看看慢启动阶段,下图网络是10Mbps、40ms,因此未确认的飞行字节数应为10Mbps*0.04s=0.05MB。红色线条是CUBIC算法下已发送字节数,而蓝色是ACK已确认字节数,绿色则是BBR算法下的已发送字节数。显然,最初CUBIC与BBR算法相同,在0.25秒时飞行字节数显然远超过了0.05MB字节数,大约在 0.1MB字节数也就是2倍BDP:

大约在0.3秒时,CUBIC开始线性增加拥塞窗口,而到了0.5秒后BBR开始降低发送速率,即排空瓶颈路由器的拥塞队列,到0.75秒时飞行字节数调整到了BDP大小,这是最合适的发送速率。

当繁忙的网络出现大幅丢包时,BBR的表现也远好于CUBIC算法。下图中,丢包率从0.001%到50%时,可以看到绿色的BBR远好于红色的CUBIC。大约当丢包率到0.1%时,CUBIC由于不停的触发拥塞算法,所以吞吐量极速降到10Mbps只有原先的1/10,而BBR直到5%丢包率才出现明显的吞吐量下降。

CUBIC造成瓶颈路由器的缓冲队列越来越满,RTT时延就会越来越大,而操作系统对三次握手的建立是有最大时间限制的,这导致建CUBIC下的网络极端拥塞时,新连接很难建立成功,如下图中RTT中位数达到 100秒时 Windows便很难建立成功新连接,而200秒时Linux/Android也无法建立成功。

BBR算法的伪代码如下,这里包括两个流程,收到ACK确认以及发送报文:

function onAck(packet) 
  rtt = now - packet.sendtime 
  update_min_filter(RTpropFilter, rtt) 
  delivered += packet.size 
  delivered_time = now 
  deliveryRate = (delivered - packet.delivered) / (delivered_time - packet.delivered_time) 
  if (deliveryRate > BtlBwFilter.currentMax || ! packet.app_limited) 
     update_max_filter(BtlBwFilter, deliveryRate) 
  if (app_limited_until > 0) 
     app_limited_until = app_limited_until - packet.size

这里的app_limited_until是在允许发送时观察是否有发送任务决定的。发送报文时伪码为:

function send(packet) 
  bdp = BtlBwFilter.currentMax × RTpropFilter.currentMin 
  if (inflight >= cwnd_gain × bdp) 
     // wait for ack or retransmission timeout 
     return 
  if (now >= nextSendTime) 
     packet = nextPacketToSend() 
     if (! packet) 
        app_limited_until = inflight 
        return 
     packet.app_limited = (app_limited_until > 0) 
     packet.sendtime = now 
     packet.delivered = delivered 
     packet.delivered_time = delivered_time 
     ship(packet) 
     nextSendTime = now + packet.size / (pacing_gain × BtlBwFilter.currentMax) 
  timerCallbackAt(send, nextSendTime)

pacing_gain便是决定链路速率调整的关键周期数组。

BBR算法对网络世界的拥塞控制有重大意义,尤其未来可以想见路由器的队列一定会越来越大。HTTP3放弃了TCP协议,这意味着它需要在应用层(各框架中间件)中基于BBR算法实现拥塞控制,所以,BBR算法其实离我们很近。理解BBR,我们便能更好的应对网络拥塞导致的性能问题,也会对未来的拥塞控制算法发展脉络更清晰。

我在《Web协议详解与抓包实战》第5部分课程中第15-20课对拥塞控制有更详细的介绍,详见下方课程二维码:

### 回答1: 函数itob(n,s,b)的功能是将整数n转换成以b为基的字符串,并存储到字符串s中。 程序可以按照以下步骤实现: 1. 定义函数itob(n,s,b),实现将整数n转换成以b为基的字符串,并存储到字符串s中的功能。 2. 在主函数中,输入整数n和基数b,定义字符串s,调用函数itob(n,s,b)将整数n转换成字符串s。 3. 输出字符串s,从最高的非零位开始输出。 下面是示例代码: ```python def itob(n, s, b): # 定义字符集 char_set = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 判断n的符号 sign = "-" if n < else "" n = abs(n) # 转换成b进制字符串 while n > : s += char_set[n % b] n //= b # 添加符号 s += sign # 反转字符串 s = s[::-1] if __name__ == '__main__': # 输入整数n和基数b n = int(input("请输入一个整数n:")) b = int(input("请输入基数b:")) # 定义字符串s s = "" # 调用函数itob将整数n转换成字符串s itob(n, s, b) # 输出字符串s,从最高的非零位开始输出 for i in range(len(s)): if s[i] != "": print(s[i:], end="") break else: print("") ``` 示例输出: ``` 请输入一个整数n:123456 请输入基数b:16 1E240 ``` 在这个示例中,输入的整数n为123456,基数b为16。程序将整数n转换成16进制字符串1E240,并从最高的非零位开始输出。 ### 回答2: 将一个整数转化为任意进制的字符串,是一个比较基础的问题,本题可以通过递归实现。 首先需要明确,进制的范围是2-36,因为一个数字字符只能是0-9、a-z(或A-Z),当进制大于10时,数字字符已经不够用了,因此需要使用字母来表示。 接下来看一下如何把一个十进制数n转换为k进制数。我们可以对n/k进行递归,得到一个更小的十进制数,并得到数n除以k的余数,这个余数就是k进制下的某一位数。以此类推,一直递归到n等于0为止。然后将每一次得到的余数拼接起来就是k进制下的字符串。 例如,把65转换为二进制:65/2=32,余1;32/2=16,余0;16/2=8,余0;8/2=4,余0;4/2=2,余0;2/2=1,余0;1/2=0,余1。将这些余数逆序拼接起来,就是1000001,也就是65的二进制。 下面是实现itob函数的思路: 1. 定义一个全局的数组,存储0-9和A-Z的36个数字字符; 2. 如果n为0,返回"0"字符串; 3. 定义一个字符串builder,用于存储计算过程中求得的余数; 4. 如果n为负数,将其取绝对值并在builder字符串前加入负号; 5. 如果进制k不在[2, 36]之间,返回空字符串; 6. 当n不为0时,进行循环: a. 计算n除以k的商和余数,分别为q和r; b. 将余数的字符添加到builder字符串的最前面; c. 将n更新为商q; d. 如果n为0,跳出循环; 7. 返回builder字符串。 下面是itob函数的代码实现: #define BASE 36 static const char digits[] = "0123456789abcdefghijklmnopqrstuvwxyz"; void reverse(char *s) { int len = strlen(s); for (int i = 0; i < len / 2; i++) { char temp = s[i]; s[i] = s[len - i - 1]; s[len - i - 1] = temp; } } void itob(int n, char *s, int k) { int i = 0; int sign = n < 0 ? -1 : 1; if (k < 2 || k > BASE) { // 进制范围错误 s[0] = '\0'; return; } if (n == 0) { s[i++] = '0'; } else { while (n != 0) { int r = sign * (n % k); // 求余 s[i++] = digits[r]; // 存储余数对应的字符 n /= k; // 求商 } if (sign < 0) { s[i++] = '-'; } } s[i] = '\0'; reverse(s); // 反转字符串 } 最后,通过调用itob函数,将整数n转换成以b进制表示,并将结果字符串s输出。可以参考以下代码: #include <stdio.h> #include <string.h> #define MAXLEN 100 int main() { int n, b; char s[MAXLEN]; scanf("%d%d", &n, &b); itob(n, s, b); printf("%s\n", s); return 0; } ### 回答3: 解题思路: 本题要求编写一个函数itob(n,s,b)来实现将整数n转换为以b为基的字符串并存储到字符串s中的功能,并且还需要编写一个主函数,将用户输入的整数n通过函数itob(n,s,b)转换为字符串s,并输出转换后的结果。 先来研究一下函数itob(n,s,b)的实现方法。目标是将整数n转换为以b为基的字符串并存储到字符串s中,那么就需要用到进制转换的思路,将整数n按照b进制转换为字符串,并存储到s中。具体步骤如下: 1. 创建一个计数器变量len,用于记录转换后的字符串长度 2. 循环执行n % b求余操作,将余数加上'0'的ASCII码值存储到字符串s的最后一个位置 3. 将n除以b向下取整,进入下一位数位 4. 如果n不为0或者len小于等于0,则继续执行循环,否则结束循环 5. 字符串s中存储的为倒序的转换结果,再将字符串s倒过来,即为正确的翻转结果 然后就可以将函数itob(n,s,b)的代码实现如下: void itob(int n, char s[], int b){ int len = 0; //记录转换后的字符串长度 do { s[len++] = n % b + '0'; //将余数加上'0'的ASCII码值存储到字符串s的最后位置 } while ((n /= b) > 0); //将n除以b,向下取整,进入下一位数位 s[len] = '\0'; //字符串s结束符,结束字符串 reverse(s); //将字符串s倒过来,即为正确的翻转结果 } 接下来,可以编写一个主函数,通过函数itob(n,s,b)将用户输入的整数n转换为字符串s,并输出转换后的结果。代码实现如下: int main() { int n, b; char s[MAXSIZE]; printf("请输入一个整数n:"); scanf("%d", &n); printf("请输入进制b:"); scanf("%d", &b); itob(n, s, b); //调用itob函数进行转换 printf("转换后的结果为:%s\n", s); return 0; }
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值