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

2023-07-29,,

lwip_14_TCP协议之可靠传输的实现

前言

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

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

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

两个时钟处理函数

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

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

    快时钟:tcp_fasttmr()

      快时钟周期为TCP_FAST_INTERVAL​,默认250ms。
      主要作用:遍历处理PCB:

        处理延迟ACK,将其发出。
        通知应用层获取接收缓冲区中的数据。

    慢时钟:tcp_slowtmr()

      快时钟周期为TCP_SLOW_INTERVAL​,默认500ms。
      主要作用:遍历处理PCB:

        各种超时计算。如TCP4大定时器:重传定时器、保活定时器、坚持定时器、2MSL定时器。
        上面这几个定时器也包含了各自的业务。如重传定时器中包含了拥塞发生后的算法,坚持定时器中包含了窗口探查等等。
        还有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 */

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

    PCB中的最近交互时间搓:
  /* 保存这控制块的TCP节拍起始值。用于当前PCB的时基初始值参考 */
/* 活动计时器,收到合法报文时自动更新。 */
u32_t tmr;
    全局变量当前时间戳:
/* 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变量来辅助:

    PCB中的最近交互时间搓:
  /* 保存这控制块的TCP节拍起始值。用于当前PCB的时基初始值参考 */
/* 活动计时器,收到合法报文时自动更新。 */
u32_t tmr;
    全局变量当前时间戳:
/* 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算法。

【lwip】14-TCP协议分析之TCP协议之可靠传输的实现(TCP干货)的相关教程结束。

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

下载本文的Word格式文档,以方便收藏与打印。