目录 ← 首页
CS168

TCP 拥塞控制实现

回顾:TCP 窗口

到目前为止,我们已经设计了一个动态调整的、基于主机的算法概念草图,其中每个源独立运行相同的算法,以获得高效、公平的带宽份额。

首先,使用慢启动(从低速率开始,指数级增长)来发现初始速率。然后,在每次迭代中,如果检测到拥塞(检测到丢失),就成倍地降低速率 R;如果没有检测到拥塞,就成 additive 地增加速率 R。

在本节中,我们将了解 TCP 如何实现这一算法。无论好坏,TCP 的拥塞控制机制与 TCP 的可靠性机制紧密交织在一起(这是原始设计的结果,TCP 是为了解决拥塞问题而进行的修补)。在本节中,我们将看到 TCP 的实现如何同时实现可靠性和拥塞控制。

回想一下,在 TCP 中,发送方维护一个连续字节 / 数据包在传输中的滑动窗口。窗口的大小由流量控制(由接收方的缓冲区空间决定)和拥塞控制(由发送方计算的速率)共同决定。

更具体地说,在流量控制中,接收方发送一个通告窗口,指示在不溢出接收方内存的情况下还能发送多少字节。这个通告窗口值有时缩写为RWND(接收窗口)

在拥塞控制中,发送方维护一个值,有时缩写为CWND(拥塞窗口),它表示发送方可以发送数据包而不会使链路过载的速率。这个值将由拥塞控制算法动态设置和调整。

发送方的窗口大小是 CWND 和 RWND 中的最小值。在本课程中,我们假设 RWND 大于 CWND,因此瓶颈是网络,而不是接收方的内存。在实际情况中,这通常是正确的,但并非总是如此。

img

回想一下,我们可以将滑动窗口视为字节流中的一个范围。窗口的左侧是第一个未被确认的字节(窗口左侧的所有内容都已发送并被确认)。窗口的右侧由窗口大小决定。只有窗口内的数据包才允许在传输中。

当窗口左侧的数据被确认后,窗口向右滑动,此时可以发送更多的数据。

为了检测丢失,我们为窗口中最左侧的数据包维护一个计时器。如果计时器到期而该数据包未被确认,我们就重新发送窗口中最左侧的数据包。此外,为了检测丢失,我们统计重复确认的数量,如果看到 3 个重复确认,就重新发送最左侧的数据包。这种基于重复确认的方法有时称为快速重传

窗口与速率

我们如何调整拥塞控制的速率,以及如何计算拥塞窗口?事实证明,这两个值直接相关,调整窗口是通过调整速率来实现的。窗口大小和数据发送速率通过以下公式关联:速率 ×RTT = 窗口大小。

直观地说,你可以将窗口大小和速率视为同一数量,只是用两种不同的 “测量单位” 表示。窗口大小增加意味着我们发送数据更快,反之亦然。

要理解这个公式为何成立,考虑第一个 RTT。在第一个 RTT 期间(在任何确认到达之前),我们可以发送 [窗口大小] 数量的数据包,速率为窗口大小 / RTT。

回想一下,我们的 TCP 概念设计为了简单起见以数据包为单位衡量数据,但在实际中,TCP 是以字节为单位考虑的。在实际实现中,窗口大小是以字节为单位测量的,但为了简单起见,我们将以数据包为单位考虑窗口大小。

要在数据包和字节之间转换,请回想一下我们定义的最大段大小(MSS),即每个数据包的字节数。这告诉我们,MSS× 数据包数量 = 字节数。同样,直观地说,你可以将字节和数据包视为同一数量(数据量)的两种不同测量单位。

事件驱动更新

在我们的概念模型中,我们的目标是每 “迭代” 一次调整速率 / 窗口,但我们尚未正式定义如何测量每次迭代。我们可以大致将每次迭代定义为一个 RTT,但 RTT 本身是一个动态变化的值,我们无法准确测量。

