前言
TCP为了保证可靠传输,使用了确认机制
:发送方每发送一个数据段,接收方都要确认应答(ACK)
,如果发送方在指定时间内没有收到ACK,则需要重传数据
。
TCP早期使用的是send-wait-send
的模式,发送方在发送数据之后,启动一个计时器,然后等待接收方的ACK,收到ACK后发送下一个数据段,如果计时器到期之后,还没有收到ACK,则重传数据。具体实现为Positive Acknowledgment With Retransmission(PAR)
。
这种模式就像回合制聊天一样,你一句我一句,如果你说完一句,而我没能及时回复你,那你就得等着我,在回复你之后,你才能说下一句。这种一问一答的方式效率太低,既然每次一个段的方式效率低,那就改为可以一次发送多个段,也就是不必等候ACK就发送下一个包?
一问一答:
一边发送一边等回复:
这种方式也有问题:
- 如果保证顺序性,即发送方先发送
数据包A
,再发送数据包B
,但是数据包B
先到达,数据包A
晚到达或者丢失。 - 如果接受方可能会接收不过来,就像我们从一个水桶里将水全部灌到另一个水桶里,接收的桶可能比较小而导致水溢出。
那么TCP是如何解决的呢?
先看看TCP协议:
累计确认
TCP 为了保证顺序性
,每个包都有一个序号SEQ
。这建立连接的时候,会商定起始SEQ的值,然后按照SEQ递增发送。为了保证不丢包,对于发送的段都要进行应答。但是应答不是一个一个来的,而是会应答某个SEQ+1
(即接收方期望对收到的下一个包的序号),表示SEQ及之前的数据都收到了,这种模式称为累计确认
。
例如下图中重传了SEQ=7
之后,ACK的是8+1
:
再如下图,相对于SEQ=5
的数据,接收方比较迟收到SEQ=4
数据,但是在收到之后,ACK的是5+1
:
累计确认是一种批量确认
的机制,以减少确认包的数据。
滑动窗口
先看一张滑动窗口动态效果图:
为了能支持发送多个数据,无需等待确认应答,TCP引入了窗口
概念,窗口实际上是操作系统开辟的一个缓存空间。TCP是双工的协议,会话的双方都可以同时接收、发送数据,TCP会话的双方都各自维护一个“发送窗口”和一个“接收窗口”。
发送方在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。假如窗口大小为3个TCP段,则发送方可以连续发送3个TCP段。
对于接收端来讲,为了确保这个接收缓存不能溢出,接收端在回复ACK时,告知自身缓存的可用空间有多大。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。
发送端和接收端分别用缓存来记录所有发送的包和接收的包,缓存里是按照包的SEQ 一个个排列。为了说明滑动窗口,我们需要先看一下TCP缓冲区的一些数据结构:
发送端:
- LastByteAcked:指向了被接收端Ack过的位置。
- LastByteSent:表示发出去了,但还没有收到成功确认的Ack。
- LastByteWritten:指向的是上层应用正在写的地方。
接收端:
- LastByteRead:指向了TCP缓冲区中读到的位置.
- NextByteExpected:指向的地方是收到的连续包的最后一个位置。
- LastByteRcved:指向的是收到的包的最后一个位置,我们可以看到中间有些数据还没有到达,所以有数据空白区。
接收端在给发送端回ACK中会汇报自己的剩余可用窗口:AdvertisedWindow = MaxRcvBuffer(最大缓冲区) – (LastByteRcvd - LastByteRead)
。而发送方会根据这个窗口来控制发送数据的大小,以保证接收方可以处理。
发送方窗口内剩余可发送的大小EffectiveWindow = AdvertisedWindow - (LastByteSent - LastByteAcked)
,保证接收方可以处理数据。对于发送方来说,LastByteSent - LastByteAcked
这一段虽然是已发送,但接收方可能还未收到数据。
以下内容来自30张图解: TCP 重传、滑动窗口、流量控制、拥塞控制发愁
对于发送方来说,发送窗口总体分为两部分,分别是已经发送的部分
(已经发送了,但是没有收到 ACK)和可用窗口
,接收端允许发送但是没有发送的那部分称为可用窗口。
- SND.WND:表示发送窗口的大小(大小是由接收方指定的);
- SND.UNA:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
- SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了。
可用窗口大小的计算就可以是:可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
接收方的窗口,不需要等待 ACK,所以相对发送方简单一些,根据处理的情况划分成三个部分:
- RCV.WND:表示接收窗口的大小,它会通告给发送方。
- RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了。
窗口滑动
下面我们来看一下发送方的滑动窗口示意图,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口。
在下图,当发送方把数据全部
都一下发送出去后,可用窗口的大小就为0
了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。
在下图,当收到之前发送的数据32~36
字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动5
个字节,因为有5
个字节的数据被应答确认,接下来52~56
字节又变成了可用窗口,那么后续也就可以发送52~56
这5
个字节的数据了。
那么窗口的大小是多少呢?
最大值:从上面的TCP协议里,可以看到TCP头里有一个字段叫Window
,又叫Advertised-Window
,是一个16bit
位字段,它代表的是窗口的字节容量
,也就是TCP的标准窗口最大为2^16-1=65535
个字节,另外在TCP的选项(option)
字段中还包含了一个TCP窗口扩大因子scaling window
,可把原来16bit
的窗口,扩大为32bit
,但是如果对方没有此option,则说明对方不支持
。
默认值:由接收方提供的窗口的大小通常可以由接收进程控制,这将影响 T C P的性能。4 . 2 B S D默认设置发送和接受缓冲区的大小为2 0 4 8个字节。在4 . 3 B S D中双方被增加为4 0 9 6个字节。正如我们在本书中迄今为止所看到的例子一样, SunOS 4.1.3、B S D / 3 8 6和S V R 4仍然使用4 0 9 6
字节的默认大小。其他的系统,如Solaris 2.2、4 . 4 B S D和AIX3.2则使用更大的默认缓存大小,如8192或16384等。(以上内容来自TCPIP详解
)
发送发窗口大小由两个因素决定:
- 接收方的提供的窗口大小 (TCP 报文段首部中的
window
和option
里的扩大因子字段),发送方在三次握手阶段首次得到这个值,之后的通信过程中接收方会根据自己的可用缓存对这个值进行动态调整
。 发送方会根据网络情况维护一个拥塞窗口变量 (后文介绍)。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大。
拥塞窗口cwnd
是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。拥塞窗口 cwnd 变化的规则:
只要网络中没有出现拥塞,cwnd 就会增大;
但网络中出现了拥塞,cwnd 就减少;
发送窗口的大小取这两个值的最小值
。
案例
下面我们来看流量控制的例子。
例子1
假设以下场景:
- 客户端是接收方,服务端是发送方。
- 假设接收窗口和发送窗口相同,都为 200。
- 假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响。
根据上图的流量控制,说明下每个过程: - 客户端向服务端发送请求数据报文。这里要说明下,本次例子是把服务端作为发送方,所以没有画出服务端的接收窗口。
- 服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 Usable 减少为 120 字节,同时 SND.NXT 指针也向右偏移 80 字节后,指向 321,这意味着下次发送数据的时候,序列号是 321。
- 客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节,RCV.NXT 也就指向 321,这意味着客户端期望的下一个报文的序列号是 321,接着发送确认报文给服务端。
- 服务端再次发送了 120 字节数据,于是可用窗口耗尽为 0,服务端无法再继续发送数据。
- 客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节,RCV.NXT 也就指向 441,接着发送确认报文给服务端。
- 服务端收到对 80 字节数据的确认报文后,SND.UNA 指针往右偏移后指向 321,于是可用窗口 Usable 增大到 80。
- 服务端收到对 120 字节数据的确认报文后,SND.UNA 指针往右偏移后指向 441,于是可用窗口 Usable 增大到 200。
- 服务端可以继续发送了,于是发送了 160 字节的数据后,SND.NXT 指向 601,于是可用窗口 Usable 减少到 40。
- 客户端收到 160 字节后,接收窗口往右移动了 160 字节,RCV.NXT 也就是指向了 601,接着发送确认报文给服务端。
- 服务端收到对 160 字节数据的确认报文后,发送窗口往右移动了 160 字节,于是 SND.UNA 指针偏移了 160 后指向 601,可用窗口 Usable 也就增大至了 200。
例子2
上述例子,接收方接收数据后,能及时从缓存里读取数据,那假如接收方没能及时读取数据,会是什么结果?
假设以下场景:
- 客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 360;
- 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据。
根据上图的流量控制,说明下每个过程: - 客户端发送 140 字节数据后,可用窗口变为 220 (360 - 140)。
- 服务端收到 140 字节数据,但是服务端非常繁忙,应用进程只读取了 40 个字节,还有 100 字节占用着缓冲区,于是接收窗口收缩到了 260 (360 - 100),最后发送确认信息时,将窗口大小通告给客户端。
- 客户端收到确认和窗口通告报文后,发送窗口减少为 260。
- 客户端发送 180 字节数据,此时可用窗口减少到 80。
- 服务端收到 180 字节数据,但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区,于是接收窗口收缩到了 80 (260 - 180),并在发送确认信息时,通过窗口大小给客户端。
- 客户端收到确认和窗口通告报文后,发送窗口减少为 80。
- 客户端发送 80 字节数据后,可用窗口耗尽。
- 服务端收到 80 字节数据,但是应用程序依然没有读取任何数据,这 80 字节留在了缓冲区,于是接收窗口收缩到了 0,并在发送确认信息时,通过窗口大小给客户端。
- 客户端收到确认和窗口通告报文后,发送窗口减少为 0。
*Zero Window
我们可以看到一个处理缓慢的Server(接收端)是怎么把Client(发送端)的TCPSliding Window
给降成0的。那如果Window变成0了,TCP会怎么样?是不是发送端就不发数据了?是的,发送端就不发数据了,你可以想像成“Window Closed”,那你一定还会问,如果发送端不发数据了,接收方一会儿Window size 可用了,怎么通知发送端呢?
解决这个问题,TCP使用了Zero Window Probe技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,第次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了。
*Silly Window Syndrome
Silly Window Syndrome翻译成中文就是“糊涂窗口综合症”。如果接收方太忙了,来不及取走缓存里的数据,那么,就会导致发送方越来越小。到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的window,而我们的发送方会义无反顾地发送这几个字节。TCP+IP头有40个字节,为了几个字节,要达上这么大的开销,这太不经济了(网络上有个MTU,对于以太网来说,MTU是1500字节,除去TCP+IP头的40个字节,真正的数据传输可以有1460,这就是所谓的MSS(Max Segment Size)
)。
要解决这个问题也不难,就是避免对小的window size做出响应,直到有足够大的window size再响应,这个思路可以同时实现在sender和receiver两端:
- 如果这个问题是由Receiver端引起的,那么就会使用 David D Clark’s 方案。在receiver端,如果收到的数据导致window size小于某个值(MSS和缓存空间的一半中的最小值),可以直接
ack(0)
回sender,这样就把window给关闭了,也阻止了sender再发数据过来,等到receiver端处理了一些数据后windows size 大于等于了MSS,或者,缓存空间有一半为空,就可以把window打开让send 发送数据过来。 如果这个问题是由Sender端引起的,那么就会使用著名的 Nagle’s algorithm。这个算法的思路也是延时处理,他有两个主要的条件:
- 要等到 Window Size>=MSS 或是 Data Size >=MSS.
- 收到之前发送数据的ack回包,他才会发数据,否则就是在攒数据。
简单来说就是未发送的包达到一定量或者达到一定时间阈值之后,才会发送一次。另外,Nagle算法没有禁止小包发送,只是禁止了大量的小包发送。Nagle算法默认是打开的,所以,对于一些需要小包场景的程序——比如像telnet或ssh这样的交互性比较强的程序,你需要关闭这个算法。你可以在Socket设置TCP_NODELAY选项来关闭这个算法(关闭Nagle算法没有全局参数,需要根据每个应用自己的特点来关闭)
例子3
前面的例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整。
当服务端系统资源非常紧张的时候,操心系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。
说明下每个过程:
- 客户端发送 140 字节的数据,于是可用窗口减少到了 220。
- 服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了 120 字节,当收到 140 字节数据后,又因为应用程序没有读取任何数据,所以 140 字节留在了缓冲区中,于是接收窗口大小从 360 收缩成了 100,最后发送确认信息时,通告窗口大小给对方。
- 此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了 100,客户端只会看自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是可用窗口减少到 40。
- 服务端收到了 180 字节数据时,发现数据大小超过了接收窗口的大小,于是就把数据包丢失了。
- 客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,把窗口的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值。
所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。
为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存(随着已发送的数据包得到确认, 保持窗口前沿不动,前移窗口后沿),这样就可以避免了丢包情况。
总览
最后上一张TCP图:
图片来自计算机网络 - TCP 协议原理总结
引用
30张图解: TCP 重传、滑动窗口、流量控制、拥塞控制发愁
通俗易懂 深入理解TCP协议(下):RTT、滑动窗口、拥塞处理
计算机网络 - TCP 协议原理总结
面试不再慌,终于有人把TCP讲明白了。。。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。