【lwip】14-TCP协议分析之TCP协议之可靠传输的实现(TCP干货)

发布时间 2023-05-29 12:12:10作者: 李柱明

lwip_14_TCP协议之可靠传输的实现

前言

前面章节太长了,不得不分开。

这里已源码为主,默认读者已知晓概念或原理,概念或原理可以参考前面章节,有分析。

参考:李柱明博客:https://www.cnblogs.com/lizhuming/p/17438743.html

两个时钟处理函数

lwip的时钟机制可以翻看前面章节。

lwip的TCP可靠传传输的实现离不开两个时钟处理函数:

  1. 快时钟:tcp_fasttmr()
    1. 快时钟周期为TCP_FAST_INTERVAL​,默认250ms。
    2. 主要作用:遍历处理PCB:
      1. 处理延迟ACK,将其发出。
      2. 通知应用层获取接收缓冲区中的数据。
  2. 慢时钟:tcp_slowtmr()
    1. 快时钟周期为TCP_SLOW_INTERVAL​,默认500ms。
    2. 主要作用:遍历处理PCB:
      1. 各种超时计算。如TCP4大定时器:重传定时器、保活定时器、坚持定时器、2MSL定时器。
      2. 上面这几个定时器也包含了各自的业务。如重传定时器中包含了拥塞发生后的算法,坚持定时器中包含了窗口探查等等。
      3. 还有RTT等计时。

RTT和RTO计算源码实现

原理参考前面大章节。

RTT和RTO相关变量

控制块中RTT和RTO相关变量:

  /* RTT (round trip time) 估算 */
  u32_t rttest; /* RTT测量,发送时的时间戳。精度500ms */
  u32_t rtseq;  /* 开始计算RTT时对应的seq号 */
  /* RTT估计出的平均值和时间差。
      注意:sa为算法中8倍的均值;sv为4倍的方差。再去分析LWIP实现RTO的算法。 */
  s16_t sa, sv; /* @see "Congestion Avoidance and Control" by Van Jacobson and Karels */

  s16_t rto;    /* 重传超时时间。节拍宏:TCP_SLOW_INTERVAL。初始超时时间宏:LWIP_TCP_RTO_TIME *//* retransmission time-out (in ticks of TCP_SLOW_INTERVAL) */
  u8_t nrtx;    /* 重发次数 */

发送前记录发出的时间搓

tcp_output_segment()​发送报文段时,如果需要计算RTT,就记录发送当前报文的时间搓:

  /* 计算RTT */
  if (pcb->rttest == 0) {
    pcb->rttest = tcp_ticks; /* 记录当前时间戳 */
    pcb->rtseq = lwip_ntohl(seg->tcphdr->seqno); /* 记录当前发送的起始seq号 */
  }

计算RTT&RTO

tcp_receive()​收到新的ACK,这个ACK包含了我们用于计算RTT的报文时,即可计算RTT:

  • 计算RTT和RTO方法已经在TCP原理篇描述了。
  • 本次RTT就是当前时间戳-当时时间戳:(s16_t)(tcp_ticks - pcb->rttest);
    • tcp_ticks​会在TCP慢时钟tcp_slowtmr()​中计算(500ms),所以RTT精度也就500ms。
    /* RTT测量:如果当前ACK已经把我们附带RTT测量的报文也ACK了,则可以计算RTT */
    if (pcb->rttest && TCP_SEQ_LT(pcb->rtseq, ackno)) {
      /* RTT值不应该超过32K,因为这是tcp计时器滴答和往返不应该那么长… */
      m = (s16_t)(tcp_ticks - pcb->rttest); /* 算出RTT */

      LWIP_DEBUGF(TCP_RTO_DEBUG, ("tcp_receive: experienced rtt %"U16_F" ticks (%"U16_F" msec).\n",
                                  m, (u16_t)(m * TCP_SLOW_INTERVAL)));

      /* RTO算法有很多种,LWIP使用的是Jacobson提出的,具体格式如下: */
      /* M:某次测量的RTT值。A:RTT平均值。D:RTT估计方差。g:常数1/8。h:常数1/4。 */
      /* 说明:pcb->sa是8倍的RTT平均值。pcb->sv是4倍的方差。 */
      /* ERR = M-A */
      /* A = A+g*ERR */
      /* D = D+h*(|ERR|-D) */
      /* RTO = A+4*D */

      /* 算出平滑RTT */
      m = (s16_t)(m - (pcb->sa >> 3)); /* 偏差 = RTT - 均值 */
      pcb->sa = (s16_t)(pcb->sa + m); /* 均值 = 原均值 + (1/8)偏差 */
      /* 绝对差 = 差值取绝对值 */
      if (m < 0) {
        m = (s16_t) - m;
      }
      m = (s16_t)(m - (pcb->sv >> 2));
      pcb->sv = (s16_t)(pcb->sv + m); /* 方差 = 原方差 + (1/4)(绝对差 - 原方差) */
      pcb->rto = (s16_t)((pcb->sa >> 3) + pcb->sv); /* RTO = 均值 + 4*方差 */

      LWIP_DEBUGF(TCP_RTO_DEBUG, ("tcp_receive: RTO %"U16_F" (%"U16_F" milliseconds)\n",
                                  pcb->rto, (u16_t)(pcb->rto * TCP_SLOW_INTERVAL)));

      /* 本次RTT测量完毕,关闭本次RTT测量 */
      pcb->rttest = 0;
    }