为了以更可预测、可测量的方式更新窗口大小,我们可以考虑现有 TCP 实现所响应的各种事件,并在每次发生这些事件时更新窗口。这些被称为事件驱动更新

需要更新窗口大小的三个 TCP 事件是:新的确认(new ack)、3 个重复确认(3 duplicate acks)和超时(timeout)。

当我们看到一个新的确认(针对之前未被确认的数据)时,这表明我们的数据成功通过网络,没有丢失。在我们的模型中,我们通过检查丢失来检测拥塞,因此新的确认是网络未拥塞的信号。因此,当看到新的确认时,我们可以增加窗口大小(无论是在慢启动发现阶段,还是在 AIMD 调整阶段)。

当我们看到 3 个重复确认时,我们标记一个数据包丢失。这是孤立丢失的信号,表明轻微拥塞。我们丢失了一个数据包,但后续的数据包仍在被接收。为了应对这种丢失,我们将减小窗口大小(在 AIMD 调整期间)。

当遇到超时时,我们标记一个数据包丢失。通过超时而非重复确认检测到丢失,这表明有许多数据包丢失(严重拥塞)。原因如下:考虑窗口大小为 100 个数据包。如果遇到超时,这意味着我们没有收到窗口中最左侧数据包的确认。但这也意味着在整个计时器期间,我们没有收到窗口中任何其他数据包的 3 个重复确认。超时意味着很少有(如果有的话)数据包被接收,并且发生了不好的事情。

如果发现超时,说明发生了意外情况(例如网络变化),我们不应再信任当前的窗口大小。为了应对,我们应该回到慢启动阶段,重新发现合适的窗口大小。这不是应对超时的唯一方法,但这是 TCP 的选择。

事件驱动的慢启动

在我们的概念模型中,我们通过选择一个慢速率来实现慢启动,并以指数方式增加速率(例如,每次迭代加倍),直到遇到第一次丢失。现在我们需要一种事件驱动的方式,每 RTT 将窗口加倍一次。

TCP 从一个小窗口(1 个数据包)开始。回想一下,我们可以用最大段大小(MSS)将数据包转换为字节,然后通过除以 MSS/RTT 将字节转换为速率。

每次我们收到一个确认,就将窗口大小增加 1 个数据包。其原理如下:

最初,窗口大小为 1 个数据包。我们发送 1 个数据包,经过一个 RTT 后,收到 1 个确认。这个确认让我们将窗口增加到 2 个数据包。

现在我们发送 2 个数据包,经过一个 RTT 后,收到 2 个确认。这 2 个确认让我们再增加 2 个数据包的窗口,新的窗口大小为 4 个数据包。

我们现在发送 4 个数据包,经过一个 RTT 后,收到 4 个确认。这 4 个确认让我们再增加 4 个数据包的窗口,新的窗口大小为 8 个数据包。

img

不过,这个直观的图景假设我们同时发送所有 4 个数据包并同时接收所有 4 个确认。实际上,滑动窗口的行为导致我们每次收到一个确认时窗口就增加 1,但其最终行为(每 RTT 窗口加倍)是相同的。

和之前一样,我们从窗口大小为 1 个数据包开始。我们发送 1 个数据包(A),经过一个 RTT 后,收到 A 的确认。这个确认让我们将窗口增加到 2 个数据包,此时没有数据包在传输中。

接下来,我们可以发送 2 个数据包(B 和 C)。当收到 B 的确认时,我们将窗口增加到 3 个数据包。此时仍有 1 个数据包(C)在传输中,因此我们可以再发送 2 个数据包(D 和 E)。

当收到 C 的确认时,我们可以将窗口增加到 4 个数据包。此时仍有 2 个数据包(D 和 E)在传输中,因此我们可以再发送 2 个数据包(F 和 G)。

