深入解析Linux内核网络-拥塞控制系列(二)

上篇文章:深入解析Linux内核网络-拥塞控制系列(一)对Linux内核网络中网络拥塞框架的框架进行了分析。本次针对具体的Cubic拥塞控制算法进行简单分析在进行代码的梳理前,同样还是先来看一下相关概念、原理:

在上一篇文章中也提到了拥塞控制算法的分类,Cubic属于基于丢包反馈的拥塞控制算法,即将丢包视为发生了网络拥塞的标志。在众多讲解Cubic拥塞控制算法的技术博文中,都会先谈及BIC拥塞控制算法。之所以涉及BIC拥塞控制算法,因为Cubic是在BIC拥塞控制算法上进一步衍生出来的。本文先将BIC进行介绍。

BIC拥塞控制算法的核心思想是通过二分搜索的思想来找到当前链路适合的拥塞窗口大小。如下图所示是BIC拥塞控制算法的拥塞窗口图像。

显然当链路在网络上因为排队而发生丢包时,链路的最佳拥塞窗口肯定是小于丢包时的拥塞窗口的,那么把丢包时的拥塞窗口大小记为 。发生丢包后,BIC使用乘法因子 缩小网络拥塞窗口的大小,并记录缩小前的拥塞窗口值为 ,随后进入上图中类似二分法的探索阶段,即每收到一个ACK的时候,便将窗口设置到 和 in的中点,拥塞窗口增长到这个中间值且没有出现丢包的话,就说明网络还可以容纳更多的数据包。那么将当前这个中值设为新的最小值,按照二分法重新计算下一个增长点,一直持续到接近Wmax。如上图所示每一个RTT轮次进行一次拥塞窗口的增加。由于二分法探索的性质,当远离 时上升较快,接近 时上升缓慢。越接近 附近,拥塞窗口的增长速度越慢,意味着在发生一次拥塞丢包缩小拥塞窗口后,拥塞窗口的增长可以更快地探索到 ,而且可以在 附近停留较多的时间。但上述描述的BIC拥塞控制算法存在不公平性问题,具体地,拥塞控制窗口的大小与RTT的大小强相关(每一个RTT轮次进行一次类似二分法的拥塞窗口探索)。例如当网络中存在两个TCP连接,其中一个TCP连接的RTT为20ms,另一个是30ms,那么RTT=20ms的TCP连接拥有更高的拥塞窗口增长率。

为缓解BIC的RTT不公平性问题,提出Cubic拥塞控制算法,Cubic的解决方法比较直接,将类似二分法的拥塞窗口函数设计成了如下1.1的三次函数 。

函数大致图像如下图所示,需要值得关注的是,函数的横坐标是时间T,而不是RTT。具体地,函数表达式如1.1所示。函数中的C是一个常数,作为调节因子,t是最近一次检测到丢包后经过的时间(如果假设丢包后进入一个新的拥塞窗口探索轮次,那么t就是当前轮次的持续时间,也是自上次窗口减少到当前的时间)。K的取值如1.2所示,其中 是乘法减少因子, 是最近一次发生网络拥塞时的拥塞窗口值,K代表1.1函数在没有丢包的条件下,从当前拥塞窗口增长到 所要花费的时间。从1.1公式里也可以看出来,拥塞窗口的变化不再是由RTT强相关。在拥塞避免阶段接收到ACK时,Cubic在下一个RTT使用公式1.1计算拥塞窗口(W(t+RTT))作为Target。

(1.1)

(1.2)

以上,将BIC到Cubic的基本原理进行了概览,下面将切入到Linux内核源码中看Cubic拥塞控制算法的相关实现。在分析代码流程前,这里先把Cubic拥塞控制算法涉及的核心变量贴出来:

