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相关变量:

  1. /* RTT (round trip time) 估算 */
  2. u32_t rttest; /* RTT测量,发送时的时间戳。精度500ms */
  3. u32_t rtseq; /* 开始计算RTT时对应的seq号 */
  4. /* RTT估计出的平均值和时间差。
  5. 注意:sa为算法中8倍的均值;sv为4倍的方差。再去分析LWIP实现RTO的算法。 */
  6. s16_t sa, sv; /* @see "Congestion Avoidance and Control" by Van Jacobson and Karels */
  7. s16_t rto; /* 重传超时时间。节拍宏:TCP_SLOW_INTERVAL。初始超时时间宏:LWIP_TCP_RTO_TIME *//* retransmission time-out (in ticks of TCP_SLOW_INTERVAL) */
  8. u8_t nrtx; /* 重发次数 */

发送前记录发出的时间搓

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

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

计算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。
  1. /* RTT测量:如果当前ACK已经把我们附带RTT测量的报文也ACK了,则可以计算RTT */
  2. if (pcb->rttest && TCP_SEQ_LT(pcb->rtseq, ackno)) {
  3. /* RTT值不应该超过32K,因为这是tcp计时器滴答和往返不应该那么长… */
  4. m = (s16_t)(tcp_ticks - pcb->rttest); /* 算出RTT */
  5. LWIP_DEBUGF(TCP_RTO_DEBUG, ("tcp_receive: experienced rtt %"U16_F" ticks (%"U16_F" msec).\n",
  6. m, (u16_t)(m * TCP_SLOW_INTERVAL)));
  7. /* RTO算法有很多种,LWIP使用的是Jacobson提出的,具体格式如下: */
  8. /* M:某次测量的RTT值。A:RTT平均值。D:RTT估计方差。g:常数1/8。h:常数1/4。 */
  9. /* 说明:pcb->sa是8倍的RTT平均值。pcb->sv是4倍的方差。 */
  10. /* ERR = M-A */
  11. /* A = A+g*ERR */
  12. /* D = D+h*(|ERR|-D) */
  13. /* RTO = A+4*D */
  14. /* 算出平滑RTT */
  15. m = (s16_t)(m - (pcb->sa >> 3)); /* 偏差 = RTT - 均值 */
  16. pcb->sa = (s16_t)(pcb->sa + m); /* 均值 = 原均值 + (1/8)偏差 */
  17. /* 绝对差 = 差值取绝对值 */
  18. if (m < 0) {
  19. m = (s16_t) - m;
  20. }
  21. m = (s16_t)(m - (pcb->sv >> 2));
  22. pcb->sv = (s16_t)(pcb->sv + m); /* 方差 = 原方差 + (1/4)(绝对差 - 原方差) */
  23. pcb->rto = (s16_t)((pcb->sa >> 3) + pcb->sv); /* RTO = 均值 + 4*方差 */
  24. LWIP_DEBUGF(TCP_RTO_DEBUG, ("tcp_receive: RTO %"U16_F" (%"U16_F" milliseconds)\n",
  25. pcb->rto, (u16_t)(pcb->rto * TCP_SLOW_INTERVAL)));
  26. /* 本次RTT测量完毕,关闭本次RTT测量 */
  27. pcb->rttest = 0;
  28. }

RTO退避指数

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

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

RTO退避指数:

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

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

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

超时重传&拥塞窗口变化

超时重传相关定时器

