情书也会慢慢老去
泛不起心中的涟漪
一页一页飘碎的
是我为你写的肠断句
5.3 传输控制
TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
在前面的章节中,我们介绍了 TCP 连接的相关概念,也介绍了 TCP 为什么说是一种面向字节流的协议,也基本介绍了为什么说 TCP 是可靠的协议。
但是 TCP 中的“C”——控制(Control),体现在哪里呢?在数据传输的过程中,TCP 又是如何控制的呢?本章就试图回答这个问题。
要研究 TCP 中的控制,首先要看 TCP 的上下文,如图5-82所示:
图5-82 TCP 上下文示意
图5-82是一个比方,它将 TCP 的发送者(用户 S)比喻成一个进水的水龙头,将 TCP 的接收者(用户 R)比喻成一个出水的水龙头。在发送侧,TCP 有一个蓄水池(TCP 发送方缓存),同样在接收侧,TCP 也有一个蓄水池(TCP 接收方缓存)。连接两个蓄水池的网络,就相当于水管。
TCP 的控制,就是面向发送者(进水速度)、接收者(出水速度)、发送方缓存(发送侧蓄水池)、接收方缓存(接收侧蓄水池)、网络(带宽、QoS(时延、丢包、抖动))的一个控制协议,它既要保证传输的可靠,也要面对各方面的复杂情况。
从某种意义上说,TCP 就相当于网络传输的大脑。当然,这个赞美听起来有点别扭,好像是说 TCP 脑子进水了,^_^。
下面我们就这对 TCP 的控制展开描述。
5.3.1 滑动窗口
在 TCP 的圈子里,滑动窗口的名气恐怕是能排第一,能与之一较长短的也许只有“三次握手/四次挥手”。
滑动窗口的过程,我们在上一章“5.2 TCP 连接”中有过涉及,本节我们再系统地介绍一下这个概念。
5.3.1.1 滑动窗口基本概念
TCP 引入滑动窗口的最直接的原因是“接收方的缓存是有限的”,如图5-83所示:
图5-83 接收方的缓存是有限的
图5-83中,发送方最大允许发送字节数 Ws,其值只能等于接收方缓存大小 Wr,即 Ws = Wr。如果 Ws > Wr,那么就有可能发送方所发送的字节数比 Wr 大,致使接收方做丢包处理——这样就失去了发送的意义。
在 “5.2.7 TCP 连接的收发空间”,我们介绍过,TCP 发送方的最大允许发送字节数,也就是发送方的发送窗口(SND.WND),是由其接收报文中的 Window 字段(SEG.WND)所决定的,即 SND.WND = SEG.WND。
SND.WND 是 TCP 发送空间其中1个概念,如图5-所示:
图5-84 SND.WND 示意
图5-84中的概念:SND.UNA、SND.NXT、SND.WND,我们在5.2.7节都介绍过,这里就不再重复。需要强调的是,我们把 SND.WND 称为滑动窗口。
滑动窗口(Sliding Window),顾名思义,是可以滑动的。也就说滑动窗口是动态变化的,如图5-85所示:
图5-85 滑动窗口示意
图5-85中,我们把滑动窗口想象成一个矩形,可以在 X 坐标轴上滑动。这个矩形在 X轴上的左坐标为 WL(window left)、右坐标为 WR(window right)。结合图5-84,我们知道:
WL = SND.NXT
WR - WL = SND.WND。
图5-85也指出了 WL 只能向右滑,WR 可以左右滑。实际上 WL 何时向右滑,滑多少,取决于 TCP 所发送的报文;WR 何时滑动(或左或右),滑多少,取决于 TCP 所接收的报文。如图5-86所示:
图5-86 滑动窗口的滑动
图5-86中,T0时刻:WL = 7,WR = 12,WR - WL = SND.WND = 5。
在 T1 时刻,TCP 发送方,发送了1个报文,这个报文的数据长度是2个字节,于是我们看到了 WL 向右滑动:WL = 9。但是此时 WR 是不滑动的,因为 TCP 发送方还没有收到 TCP 接收方的指示。也正因为此,我们看到 T1 时刻,其滑动窗口的大小 SND.WND = 3,实际上是变小了。
在 T2 时刻,TCP发送方,接收了1个报文(对方的 ACK 报文),在这个报文中,其指示了接收窗口的大小等于6(SEG.WND = 6),于是我们看到:SND.WND = 6,同时也意味着 WR 向右滑动 WR = WL + SND.WND = 9 + 6 = 15。
图5-86中,T2 时刻的 WR 是大于 T1 时刻的 WR,即 WR2 > WR1,但是在某些场景下也会出现 WR2 < WR1 的情形,如图5-87所示:
图5-87 滑动窗口的收缩
图5-87中,在 T2 时刻,TCP发送方,接收了1个报文(对方的 ACK 报文),在这个报文中,其指示了接收窗口的大小等于2(SEG.WND = 2),于是我们看到:SND.WND = 2,同时也意味着 WR 向右滑动 WR = WL + SND.WND = 9 + 2 = 11。
而 WR1 = 12,WR2 = 11,即:WR2 < WR1。这种情形,TCP 称为窗口收缩(shrinking the window)。RFC 793 对待窗口收缩的态度是:强烈不建议,但是也不拒绝。
This, so called "shrinking the window," is strongly discouraged. The robustness principle dictates that TCPs will not shrink the window themselves, but will be prepared for such behavior on the part of other TCPs.
自己不主动,但是如果对方主动,TCP 也不拒绝,并且还会负责。不主动、不拒绝、会负责,从这个角度看,TCP 还不能算一个渣男——TCP 的爱情,原来还没有降级,^_^
5.3.1.2 窗口大小与发送效率
在上一小节中,我们为了讲述方便,所举的例子中,滑动窗口的大小仅仅是个位数(2、3、5、6)。实际上 RFC 793 中所定义的 Window 字段有16个 bits,也就说滑动窗口最大可以为65535(不考虑 RFC 1323 的 TCP Window Scale Option)。
那么,滑动窗口的大小,与传输效率有什么关系呢?
一个极端的例子是假设滑动窗口的大小仅仅是1,也就是说只能传输1个字节的数据。而 TCP 的报文头的长度是20个字节(不考虑 Option 字段),如果再加上 IP 报文头20个字节(不考虑 Option 字段),那就是:一共41个字节(20 TCP Header + 20 IP Header + 1 Data)的报文,只运载了1个字节的数据。
这样的情形,与较大的滑动窗口相比,如图5-88所示:
图5-88 滑动窗口太小的对比
图5-88-上,表达的是一个比较大的滑动窗口(比如1000),此时它发送1个100字节长度的数据,只需1对报文(一来一回)即可,所花费的时间记为 t。
图5-88-下,表达的是滑动窗口大小只为1的情形,此时它发送1个100字节长度的数据,则需要100对报文(一来一回)。我们忽略网络拥塞等因素,可以简单理解为它所花费的时间是100t。
如此看来,太小的滑动窗口(即使比1大一点),其引发的问题主要有:
(1)带来很多不必要的传输延时
(2)网络带宽利用率极低,而且还很有可能造成网络拥塞
也正是鉴于此,TCP 其中有有很关键的一点就是致力于滑动窗口大小(size)的研究。
1)糊涂窗口综合症
在 TCP 的世界里,对于这种来来回回发送的小包,有一个专有称谓:糊涂窗口综合症(Silly Window Syndrome,SWS)。所谓小包,指的是 TCP 报文中的有效载荷(即数据部分)很小,甚至数据长度只有1个字节。前文我们说过,这种情形(糊涂窗口综合症),其所引发的问题还是比较大的。
引发糊涂窗口综合症,有3种情形:或者是发送端、或者是接收端、或者是两者兼而有之。
对于发送端来说,它产生数据的速度可能很慢(比如 telnet 程序),此时就会引发糊涂窗口综合症。
对于接收端来说,原因也是如此——它处理数据的速度很慢——也会引发糊涂窗口综合症,如图5-89所示:
图5-89 接收端引发的糊涂端口综合症
图5-89中,T0 时刻,接收端缓存是满的,2000个字节都被数据占满。T1 时刻,接收方处理掉1个字节的数据,给发送方发送 Window = 1 的 ACK 报文。于是 T2 时刻发送方发送过来1个字节的数据......如此循环反复,如果接收方处理数据的速度一直比较慢,那么糊涂窗口综合症就发生了。
图5-89中,接收方一次处理1个字节,仅仅是一个举例,事实上可以不是1个字节,只要是比较小的字节数即可,就会引发糊涂窗口综合症。
如果不做一些特殊的处理,在 TCP 的世界里,糊涂窗口综合症几乎是必然要发生的,也许是 A 和 B 之间,也许是 C 和 D 之间,就像天地之间,总有一些糊涂的爱。
那么,怎么做特殊处理呢?当然是:哪里糊涂治哪里,^_^
2)接收方治疗方案
如果是接收方引发的糊涂窗口综合症,那么就从接收方入手。接收方治疗方案基本可以归类为两种。
第1种是 Clark 方案。Clark 方案可以理解为:立即确认0窗口。当接收缓存比较小时,如果收到对方发送过来的报文,接收方会立即确认,但是 ACK 报文中的 Window 字段会等于0。等到自己的缓存比较大(比如大小达到 MSS(maximum segment size),或者达到最大缓存空间的的一半时),再主动 ACK 接收方,告知自己的 Window。
David Dana Clark
第2种方案是延迟确认(Delay ACK)。当接收缓存比较小时,如果收到对方发送过来的报文,接收方会稍微等待一段时间再确认——其基本思路是:在等待的这段时间内,自己会处理更多的报文,从而腾挪出更多的接收缓存,此时再发送 ACK 报文,就可以通告一个比较大的窗口。
当然,延迟确认的“延迟”时间不能过长,否则会让发送方以为发送超时,进而引发重新发送,这就弄巧成拙了。一般来说这个延迟时间是200 ms 或者500 ms(具体多少,是一个经验值),超过这个时间,接收方必须要给对方回应1个 ACK 报文。
无论是第1种方案还是第2种方案,其本质都是让发送方“休息一下”。还记得一休哥的口头禅吗——休息,休息一下。所以,我们也可以把接收方治疗方案称为“一休方案”。
暴露年龄了
应该说,虽然这2个方案蹭了一休的大名(是我蹭的,不是这两个方案蹭的,^_^),但是它们距离“聪明的一休”还是差的太远——虽然它们能解决一些问题,但并不是每个场景都适用,尤其是实时性要求很强的情形。
3)发送方治疗方案:Nagle 算法
如果是发送方引发的糊涂窗口综合症,那么就从发送方入手。发送方治疗方案也基本可以归类为两种,本小节介绍第1种:Nagle 算法。
Nagle 算法是以它的发明人 John Nagle 的名字命名的。1984年,为了解决其所供职的福特航空通信公司的网络拥塞问题,John Nagle 提出了 Nagle 算法(该算法主要用于避免过多小报文在网络中传输,从而降低网络容量利用率)。很抱歉,没有搜到 John Nagle 的照片,让我们以一张 RFC 896 的截图来表达对 John Nagle 的敬意吧。
RFC 896 与 John Nagle
Nagle 算法如果用伪码来表示,如下:
# 如果有数据要发送
if there is new data to send
# 如果发送窗口大于 MSS,并且发送数据长度也大于 MSS,就可以发送
# 其含义就是:大包可以可以发送
if the window size >= MSS and available data is >= MSS
send complete MSS segment now
# 如果是小包,则要考虑一下了
else
# 如果当前有未确认的报文,那么小包不能发送,先缓存起来
if there is unconfirmed data still in the pipe
enqueue data in the buffer until an acknowledge is received
# 如果当前没有未确认的报文,那么小包也可以发送
else
send data immediately
end if
end if
end if
在微信里面看这段伪码可能会乱行,我们贴个图:
Nagle 算法伪码
Nagle 算法可以用一句话总结:大包随便发(大于等于 MSS),小包发1个要等到确才能发下1个。
Nagle 算法的本质仍然是期望能“休息”或者等待一段时间,以使自己能将多个小包“攒”成1个大包发送。这个等待时间就是接收方 ACK 的时间。可以看到,如果对方 ACK 速度比较快,Nagle 算法仍然无法避免小包发送。
需要说明的是,对于 FIN 报文,Nagle 算法是不等待的,立即发送,因为它并不想延迟 FIN 报文的发送。这句话的潜台词是:Nagle 算法对于其他报文是有可能造成延时的。
另外,那些时延敏感的程序,如果遇到“发送方 Nagle 算法 + 接收方延迟确认”,那是雪上加霜:发送方在等接收方的确认,接收方又在等自己慢慢长大。
所以,TCP 提供了一个选项“TCP_NODELAY”来禁用 Nagle 算法——天青色等烟雨,而我不能再等你!
4)发送方治疗方案:CORK 算法
cork 不是一个人名,当然也就不是一个名人,就是英文单词“塞子”的意思。
塞子,顾名思义,就是塞住。所以 CORK 算法可以简单理解为:
(1)如果是大包(包大小 >= MSS),马上发送
(2)如果是小包,那就等。一直等到多个小包“攒”成1个大包,再发送。
当然,这种等待也不能无限期等下去,TCP 是设置了一个超时时间(经验值是200 ms),如果超时时间到,即使没有攒成1个大包,也得发送出去。
从某种意义上说,CORK 算法攒大包的概率比 Nagle 算法更大一点。如果对方 ACK 报文到达的比较快(小于200 ms),Nagle 算法有可能还没攒成1个大包,但是它就得立刻发出去,但是 CORK 算法则可以多等一会(一直等到200 ms),可以多攒一点数据,再发送出去。当然,对于 CORK 算法来说,如果超时时间到,即使它没有攒出大包,它也得发送。
所以说,从避免小包发送的角度来说,CORK 算法可能会比 Nagle 算法好一点。但是,事物总有两面性,CORK 算法可能会带来更多的延时。
也正是鉴于此,TCP 也提供了一个选项“TCP_CORK”来禁用 CORK 算法。
5)小结
小包没有大包发送的效率高,而且还有可能会引发网络的拥塞。但是小包的发送,从某种意义上讲是不可避免的。或者是接收方处理速度慢,或者是发送方总是零星地产生数据,但是这些都不受 TCP 控制。
宝宝心里苦啊
为了应对这种大量小包发送所引发的糊涂窗口综合症,TCP 从接收方到发送方都提出了各种算法。这些算法的本质,说的文雅一点,就是:聚沙成塔、集腋成裘,如果说的粗俗一点就是:攒数据、憋大招,一堆小包变大包。
这种憋大招的方法,其缺陷也是很明显的,那就是时延,所以它们的应用场景也是有限的。用户在调用 TCP API 时,必要的时候,需要通过 TCP 选项来禁止这些所谓优化的算法。
人有悲欢离合,月有阴晴圆缺,此事古难全!
-----------------
京东有售