目录 ← 首页
CS168

TCP 实现

TCP 段

到目前为止,我们一直在从概念上讨论 TCP,涉及到单个数据包的发送。但应用程序并不会给我们提供可以直接发送到第三层网络的预制数据包。应用程序依赖于字节流抽象,而是向我们发送连续的字节流。为了完整实现 TCP,我们需要从字节而非数据包的角度重新思考之前的所有概念(例如序列号、窗口大小等)。(不过,你仍然应该能够从字节或数据包的角度来推理设计选择。)

img

为了从字节流中形成数据包,我们引入一种称为TCP 段的数据单元。发送方的 TCP 实现会从字节流中逐个收集字节,并将这些字节放入 TCP 段中。当 TCP 段满了(达到固定的最大段大小),我们就发送该 TCP 段,然后开始一个新的 TCP 段。

有时,发送方想要发送的数据少于最大段大小。在这种情况下,我们不希望 TCP 段一直等待那些永远不会到来的更多字节。为了解决这个问题,我们会在开始填充一个新的空段时启动一个计时器。如果计时器到期,我们就发送这个 TCP 段,即使它还没有满。

img

在发送 TCP 段中的数据之前,发送方的 TCP 实现会添加一个包含相关元数据(例如序列号、端口号)的 TCP 头部。然后,该段和头部会被传递到 IP 层,IP 层会附加一个 IP 头部并通过网络发送该数据包。

带有 TCP 头部和 IP 头部的 TCP 段有时被称为TCP/IP 数据包。同样地,这是一个 IP 数据包,其有效载荷由 TCP 头部和数据组成。

img

最大段大小(MSS) 应该如何设置呢?回想一下,IP 数据包的大小受到每个链路的最大传输单元(MTU)的限制。然而,IP 数据包还必须包含 IP 头部和 TCP 头部,因此 TCP 最大段大小将略小于 IP 最大传输单元。具体来说:

MSS(TCP 段限制)= MTU(IP 数据包限制)- IP 头部大小 - TCP 头部大小

序列号

到目前为止,我们一直在给每个数据包标记一个编号,以便接收方能按正确顺序接收数据包。

实际上,我们不是给各个段编号,而是给字节流中的每个字节分配一个编号。每个段的头部会包含一个序列号,对应于该段中第一个字节的编号。接收方仍然可以使用序列号来确定每个段在字节流中的位置,并按正确顺序重组这些段。

每个字节流都以一个初始序列号(ISN) 开始。发送方选择一个 ISN,并将第一个字节标记为 ISN+1,下一个字节为 ISN+2,再下一个为 ISN+3,依此类推。

img

由于我们现在是给字节而不是数据包编号,确认号也将以字节为单位,而不是数据包。具体来说,确认号表示:我已经收到了所有字节,直到(但不包括)这个编号。同样地,确认号代表它期望接收的下一个字节(但尚未收到)。请注意,TCP 使用的是累积确认模型(与全信息确认或单个字节确认不同)。

例如,假设 ISN 被随机选择为 50。那么前几个字节的编号为 51、52、53 等。某个特定的 TCP 段可能包含字节 140 到 219(含首尾)。这个段的序列号是 140(代表该段中的第一个字节)。如果接收方到目前为止已经收到了所有数据,接收方可以通过发送确认号 220 来确认这个段,220 是下一个尚未收到的字节。

img

更一般地说,假设我们有一个数据包,其第一个字节的序列号为 X,且该数据包有 B 个字节。这个数据包包含字节 X、X+1、X+2、……、X+B-1。如果这个数据包(以及所有之前的数据)都被收到了,确认将确认 X+B(下一个期望的字节)。如果这个数据包没有被收到,或者这个数据包被收到了但某些之前的数据包没有被收到,那么确认将确认某个更小的编号(因为 TCP 使用累积确认)。