在PCB控制块:

  1. /* 超时重传计时器值,当该值大于RTO值时,重传报文 */
  2. 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。
  • 所有空中数据迁回待发送缓冲区准备重新发送。
  • 触发发送。
  1. /* 如果开启了重传计时器,则计时 */
  2. if ((pcb->rtime >= 0) && (pcb->rtime < 0x7FFF)) {
  3. ++pcb->rtime;
  4. }
  5. if (pcb->rtime >= pcb->rto) {
  6. /* 发生超时 */
  7. LWIP_DEBUGF(TCP_RTO_DEBUG, ("tcp_slowtmr: rtime %"S16_F
  8. " pcb->rto %"S16_F"\n",
  9. pcb->rtime, pcb->rto));
  10. /* 如果unacked队列报文迁移成功

  11. PCB还有unsent报文,但是没有unacked报文(这意味着存在某种原因导致发送报文段失败
  12. (如:可追踪下tcp_output_segment(),开启RTO,但是发送失败)) */
  13. if ((tcp_rexmit_rto_prepare(pcb) == ERR_OK) || ((pcb->unacked == NULL) && (pcb->unsent != NULL))) {
  14. /* TCP客户端发起的SYN不纳入RTO算法范围 */
  15. if (pcb->state != SYN_SENT) {
  16. /* RTO计算 */
  17. u8_t backoff_idx = LWIP_MIN(pcb->nrtx, sizeof(tcp_backoff) - 1);
  18. int calc_rto = ((pcb->sa >> 3) + pcb->sv) << tcp_backoff[backoff_idx];
  19. pcb->rto = (s16_t)LWIP_MIN(calc_rto, 0x7FFF);
  20. }
  21. /* 复位超时计时器 */
  22. pcb->rtime = 0;
  23. /* 发生重传,触发拥塞避免算法:更新慢启动上门限值为有效窗口的一半 */
  24. eff_wnd = LWIP_MIN(pcb->cwnd, pcb->snd_wnd);
  25. pcb->ssthresh = eff_wnd >> 1;
  26. /* 慢启动上门限不能低于2个MSS, */
  27. if (pcb->ssthresh < (tcpwnd_size_t)(pcb->mss << 1)) {
  28. pcb->ssthresh = (tcpwnd_size_t)(pcb->mss << 1);
  29. }
  30. /* 超时引起的拥塞避免算法:拥塞窗口需要更新为一个MSS。重新进行慢启动。 */
  31. pcb->cwnd = pcb->mss;
  32. LWIP_DEBUGF(TCP_CWND_DEBUG, ("tcp_slowtmr: cwnd %"TCPWNDSIZE_F
  33. " ssthresh %"TCPWNDSIZE_F"\n",
  34. pcb->cwnd, pcb->ssthresh));
  35. /* 复位上次成功发送的字节数为0(因为unacked都为NULL) */
  36. pcb->bytes_acked = 0;
  37. /* 调用能统计重传次数的API把数据再次发送出去 */
  38. tcp_rexmit_rto_commit(pcb);
  39. }
  40. }

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

相关变量

保活定时器在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​即可修改该值。
  1. /* keepalive计时器的上限值 */
  2. u32_t keep_idle;
  3. #if LWIP_TCP_KEEPALIVE
  4. /* keepalive探测间隔 */
  5. u32_t keep_intvl;
  6. /* keepalive探测的上限次数 */
  7. u32_t keep_cnt;
  8. #endif /* LWIP_TCP_KEEPALIVE */

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

  1. PCB中的最近交互时间搓:
  1. /* 保存这控制块的TCP节拍起始值。用于当前PCB的时基初始值参考 */
  2. /* 活动计时器,收到合法报文时自动更新。 */
  3. u32_t tmr;
  1. 全局变量当前时间戳:
  1. /* Incremented every coarse grained timer shot (typically every 500 ms). */
  2. u32_t tcp_ticks;

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

保活机制源码实现

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

  1. /* Check if KEEPALIVE should be sent */
  2. if (ip_get_option(pcb, SOF_KEEPALIVE) &&
  3. ((pcb->state == ESTABLISHED) ||
  4. (pcb->state == CLOSE_WAIT))) {
  5. if ((u32_t)(tcp_ticks - pcb->tmr) >
  6. (pcb->keep_idle + TCP_KEEP_DUR(pcb)) / TCP_SLOW_INTERVAL) {
  7. LWIP_DEBUGF(TCP_DEBUG, ("tcp_slowtmr: KEEPALIVE timeout. Aborting connection to "));
  8. ip_addr_debug_print_val(TCP_DEBUG, pcb->remote_ip);
  9. LWIP_DEBUGF(TCP_DEBUG, ("\n"));
  10. ++pcb_remove;
  11. ++pcb_reset;
  12. } else if ((u32_t)(tcp_ticks - pcb->tmr) >
  13. (pcb->keep_idle + pcb->keep_cnt_sent * TCP_KEEP_INTVL(pcb))
  14. / TCP_SLOW_INTERVAL) {
  15. err = tcp_keepalive(pcb);
  16. if (err == ERR_OK) {
  17. pcb->keep_cnt_sent++;
  18. }
  19. }
  20. }