一般来说,假设没有丢失和乱序,每次我们收到一个确认,滑动窗口就允许我们再发送一个数据包,而增大的窗口又允许我们再发送一个数据包。因为每个确认导致发送 2 个数据包,所以我们得到每 RTT 窗口加倍的行为。例如,在一个 RTT 间隔内收到 16 个确认,每个确认触发发送 2 个数据包,总共发送 32 个数据包。然后,在下一个 RTT 间隔内,这 32 个数据包将被确认,触发发送 64 个数据包。

最终,在经过一段时间的每 RTT 窗口加倍(每次收到确认时窗口增加 1)后,我们会遇到丢失。这也意味着我们已经了解到在不遇到丢失的情况下发送数据包的最大允许 “安全” 速率。我们将在一个新的参数 SSTHRESH(慢启动阈值)中记住这个速率。具体来说,一旦遇到数据包丢失,我们就将 SSTHRESH 设置为窗口大小的一半。例如,如果窗口为 16 个数据包时没有导致丢失,但窗口为 32 个数据包时导致了丢失,那么我们会将 SSTHRESH 设置为 16。

img

回想一下,慢启动之后,我们会持续调整窗口大小(AIMD)。SSTHRESH 让我们记住从慢启动中了解到的安全速率,即使速率在之后开始变化。

实现加性增加

在我们的概念模型中,慢启动之后,当没有丢失时,我们希望缓慢地(加性地)增加速率。我们需要一种事件驱动的方式,每 RTT 将窗口增加 1 个数据包。

我们没有 RTT 的精确数值,但我们知道在一个 RTT 内,我们期望收到一个窗口大小的数据包的确认。例如,窗口大小为 10 时,每 RTT 我们会收到 10 个确认。如果我们每次确认时将窗口增加 1/10 个数据包,那么在一个 RTT 内,窗口应该增加 1 个数据包,这正是我们所期望的。

每次收到一个确认时,我们会将当前窗口大小 CWND 重新赋值为 CWND + (1/CWND)。这会在每次确认时将窗口增加一小部分数据包。在收到一整个窗口大小的数据包(即一个 RTT 后),窗口会增加 1 个数据包。

形式上,TCP 以字节为单位测量窗口,因此(1/CWND)在字节上相当于 MSS×(MSS/CWND)。在(1/CWND)中,分子是 1 个数据包(一个 RTT 内的总增加量),分母是 CWND(以数据包为单位)。由于分母现在以数据包为单位,我们也必须将分子以数据包为单位:1 个数据包 = MSS 字节。

但分数 1/CWND 或 MSS/CWND 仍然是一个比率(无量纲),表示每次确认时要增加的比例。我们想要的总增加量是 1 个数据包 = MSS 字节,因此我们必须将这个分数乘以 MSS 字节。

例如,假设我们的 CWND 是 3 个数据包 = 150 字节(假设 MSS = 50 字节)。在数据包视图中,我们每次会向窗口添加 1/3 个数据包,总增加量为 1 个数据包。

在字节视图中,我们可以计算 MSS/CWND = 50/150,得到我们每次需要增加的相同比例 1/3,总增加量为 1。但我们仍然需要乘以 MSS,以便总增加量是 MSS 而不是 1。

img

请注意,这种增加并非完全线性,但提供了足够好的近似。例如,从 CWND = 4 开始,第一次更新是 4 + 1/4 = 4.25,第二次增加是 4.25 + 1/4.25 = 4.49。经过四次更新后,窗口大小在这个近似中为 4.92(在精确模型中我们希望是 5)。

实现乘性减少

如果我们从 3 个重复确认中检测到丢失,我们将窗口大小除以 2。

回想一下,如果重传计时器到期,我们将超时解释为多个数据包丢失(我们甚至没有收到重复确认)。我们假设当前窗口可能偏差很大,为了谨慎起见,我们将从头开始重新发现合适的速率。

首先,我们会记录当前速率过高,已知的最佳安全速率是当前速率的一半(遵循乘性减少原则)。为了记录这个安全速率,我们将 SSTHRESH 设置为当前窗口的一半。