更一般地,假设我们有许多数据包,每个都有 B 个字节。ISN 是 X,窗口大小为 1(停止 - 等待协议,一次只发送一个数据包或确认)。假设没有数据包丢失。那么,序列号和确认号将如下进行:第一个数据包的序列号为 X。第一个确认的确认号为 X+B。第二个数据包的序列号为 X+B。第二个确认的确认号为 X+2B。第三个数据包的序列号为 X+2B,依此类推。特别是,请注意在没有丢失的情况下,确认号对应于下一个数据包的序列号。

img

历史上,ISN 被选择为随机的,因为设计者担心如果所有字节流都从 0 开始编号,会出现序列号模糊的问题。具体来说,假设一个 TCP 连接发送一些以 ISN 0 开始的数据,然后发送方崩溃了。如果发送方重启一个新的连接,且 ISN 再次从 0 开始,接收方如果看到一个序列号为 0 的数据包,可能会感到困惑。这个数据包是来自崩溃前的第一个连接,还是崩溃后的第二个连接?

实际上,选择随机的 ISN 是出于安全原因。如果 ISN 是以可预测的方式选择的,攻击者可以推断出 ISN 并发送看起来像是来自发送方的伪造数据包。当 ISN 被随机选择时,攻击者更难推断出 ISN 并发送伪造数据包。

TCP 状态

在 TCP 中,发送方和接收方都需要维护状态。状态由实现 TCP 的端主机维护,而不是在网络中。

发送方必须记住哪些字节已经发送但尚未确认。发送方还必须跟踪各种计时器,例如发送未满段的计时器和重发字节的计时器。

接收方必须记住那些无法立即交付给应用程序的乱序字节。

由于 TCP 需要存储状态,每个字节流被称为一个连接会话,因此 TCP 是一种面向连接的协议。与第三层不同(在第三层中,每个数据包可以被单独考虑),TCP 要求双方在发送数据之前建立连接并初始化状态。TCP 还需要一种机制来拆除连接,以释放两端主机上为状态分配的内存。

TCP 是全双工的

到目前为止,我们将 TCP 视为从一个端主机(发送方)到另一个端主机(接收方)的字节流。实际上,两个端主机通常希望双向发送消息。

为了支持双向发送消息,TCP 连接是全双工的。不是指定一个发送方和一个接收方,连接中的两个端主机可以在同一个连接中同时发送和接收数据。

img

为了支持双向发送数据,每个 TCP 连接有两个字节流:一个包含从 A 到 B 的数据,另一个包含从 B 到 A 的数据。每个数据包可以包含数据和确认信息。序列号对应于发送方的字节流(我要发送的字节),而确认号对应于接收方的字节流(我从你那里收到的字节)。

TCP 握手

回想一下,TCP 是面向连接的,因此连接必须被显式地创建和销毁。此外,字节流从随机选择的初始序列号(ISN)开始,并且每个 TCP 连接是全双工的(两个字节流,每个方向一个)。当我们创建一个新连接时,我们需要双方同意两个起始 ISN(每个方向一个)。

为了建立 TCP 连接,两个主机执行三次握手以协商每个方向的 ISN。

img

第一个数据包(从 A 到 B)是SYN消息。这个消息包含 A 的 ISN(从 A 到 B 的数据将从这个 ISN 开始计数),在序列号字段中。

第二个数据包(从 B 到 A)是SYN-ACK消息。这个消息包含 B 的 ISN(从 B 到 A 的数据将从这个 ISN 开始计数),在序列号字段中。这个消息还在确认号字段中确认 B 已经收到了 A 的 ISN。

第三个数据包(再次从 A 到 B)是ACK消息。这个消息在确认号字段中确认 A 已经收到了 B 的 ISN。

这就是为什么字节流从 ISN+1 开始计数。当我发送一个 ISN 时,确认是 ISN+1,表明 ISN 已被收到,期望的下一个(第一个)字节是 ISN+1。

