夜深人静...
TCP在发现丢包的时候,会采取一定的措施,至于如何发现丢包不是本文的内容,本文主要描述发现丢包以后TCP采取什么措施。
以Linux为例,降窗发生在进入快速恢复的当时(暂时不考虑RTO以及本地拥塞),在降窗之前是一个Disorder的状态,指的是系统发现了异常,比如收到了重复ACK或者说收到一个推进的ACK携带了SACK信息,然而还不至于到重传的地步,比如还没有达到乱序度的门限值,在门限值之下,系统会假设这只是一次乱序,马上就会好的,在Disorder状态,TCP的拥塞窗口是僵住的,即既不增加也不减少,此时会有两种结果,一个是在乱序度被超越前系统恢复了正常,那么将会进入拥塞避免状态,窗口执行AI,另一方面如果乱序度被超越,那么就会进入快速恢复状态,窗口执行MD,在这个状态中,拥塞窗口会持续降低,下面的篇幅就是来描述这个降窗过程的。
TCP会进入一个叫做“快速恢复”的状态,恢复什么呢?恢复到正常状态!在快速恢复中,主要要做的任务就是重传“被认为”是丢失的数据包。“被认为”之所以要加上引号是因为一切都是猜测!在这个过程中,TCP会认为网络发生了拥塞,既然发生了拥塞,再发多少数据包也没有用。TCP被设计为一个君子协议,那就是自己发现丢包的时候,自己会主动降低窗口,大家都这么做,以期望网络迅速恢复正常,这也就是为什么像华夏创新的那种算法永远都进不了标准,永远都写不成paper的原因吧。
就目前看来,主要有4中方式降低窗口:
1.BSD原始方式:这也是《TCP/IP详解》(不是第二版啊)中描述的方式,像心电图一样,在降窗之前会有一个毛刺。目前早就已经弃用,想看究竟的话请看steven的书。2.RFC3517方式:这是典型的NewReno方式,RFC3517中有详细的描述,比较短也不复杂,10分钟就能看完,就像看历史书一样,理解其基本思想即可,来龙去脉,引启未来。
3.Linux rate halving方式:这是一种非标准的方式,但却是一种创新的方式,它“承若”不再陡降窗口,它将窗口缓慢降到原来的一半,然后在进入正常状态后再缓慢上升。
4.Linux PRR方式:基于rate halving,它不仅仅只是“承若”,而是保证!并且更加灵活了,降窗由ACK来驱动,具体降窗到哪里,取决于ssthresh,而后者是由具体的拥塞算法决定的。
以上就是典型的4中方式,教科书上的学院派方式基本都是1和2,这也是为什么随便问一个学生,他们都能答出1或2一样,而且1和2之间还会有没有休止符的争论。3和4属于内核社区,即便懂Linux内核的家伙们也没几个知道其细节,问起他们细节,他们大多数人的回答还是1或2...这倒也说明不了什么,只是说有些非标准实现不拘一格罢了。启自这种不拘一格,我自己也有一种天真的想法,那就是让快速恢复阶段的窗口走一个弧形,开始于原始窗口,结束于当前带宽允许的窗口。这也就是传说中的5。
本文就不再细说1和2了,毕竟我不是伟大的人民教师,也是个编程人员,没有那么多时间阐释大家都懂的东西,然而我擅长阐述大多数人不懂的东西。掠过1和2之前,说一下它们的缺点:
1).half silence
2).burst failed
千万不要觉得突发是优点或者缺点,如果将它们作为优点,那么当发出大量包时,会造成或加重网络拥塞,当没有这种突发时,又会造成保守的传输!不管怎样,这不是众口难调,而是算法本身的固有缺陷!
在除了1和2之外,我们来一个一个说吧。
Linux rate halving算法
Linux的TCP协议的实现中,采用了一种非标准化的降窗算法,即rate halving算法,顾名思义,它旨在将窗口降低到原有窗口的一半!目标和RFC3517是完全一致的,区别在于执行的过程。Linux rate halving算法对待拥塞窗口的方式是执行一个缓慢下降的过程,而不像RFC3517那样一下子陡降到一半,然后在整个快速恢复过程中执行窗口线性缓慢上升的过程。恰恰相反,Linux rate halving算法是一个相反的过程,它一开始并不陡降窗口,在快速恢复的最后,它的目标是将窗口缓慢降到原来的一半,进入正常状态后,才从原有窗口的一半开始执行拥塞避免,线性缓慢增加窗口。
然而,实际情况往往不想愿景所希望的那样,我们会慢慢地理清头绪。首先,Linux rate halving的算法如下:
初始状态:
CWND=进入快速恢复前的拥塞窗口大小
dec=0
在恢复正常前,对于每一个ACK,执行cwnd_down:
cwnd_down()
{
dec = dec + 1;
dec = decr&1;
CWND = CWND - dec;
CWND = min(CWND, in_flight+1);
}
为了帮助理解,下面首先给出一个常规理想化的实例,演示算法的执行过程,然后给出一个稍微变态点的例子,演示这个算法的问题:
1.你无法保证发送路径或者重传路径一定会发送一个窗口那么多的包
2.你无法保证即便发出了一个窗口的包,update计分板(详见RFC3517的Update例程)的时候不会有大量数据包被标记为LOST
因此,在rate halving算法中,in_flight值可能会陡降,带来的效果就是CWND陡降,然而却没有补偿,比如下面这个稍微变态些的例子:
如果发生了大量的数据包被标记为LOST,情形如上,事实上降窗过程对此事毫不知情,FACK模式下一个携带SACK的包就可能标记很多包为LOST!除此之外,rate halving降窗算法中,以ACK数量而不是ACKed的字节数来作为标尺,这看似是一件好事,但是由于ACKed的字节数会直接体现在in_flight变量中,结果就是,如果一个ACK确认大量的字节,in_flight会变小,最终取min的时候,还是会造成窗口陡降!不过这也倒是迎合了RFC2582的建议:
Deflate the congestion window by the amount of new data acknowledged, then add back one MSS and send a newsegment if permitted by the new value of cwnd. This "partial window deflation" attempts to ensure that, when
Fast Recovery eventually ends, approximately ssthresh amount of data will be outstanding in the network.
但是不同的是什么?不同是NewReno在快速恢复阶段的窗口是从ssthresh逐步以拥塞避免方式增加的,而Linux rate halving算法在快速恢复阶段窗口是只降不升的,这是根本的区别,NewReno在收到patial ACK的时候降窗的理由在Linux rate halving看来并不充分:
Note that in Step 5, the congestion window is deflated when a partial acknowledgement is received.
The congestion window was likely to have been inflated considerably when the partial acknowledgement
was received. In addition, depending on the original pattern of packet losses, the partial acknowledgement
might acknowledge nearly a window of data. In this case, if the congestion window was not deflated, the
data sender might be able to send nearly a window of data back-to-back.
现在我们知道Linux rate halving降窗算法的实际效果了,现在我们来看一下窗口陡降带来的影响以及造成这种影响的根源。窗口陡降的效果就是它会降到ssthresh以下,从而在快速恢复阶段结束后进入慢启动状态。其实如果你仔细看这个算法,会发现它根本就没有检查ssthresh,而只是限定了“halving”这个动作,因此就算窗口不是陡降,算法也无法感知窗口与ssthresh当前的关系。
Linux rate halving的根源在于:
1.如其名称中的half单词,它的目标是将窗口减半,然而首先,并不是所有的拥塞控制算法都是将窗口减半的,这只是早期Reno/NewReno的规范而已;
2.即便是将窗口减半,在执行的过程中,算法并没有好好的履行承诺,而是过分依赖于一个自己无法控制的in_flight变量,且被其控制;
3.再退一步,即便窗口已经降到了一半以下(已经不再用ssthresh约束它了),Linux rate halving算法并没有任何补偿措施将窗口拉上来;
4.抛开窗口会降到哪里不说,Linux rate halving算法在快速恢复阶段,每次最多只允许1个数据段被发送,即窗口in_flight+1。
鉴于Linux rate halving的不足,google推出了其Linux PRR算法,解决了上面提到的三个问题。
google的Linux PRR降窗算法
我们先看它解决了哪些问题,针对Linux rate halving的缺点,PRR算法:1.不再仅仅halving,而是完全根据拥塞算法计算出的ssthresh,来将窗口逼近于它;
2.执行的过程不再受当前的in_flight控制,而是根据快速重传以来的发送/接收ACK的总数量来将窗口按照等比例的方式逼近ssthresh;
3.如果窗口降到了ssthresh以下(比如没有数据包可发送或大量包被标记LOST),算法执行慢启动将其拉升到ssthresh附近;
4快速恢复阶段,最多“还”可以发送(或者说重传)多少数据,不再限定为1,而是取决于“当前收到的ACK/SACK总量,发出数据总量,窗口与ssthresh的关系”。
其中最重要的是上述第2点,它简单的指出了PRR算法是依靠ACK来驱动的,这就形成了一个反馈系统,只可惜PRR只利用了这个反馈系统的一部分,另一部分待我
在下一部分描述我的一个优化时再讨论。紧跟着上面的4点,这个PRR算法的最终效果就是:
1).在快速恢复过程中,拥塞窗口非常平滑地向ssthresh收敛;
2).在快速恢复结束后,拥塞窗口处在ssthresh附近。
为了实现上述的目标,PRR降窗算法必须实时监控以下的变量:
in_flight:它是窗口的一个度量,in_flight的值任何时候都不能大于拥塞窗口的大小。
(s)acked:本次收到ACK进入降窗函数的时候,一共被ACK或者SACK的数据段数量。它度量了本次从网络中清空了哪些数据段,从而影响in_flight。
out:进入快速恢复状态后已经被发送了多少数据包。在transmit例程和retransmit例程中递增。
to_be_out:当前还可以再发多少数据包。
根据数据包守恒原则,能够发送的数据包总量是本次接收到的ACK中确认的数据包的总量,然而处在拥塞状态要执行的并不是这个守恒发送的过程,而是降窗的过程,因此需要在被ACK的数据包数量和可以发送的数据包数量之间打一个折扣,PRR希望达到的目标是:
ssthresh/old_cwnd==发送数据的速率/数据被ACK的速率
进一步:
ssthresh/old_cwnd==(发送数据的速率*T)/(数据被ACK的速率*T)
即:
ssthresh/old_cwnd==pkts_out/acks_rcv
以此来将目标窗口收敛于ssthresh。刚进入快速恢复的时候的时候,窗口尚未下降,在收敛结束之前,下面的不等式是成立的:
ssthresh/old_cwnd>=pkts_out/acks_rcv
因此:
acks_rcv*(ssthresh/old_cwnd)>=pkts_out
考虑到数据包的守恒,设
extra=acks_rcv*(ssthresh/old_cwnd)-pkts_out
这意味着在收敛结束前,我们可以多发送extra这么多的数据包。上述结论的具体推导过程如下:
数据包守恒原则中我们要注意到一个递推的等式,守恒原则要求,被ACK或者SACK多少数据包,说明有这么多数据包离开了网络,方可以发出多少数据包,该等式序列如下:
进入快速恢复时的初始状态:
(s)acked0=0
out0=0
to_be_out0=0
此后每收到一个ACK(可以携带SACK block):
收到第1个ACK:
to_be_out1=this_acked=(s)acked1; out1=to_be_out1
收到第2个ACK:
to_be_out2=this_acked=(s)acked2-(s)acked1=(s)acked2-out1; out2=out1+to_be_out2
收到第3个ACK:
to_be_out3=this_acked=(s)acked3-(s)acked2=(s)acked3-(to_be_out2+out1)=(s)acked3-out2; out3=out2+to_be_out3
...
收到第5个ACK:
to_be_out5=(s)acked5-out4; out5=out4+to_be_out3
收到第6个ACK:
to_be_out6=(s)acked6-out5; out5=out5+to_be_out4
...
收到第N个ACK:
to_be_out[N]=(s)acked[N]-out[N-1]; out[N]=out[N-1]+to_be_out[N]
通过以上的等式,我们可以得到一个关系,那就是:PRR反馈系统通过to_be_out来补充被(s)acked清空的out!这就好比一个生产者/消费者之间
交易的过程,目前的PRR算法要求这个交易过程不是等价交易,而是一个折扣交易,要在收到的(s)acked上打折扣,这意味着如果发送端接有N个
数据段被ACK了,那么只能算beta*N个被ACK了,其中0<beta<1。于是上面的等式变为了:
to_be_out[N]=beta*(s)acked[N]-out[N-1]
对比一下结论:
extra=acks_rcv*(ssthresh/old_cwnd)-pkts_out
我们知道,这个折扣就是 (ssthresh/old_cwnd)!
好了,现在该给出Linux PRR算法了:
初始状态:
CWND=进入快速恢复前的拥塞窗口大小
old_cwnd=CWND
ssthresh=拥塞算法计算的结果,可以是old_cwnd的1/2,4/5,...
acked=0
out=0
在恢复正常前,对于每一个ACK,执行cwnd_down:
cwnd_down_prr(tcp_sock)
{
cnt = 0;
tcp_sock.acked = tcp_sock.acked+本次被ack或者sack的数据段总数;
if (tcp_sock.in_flight > tcp_sock.ssthresh) {
cnt = tcp_sock.acked*(tcp_sock.ssthresh/tcp_sock.old_cwnd) - tcp_sock.out;
} else {
cnt = tcp_sock.ssthresh - tcp_sock.in_flight;
}
tcp_sock.CWND = tcp_sock.in_flight + cnt;
}
和介绍Linux rate halving一样,同样给出一个实例来演示算法执行过程,类似的,第一个例子是标准理想环境下的,第二个是变态些的:
下面我给出一个和Linux rate halving的第二个例子初始状态一致的PRR的例子,我们来看看PRR是怎么补偿窗口的陡降的:
最后我们来一个综合的带有SACK的例子:
算法执行下去的话,没有任何问题,窗口即使不会陡降到ssthresh之下,下一步也会执行类似慢启动的过程提升上来的。这里所谓“类似慢启动”的过程指的就是,按照被ACK的数据的大小来增加窗口,比如ACK了n个,那么窗口就增加n个MSS大小。
从例子中,我们看到,算法执行的每一个步骤,目标拥塞窗口都是根据in_flight,(s)acked,out等实时平滑计算出来的,其降窗的结果充分反应了当前的网络情况,从而解决了Linux rate halving算法中“孤立地‘一厢情愿企图’将窗口在一个RTT内降到原有窗口的一半”这么一个连兑现基本的承诺都显得勉强的降窗算法中存在的问题。
一个简单的降窗优化
爆炸!关于拥塞降窗的问题基本都结清了。现在基于PRR算法我有一个简单的优化思路。我的企图很简单,我希望窗口不必一定下降到ssthresh,而是设置一个截止阀,下降到这个值就不必再继续往下降了,维持住即可,然后从这个窗口开始拥塞避免。事实上不管多么高大上的拥塞控制算法在进入快速恢复之前,计算的所谓ssthresh都TMD是拍脑子拍出来的!以Linux目前的默认拥塞算法cubic为例,进入快速恢复前会将ssthresh重新设置为当前窗口的717/1024,这是个恒定比例,丝毫没有考虑到拥塞的严重程度,或者只是另一个极端,这个丢包只是源自于一个偶尔的网络噪声,这些在cubic的算法层面都没有提及。
PRR算法的好处在于根据当前的TCP计数器(out,acked等)的实时值动态地,平滑地将窗口收敛到ssthresh,这是比rate halving好的方面,相比PRR,rate halving是一个完全封闭的算法,根本没有管当前的TCP计数器值的变化情况。然而PRR也不够完全动态,毕竟ssthresh可以视作一个定值,有时候窗口根本没有必要下降那么多!那么窗口下降到何种程度合适呢?我认为降到“数据被ACK的速率与数据被发送的速率相等”的这一刻即可,这说明这个时候pipe是刚好畅通的,发多少包,收到多少(s)ack!
经过改进的算法如下所示:
cwnd_down_prr_pro(tcp_sock)
{
cnt = 0;
tcp_sock.acked = tcp_sock.acked+本次被ack或者sack的数据段总数;
tmp1 = tcp_sock.acked - tcp_sock.prior_acked;
tcp_sock.prior_acked = tcp_sock.acked;
tmp2 = tcp_sock.out - tcp_sock.prior_out;
tcp_sock.prior_out = tcp_sock.out;
// 连续3次以上被确认的数量与发送数量之差小于1,说明速率均等,维持窗口,不再下降
if (tmp1 - tmp2绝对值小于等于1) {
tcp_sock.eq++;
tcp_sock.gt = 0;
if (tcp_sock.eq >= 3) {
tcp_sock.ssthresh = tcp_sock.cwnd - 1;
return;
}
}
// 连续3次以上确认量大于发出量,增窗!
else if (tmp1 > tmp2) {
tcp_sock.gt++;
tcp_sock.eq = 0;
if (tcp_sock.gt >= 3) {
增窗;
tcp_sock.ssthresh = tcp_sock.cwnd - 1;
return;
}
}
// 否则继续降窗
else {
tcp_sock.eq = 0;
tcp_sock.gt = 0;
tcp_sock.ssthresh = tcp_sock.ssthresh_by_cong_ALG;
}
if (tcp_sock.in_flight > tcp_sock.ssthresh) {
cnt = tcp_sock.acked*(tcp_sock.ssthresh/tcp_sock.old_cwnd) - tcp_sock.out;
} else {
cnt = tcp_sock.ssthresh - tcp_sock.in_flight;
}
tcp_sock.CWND = tcp_sock.in_flight + cnt;
}
你会注意到,它牺牲了公平性,也就是说在可能的情况下,不再执行MD,另一方面,这个算法还有一个作用,那就是它竟然可以简单区分是网络噪声引发的偶尔丢包还是拥塞造成的持续丢包。关于公平性,还要说的就是,在别人都在抢你资源的时候,谁对别人友好谁就是2B!