然后,我们将窗口大小重置为 1 个数据包,并再次重复慢启动过程。

请注意,当我们重试慢启动时,需要注意不要因之前的超时而回到危险速率。幸运的是,我们将 SSTHRESH 设置为略低于危险速率。因此,在后续的慢启动重试中(已设置 SSTHRESH),一旦我们的窗口超过 SSTHRESH,我们就应该从乘性增加切换到加性增加。在第一次慢启动时,SSTHRESH 未设置(或为无穷大)。

总结一下:在慢启动中,我们每次确认时将窗口增加 1 个数据包(导致每 RTT 速率加倍)。在 AIMD 模式下,我们每次确认时将窗口增加窗口大小的一小部分(导致每窗口数据增加 1 个数据包)。当收到 3 个重复确认时,我们将窗口减半;发生超时时,将窗口重置为 1 个数据包。

请注意,在减少窗口大小时,我们绝不会将窗口大小降至 1 个数据包以下。在最坏情况下,我们需要允许 1 个数据包在传输中。

TCP 锯齿形

img

如果我们绘制速率随时间的变化图,会看到初始的指数增长(慢启动)。一旦遇到丢失,我们就将速率减半,并切换到 AIMD 模式。现在,我们线性增加速率,直到遇到丢失,每次遇到丢失时将速率减半。

快速恢复:运行示例

在我们的拥塞控制实现中,还有最后一个问题需要处理。当遇到孤立的数据包丢失时,拥塞窗口如预期般减半。然而,这会产生一个意外的副作用:导致发送方在继续发送数据包之前停滞一段时间。

为了实际了解这一点,让我们考虑一个运行示例。我们发送 10 个数据包,编号为 101 到 110。第一个数据包(101)被丢弃。

因此,其他 9 个数据包(102 到 110)都被确认为 ack (101),因为下一个期望的字节仍然是 101。

在收到第三个重复的 ack (101)(由接收 102、103 和 104 产生)后,发送方重新发送 101。

最终,重新发送的 101 的确认到达。确认信息为 ack (111),因为 102 到 110 之前都已被接收,而收到 101 后,下一个期望的字节是 111。

img

总结:在发送方端,我们发送 101 到 110,其中 101 被丢弃。我们从 102 收到 ack (101),从 103 收到 ack (101),从 104 收到 ack (101)。此时,我们重新发送 101。然后,我们从 105 到 110 收到 ack (101)。最后,我们最终从 101 收到 ack (111)。

在接收方端,我们收到 102 到 110,并每次发送回 ack (101),因为下一个未接收的字节仍然是 101。最终,我们收到重新发送的 101,并发送回 ack (111),因为下一个未接收的字节是 111。

在这个运行示例中,CWND 是怎样的呢?请记住,窗口从第一个未被确认的字节开始,并延伸 CWND 个连续字节。只有收到第一个未被确认的字节,窗口才能向前移动。如果我们收到窗口中其他一些字节的确认,窗口保持不变,因为窗口是由第一个未被确认的字节决定的。

快速恢复:问题所在

假设 CWND 初始为 10。允许 101 到 110 的数据包在传输中。发送方发送 101 到 110,但 101 被丢弃。

img

发送方看到 ack (101),这是由接收方收到 102 产生的。此时,第一个未被确认的字节仍然是 101,所以窗口保持不变。允许在传输中的数据包仍然是 101 到 110,发送方不能发送任何新的数据包(例如,不能发送 111)。

接下来,发送方看到 ack (101),这是由接收方收到 103 产生的。同样,第一个未被确认的字节仍然是 101,所以窗口不变。窗口仍然从 101 开始,延伸到 110,发送方不能发送任何新的数据包。