RTO退避指数

上面只是每次RTT计算出来的RTO,适用于没有发送超时的情况下。

而当发生发送超时时,RTO并不是维持RTT计算的结果,而是超时后每次超时都会按照RTO退避指数来放大RTO。

RTO退避指数:

static const u8_t tcp_backoff[13] =
{ 1, 2, 3, 4, 5, 6, 7, 7, 7, 7, 7, 7, 7};

发生超时重传后的RTO计算:

  • tcp_slowtmr()​函数处理超时重传时,RTO会根据本次的重传次数来选择RTO退避指数来放大RTO。
/* TCP客户端发起的SYN不纳入RTO算法范围 */
if (pcb->state != SYN_SENT) {
  /* RTO计算 */
  u8_t backoff_idx = LWIP_MIN(pcb->nrtx, sizeof(tcp_backoff) - 1);
  int calc_rto = ((pcb->sa >> 3) + pcb->sv) << tcp_backoff[backoff_idx];
  pcb->rto = (s16_t)LWIP_MIN(calc_rto, 0x7FFF);
}

超时重传&拥塞窗口变化

超时重传相关定时器

在PCB控制块:

/* 超时重传计时器值,当该值大于RTO值时,重传报文 */
s16_t rtime;

存在空中数据时,就会一直开启这个超时定时器,在慢时钟tcp_slowtmr()​中计时。

在收到新的ACK时,会复位这个定时器值。

如果在超过RTO值都还没收到新的ACK,则表示超时,需要重传。

由于lwip的特点(轻量)每条TCP只有一个重传定时器,而不是每个报文段都有一个独立的定时器,所以只要发生超时重传,就会把当前空中链表pcb->unacked​中的所有空中数据全部挪回发送缓冲区pcb->unsent​,哪怕是刚刚才发送出去的也要挪回。其源码根据参考tcp_rexmit_rto_prepare()​即可。

超时重传算法

tcp_slowtmr()​函数中,会检查超时重传,超时值比当前RTO值大就表示超时,需要触发超时重传算法:

  • 慢启动上门限值pcb->ssthresh​减半。但是不能低于2个MSS。
  • 拥塞窗口pcb->cwnd​降到1个MSS。
  • 所有空中数据迁回待发送缓冲区准备重新发送。
  • 触发发送。
/* 如果开启了重传计时器,则计时 */
if ((pcb->rtime >= 0) && (pcb->rtime < 0x7FFF)) {
  ++pcb->rtime;
}

if (pcb->rtime >= pcb->rto) {
  /* 发生超时 */
  LWIP_DEBUGF(TCP_RTO_DEBUG, ("tcp_slowtmr: rtime %"S16_F
                              " pcb->rto %"S16_F"\n",
                              pcb->rtime, pcb->rto));

  /* 如果unacked队列报文迁移成功
      或
      PCB还有unsent报文,但是没有unacked报文(这意味着存在某种原因导致发送报文段失败
      (如:可追踪下tcp_output_segment(),开启RTO,但是发送失败)) */
  if ((tcp_rexmit_rto_prepare(pcb) == ERR_OK) || ((pcb->unacked == NULL) && (pcb->unsent != NULL))) {
    /* TCP客户端发起的SYN不纳入RTO算法范围 */
    if (pcb->state != SYN_SENT) {
      /* RTO计算 */
      u8_t backoff_idx = LWIP_MIN(pcb->nrtx, sizeof(tcp_backoff) - 1);
      int calc_rto = ((pcb->sa >> 3) + pcb->sv) << tcp_backoff[backoff_idx];
      pcb->rto = (s16_t)LWIP_MIN(calc_rto, 0x7FFF);
    }

    /* 复位超时计时器 */
    pcb->rtime = 0;

    /* 发生重传,触发拥塞避免算法:更新慢启动上门限值为有效窗口的一半 */
    eff_wnd = LWIP_MIN(pcb->cwnd, pcb->snd_wnd);
    pcb->ssthresh = eff_wnd >> 1;
    /* 慢启动上门限不能低于2个MSS, */
    if (pcb->ssthresh < (tcpwnd_size_t)(pcb->mss << 1)) {
      pcb->ssthresh = (tcpwnd_size_t)(pcb->mss << 1);
    }
    /* 超时引起的拥塞避免算法:拥塞窗口需要更新为一个MSS。重新进行慢启动。 */
    pcb->cwnd = pcb->mss;
    LWIP_DEBUGF(TCP_CWND_DEBUG, ("tcp_slowtmr: cwnd %"TCPWNDSIZE_F
                                 " ssthresh %"TCPWNDSIZE_F"\n",
                                 pcb->cwnd, pcb->ssthresh));
    /* 复位上次成功发送的字节数为0(因为unacked都为NULL) */
    pcb->bytes_acked = 0;

    /* 调用能统计重传次数的API把数据再次发送出去 */
    tcp_rexmit_rto_commit(pcb);
  }
}