三次握手完成后,B 可以开始发送数据。

结束连接

有两种方式可以结束一个连接。

在正常情况下,当我完成数据发送后,我可以发送一个特殊的 FIN 数据包,它表示:我将不再发送任何数据,但我会继续接收你可能发送的任何数据。此时,连接处于半关闭状态。这个数据包会像其他任何数据包一样被确认。

最终,另一方也会完成数据发送并发送一个 FIN 数据包。当这个 FIN 数据包被确认后,连接就关闭了。

img

有时,我们不得不突然终止一个连接,而无需另一方的同意。为了单方面结束连接,我可以发送一个特殊的 RST 数据包,它表示:我将不再发送或接收任何数据。这个数据包不需要被确认,我一旦发送这个数据就可以拆除我的连接。

RST 数据包通常在主机遇到错误且无法继续发送或接收数据包时使用。请注意,如果发生 RST 且端主机崩溃并丢失其状态,任何在传输中的数据都会丢失。

如果我发送了一个 RST,而有人继续向我发送数据,只要我能够,我会继续发送 RST 数据包的副本,反复尝试终止连接。

攻击者也可以使用 RST 数据包来审查连接。攻击者可以伪造并注入一个 RST 数据包,导致整个连接终止。

img

完整的 TCP 状态图相当复杂,在打开或关闭连接的过程中有许多中间状态。中间状态的例子包括:我已经发送了一个 SYN,正在等待 SYN-ACK。或者,我已经收到了一个 FIN,发送了我的 FIN,但正在等待我的 FIN 被确认。大多数 TCP 连接大部分时间都处于已建立(Established)状态,此时连接已启动(但未结束),数据正在来回交换。对于这些笔记,你不需要理解这个完整的状态图。

img

在简化的状态图中,我们从关闭(closed)状态开始(没有正在进行的连接)。要启动一个连接,我们发送一个 SYN。最终,我们收到一个 SYN-ACK 并回复一个 ACK,进入已建立的连接状态。当我们完成数据发送后,我们发送一个 FIN,并收到一个 ACK。最终,我们收到一个 FIN,连接再次关闭。

img

捎带确认

由于 TCP 是全双工的,一个数据包有可能既确认一些数据又发送新的数据。

当接收方收到一个数据包时,如果它没有数据要发送,接收方有两个选择。接收方可以立即发送确认,不携带任何数据。或者,接收方可以等待直到有数据要发送,然后将确认与新数据一起发送。后一种方法称为捎带确认

实际上,我们可能不使用捎带确认的一个原因是 TCP 是在操作系统中实现的,与应用程序分离。

考虑操作系统,它根本不知道应用程序代码在做什么。当操作系统收到一个数据包时,它不知道发送方何时会有更多数据要发送(或者发送方是否会有更多数据要发送),所以它可能会等待很长时间才能将确认与一些新数据捎带发送。

另一方面,考虑应用程序,它不知道操作系统在做什么。应用程序运行在字节流抽象之上,根本不考虑数据包,所以它完全无法考虑捎带确认。

捎带确认还因操作系统不会同时运行所有程序而变得更加复杂。回想一下计算机体系结构课程(如加州大学伯克利分校的 CS 61C),CPU 会不断在计算机上的不同进程之间切换,这取决于哪些进程需要关注。如果每次 TCP 数据包到达时,CPU 都中断正在做的事情,将该数据包传递给应用程序,并给应用程序一些时间来响应,那将是非常愚蠢的。相反,当 TCP 数据包到达时,操作系统可能会先发送确认,然后应用程序才有机会在确认上捎带新数据。

有一种情况总是会使用数据捎带,那就是握手过程中的 SYN-ACK 数据包。除了确认之外,我们还在捎带我们自己的初始序列号。这没有上述问题,因为 TCP 握手完全由操作系统执行(应用程序根本不会考虑 SYN 或 SYN-ACK 数据包)。