接下来,发送方看到 ack (101),这是由接收方收到 104 产生的。这是第三个重复确认,因此我们必须将 CWND 减小到 5。第一个未被确认的字节仍然是 101,CWND 为 5,所以允许 101 到 105 的数据包在传输中。发送方仍然不能发送任何新的数据包。因为看到了第三个重复确认,我们重新发送窗口中最左侧的数据包(101)。

接下来,发送方看到 ack (101),这是由接收方收到 105 产生的。窗口仍然是从 101(第一个未被确认的字节)到 105(CWND 字节之后),所以我们不能发送任何新的数据包。

img

接下来,发送方看到 ack (101),这是由接收方收到 106 产生的。再次,窗口没有变化,我们不能发送任何新的数据包。

发送方从接收方收到 107、108、109、110 产生的 ack (101)、ack (101)、ack (101)、ack (101)。在所有这些情况下,101 仍然是第一个未被确认的字节,所以窗口仍然是 101 到 105,发送方不能发送任何新的数据包。

这是怎么回事呢?只丢失了一个数据包,但结果是发送方不得不完全停止发送很长一段时间。

窗口由第一个未被确认的字节定义,所以在 101 被重新发送并确认之前,窗口拒绝向前移动。即使其他所有数据包(102 到 110)都到达了,窗口仍然停留在 101,后续的数据包(111 及以后)不能被发送。发送方停滞了!

最终,发送方收到重新发送的 101 的 ack (111)。这导致窗口向前跳跃,滑动到新的第一个未被确认的数据包 111。CWND 仍然是 5,所以发送方现在能够发送 111 到 115。

img

这又发生了什么?我们现在有了第二个问题。发送方停滞了很长时间,但是一旦 101 被 ack (111) 确认,窗口就一下子跳到 111-115,发送方突然不得不匆忙同时发送 111-115。

发送方停滞了很长时间,什么都没发送,然后突然匆忙同时发送 111-115。现在,发送方必须再等待一个完整的往返时间,直到 111-115 被确认,才能发送 116 及以后的数据包。

img

总结:孤立的数据包丢失导致窗口卡住,这使得发送方停滞并停止发送。最终,当该数据包被重新发送并确认后,窗口向前跳跃,导致发送方匆忙同时发送一堆新的数据包。发送方现在必须再等待一个往返时间,直到这些新数据包被确认,才能恢复正常发送。

img

关于这个问题的几点说明:

如果这个问题仍然难以理解,或许有助于注意到,这个问题更多是由于 TCP 滑动窗口机制,而不是真正由于拥塞控制机制。拥塞控制导致窗口缩小,但即使窗口没有缩小,发送方仍然会被迫停滞,直到 101 被接收且窗口向前跳跃。

直观地思考这个问题时,画出发送方窗口的图表会有所帮助,标记出已被确认的字节。例如,在三次重复确认之后,我们标记 102、103、104 已被接收,窗口允许 101(第一个未被确认的字节)到 105 在传输中。

然而,这并不是发送方实际看到的情况。请记住,发送方只看到累积确认,所以它实际上并不知道 102、103 和 104 已被接收。发送方可以推断窗口中有 3 个数据包(不是 101)已被接收,但它不知道具体是哪 3 个数据包。

最后,请注意,在我们收到 3 个重复的 ack (101) 消息后,我们重新发送 101,即使有更多的重复 ack (101) 消息到来,我们也不会再次重新发送 101。这只是 TCP 基于重复确认进行重传的规则。

快速恢复:思路

那么,我们如何解决这个问题呢?理想情况下,我们不希望发送方停滞,并且希望发送方能够继续发送后续的数据包(111 及以后),即使 101 丢失了。

注意到,尽管发送方无法准确推断哪些数据包到达了,但发送方可以推断后续的(非 101)数据包正在被接收。

当我们看到由 102 被接收而产生的 ack (101) 时,我们实际上并不知道 102 已被接收,但我们知道某个数据包(非 101)已被接收。因此,只有 9 个数据包仍在传输中。

