【lwip】14-TCP协议分析之TCP协议之可靠传输的实现(TCP干货)
2023/5/29 14:22:10
本文主要是介绍【lwip】14-TCP协议分析之TCP协议之可靠传输的实现(TCP干货),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
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干货)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-25【机器学习(二)】分类和回归任务-决策树(Decision Tree,DT)算法-Sentosa_DSML社区版
- 2024-11-23增量更新怎么做?-icode9专业技术文章分享
- 2024-11-23压缩包加密方案有哪些?-icode9专业技术文章分享
- 2024-11-23用shell怎么写一个开机时自动同步远程仓库的代码?-icode9专业技术文章分享
- 2024-11-23webman可以同步自己的仓库吗?-icode9专业技术文章分享
- 2024-11-23在 Webman 中怎么判断是否有某命令进程正在运行?-icode9专业技术文章分享
- 2024-11-23如何重置new Swiper?-icode9专业技术文章分享
- 2024-11-23oss直传有什么好处?-icode9专业技术文章分享
- 2024-11-23如何将oss直传封装成一个组件在其他页面调用时都可以使用?-icode9专业技术文章分享
- 2024-11-23怎么使用laravel 11在代码里获取路由列表?-icode9专业技术文章分享