struct bictcp {
 u32 cnt;  /* increase cwnd by 1 after ACKs */
 u32 last_max_cwnd; /* last maximum snd_cwnd */
 u32 last_cwnd; /* the last snd_cwnd */
 u32 last_time; /* time when updated last_cwnd */
 u32 bic_origin_point;/* origin point of bic function */
 u32 bic_K;  /* time to origin point
       from the beginning of the current epoch */
 u32 delay_min; /* min delay (msec << 3) */
 u32 epoch_start; /* beginning of an epoch */
 u32 ack_cnt; /* number of acks */
 u32 tcp_cwnd; /* estimated tcp cwnd */
 u16 unused;
 u8 sample_cnt; /* number of samples to decide curr_rtt */
 u8 found;  /* the exit point is found? */
 u32 round_start; /* beginning of each round */
 u32 end_seq; /* end_seq of the round */
 u32 last_ack; /* last time when the ACK spacing is close */
 u32 curr_rtt; /* the minimum rtt of current round */
}

如上,结构体struct bictcp包含了上述讲到的Cubic拥塞控制算法的计算公式所涉及到的数值。struct bictcp作为拥塞控制中的核心结构体,嵌入在struct inet_connection_sock中。[提示:关于网络协议栈相关的核心结构体,以及之间的关系、在拥塞控制的相关源码中会看到如下转换:

struct sock *sk;
struct tcp_sock *tp = tcp_sk(sk);
struct bictcp *ca = inet_csk_ca(sk);

回到struct bictcp结构体上,内核源码的注释较清楚,这里就重点介绍四个重要的结构体成员。

第一个是epoch_start,当发生丢包后,进行拥塞窗口的乘法减小,即cwnd = *Wmax,同时也会重置epoch_start = 0。随即会进入一个探索拥塞窗口的新阶段,称该阶段为一个新的轮次,通过判断epoch_start是否被重置为0,若被重置,接下来会设置epoch_start = current_time,意味着正式进入一个新的拥塞窗口探索轮次,理解这个轮次是非常重要的。可以认为ecpoch_start的作用是为每个新轮次的打时间戳。

第二个是cnt,为Cubic算法中的极为重要的变量,cubic的核心函数的最终目的就是计算出cnt值,用来控制在拥塞避免状态阶段,何时才能增大拥塞窗口,具体实现是通过与struct tcp_sock中的snd_cwnd_cnt(snd_cwnd_cnt表示当前的拥塞窗口中已经发送,即经过对方ACK确认的数据段的个数)进行比较,决定是否增大拥塞窗口大小,可以认为cnt是增加一个单位cwnd需要的ACK数量。两者通过比较,共同完成拥塞窗口的增长控制。

第三个是bic_origin_point,代表新的链路饱和点,取MAX(last_max_cwnd,snd_cwnd),即取上一次丢包时的拥塞窗口大小与当前拥塞窗口的最大值。

第四个是bic_K,对应到公式中的K,源码中也有注释:time to origin point from the begining of the current epoch。表示在当前轮次内,假设没有丢包的情况下,从 *Wmax增长到链路饱和点bic_origin_point所要花费的时间,即bic_k决定了 *Wmax在没有进一步丢包的情况下到达bic_origin_point的时间。bic_K只会在进入每个新的轮次开始时进行计算。如下图所示,将bic_origin_point、epoch_start、bic_k描述在Cubic算法函数图像中。

在上一篇文章深入解析Linux内核网络-拥塞控制系列(一)中提到了拥塞控制算法的框架,Cubic算法实现的接口如下所示:

static struct tcp_congestion_ops cubictcp __read_mostly = {
 .init  = bictcp_init,
 .ssthresh = bictcp_recalc_ssthresh,
 .cong_avoid = bictcp_cong_avoid,
 .set_state = bictcp_state,
 .undo_cwnd = tcp_reno_undo_cwnd,
 .cwnd_event = bictcp_cwnd_event,
 .pkts_acked     = bictcp_acked,
 .owner  = THIS_MODULE,
 .name  = "cubic",
};

先着重看一下cong_avoid接口的实现,如下所示为该接口的实现,其中参数acked代表当前新的已确认的数据包。tcp_is_cwnd_limited函数是用于判断TCP连接是否受到拥塞窗口的限制,即检查发出去,但是还、有收到ACK的包是否达到了拥塞窗口的上限,可以暂且先不关注这个函数。重点看tcp_slow_start和bictcp_update、tcp_cong_avoid_ai函数。

static void bictcp_cong_avoid(struct sock *sk, u32 ack, u32 acked)
{
 struct tcp_sock *tp = tcp_sk(sk);
 struct bictcp *ca = inet_csk_ca(sk);

 if (!tcp_is_cwnd_limited(sk))
  return;

 if (tcp_in_slow_start(tp)) {
  ......
  acked = tcp_slow_start(tp, acked);
  if (!acked)
   return;
 }
 bictcp_update(ca, tp->snd_cwnd, acked); /*计算一个ca->cnt出来 */
 tcp_cong_avoid_ai(tp, ca->cnt, acked);  /* 通过 计算的ca->cnt 进行拥塞控制 控制窗口cwnd的增长 */
}

tcp_in_slow_start函数通过判断是否处于慢启动区域,如下所示,通过比较当前的拥塞窗口大小与慢启动门限值,进而判断是否处于慢启动阶段。

static inline bool tcp_in_slow_start(const struct tcp_sock *tp)
{
 return tp->snd_cwnd < tp->snd_ssthresh;
}

 资料直通车:Linux内核源码技术学习路线+视频教程内核源码

学习直通车:Linuxc/c++高级开发【直播公开课】

零声白金VIP体验卡:零声白金VIP体验卡(含基础架构/高性能存储/golang/QT/音视频/Linux内核)

若处于慢启动阶段,则按照如下算法对拥塞窗口进行增加。那么该函数返回的acked如何理解?acked:刚刚被确认的数据包数量,且还未用来更新到拥塞窗口的剩余大小。如果在tcp_slow_start的慢启动流程中,acked值最终消耗变为0,那么说明刚刚确认的数据包均用在了慢启动环节的拥塞窗口增长上。当前还未退出慢启动阶段。

u32 tcp_slow_start(struct tcp_sock *tp, u32 acked)
{
   //在慢启动阶段,cwnd最多增加到慢启动门限值
 u32 cwnd = min(tp->snd_cwnd + acked, tp->snd_ssthresh);
   //acked用于更新cwnd,acked已使用cwnd-tp->snd_cwnd,更新acked
 acked -= cwnd - tp->snd_cwnd;
    //snd_cwnd_clamp; /* Do not allow snd_cwnd to grow above this */
 tp->snd_cwnd = min(cwnd, tp->snd_cwnd_clamp);

 return acked;
}

若在慢启动阶段返回的acked不为0,说明在慢启动阶段,刚刚确认的数据包已经足够使得拥塞窗口大小超过慢启动门限值,足以退出慢启动,剩余的acked用于拥塞避免阶段的增长。此时进入bictcp_update函数中,该函数也是Cubic的核心函数,函数的第一个参数是存放的Cubic拥塞控制算法相关变量信息的结构体,第二个参数是当前的拥塞窗口大小,第三个参数是在上面也介绍过:acked:刚刚被确认的数据包数量,且还未用来更新到拥塞窗口的剩余大小。这里要注意第三个参数acked可能是由慢启动环节中还未消耗掉的剩余量,也有可能是拥塞避免阶段的。

算法的实现过程中涉及到很多巧妙的设计,这里暂且不去关注这些细节,我们重点来看cubic三次函数的计算过程:

static inline void bictcp_update(struct bictcp *ca, u32 cwnd, u32 acked)
{
 u32 delta, bic_target, max_cnt;
 u64 offs, t;

 ca->ack_cnt += acked; /* count the number of ACKed packets */
    ......
 ca->last_cwnd = cwnd;   /* 更新上一次的拥塞窗口值 */
 ca->last_time = tcp_jiffies32; /* 最后一次更新last_cwnd的时间 */

 if (ca->epoch_start == 0) {  /*开始一个新的epoch */
  ca->epoch_start = tcp_jiffies32; /* record beginning */
  ca->ack_cnt = acked;   /* start counting */
  ca->tcp_cwnd = cwnd;   /* syn with cubic */

  if (ca->last_max_cwnd <= cwnd) {
   ca->bic_K = 0;
   ca->bic_origin_point = cwnd;
  } else {
   /* Compute new K based on
    * (wmax-cwnd) * (srtt>>3 / HZ) / c * 2^(3*bictcp_HZ)
    */
   ca->bic_K = cubic_root(cube_factor
            * (ca->last_max_cwnd - cwnd));
   ca->bic_origin_point = ca->last_max_cwnd;
  }
 }

 t = (s32)(tcp_jiffies32 - ca->epoch_start); /*当前时间到epoch_start的时间*/
 t += msecs_to_jiffies(ca->delay_min >> 3);  /* + ca->delay_min  预测下一个rtt时间内的cwnd */
 /* change the unit from HZ to bictcp_HZ */
 t <<= BICTCP_HZ;
 do_div(t, HZ);

 if (t < ca->bic_K)  /* t - K */
  offs = ca->bic_K - t;
 else
  offs = t - ca->bic_K;

 /* c/rtt * (t-K)^3 */
 delta = (cube_rtt_scale * offs * offs * offs) >> (10+3*BICTCP_HZ);
 if (t < ca->bic_K)                            /* below origin  */
  bic_target = ca->bic_origin_point - delta;
 else                                          /* above origin* /
  bic_target = ca->bic_origin_point + delta;

 /* cubic function - calc bictcp_cnt*/
 if (bic_target > cwnd) {
  ca->cnt = cwnd / (bic_target - cwnd);
 } else {
  ca->cnt = 100 * cwnd;              /* very small increment*/
 }

 /*
  * The initial growth of cubic function may be too conservative
  * when the available bandwidth is still unknown.
  */
 if (ca->last_max_cwnd == 0 && ca->cnt > 20)
  ca->cnt = 20; /* increase cwnd 5% per RTT */
    ......
}

每次发生丢包时,epoch_start会重置为0,意味着将开始一个新的轮次、新的时段。丢包后调到bictcp_update函数时,设置epoch_start为当前时间,正式开始新的轮次,并开始更新 、K值。所以可以认为epoch_start是为丢包后的每个新轮次的开始打上时间戳。如下代码片段所示,若当前的拥塞控制窗口大于 ,那么更新当前链路的拥塞窗口的饱和点为 (即当前达到了新的饱和点),此时设置K为0(ca->bic_K),k=0代表当前拥塞窗口的大小就是饱和点,达到饱和点的时间是0。若当前拥塞窗口小于 ,更新当前链路的拥塞窗口饱和点为 ,并且计算出K,此时的K按照公式1.2进行计算当前拥塞窗口在没有丢包的情况下到达饱和点的时间。

if (ca->last_max_cwnd <= cwnd) {
   ca->bic_K = 0;
   ca->bic_origin_point = cwnd;
  } else {
   /* Compute new K based on
    * (wmax-cwnd) * (srtt>>3 / HZ) / c * 2^(3*bictcp_HZ)
    */
   ca->bic_K = cubic_root(cube_factor
            * (ca->last_max_cwnd - cwnd));
   ca->bic_origin_point = ca->last_max_cwnd;
  }
}

