TCP系列43—拥塞控制—6、Congestion Window Validation(CWV)

一、概述

在RFC2861中,区分了TCP连接数据传输的三种状态

network-limited:TCP的数据传输受限于拥塞窗口而不能发送更多的数据

application-limited:TCP的数据传输速率受限与应用层的数据写入速率,并没有到达拥塞窗口上限,有些文档也称呼这种场景为data-limited

idle:发送端没有额外的数据等待发送,当数据发送间隔超过一个RTO的时候就认为是ilde态。

之前我们介绍慢启动和拥塞避免的过程都是基于conservation of packets principle和Ack clocking建立的,cwnd代表了对网络拥塞状态的一个评估,很明显拥塞控制要根据ACK来更新cwnd的前提条件是,当前的数据发送速率真实的反映了cwnd的状况,也就是说当前传输状态是network-limited。想象一个场景,假如tcp隔了很长时间没有发送数据包,即进入idle,那么当前真实的网络拥塞状态很可能就会与cwnd反映的网络状况有差距。而application-limited的场景下,受限数据的ACK报文还可能把cwnd增长到一个异常大的值,显然是不合理的。

基于上面提到的这个问题,RFC2861引入了拥塞窗口校验(CWV,Congestion Window Validation)算法,协议中给出的伪代码如下,其中tcpnow表示获取当前时间,T_last表示上次数据发送的时间,T_prev表示TCP从network-limited切换到application-limited状态的时间点,W_used表示程序实际使用的窗口大小。

  1. Initially:
  2.       T_last = tcpnow, T_prev = tcpnow, W_used = 0
  3.   After sending a data segment:
  4.       If tcpnow - T_last >= RTO
  5.           (The sender has been idle.)
  6.           ssthresh =  max(ssthresh, 3*cwnd/4)
  7.           For i=1  To (tcpnow - T_last)/RTO
  8.               win =  min(cwnd, receiver's declared max window)
  9.               cwnd =  max(win/2, MSS)
  10.           T_prev = tcpnow
  11.           W_used = 0
  12.       T_last = tcpnow
  13.       If window is full
  14.           T_prev = tcpnow
  15.           W_used = 0
  16.       Else
  17.           If no more data is available to send
  18.               W_used =  max(W_used, amount of unacknowledged data)
  19.               If tcpnow - T_prev >= RTO
  20.                   (The sender has been application-limited.)
  21.                   ssthresh =  max(ssthresh, 3*cwnd/4)
  22.                   win =  min(cwnd, receiver's declared max window)
  23.                   cwnd = (win + W_used)/2
  24.                   T_prev = tcpnow
  25.                   W_used = 0

可以看到基本思路是:当TCP idle超过一个RTO时更新ssthresh =  max(ssthresh, 3*cwnd/4),cwnd 每隔一个RTO更新为 max(win/2, MSS)。当TCP处于application-limited超过一个RTO的时候,更新ssthresh =  max(ssthresh, 3*cwnd/4),cwnd = (win + W_used)/2。

CWV是在RFC2861引入的,目前RFC2861已经被RFC7661取代,RFC7661中提出了一个new CWV的算法,不在区分application-limited和idle两种状态,统称为Rate-Limited,RFC7661对于RFC2861的评价是It had the  correct motivation but the wrong approach to solving this problem。翻译过来就是说提出RFC2861 CWV算法的人虽然脑子笨了点没能解决好问题但是动机还是好的。虽然有new CWV的patch,但目前(2016.9)最新的linux内核代码仍然是使用的RFC2861。因此本篇也以介绍RFC2861的CWV算法为主(实际上对于RFC7661我也没看完,只是看了协议前面的基本介绍)。

linux中CWV功能受到/proc/sys/net/ipv4/tcp_slow_start_after_idle参数的控制,这个参数设置为非0的时候打开CWV功能,默认是打开的。linux中使用is_cwnd_limited标记当前为network-limited的状态,is_cwnd_limited可以看成是每个rtt更新一次。linux在实现上实际与RFC2861有些差异,例如协议伪代码中If window is full这个条件,在慢启动阶段,linux会判断如果上一个窗口发出的数据中packets_out的最大值(max_packets_out)超过了cwnd的一半,就认为窗口是满的不会按照application-limited来更新ssthresh和cwnd,如果不是慢启动阶段则直接根据is_cwnd_limited来判断上一个窗口是否满,窗口或者说rtt的维护与RTO计算中rtt_seq状态变量的维护类似,同样是使用snd.una和snd.nxt来更新的。 对于W_used是以packets_out来更新的。对于ilde态的处理是在应用层write操作时候进行判断的。

上面这些差异是可以理解的,但是还有一处差异就是,linux在application-limited场景下,只有拥塞状态处于Open状态的时候才会更新ssthresh和cwnd,但是另外一方面Open状态下每次收到ack number反馈确认了新的数据包的时候又会更新T_prev,这样在linux中基本很难满足tcpnow - T_prev >= RTO这个条件,这就导致linux在application-limited场景下的处理与RFC2861相差甚远,一般只会触发idle场景处理,而不能进入application-limited场景。相关代码在git上已经找不到最初的修改记录了,具体原因也就不清楚。但是好在reno拥塞控制算法中当TCP发送端处于application-limited状态时候并不会更新cwnd。有可能是因为在application-limited场景下,直接在具体拥塞控制算法中控制不更新cwnd比原始的RFC2861 CWV算法效果更好吧。

二、wireshark示例

同样在测试进行前我们如下设置,使得server端与127.0.0.2的连接,初始cwnd=2,初始ssthresh=8,拥塞控制算法选择reno。关闭tso、gso功能

  1. ******@Inspiron:~$ sudo ip route add local 127.0.0.2 dev lo congctl reno initcwnd 2 ssthresh lock 8      #参考本系列destination metric文章
  2. ******@Inspiron:~$ sudo ethtool -K lo tso off gso off  #关闭tso gso以方便观察cwnd变化

此处的两个示例重点关注cwnd和ssthresh的变化,因为没有SACK及重传等干扰,sacked_out、lost_out、retrans_out中间变量一直为0,对于下面示例的场景linux内部计算的in_flight = packets_out - ( sacked_out + lost_out) + retrans_out =packets_out,与wireshark中in_fligth列是一致的,因此下面不在详细解释这些中间变量。对于有SACK及重传时这些状态变量的变化庆请参考前面文章拥塞控制的综合示例。而is_cwnd_limited、max_packets_out变量的更新示例只会给出更新后的结果,更新的过程涉及到比较多的其他变量,即使贴上内核代码也需要大量的篇幅来解释清楚,因为本文不再详细介绍。感兴趣的可以自行对照本示例去学习内核代码。我会在补充说明中给出几个与下面示例相关联的关键函数。读者因此可能很难理解清楚下面的wireshark示例,但是重点观察两个宏观的现象就行了,一个是application-limited状态下reno不会更新cwnd,另外一个是ilde时间超过RTO后,CWV会更新cwnd和ssthresh。不必过于纠结更新的细节。

1、application-limited状态linux的处理以及idle后cwnd和ssthresh的更新

client与server端建立连接后先发送一个请求报文,然后server端内核正常回复ACK,另外server应用层在建立与client的连接后休眠50ms,然后发送4个数据包,每个数据包大小为50bytes间隔为5ms,接着以60ms为间隔,连续发送6个数据包,每个数据包的大小为50bytes,构造RFC2861中的application-limited的场景,接着server端休眠300ms,构造RFC2861的idle场景,最后server端再以5ms为间隔,连续发送15个数据包,每个数据包大小为50bytes。client端对于每个server端的报文都回复一个ACK,client与server端的rtt为50ms。

No1-No3:client与server通过三次握手建立连接,连接建立后根据路由表配置,初始化cwnd=2,ssthresh=8。连接建立后进入慢启动流程

No4-No5:client端发送请求,server端回复ACK报文

No6-No7:server端开始以5ms的间隔写入报文,可以看到受限cwnd,只能发出No6和No7两个报文。其余写入的报文只能暂时缓存在内核中。注意server端在发出No6和No7两个数据包后,发现当前in_flight的报文个数以cwnd是一致的,因此更新is_cwnd_limited=1,表示当前是处于network-limited状态。

No8-No10:server端正在进行慢启动,收到一个ACK后cwnd=cwnd+1=3,此时可以额外发出两个报文了。No10发出后更新max_packets_out=3。

No11:server端收到No11后,更新cwnd=cwnd+1=4,但是server端第一阶段以5ms间隔仅仅写入了4个数据包,然后休眠60s后在继续写入的,因此此时已经没有额外的数据可以发送了,所以server端在收到No11后并没有立即发出新的数据包。

No12:server端开始以60ms的间隔写入数据,No12是第一个写入的数据包,从这时候起,server端开始进入RFC2861所说的application-limited状态。

No13-No15:这几个报文是之前server端发出报文的ACK报文,注意收到No13报文后更新cwnd=cwnd+1=5,收到No14后更新cwnd=cwnd+1=6。收到No15后,reno拥塞控制算法发现当前处于慢启动阶段,而且cwnd=6>=2*max_packets_out=6,因此认为当前处于application-limited状态,虽然收到了ACK报文,但是不再更新cwnd。

No16-No25:server端发出No16的时候会更新max_packets_out=1,后面收到No17这个ACK报文的时候,同样会因为cwnd=6>=2*max_packets_out=2,reno拥塞控制算法并不更新cwnd。后面reno收到No19、No21、No23、No25对应的ACK报文时候同样是因为这个原因而不能更新cwnd。但是如上所说No17、No19、No21、No23、No25这些报文都会更新伪代码中T_prev这个变量,因此linux每次发送数据的时候If tcpnow - T_prev >= RTO这个判断条件都很难满足,因此也就不会进入application-limited下cwnd和ssthresh的更新流程了。因此在收到No25之后,cwnd=6,ssthresh=8。


No26:注意No26与No24之间的间隔大约为300ms。server端的应用层在进行write操作的时候,发现当前数据发送的时间间隔已经超过了RTO(从server端程序可以获取当前RTO为252ms),因此更新ssthresh =  max(ssthresh, 3*cwnd/4)=8,cwnd =  max(win/2, MSS)=3,实际linux更新cwnd的时候还要cwnd大于等于路由表中配置的2。

接着server端以5ms为间隔连续写入15个数据包

No27-No28:可以看到发出No28后,server端不能在额外发出新的数据,此时in_flight为150bytes,正好对应cwnd=3。

No29-No43:接着server端进入慢启动流程,可以看到直到发出No43后,in_flight=400bytes,对应cwnd=8,此时ssthresh=8,因此随后应该进入拥塞避免阶段

No44-No49:进入拥塞避免阶段,每收到一个ACK报文,cwnd_cnt=cwnd_cnt+1,直到收到No48后,cwnd_cnt=3

No50-No57:server端陆续收到其余的ACK报文,其中在收到No54报文的时候,cwnd_cnt增长到8,因此更新cwnd=cwnd+1=9,并重置cwnd_cnt=0。最终收到No57报文后,ssthresh=8,cwn=9,cwnd_cnt=3

2、构造application-limited下更新cwnd、ssthresh流程场景

在上一个示例中我们已经看到了在RFC2861表述的application-limited场景下,linux并不会进入更新cwnd和ssthresh的application-limited代码流程,下面我们人为根据代码构造一个这样的场景。在应用层数据受限的情况下,我们前面介绍过linux会在数据真实发送时刻判断是否进入更新cwnd和ssthresh的application-limited流程,而idle态的判断则是在应用层write操作的时候进行的。因为linux会在收到确认新数据包的ACK报文的时候更新T_prev,因此如果要在数据实际发出的时候满足tcpnow - T_prev >= RTO这个条件,数据应用层write时刻不满足tcpnow - T_last >= RTO才行。读者可能会想到通过前面介绍的nagle算法或者cork算法,使得应用层的write操作与tcp层真实的发送操作隔离。但是这两个都行不通的,原因是Nagle算法是ACK触发,收到ACK会更新T_prev,因此不会满足tcpnow - T_prev >= RTO。而cork算法数据包的超时发送是persist timer定时器触发的,这个定时器触发的数据包发送流程不会经过linux中更新cwnd和ssthresh中的application-limited流程。

最终构造的场景如下,client端对于server端的每个数据包都会触发ACK回复,server端的No26数据包是间隔5ms后收到的ACK确认报文,而server端的其余数据包都是在发出50ms后收到的ACK报文。可以看到No26与No28间隔时间大约为240ms,低于RTO时间,因此不会判断进入tcpnow - T_last >= RTO的流程,而No27和No29之间间隔275ms,高于RTO时间,因此会进入更新cwnd和ssthresh的application-limited流程。

在No29之前,ssthresh=8,cwnd=8,cwnd_cnt=4,注意server端在收到No27报文的时候判断并没有处于network-limited阶段,因此reno并不会更新cwnd_cnt。收到No29之后更新ssthresh =  max(ssthresh, 3*cwnd/4)=8,cwnd = (win + W_used)/2=(8+2)/2=5,cwnd_cnt不变。更新后ssthresh、cwnd、cwnd_cnt的值也可以从后面慢启动和拥塞避免的流程看出来。注意的是在No35到No43慢启动的过程中cwnd_cnt的值并没有清空。在No44-No50拥塞避免阶段,cwnd_cnt从4增长到8,收到No50后更新cwnd=cwnd+1=9,重置cwnd_cnt=0;




补充说明:

1、RFC7661 new CWV的patch可以参考 https://github.com/rsecchi/newcwv

2、https://riteproject.eu/resources/new-cwv/

3、is_cwnd_limited等变量的更新请参考tcp_cwnd_validate、tcp_cwnd_application_limited、tcp_is_cwnd_limited

4、idle的处理请参考tcp_slow_start_after_idle_check

5、第二版的TCPIP详解中对于application-limit的解释正好是错误的,application-limited是指受限应用层而没有更多的数据可以发送,从协议给出的伪代码示例中也可以看到这一点。






posted @ 2016-11-07 14:26  lshs  阅读(3507)  评论(4编辑  收藏  举报