当我们看到另一个由 103 被接收而产生的 ack (101) 时,我们再次不知道具体是 103 已被接收,但我们知道又有一个数据包(非 101)已被接收。因此,只有 8 个数据包仍在传输中。

随着我们不断收到重复的 ack (101) 消息,我们可以推断传输中的数据包越来越少:

收到来自 102 的 ack (101) 后:9 个数据包在传输中。

收到来自 103 的 ack (101) 后:8 个数据包在传输中。

收到来自 104 的 ack (101) 后:7 个数据包在传输中。

收到来自 105 的 ack (101) 后:6 个数据包在传输中。

收到来自 106 的 ack (101) 后:5 个数据包在传输中。

收到来自 107 的 ack (101) 后:4 个数据包在传输中。

收到来自 108 的 ack (101) 后:3 个数据包在传输中。

收到来自 109 的 ack (101) 后:2 个数据包在传输中。

收到来自 110 的 ack (101) 后:1 个数据包在传输中。

最终,在我们收到 9 次 ack (101)(来自 102 到 110 被接收)后,我们知道只有 1 个数据包仍在传输中,即 101。

在孤立丢失之后,我们确实希望 CWND 为 5,这意味着我们希望在任何时候都有 5 个数据包在传输中。当我们收到来自 107 的 ack (101) 时,我们可以推断只有 4 个数据包仍在传输中(实际上,它们是 101、108、109、110,尽管发送方不知道)。

此时,我们希望能够发送 111,使传输中的数据包总数达到 5 个。但窗口不允许我们这样做,因为窗口仍然卡在 101(第一个未被确认的字节)到 105(CWND 字节之后)。

解决发送方停滞问题的关键思路是:让我们为每个重复确认给予发送方临时的 “信用”。

当一个重复确认到达时,我们可以推断传输中的数据包减少了一个,尽管我们不知道是哪一个。为了应对这一点,我们将人为地将窗口扩展 1 个数据包,允许发送方再发送一个数据包。

快速恢复:解决方案

让我们将为每个重复确认人为扩展窗口的想法应用到之前的示例中。

和之前一样,窗口从 101 到 110 开始,我们发送 10 个数据包。

img

和之前一样,我们收到来自 102 的 ack (101),窗口保持不变,我们不能发送任何新的数据包。

和之前一样,我们收到来自 103 的 ack (101),窗口保持不变,我们不能发送任何新的数据包。

img

和之前一样,我们收到来自 104 的 ack (101),窗口保持不变,我们不能发送任何新的数据包。

第三个重复确认意味着我们将 CWND 减小到 5,所以窗口现在是 101 到 105。

然而,我们收到了 3 个确认,所以我们人为地将窗口扩展 3,以应对这些确认。因此,CWND 实际上被设置为 5 + 3 = 8。

接下来,我们收到来自 105 的 ack (101)。这允许我们再次扩展窗口到 9。现在窗口范围是 101 到 109,所以我们仍然不能发送新的数据包。

img

接下来,我们收到来自 106 的 ack (101)。我们再次将窗口扩展到 10,范围是 101 到 110,我们不能发送任何新的数据包。

接下来,我们收到来自 107 的 ack (101)。我们再次将窗口扩展到 11,范围是 101 到 111。我们现在可以发送 111 了!

接下来,我们收到来自 108 的 ack (101)。我们再次将窗口扩展到 12,范围是 101 到 112。我们现在可以发送 112 了!

接下来,我们收到来自 109 的 ack (101)。我们再次将窗口扩展到 13,范围是 101 到 113。我们现在可以发送 113 了!

接下来,我们收到来自 110 的 ack (101)。我们再次将窗口扩展到 14,范围是 101 到 114。我们现在可以发送 114 了!

img

最终,我们收到重新发送的 101 的 ack (111)。此时,我们可以将 CWND 重置为其原始预期值 5,因此窗口范围是 111 到 115。这允许我们发送 115!