保活探测报文

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

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

tcp_keepalive()​:pcb->snd_nxt - 1

  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次,超过也没收到对端响应,则关闭当前连接。
  1. /* 坚持定时器:用于解决远端接收窗口为0时,定时询问使用 */
  2. u8_t persist_cnt; /* 坚持定时器节拍计数值 */
  3. u8_t persist_backoff; /* 坚持定时器探查报文时间间隔列表索引及开关 */
  4. u8_t persist_probe; /* 坚持定时器窗口0时发出的探查报文次数 */

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

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

零窗口探测源码实现

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

  1. if (pcb->persist_backoff > 0) {
  2. LWIP_ASSERT("tcp_slowtimr: persist ticking with in-flight data", pcb->unacked == NULL);
  3. LWIP_ASSERT("tcp_slowtimr: persist ticking with empty send buffer", pcb->unsent != NULL);
  4. if (pcb->persist_probe >= TCP_MAXRTX) {
  5. ++pcb_remove; /* max probes reached */
  6. } else {
  7. u8_t backoff_cnt = tcp_persist_backoff[pcb->persist_backoff - 1];
  8. if (pcb->persist_cnt < backoff_cnt) {
  9. pcb->persist_cnt++;
  10. }
  11. if (pcb->persist_cnt >= backoff_cnt) {
  12. int next_slot = 1; /* increment timer to next slot */
  13. /* If snd_wnd is zero, send 1 byte probes */
  14. if (pcb->snd_wnd == 0) {
  15. if (tcp_zero_window_probe(pcb) != ERR_OK) {
  16. /* 发送窗口探查失败,即是本次坚持定时器相关报文发送失败,不能清空现有计时数值,因为下次进入需要马上补回窗口探查报文的发送 */
  17. next_slot = 0; /* try probe again with current slot */
  18. }
  19. /* snd_wnd not fully closed, split unsent head and fill window */
  20. } else {
  21. /* 窗口不够大,那切割也得发送 */
  22. if (tcp_split_unsent_seg(pcb, (u16_t)pcb->snd_wnd) == ERR_OK) {
  23. if (tcp_output(pcb) == ERR_OK) {
  24. /* 切割后,发送成功会关闭坚持定时器清理相关值,这里标记下后面不用刷新坚持定时器相关值了 */
  25. next_slot = 0;
  26. }
  27. }
  28. }
  29. if (next_slot) {
  30. /* 坚持定时器本次轮询已经成功发出相关报文了,进入下次轮询计时 */
  31. pcb->persist_cnt = 0;
  32. if (pcb->persist_backoff < sizeof(tcp_persist_backoff)) {
  33. pcb->persist_backoff++;
  34. }
  35. }
  36. }
  37. }
  38. }

零窗口探测报文