滑动窗口

当我们讨论数据包时,我们将窗口定义为任何时候可以在传输中的数据包数量。现在我们从字节的角度实现 TCP,我们将滑动窗口定义为任何时候可以在传输中的最大连续字节数。

传输中字节必须连续的限制与之前不同。我们基于数据包的窗口定义允许非连续的数据包(例如 5、7、8)在传输中。然而,传输中的字节必须是连续的,没有间隙。这个要求在字节流中创建了一个窗口(字节范围)。

窗口的左侧是第一个未被确认的字节(由接收方的确认号确定)。从这个字节开始,接下来的 W 个字节,直到窗口的右侧,可以处于传输中。

img

请注意,即使窗口中的一些中间字节已被确认,我们仍然不能发送窗口之外的更多字节。我们能够发送更多字节的唯一方式是窗口向右滑动,即确认号增加(窗口左侧的字节被确认)。

img

回想一下,窗口大小(决定窗口的右边缘)受到流量控制和拥塞控制的限制。在流量控制的情况下,窗口大小由接收方通告的窗口决定。接收方根据接收端可用的缓冲区空间来决定通告的窗口。

检测丢失和重发数据

有两个条件会触发数据重发。只需满足其中一个条件(而不是两个都满足)就会触发重发。

img

重传的第一个触发因素是计时器(数据在一段时间后未被确认)。在基于数据包的 TCP 中,每个数据包都有一个计时器,当计时器到期而该数据包未被确认时,我们会重发该数据包。

在基于字节的 TCP 中,不是每个字节或每个数据包都有一个计时器,我们将只有一个计时器,对应于第一个未被确认的字节(窗口的左侧)。如果计时器到期,我们将重发最左侧的未被确认的段。回想一下,计时器长度基于往返时间(RTT),而 RTT 是通过测量发送数据和接收确认之间的时间来估计的。此外,每次收到新的确认(并且窗口改变)时,计时器都会重置。

重传的第二个触发因素是当我们收到后续数据包的确认时,假设数据丢失。在使用累积确认的基于数据包的 TCP 中(TCP 就是这样),如果我们收到 K 个重复确认(K=3 是常见的),这表明三个后续数据包已被确认,我们就会重发一个数据包。

在基于字节的 TCP 中,如果我们收到 K 个重复确认,我们将重发最左侧的未被确认的段。

TCP 头部

img

TCP 头部有 16 位的源端口和目的端口。

TCP 头部有 32 位的序列号(此数据包中第一个字节的字节偏移量)和 32 位的确认号(已收到的最高连续序列号加 1)。

TCP 头部有一个覆盖整个数据(不仅仅是头部)的校验和,用于检测损坏的数据。

TCP 头部有通告窗口,用于支持流量控制和拥塞控制。

头部长度指定 TCP 头部中 4 字节字的数量。假设没有额外的选项,这个长度是 5。

标志位是一系列可以被设置为 1 或 0 的位。当一个位被设置为 1 时,相应的标志被启用。每个人都理解头部的语义,所以他们知道哪些位对应哪些标志。对于这些笔记,有四个相关的标志。

同步(SYN)标志在主机发送其 ISN 时被开启。这个标志通常只在三次握手的前两个消息中启用。

确认(ACK)标志在确认号相关且用于确认数据时被开启。如果我想发送数据,但没有收到需要确认的数据,我可以关闭这个标志,这告诉另一方忽略确认号。

头部长度之后有 6 个保留位,这些位总是被设置为 0。你可以安全地忽略这些位。

紧急指针可用于将某些字节标记为紧急,告诉接收方尽快将这些数据发送给应用程序。这是一个历史字段,我们不会进一步讨论。

TCP 头部可以在末尾附加额外的选项(这会使头部更长),但在本课程中我们将忽略这些选项。例如,如果你想实现全信息确认,有一个称为选择性确认(SACK)的选项可以添加到头部。