通过对这段代码的分析,再啰嗦一下对K的理解,K:time to origin point from the begining of the current epoch; orign point:Cubic函数的中心点,也是饱和点(bic_origin_point)对应的位置,故ca->bic_K代表的是从当前轮次开始对应的拥塞窗口,在没有进一步丢包的情况下,到达饱和点bic_origin_point所需要的时间(换句话就是:在当前轮次内,假设没有丢包的情况下,从当前拥塞窗口增长 *Wmax到链路饱和点bic_origin_point所要花费的时间)。下一步开始计算时间t,首先计算当前时间到当前轮次开始的时间,然后加下一个RTT的时间(由于下一个RTT时间是不确定的值,故在具体实现时是选择的rtt的平均值),t = 当前时间-进入当前轮次的时间+最小RTT。这里最终的t是一个预测时间。因为我们的目标就是要计算下个RTT时间的拥塞窗口大小,即保证在下一次ACK到达之前,有充足的cwnd配额可供持续发送数据。

 t = (s32)(tcp_jiffies32 - ca->epoch_start); /*当前时间到epoch_start的时间*/
 t += msecs_to_jiffies(ca->delay_min >> 3);  /* + ca->delay_min  

如下图所示,是时间t在cubic三次函数中的描述,蓝色括号代表当前轮次已经经过的时间,红色的是下一个rtt的时间。

上文提到的cubic计算公式,如下所示,t、K、Wmax、C均已知。

下面开始计算目标值,首先判断时间t,计算|t-K|的值,从而计算C*(t-K)^3;然后根据t与K的大小,即当前的t是否超过了到达饱和点的时间,对应的是Cubic函数图像的中心位置。计算最终的目标值,是预测的下个rtt拥塞窗口值,即保证下个时间段内有足够的cwnd配额可供持续发送数据。

if (t < ca->bic_K)  /* t - K */
  offs = ca->bic_K - t;
 else
  offs = t - ca->bic_K;

 /* c/rtt * (t-K)^3 */
 delta = (cube_rtt_scale * offs * offs * offs) >> (10+3*BICTCP_HZ);
 if (t < ca->bic_K)                            /* below origin  */
  bic_target = ca->bic_origin_point - delta;
 else                                          /* above origin* /
  bic_target = ca->bic_origin_point + delta;

最后根据当前拥塞窗口大小、目标值获取最终的cnt值,cnt值是cubic拥塞算法的核心,主要用来控制在拥塞避免状态时,什么时候才能增大拥塞窗口。具体取值时,按照cubic函数的形状:target与cwnd相差越多,增长越快,如果当前cwnd已经超出预期的target,应该做降速,所以此时取值cnt为100*cwnd。(关于cnt的定义在本文介绍cubic相关变量时有做特别说明)。

/* cubic function - calc bictcp_cnt*/
 if (bic_target > cwnd) {
  ca->cnt = cwnd / (bic_target - cwnd);
 } else {
  ca->cnt = 100 * cwnd;              /* very small increment*/
 }

