【lwip】14-TCP協定之可靠傳輸的實現(TCP乾貨)

2023-05-29 15:01:47

前言

前面章節太長了,不得不分開。

這裡已原始碼為主,預設讀者已知曉概念或原理,概念或原理可以參考前面章節,有分析。

參考:李柱明部落格: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演演算法。