保活定时器&保活探测报文

相关变量

保活定时器在PCB控制块中的变量:

  • u32_t keep_idle​:
    • 其值为TCP_KEEPIDLE_DEFAULT​,默认7200秒,即是两小时。
    • 空闲的最长时间,超过这个时间都没有数据交互就会触发保活机制。
    • 调用setsocketopt()​搭配TCP_KEEPIDLE​即可修改该值。
  • u32_t keep_intvl​:
    • 其值为TCP_KEEPINTVL_DEFAULT​,默认75秒。
    • 触发保活机制后,每隔keep_intvl​秒会发送一个保活探测报文。
    • 调用setsocketopt()​搭配TCP_KEEPINTVL​即可修改该值。
  • u32_t keep_cnt​:
    • 其值为TCP_KEEPCNT_DEFAULT​,默认9次。
    • 触发保活机制后,最多发送keep_cnt​次保活探测报文,超过后都未收到对端响应,则断开当前连接。
    • 调用setsocketopt()​搭配TCP_KEEPCNT​即可修改该值。
/* keepalive计时器的上限值 */
u32_t keep_idle;
#if LWIP_TCP_KEEPALIVE
  /* keepalive探测间隔 */
  u32_t keep_intvl;
  /* keepalive探测的上限次数 */
  u32_t keep_cnt;
#endif /* LWIP_TCP_KEEPALIVE */

当然,除了上面三个参数外,还有两个关键参数:

  1. PCB中的最近交互时间搓:
  /* 保存这控制块的TCP节拍起始值。用于当前PCB的时基初始值参考 */
  /* 活动计时器,收到合法报文时自动更新。 */
  u32_t tmr;

  1. 全局变量当前时间戳:
/* Incremented every coarse grained timer shot (typically every 500 ms). */
u32_t tcp_ticks;

tcp_ticks​ - pcb->tmr​就是当前连接的持续空闲时间了。

保活机制源码实现

tcp_slowtmr()​函数中,实现保活机制:

/* Check if KEEPALIVE should be sent */
    if (ip_get_option(pcb, SOF_KEEPALIVE) &&
        ((pcb->state == ESTABLISHED) ||
         (pcb->state == CLOSE_WAIT))) {
      if ((u32_t)(tcp_ticks - pcb->tmr) >
          (pcb->keep_idle + TCP_KEEP_DUR(pcb)) / TCP_SLOW_INTERVAL) {
        LWIP_DEBUGF(TCP_DEBUG, ("tcp_slowtmr: KEEPALIVE timeout. Aborting connection to "));
        ip_addr_debug_print_val(TCP_DEBUG, pcb->remote_ip);
        LWIP_DEBUGF(TCP_DEBUG, ("\n"));

        ++pcb_remove;
        ++pcb_reset;
      } else if ((u32_t)(tcp_ticks - pcb->tmr) >
                 (pcb->keep_idle + pcb->keep_cnt_sent * TCP_KEEP_INTVL(pcb))
                 / TCP_SLOW_INTERVAL) {
        err = tcp_keepalive(pcb);
        if (err == ERR_OK) {
          pcb->keep_cnt_sent++;
        }
      }
    }

保活探测报文

调用tcp_keepalive()​函数即可发送保活探测报文。

保活探测报一般是包含一个字节的TCP数据,但是该字节的SEQ已经被对端ACK过了的(代码证明如下),所以发送该SEQ到对端并不影响对端的字节流,但是对端如果收到会响应一个ACK回来,我们便可判断对端主机在线,可重新计时保活探测。

tcp_keepalive()​:pcb->snd_nxt - 1

p = tcp_output_alloc_header(pcb, optlen, 0, lwip_htonl(pcb->snd_nxt - 1));

坚持定时器&零窗口探测报文

相关变量