通过这个修复,我们解决了发送方停滞的问题。原本,发送方必须等待重新发送的 101 被确认后才能发送新的数据包。现在,发送方能够在重新发送的 101 被确认之前继续发送数据包。

img

我们还解决了之前的第二个问题,即窗口向前跳跃并导致我们发送一堆新的数据包(111 到 115)。现在,111 到 114 已经提前发送,当窗口向前跳跃时,我们只需要发送 115。

如果没有这个修复,我们必须再等待一个往返时间,直到 111 到 115 的突发数据包被确认。现在,因为我们之前一直在发送并发送了 111 到 114,它们会更早被确认,我们可以继续发送 116 及以后的数据包,而不会出现整个 RTT 的停滞。

img

另一种看待这个修复的方式是关注人为扩展的窗口中的数据包。

当我们收到第三个重复确认时,CWND 缩小到 5,但我们为 3 个重复确认人为扩展窗口,使 CWND 为 8。如果你查看这个扩展后的窗口,其中 3 个数据包已被确认(102、103、104,尽管我们不知道具体是这些),其他 5 个在传输中。这实现了我们预期的 5 个数据包在传输中的窗口。

接下来,当我们收到来自 105 的另一个 ack (101) 时,窗口扩展到 9。同样,查看这个窗口,其中 4 个数据包已被确认(我们不知道是哪些),其他 5 个在传输中,这就得到了我们预期的 5 个传输中数据包的窗口大小。

当我们收到来自 106 的 ack (101) 时,窗口扩展到 10,包括 5 个已接收的数据包(来自 5 个重复确认),加上 5 个传输中的数据包(预期窗口大小)。

在每个步骤中,在我们扩展的窗口中,如果不计算已被确认的数据包,窗口中正好有 5 个数据包在传输中。同样,我们不知道窗口中具体哪些数据包已被确认,但我们可以使用重复确认来计算已被确认的数据包数量,并利用该计数保持 5 个传输中的数据包。

当我们收到来自 107 的 ack (101) 时,窗口扩展到 11,包括 6 个已接收的数据包(来自 6 个重复确认)。窗口中的其他 5 个数据包允许在传输中。

此时,我们最初发送了 10 个数据包,收到了 6 个重复确认,这告诉我们只有 4 个数据包仍在传输中。这允许我们发送 111。人为扩展的窗口体现了这一逻辑,因为它将窗口扩展到包括 111。

当我们收到来自 108 的 ack (101) 时,我们推断现在传输中的数据包又减少了一个。因此,我们再次人为地将窗口扩展到 12,这允许发送 112。

快速恢复:实现

当我们从重复确认中检测到数据包丢失时,我们暂时进入快速恢复模式,在这种模式下,额外的重复确认会人为地扩展窗口以防止停滞。

当收到 3 个重复确认时,触发快速恢复模式。我们不再像以前那样只将 CWND 减半,而是将 CWND 设置为 CWND/2 + 3,其中窗口人为地扩展 3 以应对我们收到的 3 个重复确认。我们还将 SSTHRESH 设置为 CWND/2,以便稍后记住新的安全速率。

在快速恢复模式下,每个额外的重复确认都会导致 CWND 增加 1,允许窗口人为地扩展。

最终,当我们收到一个新的、非重复的确认时,我们离开快速恢复模式,并将 CWND 设置为 SSTHRESH。请注意,当我们人为地扩展窗口时,SSTHRESH 始终帮助我们记住我们最终想要发送的原始减半速率。

TCP 状态机

我们终于准备好将所有部分整合在一起,实现带有拥塞控制的 TCP。

发送方维护 5 个值:

重复确认计数有助于我们比超时更早地检测丢失。初始化为 0。

计时器用于检测丢失。只有一个计时器。

RWND 用于流量控制(不淹没接收方缓冲区)。

CWND 用于拥塞控制。初始化为 1 个数据包。

