这节讲解TCP的坚持定时器和保活定时器,先看坚持定时器。
TCP的接收方通过通告窗口大小来告诉发送方自己可以接收的数据字节数,接收方采用这种方式来进行流量控制。假如接收方通告的窗口大小为0会发生什么情况呢?这将有效地阻止发送方传送数据,直到通告窗口变为非0为止。
发送方接到0窗口通告时,则会停止数据段的发送,直到接收方通过非0的窗口。很重要的一点,TCP必须能够处理含新非0窗口通告的数据包丢失的情况,通常这个非0窗口通告是在一个不含任何数据的ACK包中发送的。ACK的传输并不可靠,也就是说,TCP不对ACK报文段进行确认(很明显,也就不会存在该ACK报文段的重发),TCP只确认那些包含有数据的ACK报文段。
如果一个确认丢失了,则双方就有可能因为等待对方而使连接终止:接收方等待接收数据(因为它已经向发送方通告了一个非 0的窗口),而发送方在等待允许它继续发送数据的非0窗口更新。为防止这种死锁情况的发生,发送方使用一个坚持定时器 (persisttimer)来周期性地向接收方查询,以便发现窗口是否已增大。这些从发送方发出的报文段称为窗口探查 (windowprobe)。
控制块中有两个字段与坚持定时器有关:persist_cnt和persist_backoff。persist_cnt用于坚持定时器计数,当计数值超过某个值时,则发出窗口探查数据包。persist_backoff表示坚持定时器是否被启动(是否>0)以及已经发出去了几个探查数据包(persist_backoff为大于0的整数时)。若坚持定时器已经被启动,则在内核500ms中断处理函数tcp_slowtmr会进行如下处理:
if (pcb->persist_backoff> 0) {
pcb->persist_cnt++;
if (pcb->persist_cnt>=tcp_persist_backoff[pcb->persist_backoff-1]) { //计数值超过
// 某个计数值上限时则进行窗口探查
pcb->persist_cnt = 0; //复位计数值
if (pcb->persist_backoff< sizeof(tcp_persist_backoff)) { // 增加计数值上限
pcb->persist_backoff++;
}
tcp_zero_window_probe(pcb); //发送一个窗口探查包
}
}
有两点需要提及的。一是数组tcp_persist_backoff,它保存了一系列的坚持定时器的计数值上限,persist_backoff是该数组的索引。即发送第一个探测包的时间为3次500ms(1.5s)中断后,发送第二个探测包的时间为6次(3s)后,当第六次及其以上发送探测包时,时间间隔都变为120次中断(60s)。
const u8_t tcp_persist_backoff[7] = {3, 6, 12, 24, 48, 96, 120 };
再来看看函数tcp_zero_window_probe是如何进行窗口探查的。tcp_zero_window_probe函数很简单,组装一个含一字节数据的TCP报文段发送出去,这个字节的数据是从unacked或从unsent队列上取得的,且窗口探查包的数据序号字段被填成这个字节的数据序号。所以当这两个队列都为空时,则表示没有任何数据需要处理,当然窗口探查也没有必要进行;当这个字节的数据是从unacked队列中得到时,由于该队列是已经被发送过的,对应窗口探查到达接收端时,会被看做是重复报文而不进行相关数据处理,只向发送方是返回一个ACK包;当这个字节的数据是从unsent队列中得到时,则这个字节数据到达接收端时会被挂接在ooseq队列或直接递交给上层应用,当发送方窗口允许时,unsent队列中的第一个数据段的第一个字节被发送后在接收方看来是重复的,接收方能够检测出这个重复的字节,并直接删除该字节数据。从整个过程可以看出,窗口探查包里面的1字节数据并不影响整个数据传输过程。
什么时候启动一个窗口探查呢?这是在函数tcp_output最后完成的。当发送完能够发送的数据段后,unsent队列还不为空,且此时窗口探查未启动,且当前窗口太小以至不能发送下一个数据段,此时要启动窗口探查。
if (seg != NULL&&pcb->persist_backoff == 0&&
ntohl(seg->tcphdr->seqno) -pcb->lastack + seg->len> pcb->snd_wnd) {
pcb->persist_cnt =0;
pcb->persist_backoff =1;
}
什么时候停止一个窗口探查呢?从前面已经知道,在函数tcp_receive刚开始的部分,就会根据接收数据包的情况更新发送窗口,也即是在这里若检测到一个非0窗口,则停止窗口探查,如下所示。
if(TCP_SEQ_LT(pcb->snd_wl1, seqno)
(pcb->snd_wl1 == seqno&&TCP_SEQ_LT(pcb->snd_wl2, ackno))
(pcb->snd_wl2 == ackno&& tcphdr->wnd> pcb->snd_wnd)) { // 若满足窗口跟新条件
pcb->snd_wnd =tcphdr->wnd;
pcb->snd_wl1 =seqno;
pcb->snd_wl2 =ackno;
if (pcb->snd_wnd> 0 &&pcb->persist_backoff > 0){
pcb->persist_backoff =0;
}
}
再来看保活定时器。如果一个已经处于稳定状态的TCP连接双方都没有向对方发送数据,则在两个TCP模块之间不交换任何信息。然而很多时候,连接的双方都希望知道对方的是否处于非活动状态。常见的状况是一个服务器希望知道客户主机是否崩溃并关机或者崩溃又重新启动,许多TCP/IP实现中提供的保活定时器可以提供这种检测功能。
保活功能主要是为服务器应用程序提供的。服务器应用程序希望知道客户主机是否崩溃,从而可以合理分配客户使用资源。如果一个给定的连接在两个小时之内没有任何动作,则服务器就向客户发送一个探查报文段。客户主机必处于以下4个状态之一:
1)客户主机依然正常运行,并从服务器可达。客户的TCP响应正常,而服务器也知道对方是正常工作的。服务器在两小时以后将保活定时器复位,并发送探查报文。如果在两个小时定时器到时间之前有应用程序的通信量通过此连接,则定时器在交换数据后的未来2小时再复位,发送探查报文。
2)客户主机已经崩溃,并且关闭或者正在重新启动,在这些情况下,客户的TCP都不会有任何响应。服务器将不能够收到对探查报文的响应,并在等待75秒后超时,以后服务器还会发送9个这样的探查报文,每个间隔75秒。如果服务器没有收到一个响应,它就认为客户主机已经关闭并终止连接。
3)客户主机崩溃并已经重新启动。这时服务器将收到一个对其保活探查的响应,但是这个响应是一个复位,使得服务器终止这个连接。
4)客户主机正常运行,但是从服务器不可达。这与状态2相同,因为TCP不能够区分状态4与状态2之间的区别,它所能发现的就是没有收到探查的响应。
在第1种情况下,服务器的应用程序没有感觉到保活探查的发生。TCP层负责一切,这个过程对应用程序都是不可见的。当第2、3或4种情况发生时,服务器应用程序将收到来自它的TCP层的差错报告(通常服务器应用程序向网络发出了读操作请求,然后等待来自客户的数据。如果保活功能返回一个差错,则该差错将作为读操作的返回值返回给应用程序)。在第2种情况下,差错是诸如“连接超时”之类的信息,而在第3种情况则为“连接被对方复位” 。第4种情况看起来像是连接超时,也可根据是否收到与连接有关的ICMP差错报文来判断是否是目的不可达引起的。
至此又会涉及TCP控制块中四个字段:keep_idle、keep_intvl、keep_cnt和keep_cnt_sent。其中keep_intvl和keep_cnt与编译选项LWIP_TCP_KEEPALIVE相关,当该编译选项为1时,keep_intvl和keep_cnt字段分别用于保存用户自定义的保活时间选项值,这点在后面介绍。实际应用中使用系统默认的保活时间选项值即可,所以我们将LWIP_TCP_KEEPALIVE设置为0,则keep_intvl和keep_cnt字段不会被编译,自然也不在我们的讨论范围之内了。
keep_idle字段记录了在多久后进行保活探测,一般为2小时,keep_cnt_sent字段表示已经发送的保活数据包的个数。除了这两个字段外,还有几个与默认保活时间选项值宏定义:
#define
#define
#define
#define
//执行保活探测需要消耗的时间
用户可以通过宏LWIP_TCP_KEEPALIVE允许keep_intvl和keep_cnt字段,它们分别用于记录用户自定义的保活包时间间隔与保活包个数。当不使用自定义值时,就用上面的两个DEFAULT值作为保活选项值。
在这里,我们使用系统默认的保活选项值来分析保活的整个过程,此时字段keep_idle被设置为TCP_KEEPIDLE_DEFAULT的值。保活处理也是在内核500ms中断处理函数tcp_slowtmr中进行的。TCP控制块中还有一个字段要重新提及一下,即tmr记录了该TCP连接上最近一个数据段到来时的系统时间tcp_ticks值。
if((pcb->so_options& SOF_KEEPALIVE) &&
((pcb->state ==ESTABLISHED) || (pcb->state == CLOSE_WAIT))) {
if((u32_t)(tcp_ticks -pcb->tmr) >
(pcb->keep_idle +TCP_MAXIDLE) / TCP_SLOW_INTERVAL)
{
tcp_abort(pcb);
}
else if((u32_t)(tcp_ticks -pcb->tmr) > // 在2小时+9*75秒内则发送保活包
(pcb->keep_idle +pcb->keep_cnt_sent * TCP_KEEPINTVL_DEFAULT)
/ TCP_SLOW_INTERVAL)
{
tcp_keepalive(pcb);
pcb->keep_cnt_sent++;
}
}
tcp_abort函数用于释放一个连接,主要工作包括将控制块从相应的TCP链表中删除,若该连接上还有数据则释放数据所占用的内存空间,最后向对方发送一个RST数据包。tcp_keepalive函数用于发送一个保活包,保活包只是一个TCP首部,并不包含任何数据,所以不会对对方的数据接收造成影响。
关于保活选项的两个小时的空闲时间是可以改变。用户只要自己定义keep_idle的值就可以了,但是系统一般不建议修改这些值。Don'tchange this unless you know what you're doing!哈哈。。。