在我们当初学习网络编程的时候,都接触过TCP,在TCP中,对于数据传输有各种策略,比如滑动窗口、拥塞窗口机制,又比如慢启动、快速恢复、拥塞避免等。通过本文,我们将了解滑动窗口在TCP中是如何使用的。
滑动窗口实现了TCP流控制。首先明确滑动窗口的范畴:
- TCP是双工的协议,会话的双方都可以同时接收和发送数据。
- 会话的双方都各自维护一个发送窗口和一个接收窗口。各自的接收窗口大小取决于应用、系统、硬件的限制(TCP传输速率不能大于应用的数据处理速率)。各自的发送窗口则要求取决于对端通告的接收窗口,要求相同。
滑动窗口解决的是流量控制的的问题,就是如果接收端和发送端对数据包的处理速度不同,如何让双方达成一致。接收端的缓存传输数据给应用层,但这个过程不一定是即时的,如果发送速度太快,会出现接收端数据overflow,流量控制解决的是这个问题。
发送端窗口
上图是发送端滑动窗口的简图。 我们可以将数据分为4个部分:
- 发送和已确认的字节(蓝色部分)
- 已发送但尚未确认的字节(黄色部分)
- 未发送的字节和接收方准备接收的字节,即在缓冲区buffer中(绿色部分)
- 未发送且接收方未准备接收的字节(灰色部分)
其中第三部分,也就是绿色部分,也称为可用窗口,因为这是发送方可以使用的窗口。
发送窗口由黄色和绿色部分组成。 这些字节要么已经发送,要么可以发送。
当发送方发送21-25字节并使用可用窗口中的所有字节时,可用窗口可能为空,发送窗口保持不变(如下图)。
当发送方收到第16-19字节的 ACK 时,发送窗口向右滑动 4 个字节。 更新的可用窗口可用于队列中的以下字节(如下图)。
为了便于理解,我们后续将窗口名使用简称,即:
- SND.WND,代表发送窗口
- SND.UNA, 代表Send Unacknowledged指针,指向发送窗口的第一个字节
- SND.NXT, 代表Send Next指针,指向可用窗口的第一个字节
使用简写后,如下图所示:
基于这些定义,我们可以用公式表示可用的窗口大小。
可用窗口(可用窗口)大小 = SND.UNA + SND.WND - SND.NXT复制代码
接收端窗口
接收窗口有三种:
- 1、接收并且已经向发送端发送确认ACK
- 2、尚未接收但允发送端发送数据
- 3、尚未接收且不允许发送端发送数据
第二种称为接收窗口,也称为RCV.WND。 类似于发送窗口,指针RCV.NXT,代表Receive Next指针,指向接收窗口的第一个字节。
接收窗口不是静态的。如果服务端性能高,读取数据快,接收窗口可能会扩大。 否则,它可能会缩小。
接收方通过在TCP段报头中的窗口字段中指示大小来传达其接收窗口。 当发送方收到它时,这个窗口大小就成为可用窗口。
发送和接收数据需要时间。 因此,接收窗口不等于特定时刻的可用窗口。
下面,为了更好的理解滑动窗口在TCP中的使用,我们将使用一个简单的例子进行模拟说明。
示例(大小不变)
我们模拟一个请求和响应,以更好地理解滑动窗口的工作原理。 为了模拟起来简单,我们尽可能的简化里面的过程,比如:
- 我们忽略最大段大小 (MSS)。 MSS 因选择的网络路由而不同。
- 使接收窗口等于可用窗口,并且在此过程中两者保持不变。
上图示例中,有10个步骤。 客户端请求资源,服务器分三段响应:
- 1、一个 50 字节的包头
- 2、一个 80 字节的数据1
- 3、一个 100 字节的数据2
每一方都可以同时是发送方和接收方。
我们假设客户端的发送窗口 (SND.WND) 是 300 字节,接收窗口 (RCV.WND) 是 150 字节。 因此,服务器的 SND.WND 为 150 字节,RCV.WND 为 300 字节。
上图客户端的起始状态。
我们假设它之前已经从服务器接收了300个字节,所以RCV.NXT指向301。由于它还没有发送任何东西,SND.UNA和SND.NXT都指向1。
可用窗口(可用窗口)大小 = SND.UNA + SND.WND - SND.NXT复制代码
根据这个公式,客户端的可用窗口大小为 1 + 300 - 1 = 300。
这是服务端的起始状态,镜像另一端即客户端的状态。
因为它已经发送了300个字节,所以SND.UNA和SND.NXT都指向301。
RCV.NXT指向1,因为客户端尚未发送任何请求。 服务器的可用窗口是301 + 150 - 301 = 150。
现在,我们从步骤1开始:
客户端发送它的第一个100字节请求。
此刻,窗户发生了变化。
- 这 100 个字节已发送,但尚未收到 ACK。 因此,SND.NXT 向右滑动 100 个字节。
- 其他指针保持不变。
可用窗口更改为 1 + 300 - 101 = 200。
在第 2 步,我们的焦点转移到服务器上,从服务端的角度来分析。
- 当服务器收到请求时,RCV.NXT 向右滑动 100 个字节。
- 然后它发送一个带有 ACK 的 50 字节回复。 这 50 个字节已发送,但尚未发送 ACK,因此 SND.NXT 向右移动 50 个字节。
- SND.UNA不动。
可用窗口大小变为301 + 150 - 351 = 100。
让我们现在继续转向客户端。
- 当收到50字节的回复时,RCV.NXT向右移动50字节。
- SND.UNA 在收到前一个发送的 100 个字节的 ACK 时向右滑动。
- SND.NXT保持不变,因为客户端不发送任何数据。
可用窗口更改为101 + 300 - 101 = 300。
再次移动到服务器端。
可用窗口为 100 字节。服务器可以发送 80 字节的段。
- SND.NXT 向右滑动 80 个字节。
- SND.UNA 保持不变,因为最后 50 字节尚未得到确认。
- RCV.NXT 保持不变,因为服务器没有收到任何数据。
可用窗口更改为 301 + 150 - 431 = 20。
客户端收到文件的第一部分并立即发送ACK。
- 当客户端接收到 80 字节的数据时,RCV.NXT 向右移动。
- 其他部分不变。
可用窗口大小仍为300。
此时,服务器在发送 50 字节的回复时收到了第 2 步的 ACK。
- SND.UNA 向右移动 50 个字节。
- 其他部分保持不变。
可用窗口大小变为351 + 150 - 431 = 70。
当服务器发送数据1即80字节部分时,再次收到第4步的另一个ACK。
- SND.UNA 向右移动 80 个字节。
- 其他部分保持不变。
可用窗口大小变为431 + 150 - 431 = 150。
在第 8 步,服务器数据2,大小为100字节。
- SND.NXT向右移动 100 个字节。
- 其他部分保持不变。
可用窗口大小变为431 + 150 - 531 = 50。
继续转到客户端。
- 当客户端收到 100 字节时,RCV.NXT 向右移动 100 字节。
- 其他部分保持不变。
可用窗口大小保持不变。
最后,服务器收到前一个响应的 ACK。
- SND.UNA向右移动100个字节。
- 其他部分保持不变。
可用窗口大小变为531 + 150 - 531 = 150。
至此,对于滑动窗口不变的示例,讲解完毕,那么对于滑动窗口大小变化的呢?在TCP中又是如果实现的呢?
示例(大小变化的窗口)
在前面的示例中,我们假设发送窗口和接收窗口保持不变。 这个假设本身在实际中就是不成立的,因为不存在。
两个窗口中的字节都存在于操作系统缓冲区中,可以对其进行调整。 例如,当我们的应用程序没有足够快地从中读取字节时,缓冲区中的可用空间就会缩小。
我们来介绍一下这种情况下的窗口变化,看看它是如何影响可用窗口的。
我们简化了这种情况以将可用窗口集中在客户端上。 在这个例子中,客户端始终是发送方,而服务器是接收方。
当服务器发送 ACK 时,它也会在其中包含更新后的窗口大小。
一开始,客户端发送第一个150字节的请求。
- 这 150 个字节已发送,但尚未发送 ACK。
- 可用窗口缩小到 150 字节。
发送窗口保持在300字节。
当服务器收到请求时,应用程序读取前 50 个字节,还有 100 个字节仍在缓冲区中,从接收窗口中占用 100 个字节的可用空间。 因此,接收窗口缩小到 200 字节。
接下来,服务器发送带有更新的 200 字节接收窗口的 ACK。
客户端收到 ACK 并将其发送窗口大小更新为 200。
此时,可用窗口与发送窗口相同,因为所有 150 个字节都被确认。
客户端再次发送另一个 200 字节的请求,使用可用窗口中的所有可用空间。
服务器接收到 200 字节后,应用程序仍然运行缓慢,总共只读取了 70 字节,并在缓冲区中留下了 280 字节。
这会导致接收窗口再次缩小。 现在,我们只剩下 20 个字节了。
在 ACK 消息中,服务器与客户端共享更新的窗口大小。
同样,客户端在收到 ACK 后将其发送窗口更新为 20 字节。 可用窗口也变为 20 字节。
在这种情况下,客户端停止发送任何大于 20 字节的请求,直到它收到以下消息中的另一个窗口更新。
如果没有更多来自服务器的消息,我们会被困在 20 字节的可用窗口吗?
我们不会。 为了避免这种情况,客户端的 TCP 会定期检测窗口大小。 一旦释放更多空间,可用窗口就会扩大,并且可以发送更多数据。
结语
可用窗口的计算是理解TCP滑动窗口的关键。
要学习可用窗口的计算,我们需要了解 3 个指针——SND.UNA、SND.NXT 和 RCV.NXT。
假设一个永不改变的窗口大小可以帮助我们了解进度。
更多文章,请关注公众号:高性能架构探索