在PCB控制块中:

  • u8_t persist_cnt​:
    • 坚持定时器节拍计数。在tcp_slowtmr()​中计时,精度500ms。
  • u8_t persist_backoff​:
    • 坚持定时器探查报文时间间隔列表索引及开关。如果该索引值大于0,表示开启坚持定时器计时。
    • 该值表示tcp_persist_backoff[]​数组的索引,也表示本次窗口探测报文的时间间隔的节拍数。
  • u8_t persist_probe​:
    • 坚持定时器窗口0时发出的探查报文次数。
    • 最大为TCP_MAXRTX​,默认12次,超过也没收到对端响应,则关闭当前连接。
  /* 坚持定时器:用于解决远端接收窗口为0时,定时询问使用 */
  u8_t persist_cnt; /* 坚持定时器节拍计数值 */
  u8_t persist_backoff; /* 坚持定时器探查报文时间间隔列表索引及开关 */
  u8_t persist_probe; /* 坚持定时器窗口0时发出的探查报文次数 */

坚持定时器时间间隔节拍数数组:

/* 坚持定时器的阻塞时长列表,发送窗口探查报文越来越稀疏 */
static const u8_t tcp_persist_backoff[7] = { 3, 6, 12, 24, 48, 96, 120 };

零窗口探测源码实现

tcp_slowtmr()​函数中,实现零窗口探测:

  if (pcb->persist_backoff > 0) {
    LWIP_ASSERT("tcp_slowtimr: persist ticking with in-flight data", pcb->unacked == NULL);
    LWIP_ASSERT("tcp_slowtimr: persist ticking with empty send buffer", pcb->unsent != NULL);
    if (pcb->persist_probe >= TCP_MAXRTX) {
      ++pcb_remove; /* max probes reached */
    } else {
      u8_t backoff_cnt = tcp_persist_backoff[pcb->persist_backoff - 1];
      if (pcb->persist_cnt < backoff_cnt) {
        pcb->persist_cnt++;
      }
      if (pcb->persist_cnt >= backoff_cnt) {
        int next_slot = 1; /* increment timer to next slot */
        /* If snd_wnd is zero, send 1 byte probes */
        if (pcb->snd_wnd == 0) {
          if (tcp_zero_window_probe(pcb) != ERR_OK) {
            /* 发送窗口探查失败,即是本次坚持定时器相关报文发送失败,不能清空现有计时数值,因为下次进入需要马上补回窗口探查报文的发送 */
            next_slot = 0; /* try probe again with current slot */
          }
          /* snd_wnd not fully closed, split unsent head and fill window */
        } else {
          /* 窗口不够大,那切割也得发送 */
          if (tcp_split_unsent_seg(pcb, (u16_t)pcb->snd_wnd) == ERR_OK) {
            if (tcp_output(pcb) == ERR_OK) {

              /* 切割后,发送成功会关闭坚持定时器清理相关值,这里标记下后面不用刷新坚持定时器相关值了 */
              next_slot = 0;
            }
          }
        }
        if (next_slot) {
          /* 坚持定时器本次轮询已经成功发出相关报文了,进入下次轮询计时 */
          pcb->persist_cnt = 0;
          if (pcb->persist_backoff < sizeof(tcp_persist_backoff)) {
            pcb->persist_backoff++;
          }
        }
      }
    }
  }

零窗口探测报文

调用tcp_zero_window_probe()​即可发送零窗口探测报文。

窗口探测报文的是包含一字节TCP数据的,该字节就是待发送的下一个字节。

tcp_zero_window_probe()​函数源码就不贴了,给出大概实现的流程:

  • 如果当前没有需要发送的数据,则不需要进行窗口探查。
  • 如果待发送的数据中,是FIN报文,则本次窗口探查不需要附加数据字节。
  • 申请一个新的pbuf,作为窗口探查报文的pbuf。
  • 从待发送的数据中拷贝一个TCP数据字节,作为本次窗口探查报文的附带字节。
    • 注意:是复制,原有的tcp_seg的数据区是不偏移的,下次发送这段数据时,第一个数据字节也会被重复发送到对端,对端会根据seq号进行裁剪处理的。
  • 发送窗口探查报文。

整个过程中,就算携带了一字节的数据,也不会将当前数据包加入pcb->unacked队列,也就是本地不会监听这个字节的ack确认,因为没必要,等待窗口放开后,这个字节也会被正常发送过去。

2MSL定时器

在TIME_WAIT状态下会开启2MSL计时来清除当前连接的PCB。

当然,也是需要两个PCB变量来辅助:

  1. PCB中的最近交互时间搓:
  /* 保存这控制块的TCP节拍起始值。用于当前PCB的时基初始值参考 */
  /* 活动计时器,收到合法报文时自动更新。 */
  u32_t tmr;

  1. 全局变量当前时间戳:
/* Incremented every coarse grained timer shot (typically every 500 ms). */
u32_t tcp_ticks;

tcp_ticks​ - pcb->tmr​就是当前连接的持续空闲时间了。