SSTHRESH 帮助拥塞控制算法记住最新的安全速率。初始化为无穷大。

接收方维护一个乱序数据包的缓冲区。

发送方响应 3 个事件:新数据的确认(之前未被确认)、重复确认和超时。

接收方在收到数据包时做出响应,回复一个确认和一个 RWND 值。

让我们看看发送方如何响应这 3 个事件。

当我们收到对新数据的确认(之前未被确认)时:如果处于慢启动模式,我们将 CWND 增加 1。这使得 CWND 每 RTT 加倍。如果处于快速恢复模式,我们将 CWND 设置为 SSTHRESH,以便离开快速恢复模式(因为我们刚刚收到一个新的确认)。如果处于拥塞避免模式,我们向 CWND 添加 1/CWND,使得 CWND 每 RTT 增加 1(加性增加)。我们还重置计时器、重置重复确认计数,并且如果窗口允许,发送新数据。

当我们收到一个重复确认时,我们增加重复确认计数。如果计数达到 3,我们重新发送窗口中最左侧的数据包。这有时称为快速重传。我们还通过将 SSTHRESH 设置为 CWND/2(记住最后的安全速率)并将 CWND 设置为 CWND/2 + 3(添加 3 以人为地扩展窗口以应对重复确认)来进入快速恢复模式。如果计数超过 3,我们保持在快速恢复模式,并为每个后续的重复确认人为地将 CWND 增加 1。

当计时器到期时,我们重新发送窗口中最左侧的数据包。我们还回到慢启动模式,将 SSTHRESH 设置为 CWND/2(记住最后的安全速率),并将 CWND 重置为 1 个数据包。

拥塞控制状态机显示了 TCP 可能处于的 3 种模式,以及触发模式之间转换的条件。

img

如果我们收到 3 个重复确认,我们进入快速恢复模式。一旦进入此模式,任何进一步的重复确认都会使我们保持在快速恢复模式(继续人为地扩展窗口)。要离开快速恢复模式,要么超时将我们切换回慢启动模式,要么新的确认让我们回到拥塞避免模式。

超时触发慢启动模式。任何进一步的确认(重复的或新的)都让我们保持在慢启动模式。最终,如果 CWND 超过 SSTHRESH(安全速率),我们进入拥塞避免模式。或者,如果我们检测到丢失,我们将速率减半,并在进入拥塞避免模式之前进入快速恢复模式一段时间。

在拥塞避免模式下,新的确认让我们保持在该模式(加性增加),但重复确认将我们发送到快速恢复模式,超时将我们发送到慢启动模式。

TCP 拥塞控制变体

TCP 拥塞控制算法有几种变体,都在终端主机的操作系统中实现。有趣的事实:这些名称与伯克利软件分发(BSD)操作系统有关。

在 TCP Tahoe 中,如果我们收到三个重复确认,我们将 CWND 重置为 1,而不是将 CWND 减半。

在 TCP Reno 中,如果我们收到三个重复确认,我们将 CWND 减半。超时时,我们将 CWND 重置为 1。

TCP New Reno 与 Reno 相同,但添加了快速恢复。这就是我们刚刚实现的。

也存在其他变体。在 TCP-SACK 中,我们添加了选择性确认,其中确认包含更多细节(例如,收到了直到 13 的所有数据,也收到了 18)。

所有这些不同的变体如何共存?为什么我们不需要一个所有人都遵循的统一协议?请记住,拥塞控制是在终端主机上实现的,因此发送方可以采取任何方式来调整其速率。最终,网络和其他终端主机只看到 TCP 数据包以(希望是合理的)速率发送,它们并不关心速率是如何计算的。不同的拥塞控制算法不会改变底层的 TCP 数据包格式。

不过,并非所有协议都是兼容的。如果你使用带有选择性确认的 TCP-SACK 变体,而我使用 TCP Tahoe,我们就会有问题。你期望选择性确认,但我只提供累积确认。