bictcp_update函数最终要计算的就是上面的ca->cnt,当执行完bictcp_update后,顺序执行tcp_cong_avoid_ai函数,如下所示,函数第二个参数就是bictcp_update计算后的ca->cnt值。下面函数中tp->snd_cwnd_cnt是一个核心变量,文章前面也介绍过,代表当前的拥塞窗口中已经发生(经过对方ACK确认)的数据段的个数,该变量在下面函数中与ca->cnt进行对比,来决定是否增大拥塞窗口大小。

/* In theory this is tp->snd_cwnd += 1 / tp->snd_cwnd (or alternative w),
 * for every packet that was ACKed.
 */
void tcp_cong_avoid_ai(struct tcp_sock *tp, u32 w, u32 acked)
{
    
    /*
    tp->snd_cwnd_cnt 表示在当前的拥塞窗口中已经发送(经过对方ack包确认)的数据段个数.
    ca->cnt = w :它是cubic拥塞算法的核心,主要用来控制拥塞避免状态的时候,什么时候才能增大拥塞窗口
    具体实现是通过比较cnt和snd_cwnd_cnt,来决定是否增大拥塞窗口
    */
    
    /* If credits accumulated at a higher w, apply them gently now. */
    /*当被确认的包的数量大于w时,将snd_cwnd_cnt清0,继续加大拥塞窗口值,继续probe Wmax*/
    if (tp->snd_cwnd_cnt >= w) {
        tp->snd_cwnd_cnt = 0;
        tp->snd_cwnd++;
    }
 
    tp->snd_cwnd_cnt += acked; //累计被确认的包
    if (tp->snd_cwnd_cnt >= w) {
        /*按比例增加拥塞窗口,并减少snd_cwnd_cnt*/
        u32 delta = tp->snd_cwnd_cnt / w;
 
        tp->snd_cwnd_cnt -= delta * w;
        tp->snd_cwnd += delta;
    }
    tp->snd_cwnd = min(tp->snd_cwnd, tp->snd_cwnd_clamp);
}

至此,Cubic拥塞控制算法在Linux内核的核心计算过程分析结束,除了本文涉及到的分析要点外,Cubic拥塞控制算法还有很多其他的思想,例如混合慢启动、快速收敛、TCP友好型等,后面有机会也进行几次分析分享。本文若有描述不恰当、有误的地方,欢迎批评指正!

原文作者:技术简说

  • 20
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值