源码也是在tcp_slowtmr()​函数中实现:

  • TCP_MSL​:默认为60秒。
  pcb = tcp_tw_pcbs;
  while (pcb != NULL) {
    LWIP_ASSERT("tcp_slowtmr: TIME-WAIT pcb->state == TIME-WAIT", pcb->state == TIME_WAIT);
    pcb_remove = 0;

    /* Check if this PCB has stayed long enough in TIME-WAIT */
    if ((u32_t)(tcp_ticks - pcb->tmr) > 2 * TCP_MSL / TCP_SLOW_INTERVAL) {
      ++pcb_remove;
    }

    /* If the PCB should be removed, do it. */
    if (pcb_remove) {
      struct tcp_pcb *pcb2;
      tcp_pcb_purge(pcb);
      /* Remove PCB from tcp_tw_pcbs list. */
      if (prev != NULL) {
        LWIP_ASSERT("tcp_slowtmr: middle tcp != tcp_tw_pcbs", pcb != tcp_tw_pcbs);
        prev->next = pcb->next;
      } else {
        /* This PCB was the first. */
        LWIP_ASSERT("tcp_slowtmr: first pcb == tcp_tw_pcbs", tcp_tw_pcbs == pcb);
        tcp_tw_pcbs = pcb->next;
      }
      pcb2 = pcb;
      pcb = pcb->next;
      tcp_free(pcb2);
    } else {
      prev = pcb;
      pcb = pcb->next;
    }

拥塞控制

相关变量

PCB控制块中:

  tcpwnd_size_t cwnd; /* 拥塞窗口大小 */
  tcpwnd_size_t ssthresh; /* 拥塞避免算法启动阈值。也叫慢启动上门限值。 */

慢启动

慢启动时,拥塞窗口cwnd​起始为1MSS,收到多少ACK就扩大多少(但是一般都是以MSS为步伐、单位)(lwip实际实现得看源码,下面有),直至达到慢启动上门限ssthresh​后才进入拥塞避免,每次最大只追加1MSS。

慢启动拥塞窗口cwnd​起始为1MSS源码在SYN_SENT​状态下收到SYN和ACK时配置的,具体在tcp_process()​函数中:

/* 计算初始拥塞窗口 */
pcb->cwnd = LWIP_TCP_CALC_INITIAL_CWND(pcb->mss);

此时的拥塞窗口还是PCB初始化时配置的初始值:默认为发送缓冲区size TCP_SND_BUF​。

/* RFC 5618建议设置ssthresh值尽可能高,比如设置为最大可能的窗口通告值大小(可以理解为最大可能的发送窗口大小 )。 */
/* 这里先设置为本地发送缓冲区大小,即是最大飞行数据量。后面进行窗口缩放和自动调优时自动调整。 */
pcb->ssthresh = TCP_SND_BUF;

慢启动拥塞窗口变化是在收到新ACK中处理,即是tcp_receive()​函数:包含慢启动和拥塞避免:

  • 慢启动:收到一个新的ACK后,会扩大拥塞窗口cwnd​,如果在超时重传状态下,仅增大1MSS;如果在正常状态下,会增大2MSS。
  • 拥塞避免:如果累计ACK的数据大于一个拥塞窗口,则拥塞窗口cwnd​增大1MSS,然后重新累计ACK。
  /* 更新拥塞控制字段:拥塞窗口cwnd 和 慢启动上门限ssthresh */
  if (pcb->state >= ESTABLISHED) { /* 连接处于ESTABLISHED状态 */
    if (pcb->cwnd < pcb->ssthresh) { /* 慢启动算法 */
      tcpwnd_size_t increase;
      /* 参考:RFC 3465, section 2.2 Slow Start */
      /* 如果是超时重传后的慢启动,则选1MSS;
          如果是正常状态下的慢启动,选2MSS */
      u8_t num_seg = (pcb->flags & TF_RTO) ? 1 : 2;
      /* 拥塞窗口增长:ACK新数据的量 和 nMSS 中的最小值 */
      increase = LWIP_MIN(acked, (tcpwnd_size_t)(num_seg * pcb->mss));
      TCP_WND_INC(pcb->cwnd, increase);
      LWIP_DEBUGF(TCP_CWND_DEBUG, ("tcp_receive: slow start cwnd %"TCPWNDSIZE_F"\n", pcb->cwnd));
    } else { /* 拥塞避免算法 */  
      /* 参考:RFC 3465, section 2.1 Congestion Avoidance */
      /* 如果累计ACK新数据量不少于一个拥塞窗口,
          则累计ACK新数据流减一个拥塞窗口值;拥塞窗口加一个MSS */
      TCP_WND_INC(pcb->bytes_acked, acked);
      if (pcb->bytes_acked >= pcb->cwnd) {
        pcb->bytes_acked = (tcpwnd_size_t)(pcb->bytes_acked - pcb->cwnd);
        TCP_WND_INC(pcb->cwnd, pcb->mss);
      }
      LWIP_DEBUGF(TCP_CWND_DEBUG, ("tcp_receive: congestion avoidance cwnd %"TCPWNDSIZE_F"\n", pcb->cwnd));
    }
  }

上面pcb->bytes_acked​变量是累计ACK新数据的量。拥塞避免时,用于判断拥塞窗口cwnd​是否需要+1MSS。

拥塞避免

当拥塞窗口增大到慢开始上门限值ssthresh​时,就开始拥塞避免算法。每次只增加1MSS。这里的每次是指每累计收到一个拥塞窗口量的ACK。

其源码在tcp_receive()​函数中,慢启动中有分析。

拥塞发送:超时重传&快重传

拥塞发送包括超时重传和快重传,这两者要区别起来,因为前者的算法会严重影响性能。

超时重传参考本章前面小节,有分析,这里续上分析快重传。

快重传在PCB中的变量:

u8_t dupacks; /* 收到最大重复ACK的次数:一般收1-2次认为是重排序引起的。收到3次后,可以确认为失序,需要立即重传。然后执行拥塞避免算法中的快恢复。 */

PCB快重传标志位:TF_INFR

当收到对端连续三次ACK同一个SEQ时,我们就能判断为发送了网络丢包,这时就不用等待超时,不用执行超时重传的拥塞算法了,而是执行快速重传的拥塞发生算法:

  • 拥塞窗口cwnd​设为原来的一半:cwnd /= 2​;
  • 慢开始上门限值ssthresh = cwnd​;(cwnd为减半后的拥塞窗口)
  • 然后拥塞窗口cwnd = ssthresh + 3​(3:每收到1个ACK,可以认为对端收到1次TCP包,网络上就少了1个TCP包,一个包最大为1个报文段,所以快恢复的拥塞窗口就追加3个报文段)
  • 进入快恢复算法。

既然是需要判断收到三次重复ACK,那么源码肯定就在tcp_receive()​函数中实现:

重复ACK的判断条件、做法如下:

/* (From Stevens TCP/IP Illustrated Vol II, p970.)
 * 通过以下条件可以判断是否是重复的ACK:
 * 1) 没有ACK新数据;
 * 2) 没有TCP数据,也没有SYN、FIN标志;
 * 3) 前面更新窗口算法中,本地发送窗口没有更新;(看具体源码)
 * 4) 本地还有unacked数据,并且重传计时器在跑;
 * 5) 当前收到的ACK,是本次连接历史最大的ACK。
 *
 * 如果上面5个条件都满足,则是一个重复的ACK:
 * a) 重复 < 3次:do nothing
 * b) 重复 == 3次: 快重传
 * c) 重复 > 3次: 拥塞窗口CWND+1MSS(拥塞避免算法)
 *
 * 如果只满足条件1、2、3:重置重复ACK计数器。(并添加到统计中,但是LWIP没有做这个统计)
 *
 * 如果只满足条件1:重置重复ACK计数器。
 *
 */

具体源码:

/* Clause 1:没有ACK新数据 */
if (TCP_SEQ_LEQ(ackno, pcb->lastack)) {
  /* Clause 2:报文段中没有数据,也没有SYN、FIN */
  if (tcplen == 0) {
    /* Clause 3:本地发送窗口没有更新 */
    if (pcb->snd_wl2 + pcb->snd_wnd == right_wnd_edge) {
      /* Clause 4:本地还有unacked数据,重传计时器还在跑 */
      if (pcb->rtime >= 0) {
        /* Clause 5:收到的ACK是本连接历史最大的ACK */
        if (pcb->lastack == ackno) {
          if ((u8_t)(pcb->dupacks + 1) > pcb->dupacks) { /* 防溢出 */
            ++pcb->dupacks; /* 收到重复的ACK */
          }
          if (pcb->dupacks > 3) {
            /* Inflate the congestion window */
            /* 拥塞避免:拥塞窗口cwnd+一个MSS */
            TCP_WND_INC(pcb->cwnd, pcb->mss);
          }
          if (pcb->dupacks >= 3) {

            /* 快重传:是检查unacked和TF_INFR标志位来确定是否触发快重传。 */
            tcp_rexmit_fast(pcb);
          }
        }
      }
    }
  }
}

调用的是tcp_rexmit_fast()​来实现快重传:

/**
 * 收到3个及以上重复ACK才会调用当前函数实现快重传算法。
 */
void
tcp_rexmit_fast(struct tcp_pcb *pcb)
{
  LWIP_ASSERT("tcp_rexmit_fast: invalid pcb", pcb != NULL);

  /* 存在未被ACK的数据 && 快重传标志位没有被标记 */
  if (pcb->unacked != NULL && !(pcb->flags & TF_INFR)) {
    /* 重传pcb->unacked队列中第一个报文 */
    LWIP_DEBUGF(TCP_FR_DEBUG,
                ("tcp_receive: dupacks %"U16_F" (%"U32_F
                 "), fast retransmit %"U32_F"\n",
                 (u16_t)pcb->dupacks, pcb->lastack,
                 lwip_ntohl(pcb->unacked->tcphdr->seqno)));
    if (tcp_rexmit(pcb) == ERR_OK) {
      /* 设置慢启动上门限pcb->ssthresh = MIN(当前拥塞窗口,发送窗口) 的一半。
          但是不能低于2个MSS */
      pcb->ssthresh = LWIP_MIN(pcb->cwnd, pcb->snd_wnd) / 2;

      /* The minimum value for ssthresh should be 2 MSS */
      if (pcb->ssthresh < (2U * pcb->mss)) {
        LWIP_DEBUGF(TCP_FR_DEBUG,
                    ("tcp_receive: The minimum value for ssthresh %"TCPWNDSIZE_F
                     " should be min 2 mss %"U16_F"...\n",
                     pcb->ssthresh, (u16_t)(2 * pcb->mss)));
        pcb->ssthresh = 2 * pcb->mss;
      }

      /* 拥塞窗口更新为 = 慢启动上门限 + 3MSS */
      pcb->cwnd = pcb->ssthresh + 3 * pcb->mss;
      /* 标记PCB处于快重传状态 */
      tcp_set_flags(pcb, TF_INFR);

      /* 重置超时重传计时器 */
      pcb->rtime = 0;
    }
  }
}

快恢复

快速重传和快速恢复算法一般同时使用

因为快恢复算法认为,能收到三个ACK,说明网络还不是很差,没必要像RTO一样搞得那么僵。

快恢复算法:

  • 收到第3个重复ACK时,先执行快速重传算法。
  • 收到超过3个重复的ACK时,每次都会增大拥塞窗口:cwnd += 1​。
  • 当收到新的ACK后:cwnd = ssthresh​。然后进入拥塞避免算法。

上面是推荐算法,下面才是lwip实际算法。

源码当然还是在tcp_receive()​函数中:

  • 说明:快重传
  /* 需要退出快重传状态 */
  if (pcb->flags & TF_INFR) {
    tcp_clear_flags(pcb, TF_INFR); /* 退出快重传状态 */
    pcb->cwnd = pcb->ssthresh; /* 快恢复算法:拥塞窗口重置为慢启动上门限值 */
    pcb->bytes_acked = 0; /* 重置被ACK的数据长度的统计 */
  }

Nagle算法

nagle算法: 尽可能组合更多数据合到同一个报文段中。所以,该算法是在TCP出口函数中实现的,所以查看tcp_output()​函数即可:

/* 如果nagle算法生效,则延迟发送。
 * 打破nagle算法生效的条件(即是nagle生效,也要马上发送的条件)之一:
 * - 如果之前调用tcp_write()时有内存错误未能成功发送,为了防止延迟ACK超时,需要立即发送。
 * - 如果FIN已经在队列中了,则没必要再延迟发送了,立即把数据发出,加速闭环。
 *    注意:SYN一直都是单独报文段的。所以要么不存在SYN,如存在未发送数据seg->next != NULL; 要么只存在SYN,即是还没有发送数据,如pcb->unacked == NULL;。
 *    注意:RST是不会通过tcp_wirte()和tcp_output()发送的。
 */
if ((tcp_do_output_nagle(pcb) == 0) &&
    ((pcb->flags & (TF_NAGLEMEMERR | TF_FIN)) == 0)) {
  /* nagle算法生效 && 上次发送内存正常 && 还没有FIN */
  break;
}

判断Nagle是否生效实现在tcp_do_output_nagle()​函数中:Nagle失效条件如下(即是可以立即发送的条件):

  • 没有飞行中的数据。
  • 用户设置了TF_NODELAY​标志。(该标志表示关闭nagle算法)
  • 用户设置了TF_INFR​标志。(该标志表示正在快恢复)
  • 未发送的报文段不止一个,也满足立即发送条件。
  • 未发送的报文段长度大于或大于一个MSS,也满足立即发送条件。
  • 内存不足,nagle算法也会失效,因为可能需要告知应用层发送缓冲区内存不足。
#define tcp_do_output_nagle(tpcb) ((((tpcb)->unacked == NULL) || \
                            ((tpcb)->flags & (TF_NODELAY | TF_INFR)) || \
                            (((tpcb)->unsent != NULL) && (((tpcb)->unsent->next != NULL) || \
                              ((tpcb)->unsent->len >= (tpcb)->mss))) || \
                            ((tcp_sndbuf(tpcb) == 0) || (tcp_sndqueuelen(tpcb) >= TCP_SND_QUEUELEN)) \
                            ) ? 1 : 0)