调用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中的最近交互时间搓:
  1. /* 保存这控制块的TCP节拍起始值。用于当前PCB的时基初始值参考 */
  2. /* 活动计时器,收到合法报文时自动更新。 */
  3. u32_t tmr;
  1. 全局变量当前时间戳:
  1. /* Incremented every coarse grained timer shot (typically every 500 ms). */
  2. u32_t tcp_ticks;

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

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

  • TCP_MSL​:默认为60秒。
  1. pcb = tcp_tw_pcbs;
  2. while (pcb != NULL) {
  3. LWIP_ASSERT("tcp_slowtmr: TIME-WAIT pcb->state == TIME-WAIT", pcb->state == TIME_WAIT);
  4. pcb_remove = 0;
  5. /* Check if this PCB has stayed long enough in TIME-WAIT */
  6. if ((u32_t)(tcp_ticks - pcb->tmr) > 2 * TCP_MSL / TCP_SLOW_INTERVAL) {
  7. ++pcb_remove;
  8. }
  9. /* If the PCB should be removed, do it. */
  10. if (pcb_remove) {
  11. struct tcp_pcb *pcb2;
  12. tcp_pcb_purge(pcb);
  13. /* Remove PCB from tcp_tw_pcbs list. */
  14. if (prev != NULL) {
  15. LWIP_ASSERT("tcp_slowtmr: middle tcp != tcp_tw_pcbs", pcb != tcp_tw_pcbs);
  16. prev->next = pcb->next;
  17. } else {
  18. /* This PCB was the first. */
  19. LWIP_ASSERT("tcp_slowtmr: first pcb == tcp_tw_pcbs", tcp_tw_pcbs == pcb);
  20. tcp_tw_pcbs = pcb->next;
  21. }
  22. pcb2 = pcb;
  23. pcb = pcb->next;
  24. tcp_free(pcb2);
  25. } else {
  26. prev = pcb;
  27. pcb = pcb->next;
  28. }

拥塞控制

相关变量

PCB控制块中:

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

慢启动

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

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

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

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

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

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

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

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

拥塞避免

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

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

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

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

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

快重传在PCB中的变量:

  1. 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的判断条件、做法如下:

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

具体源码:

  1. /* Clause 1:没有ACK新数据 */
  2. if (TCP_SEQ_LEQ(ackno, pcb->lastack)) {
  3. /* Clause 2:报文段中没有数据,也没有SYN、FIN */
  4. if (tcplen == 0) {
  5. /* Clause 3:本地发送窗口没有更新 */
  6. if (pcb->snd_wl2 + pcb->snd_wnd == right_wnd_edge) {
  7. /* Clause 4:本地还有unacked数据,重传计时器还在跑 */
  8. if (pcb->rtime >= 0) {
  9. /* Clause 5:收到的ACK是本连接历史最大的ACK */
  10. if (pcb->lastack == ackno) {
  11. if ((u8_t)(pcb->dupacks + 1) > pcb->dupacks) { /* 防溢出 */
  12. ++pcb->dupacks; /* 收到重复的ACK */
  13. }
  14. if (pcb->dupacks > 3) {
  15. /* Inflate the congestion window */
  16. /* 拥塞避免:拥塞窗口cwnd+一个MSS */
  17. TCP_WND_INC(pcb->cwnd, pcb->mss);
  18. }
  19. if (pcb->dupacks >= 3) {
  20. /* 快重传:是检查unacked和TF_INFR标志位来确定是否触发快重传。 */
  21. tcp_rexmit_fast(pcb);
  22. }
  23. }
  24. }
  25. }
  26. }
  27. }

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

  1. /**
  2. * 收到3个及以上重复ACK才会调用当前函数实现快重传算法。
  3. */
  4. void
  5. tcp_rexmit_fast(struct tcp_pcb *pcb)
  6. {
  7. LWIP_ASSERT("tcp_rexmit_fast: invalid pcb", pcb != NULL);
  8. /* 存在未被ACK的数据 && 快重传标志位没有被标记 */
  9. if (pcb->unacked != NULL && !(pcb->flags & TF_INFR)) {
  10. /* 重传pcb->unacked队列中第一个报文 */
  11. LWIP_DEBUGF(TCP_FR_DEBUG,
  12. ("tcp_receive: dupacks %"U16_F" (%"U32_F
  13. "), fast retransmit %"U32_F"\n",
  14. (u16_t)pcb->dupacks, pcb->lastack,
  15. lwip_ntohl(pcb->unacked->tcphdr->seqno)));
  16. if (tcp_rexmit(pcb) == ERR_OK) {
  17. /* 设置慢启动上门限pcb->ssthresh = MIN(当前拥塞窗口,发送窗口) 的一半。
  18. 但是不能低于2个MSS */
  19. pcb->ssthresh = LWIP_MIN(pcb->cwnd, pcb->snd_wnd) / 2;
  20. /* The minimum value for ssthresh should be 2 MSS */
  21. if (pcb->ssthresh < (2U * pcb->mss)) {
  22. LWIP_DEBUGF(TCP_FR_DEBUG,
  23. ("tcp_receive: The minimum value for ssthresh %"TCPWNDSIZE_F
  24. " should be min 2 mss %"U16_F"...\n",
  25. pcb->ssthresh, (u16_t)(2 * pcb->mss)));
  26. pcb->ssthresh = 2 * pcb->mss;
  27. }
  28. /* 拥塞窗口更新为 = 慢启动上门限 + 3MSS */
  29. pcb->cwnd = pcb->ssthresh + 3 * pcb->mss;
  30. /* 标记PCB处于快重传状态 */
  31. tcp_set_flags(pcb, TF_INFR);
  32. /* 重置超时重传计时器 */
  33. pcb->rtime = 0;
  34. }
  35. }
  36. }

快恢复

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

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

快恢复算法:

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

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

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

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

Nagle算法

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

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

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

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

延迟确认

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

tcp_fasttmr()​:

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

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()​即可:

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

tcp_update_rcv_ann_wnd():

  • 小窗口不通告。滑动大于LWIP_MIN((TCP_WND / 2), pcb->mss)​才通告。
  1. /**
  2. * 窗口滑动。窗口滑动阈值:LWIP_MIN((TCP_WND / 2), pcb->mss)
  3. * 返回窗口滑动偏移值。
  4. *
  5. * 通俗点:如果窗口滑动后能接收大于等于 LWIP_MIN((TCP_WND / 2), pcb->mss) 这么多数据时,才滑动窗口通告值。
  6. * 如果窗口滑动后,只能接收一点点数据,还不如不滑动呢。
  7. */
  8. u32_t
  9. tcp_update_rcv_ann_wnd(struct tcp_pcb *pcb)
  10. {
  11. u32_t new_right_edge;
  12. LWIP_ASSERT("tcp_update_rcv_ann_wnd: invalid pcb", pcb != NULL);
  13. /* 新的接收窗口右边沿 */
  14. new_right_edge = pcb->rcv_nxt + pcb->rcv_wnd;
  15. if (TCP_SEQ_GEQ(new_right_edge, pcb->rcv_ann_right_edge + LWIP_MIN((TCP_WND / 2), pcb->mss))) {
  16. /* 新窗口右边沿比旧窗口右边沿多出一个MSS时(或1/2 宏定义接收窗口大小时),更新窗口通告值大小为当前新的接收窗口大小 */
  17. pcb->rcv_ann_wnd = pcb->rcv_wnd;
  18. return new_right_edge - pcb->rcv_ann_right_edge;
  19. } else { /* 新、旧窗口右边沿还没拉开足够距离,不更新通告窗口 */
  20. if (TCP_SEQ_GT(pcb->rcv_nxt, pcb->rcv_ann_right_edge)) {
  21. /* 接收窗口已满 */
  22. /* 窗口通告值设为0,不允许再发送数据到本地,等待窗口滑动后再发送 */
  23. pcb->rcv_ann_wnd = 0;
  24. } else {
  25. /* 窗口未满,而且滑动的长度不满足滑动阈值,保持窗口右边沿,不滑动 */
  26. u32_t new_rcv_ann_wnd = pcb->rcv_ann_right_edge - pcb->rcv_nxt;
  27. #if !LWIP_WND_SCALE
  28. LWIP_ASSERT("new_rcv_ann_wnd <= 0xffff", new_rcv_ann_wnd <= 0xffff);
  29. #endif
  30. pcb->rcv_ann_wnd = (tcpwnd_size_t)new_rcv_ann_wnd;
  31. }
  32. return 0;
  33. }
  34. }

作为发送方:

  • 参考nagle算法。

【lwip】14-TCP协议分析之TCP协议之可靠传输的实现(TCP干货)的更多相关文章

  1. 协议分析 - DHCP协议解码详解

    协议分析 - DHCP协议解码详解 [DHCP协议简介]         DHCP,全称是 Dynamic Host Configuration Protocol﹐中文名为动态主机配置协议,它的前身是 ...

  2. TCP协议中是如何保证报文可靠传输的

    1.什么是TCP的可靠传输 它向应用层提供的数据是无差错的.有序的.无丢失的,换言之就是:TCP最终递交给应用层的数据和发送者发送的数据是一模一样的. 2.TCP保证可靠传输的办法有哪些? TCP采用 ...

  3. 协议分析之qq协议---qq登录

    QQ 协议分析:获取各类登录会话密钥 我们知道QQ的一些会话密钥是在登录过程中生成的,尤其是Session Key,有了它便可以解密出聊天文本内容.本文主要是了解一下QQ的加密机制,首先是用嗅探工具W ...

  4. [转] 用协议分析工具学习TCP/IP

    一.前言 目前,网络的速度发展非常快,学习网络的人也越来越多,稍有网络常识的人都知道TCP/IP协议是网络的基础,是Internet的语言,可以说没有TCP/IP协议就没有互联网的今天.目前号称搞网的 ...

  5. TCP/IP协议分析

    一;前言 学习过TCP/IP协议的人多有一种感觉,这东西太抽象了,没有什么数据实例,看完不久就忘了.本文将介绍一种直观的学习方法,利用协议分析工具学习TCP/IP,在学习的过程中能直观的看到数据的具体 ...

  6. TCP/IP协议分析(推荐)

    一;前言 学习过TCP/IP协议的人多有一种感觉,这东西太抽象了,没有什么数据实例,看完不久就忘了.本文将介绍一种直观的学习方法,利用协议分析工具学习TCP/IP,在学习的过程中能直观的看到数据的具体 ...

  7. 计算机网络 学习笔记-传输层:TCP协议简介

    概述: TCP传输前先要建立连接 TCP在传输层 点对点,一条TCP只能连接两个端点 可靠传输.无差错.不丢失.不重复.按顺序 全双工 字节流 TCP报文段 TCP报文段的报头前20字节是固定的,后面 ...

  8. TCP/IP笔记(八)应用层协议

    TCP/IP的应用层涵盖了OSI参考模型中第5.第6.第7层的所有功能,不仅包含了管理通信连接的会话层功能.转换数据格式的标识层功能,还包括与对端主机交互的应用层功能在内的所有功能. 利用网络的应用程 ...

  9. TCP协议如何保证可靠传输?

    一.TCP的可靠传输如何保证? 在TCP连接中,数据流必须以正确的顺序传送给对方.TCP的可靠性是通过顺序编号和确认(ACK)实现的.TCP在开始传送一个段时,为准备重传而首先将该段插入到发送队列中, ...

  10. TCP协议如何保证可靠传输

    TCP协议如何保证可靠传输 概述: TCP协议保证数据传输可靠性的方式主要有: (校 序 重 流 拥) 校验和: 发送的数据包的二进制相加然后取反,目的是检测数据在传输过程中的任何变化.如果收到段的检 ...

随机推荐

  1. P7213 [JOISC2020] 最古の遺跡 3 乱写

    不想写题解了,把写在草稿纸上的东西整理了一下 感谢 crashed 大佬的题解与对本人问题的回答,没有他我就不会搞懂这道神仙计数题.

  2. 【Unity3D】常用快捷键

    1 单键 Q:扒手拖动(Scene) W:移动(GameObject) E:旋转 R:三维缩放(GameObject 不会变形) T:单维缩放(GameObject 会变形) Y:平移.旋转.缩放 F ...

  3. SQL优化---慢SQL优化

    于2023.3.17日重写,之前写的还是太八股文太烂了一点逻辑都没有,这次重新写了之后,感觉数据库优化还是很有必要的,之前觉得不必要是我年轻了. 一.如何定位慢SQL语句 1.通过慢查询日志查询已经执 ...

  4. Node.js爬取百度图片瀑布流,使用class类封装。

    //爬取百度高清图片 const phantom = require('phantom') const express = require('express'); const app = expres ...

  5. kubernetes(k8s)命名空间一直Terminating

    root@hello:~# kubectl get ns NAME STATUS AGE auth Terminating 34m default Active 23h kube-node-lease ...

  6. 五月二号java基础知识

    1.使用Runnable接口可以轻松实现多个线程共享相同数据,只要用用一个可运行对象作为参数创建多个线程就可以了2.当一个线程对共享的数据进行操作时,应使之成为一个"原子操作"即在 ...

  7. 家用wife密码设置

    1.在浏览器上面输入ip地址:http://192.168.1.1/或http://192.168.0.1/出现路由器登陆窗口输入用户名跟密码.用户名默认一般为:admin,密码为空或为:admin ...

  8. 简单记录下RestTemplate使用方法

    1.设置get方法 ResponseEntity<JSONObject> responseEntity= restTemplate.getForEntity(url,JSONObject. ...

  9. 浅谈如何使用 github.com/kardianos/service

    在实际开发过程中,有时候会遇到如何编写Go开机自启服务的需求,在linux中我们可以使用systemd来进行托管,windows下可以通过注册表来实现,mac下可以通过launchd来实现,上面的方式 ...

  10. Java的抽象类 & 接口

    抽象类 如果自下而上在类的继承层次结构中上移,位于上层的类更具有通用性,甚至可能更加抽象.从某种角度看,祖先类更加通用,人们只将它作为派生其他类的基类,而不作为想使用的特定的实例类.例如,考虑一下对 ...