OOIII/

TCP 流量控制

TCP 是一种保证我们在不可靠网络上进行可靠传输的协议。当我们从一个节点发送数据到另一个节点时,数据包可能会丢失,他们可能到达顺序不同,网络可能阻塞或者接收端过载。 但是,在编写程序时,通常不需要处理这种复杂情况,我们只需要将数据写入socket,TCP便可以确保将数据包正确传递到接受节点。TCP提供的另一项重要的服务是所谓的流量控制。让我们探讨下什么是流量控制以及TCP是如何发挥神奇作用的。

什么是流量控制

流量控制指的是TCP确保发送方不会由于数据包发送太快,超出接收方的消耗能力来压倒接收方。 这与“分布式系统”文献中通常所说的“背压”非常相似。 主要思想是,接收数据的节点将向发送数据的节点发送某种反馈,使其了解其当前状况。

请务必注意,这与“拥塞控制”不同。 尽管TCP用于提供两种服务的机制之间有些重叠,但是它们是不同的功能。 拥塞控制是为了防止节点淹没网络(即两个节点之间的链接),而流量控制是关于终端节点的。

它是如何运作的

当我们通过网络发送数据时,通常会发生这种事情

layers

发送方应用程序将数据写入套接字,传输层(在我们的示例中为TCP)会将数据包装在一个网段中,然后将其交给网络层(例如IP),以某种方式将此数据包路由到接收节点。

在此通信的另一端,网络层会将这部分数据传送到TCP,TCP会将接收到的精确数据副本提供给接收方应用程序,意思是发送端不会发送无序的数据包,并且它将等待重传,以防字节流中出现间隙。

如果将过程放大,将会看到类似这样的内容。

buffers

TCP将其需要发送的数据存储在发送缓冲区中,并将其接收的数据存储在接收缓冲区中。 应用程序准备就绪后,它将从接收缓冲区读取数据。

流控控制旨在确保在接收缓冲区已满时我们不发送更多数据包,因为接收方将无法处理它们进而会丢弃这些数据包。

为了控制TCP可以发送的数据量,接收者将通告其接收窗口(rwnd),即在接收缓冲区中的可用空间。

rwnd

每次TCP收到数据包时,它都需要向发送方发送一个ACK消息,确认它正确接收了该数据包,并使用此ACK消息发送当前接收窗口的值,发送方据此知道是否可以继续发送数据。

滑动窗口

TCP使用滑动窗口协议来控制它可以操作的字节数。

假设我们要从节点A向节点B发送一个150000字节的文件。TCP可以将该文件分解为100个数据包,每个1500个字节。现在,假设在节点A和B之间建立连接时,节点B会反馈一个45000字节的接收窗口给A。

可见,TCP知道可以在收到确认之前发送前30个数据包(1500 * 30 = 45000)。如果它收到前10个数据包的确认消息(这意味着我们现在只有20个数据包未收到),并且这些确认消息中的接收窗口仍为45000,则它可以发送接下来的10个数据包,从而使发送数据包的数量达到30,这是接收窗口定义的界限。换句话说,在任何给定的时间点,它可能有30个正在发送但尚未确认的数据包正在运行。

sliding-window

滑动窗口的示例。 一旦收到数据包3,我们就可以向右滑动窗口并发送数据包8。

现在,如果由于某种原因读取节点B中的这些数据包的应用程序速度变慢,TCP仍会确认已正确接收到这些数据包,但是由于这些数据包需要存储在接收缓冲区中等待应用程序读取(读取后这部分内存会释放),接收窗口将更小,因此即使TCP收到了对下一个10个数据包的确认(这意味着当前有20个数据包或30000个字节在传送中),但此确认中接收的接收窗口值现在为30000(而不是45000) ,它将不会发送更多的数据包,因为传输中的字节数已经等于刚刚ACK中最新接收窗口值。

发件人将始终保持不变:

LastByteSent - LastByteAcked <= ReceiveWindowAdvertised

可视化接受窗口

为了观察这种行为,让我们编写一个非常简单的应用程序,该应用程序从套接字读取数据,并观察使该应用程序变慢时接收窗口的行为。 我们将使用Wireshark查看这些数据包,使用netcat将数据发送到此应用程序,并使用go程序从套接字读取数据。

这是简单的go程序,可读取并打印接收到的数据:

package main

import (
	"bufio"
	"fmt"
	"net"
)

func main() {
	listener, _ := net.Listen("tcp", "localhost:3040")
	conn, _ := listener.Accept()

	for {
		message, _ := bufio.NewReader(conn).ReadBytes('\n')
		fmt.Println(string(message))
	}
}

该程序将侦听端口3040上的连接并打印收到的字符串。

然后,我们可以使用netcat将数据发送到此应用程序:

$ nc localhost 3040

我们可以使用Wireshark看到已建立连接,并反馈了一个窗口大小:

conn-established

现在,运行此命令以创建数据流。 它将简单地将字符串“foo”添加到文件中,我们将使用该文件将其发送到此应用程序:

$ while true; do echo "foo" > stream.txt; done

现在,将这些数据发送到应用程序:

tail -f stream.txt | nc localhost 3040

现在,如果我们检查Wireshark,我们将看到许多数据包正在发送,并且接收窗口正在更新:

win-decreasing-1

win-decreasing-2

现在,该接受程序能快速从接受缓冲区读取数据。让我们稍微慢一点看看会发生什么:

package main

import (
	"bufio"
	"fmt"
	"net"
	"time"
)

func main() {
	listener, _ := net.Listen("tcp", "localhost:3040")
	conn, _ := listener.Accept()

	for {
		message, _ := bufio.NewReader(conn).ReadBytes('\n')
		fmt.Println(string(message))
		time.Sleep(1 * time.Second) //休眠1秒
	}
}

现在我们要休眠1秒钟,然后从接收缓冲区读取数据。如果我们再次运行netcat并观察Wireshark,可以发现接收缓冲区很快就满了并且TCP开始反馈窗口大小为0:

zero-window

此时,由于接收者的缓冲区已满,TCP将停止传输数据。

持续计时器

不过,仍然存在一个问题。接收者收到零窗口后,如果它不向发送者发送任何其他确认消息(或者确认丢失),它将永远无法知道何时可以再次发送数据。我们将陷入僵局,接收方正在等待更多数据,发送方正在等待可以再次开始发送数据的消息。

为了解决此问题,当TCP收到零窗口消息时,它将启动持续计时器,该计时器将定期向接收方发送一个小数据包(通常称为WindowProbe),因此有机会通知一个非零窗口大小。

window-probe

当接收者的缓冲区中再次有剩余空间时,它可以通知一个非零的窗口大小,传输继续。

概括

  • TCP的流量控制是一种确保发送方不会向接收方提供过多数据的机制,防止超出接收方的处理能力;
  • 接收者通过每条确认消息(Acknowledgment)都会通告其当前接收窗口。
  • 接收窗口是接收缓冲区中的可用空间,即rwnd = ReceiveBuffer - (LastByteReceived – LastByteReadByApplication);
  • TCP将使用滑动窗口协议来确保其传输中的字节数永远不会比接收方反馈的窗口多,即发送数据不会超过接收方缓冲区可用空间。
  • 当窗口大小为0时,TCP将停止传输数据并启动持久计时器;
  • 然后它将定期向接收方发送一条小的WindowProbe消息,以检查它是否可以再次开始接收数据。
  • 当它收到一个非零的窗口大小时,它将恢复传输。

原文:https://www.brianstorti.com/tcp-flow-control/