延迟确认

如果Nagle算法生效,则会延迟确认,延迟的确认会在tcp_fasttmr()​发送出去,该函数周期默认为250ms,表示延迟的确认在(0:250]ms内会发送出去。

tcp_fasttmr()​:

  /* 如果存在延迟发送的ACK,需要发送这个延迟的ACK */
  if (pcb->flags & TF_ACK_DELAY) {
    LWIP_DEBUGF(TCP_DEBUG, ("tcp_fasttmr: delayed ACK\n"));
    tcp_ack_now(pcb); /* 标记立即发送ACK */
    tcp_output(pcb); /* 发送数据 */
    tcp_clear_flags(pcb, TF_ACK_DELAY | TF_ACK_NOW); /* 清空相关标志位 */
  }

LWIP源码中有两个宏可能会导致读者混淆,所以我在这里说明下,希望能有助于你理解:

TF_ACK_NOW​:不管未发送队列中是否有无数据,也不管窗口是否满足,都必须立即响应一个ACK,哪怕是纯粹的ACK。

TF_ACK_DELAY​:如果收到数据,需要响应ACK,但是开启了拥塞控制,Nagle算法生效,就需要开启延迟ACK。lwip 开启了一个 250ms 的定时器,如果在超时前都还没满足发送,超时时必须响应ACK。是为了让lwip的tcp_fasttmr()​定时器超时时,检查当前连接是否存在延迟ACK,如果存在,则响应ACK。

  • 其实就是表示当前连接存在延迟发送的ACK,如果超时了,一定要把这个延迟发送的ACK发送出去。
  • 需要注意的是,这里的250ms定时器是一直在跑的,是每250ms会检查处理一次。如果某个时刻标记了TF_ACK_DELAY​,且一直未满足发送,并不是此刻起等待250ms后才发送ACK,而是下一个250ms定时器到来时就发送这个延迟的ACK了,可能下一个ms就到了。

糊涂窗口综合症的处理

作为接收方时的解决:小窗口不通告。

在滑动更新接收窗口size时,小窗口不通告。而更新接收窗口是在应用层从TCP接收缓冲区成功提取数据时更新的,所以查看tcp_recved()​即可:

/* 更新滑动窗口。支持糊涂窗口避免算法。 */
wnd_inflation = tcp_update_rcv_ann_wnd(pcb);

tcp_update_rcv_ann_wnd():

  • 小窗口不通告。滑动大于LWIP_MIN((TCP_WND / 2), pcb->mss)​才通告。
/**
 * 窗口滑动。窗口滑动阈值:LWIP_MIN((TCP_WND / 2), pcb->mss)
 * 返回窗口滑动偏移值。
 *
 * 通俗点:如果窗口滑动后能接收大于等于 LWIP_MIN((TCP_WND / 2), pcb->mss) 这么多数据时,才滑动窗口通告值。
 *        如果窗口滑动后,只能接收一点点数据,还不如不滑动呢。
 */
u32_t
tcp_update_rcv_ann_wnd(struct tcp_pcb *pcb)
{
  u32_t new_right_edge;

  LWIP_ASSERT("tcp_update_rcv_ann_wnd: invalid pcb", pcb != NULL);
  /* 新的接收窗口右边沿 */
  new_right_edge = pcb->rcv_nxt + pcb->rcv_wnd;

  if (TCP_SEQ_GEQ(new_right_edge, pcb->rcv_ann_right_edge + LWIP_MIN((TCP_WND / 2), pcb->mss))) {
    /* 新窗口右边沿比旧窗口右边沿多出一个MSS时(或1/2 宏定义接收窗口大小时),更新窗口通告值大小为当前新的接收窗口大小 */
    pcb->rcv_ann_wnd = pcb->rcv_wnd;
    return new_right_edge - pcb->rcv_ann_right_edge;
  } else { /* 新、旧窗口右边沿还没拉开足够距离,不更新通告窗口 */
    if (TCP_SEQ_GT(pcb->rcv_nxt, pcb->rcv_ann_right_edge)) {
      /* 接收窗口已满 */
      /* 窗口通告值设为0,不允许再发送数据到本地,等待窗口滑动后再发送 */
      pcb->rcv_ann_wnd = 0;
    } else {
      /* 窗口未满,而且滑动的长度不满足滑动阈值,保持窗口右边沿,不滑动 */
      u32_t new_rcv_ann_wnd = pcb->rcv_ann_right_edge - pcb->rcv_nxt;
#if !LWIP_WND_SCALE
      LWIP_ASSERT("new_rcv_ann_wnd <= 0xffff", new_rcv_ann_wnd <= 0xffff);
#endif
      pcb->rcv_ann_wnd = (tcpwnd_size_t)new_rcv_ann_wnd;
    }
    return 0;
  }
}

作为发送方:

  • 参考nagle算法。