【网络】计算机网络笔记——TCP 协议笔记

TCP 在 IP 协议不可靠(尽力而为也就是无服务)之上建立了可靠的全双工字节流数据传输服务。首先,它是基于字节流的,也就是说它是以字节为单位对传输的数据进行处理,同时可以视为数据在它上面是像一条流一样流动,它只确保了数据能够正确到达。其次,它是全双工的,意味着一条 TCP 连接的两端可以互相发送数据。最后,它是可靠的,这意味着它通过某些机制保证了传输的数据的可靠性。

报文结构

首先我们看看 TCP 的首部有哪些信息:

它包含了如下的信息:

  • 源、目的端口号:发送端以及接收端的端口号。
  • 序号:这个报文段首字节的字节编号。
  • 确认号:对于 ACK 报文有效,指发送方所期待的下一个字节的编号。
  • 数据偏移:由于 TCP 的首部存在选项字段,是可变的,因此可以通过它来确定数据部分的位置,从而更快的对数据处理(也可称为头部长度)
  • 保留字段:暂时没用
  • URG:用于标明是否紧急,对应了紧急指针。
  • ACK:表示该报文段是否是 ACK 报文段(ACK 报文段不消耗序号,且不支持重传)
  • PSH:推送,表示接收方应尽快将整个报文段交给应用层(没有被实现)
  • RST:重建连接,往往因为错误而使用
  • SYN:发起连接
  • FIN:关闭连接
  • 窗口:用于支持 TCP 流量控制,表达发送方接收窗口的大小
  • 检验和:由发送方计算,接收方进行验证,从而进行差错检验,若报文段出现差错则直接丢弃
  • 紧急指针:一个偏移量,告诉接收方应尽快处理偏移量后的数据。

同时,一个 TCP 连接(TCP Socket)可以被源 IP 地址源端口号目的 IP 地址目的端口号这样一个四元组所唯一确定

累积确认机制

通过前面我们了解到,TCP 报文段中具有两个字段——序号确认号

其中序号代表了这个报文段首字节的字节编号。其中,数据流的初始序号其实是随机选择的。

而对于确认号,则代表了接收方期望从发送方所收到的下一个字节的编号。

例如如假设接收方接收到了 723-773 以及 920-970 这两个报文段,但仍未收到 774-919 这段数据,则接收方会在确认号中填入 774,代表接收方下一次想从发送方接收到 774 开始的数据。

由于 774-919 这段数据是丢失了的,因此 TCP 不会确认在其之前先收到的这段失序的 920-970 报文段。对于这段失序报文段的处理,TCP 并没有规定。可能有如下的两种实现:

  1. 直接丢弃。
  2. 保留,等待缺少的字节填充间隔。

由于后者对网络更有利,因此通常采用的是后者的实现。而这种对丢失数据后面的数据不再确认的机制,就是 TCP 的累积确认机制

超时重传机制

TCP 仅仅使用了一个单一的定时器,从而减少定时器管理的开销。在一个报文段发送时,如果这个定时器还没有被其他报文段所关联,则该报文段传给 IP 层时则会启动该定时器,我们可以理解为定时器与最早未被确认的报文段关联

而如果定时器的时间超过了根据算法所计算的时间间隔时,则代表发生了超时。TCP 是通过重传引起超时的报文段来处理超时事件的,之后便重启定时器。由于其具有累积确认机制,因此这个引起超时的报文段当然是还未被确认的第一个报文段。

当 TCP 收到来自接收方的 ACK 时,假设其值为 n,则它会将 n 与它首个未被确认的字节编号 SendBase 进行比较,如果 n 大于它,由于 TCP 采用了累计确认机制,因此说明 n 之前的所有字节都已被确认,因此 TCP 会将 SendBase 替换为 n 值,此时如果仍有未被确认的报文段,则重启定时器。

快速重传机制

如果每次报文段丢失后都是在等到超时后再进行重传,无疑是有些浪费时间的。因此 TCP 还采用了一种快速重传机制。如果接收方收到了一个序号大于下一个期望序号的报文段时,它就发现了报文段丢失,此时由于没有否定确认,因此接收方会向发送方发送一个对已经接收到的最后一个字节的重复 ACK,从而告诉发送方发生了丢包。当发送方收到了 3 个冗余 ACK 时,说明这个被确认过 3 次的报文段之后的报文段已丢失,此时发送方就会执行快速重传,也就是在定时器超时之前重传丢失报文段。

通过这样的快速重传机制,可以在一些情况下更快地处理丢失报文段。

TCP 连接

连接建立(三次握手)

在 TCP 连接传输数据之前,首先要在两端之间建立起一条 TCP 连接,其步骤通常如下:

  • 第一步:客户端向服务端发送一个 SYN 字段为 1 的 SYN 报文段。并随机选择一个初始序号(CLIENT_ISN),填入序号字段。随机的目的是安全性。
  • 第二步:服务端收到 SYN 报文段后,会向客户端发送一个 SYNACK 报文段。SYNACK 报文段不包含数据,其 SYN 字段、ACK 字段均为 1,且将确认号置为了 CLENT_ISN + 1(表示下一个想接收这个序号的报文段),并且也会生成一个随机的初始序号(SERVER_ISN)并填入序号字段。

  • 第三步:客户端收到 SYNACK 报文段后会为该连接分配缓存,并向服务端发送一个 ACK 报文段,其 ACK 字段为 1,并将序号置为了 CLIENT_ISN + 1,将确认号置为了 SERVER_ISN + 1。当服务端收到该 ACK 报文后,则会为客户端的连接分配缓存。(这个步骤的 ACK 报文是可以携带数据的)

可以发现,TCP 的连接总共有三步,伴随着三次报文段的发送。因此这个过程通常也被称为『三次握手』。

在之前的设计中,服务器方在收到建立连接方的 SYN 报文段时就会立即为其分配缓存,但这就存在着被 SYN 洪泛攻击的危险。如果有恶意机器发送大量的 SYN 报文段,但不完成第三次握手,则服务器会不断为这种半开的连接分配资源从而导致连接资源被消耗殆尽。

同时,初始序号 ISN 设置为随机主要是为了安全考虑,因为如果均为一个固定的值,那么恶意者可以通过这个已知的序号来伪装发送方或接收方的其中一方,并造成一些破坏。

TCP 为什么是三次握手,而不是四次或两次呢

为什么不是两次握手

如果我们仅仅进行了两次握手,则服务端发送了 SYNACK 报文段后并不会收到客户端的确认,也就是说它只能自动认为客户端收到了这个 SYNACK 报文段。但客户端很有可能并未收到该报文段,此时客户端认为连接未建立,而服务端则已经为这条连接分配了资源。如果出现大量的此类情况,则服务端很有可能会崩溃。

为什么不是四次握手

其实按道理来说,应该是客户端发送 SYN 报文段,服务端收到以后,发送 ACK 报文段,接着发送一个 SYN 报文段。客户端收到后,向服务端发送 ACK 报文段。这样双方都能够确认对方收到了自己的 SYN 报文段。但为什么没有这样设计呢?

很显然,这样的四次握手的第 2、3 步都是从服务端发出,且它们所需要用到的数据字段并没有交叉,并且这个时间几乎是连续的。因此出于性能的考虑,我们完全可以将这两次发送的过程合为一次。因此三次握手就足够了。

三次握手中如果 SYNACK 报文段丢包如何处理

我们知道,当服务端收到 SYN 报文段后,会发送 SYNACK 报文段给客户端,但如果这个 SYNACK 报文段丢失了该怎么办呢?

我们知道 SYNACK 报文段实际上是会出现超时现象的,一旦超时,服务端会重发该报文段。但如果重发了超过设定的次数,服务端仍未收到 ACK 报文段的话,服务端会自动关闭该连接。

但此时客户端仍无法感知到连接的关闭,因此服务端还会向客户端发送 RST 报文段,让客户端感知到并重置此连接。

三次握手的作用

三次握手有很多作用,如:

1、确认双方的接受能力、发送能力是否正常。

2、指定自己的初始序号,为后面的可靠传输做准备。

3、对于 HTTPS 协议,三次握手过程还会进行数字证书的验证以及加密密钥的生成。

连接关闭(四次挥手)

对于 TCP 连接,两个进程的任何一个都可以主动终止这个连接。比如假设客户端想要关闭应用,则会经过下面的几步:

  • 第一步:客户端向服务端发送一个 FIN 报文段,表示客户端想要关闭向服务端的数据流向,不再向服务端发送数据。
  • 第二步:服务端收到该 FIN 报文段后,会向客户端发送一个 ACK 报文段,对该连接的关闭进行了确认,并回收为其分配的资源。
  • 第三步:服务端向客户端发送一个 FIN 报文段,表示服务端想要关闭向客户端的数据流向,不再向客户端发送数据。
  • 第四步:客户端收到该 FIN 报文段后,会向服务端发送一个 ACK 报文段,对该连接的关闭进行了确认,并回收为其分配的资源。此时整个 TCP 连接正式关闭。

需要注意的是,在客户端向服务端发送 FIN 并收到 ACK 后,仅仅是客户端向服务端发送数据的这一条数据流向被关闭,服务端仍然能继续向客户端发送数据

这种连接一端结束发送后还能接收另一端数据的能力叫做半关闭

为什么不是三次挥手

之前提到,四次握手可以将第二步、第三步合为一步,服务端发送 SYN 报文段的同时也发送了 ACK 报文段,那为什么在四次挥手时又不能将第二步与第三步合为一步,在发送 ACK 的同时发送 FIN 了呢?

因为当客户端发送 FIN 并收到 ACK 时,只是代表客户端向服务端发送数据的这一条数据流向被关闭,服务端还能继续向客户端发送数据,直到服务端认为不再需要向客户端发送数据时,才会进行对服务端到客户端这一条数据流向的关闭。

因此服务端发送的 ACK 与发送的 FIN 的时机不一定是相同的,可能会有一段较长的时间间隔,因此无法将两条数据合并为同一条。

TCP 状态变迁

如下图是 TCP 的状态变迁图:

流量控制与拥塞控制

由于主机收到数据时,不一定能够马上将其交付给上层,因此如果发送方发送的数据太多,会造成数据过多而导致部分数据丢包,因此 TCP 提供了流量控制机制来对流量的大小进行控制。

TCP 通过大小可变的滑动窗口来实现流量控制,由于 TCP 是全双工的协议,因此两端都可以作为发送端以及接收端。因此 TCP 连接的两端都具有一个发送窗口和一个接收窗口

由于两个端口之间的网络的承载能力有限,因此如果不论中间的网络当前的承载能力,只看双方的接收能力来发送数据,可能会加重网络的拥堵,因此 TCP 还提供了拥塞控制机制来对网络的拥塞情况进行控制。

TCP 同样是通过大小可变的滑动窗口来实现拥塞控制,TCP 的每一端都具有一个拥塞窗口

接收窗口

TCP 的任何一个端点作为接收方都会维护一个接收窗口,其中 rwnd 表示了其还能接收的数据的剩余大小,也就是接收窗口的剩余大小。

发送窗口

同样,TCP 的任何一个端点都可以作为发送端,作为发送端时会维护一个发送窗口,这个发送窗口的宽度表示了其所能发送的最大宽度,用 swnd 来表示,它的取值我们后续再进行讨论。

拥塞窗口

流量控制与拥塞控制的核心——控制发送窗口宽度

为了实现流量和拥塞的控制,我们最终的结果必然是要对发送窗口宽度 swnd 进行限制,那么我们是如何对其进行限制的呢?

显然,我们要同时保证流量控制和拥塞控制都能实现,因此我们应当将 swnd 取为 rwndcwnd 的最小值。

因此我们有:swnd = min{rwnd, cwnd}

只有取了两者的最小值,才能保证既不会使得网络更加拥堵,又不会使得对方没有能力接收我们发送的消息。

接收窗口大小的同步

那么我们现在需要思考一个问题,发送方是如何了解到我们的接收方所剩余的窗口大小 rwnd 的呢?

实际上就是通过 TCP 报文段中的窗口字段。

当接收方收到了发送方所发送的数据并更新了 rwnd 时,它会在 ACK 报文中的窗口字段填入自己目前的接收窗口大小 rwnd,发送方收到 ACK 时就会根据 rwnd 的大小结合 cwnd 一起来调整当前发送窗口的大小。

但这样就存在一个问题了,当接收方窗口大小为 0 时,发送方必然会将发送窗口也设置为 0,此时发送方便不会再向接收方发送数据了。可是如果之后接收方的窗口大小进行了更新,有了接收报文的能力时,发送方由于没有向接收方发送数据,就无法收到 ACK 从而知道接收方的窗口更新了。

为了解决这个问题,当发送方知道接收方的 rwnd 为 0 时,仍然会继续向接收方发送 1 字节的数据,以获取 ACK 报文段

拥塞窗口大小的更新

如何感知发生了拥塞

我们需要思考一个问题,IP 协议向我们提供的是一种尽力而为服务,它并不会向上层报告网络中是否发生了拥堵,遇到了网络拥堵也只会将包给丢弃。那么我们该如何才能了解到网络中是否发生了拥塞呢?

我们知道,如果发生了超时或是收到了三个冗余 ACK,则说明发生了丢包事件。我们知道,如果过度拥塞时,会导致网络中的某些路由器缓存溢出,从而丢弃数据报。这就会引起发送方的丢包事件。

因此 TCP 协议是通过丢包事件来感知是否发生了拥塞的

拥塞控制算法

接着我们看看发生了拥塞的时候是如何调整 cwnd 的大小的,TCP 中的拥塞控制算法主要有三个部分:1. 慢启动、2. 拥塞避免、3.快速恢复,其中慢启动和拥塞避免是一定存在的强制实现,而快速恢复则不是强制实现。

慢启动(指数增长)

TCP 连接开始时,会将 cwnd 设置为一个很小的值(1)。每当运输的报文段被确认,就增加 1 ,这样下去,每经过一次 RTT,则发送速率就会翻一倍。

因此, TCP 发送速率的起始速度是很慢的,但在慢启动阶段其发送速率会以指数级增长。

但这种指数级增长何时结束呢?

  • 当发生超时引起的丢包事件时,TCP 发送方会将 cwnd 重新设置为 1 并重新开始慢启动,并且,还会将一个名为 ssthresh(慢启动阈值)的值设置为之前 cwnd 的一半。
  • 当cwnd 到达或超过 ssthresh 的值时,继续采用这种指数增长的方式显然不是很合适,此时会进入更为保守的拥塞避免模式
拥塞避免(线性增长)

进入拥塞避免模式时,cwnd 的值大约为 ssthresh,即上次拥塞时 cwnd 值的一半。因此这个阶段距离拥塞可能并不遥远,TCP 不会再将 cwnd 的值翻一倍,而是每经过一次 RTT, cwnd 的值仅增加 1(也就是如果发送 n 个报文段,则每次确认只增加 1/n)

这种线性增长又是何时结束呢?

  • 当出现了超时引起的丢包事件时,与慢启动类似,cwnd 的值又会被设置为 1。
  • 当出现了三次冗余 ACK 引起的丢包事件时,则 ssthresh 的值会被更新为当前 cwnd 的一半。之后则进入了快速恢复状态。
快速恢复(固定大小)

快速恢复的思想是『数据包守恒』原则,即同一个时刻在网络中的数据包数量是恒定的:只有当“老”数据包离开了网络后,才能向网络中发送一个“新”的数据包。如果发送方收到一个重复的 ACK,则根据 TCP 的 ACK 机制表明有一个数据包离开了网络,于是 cwnd 加 1。

合并优化算法

Nagle 算法

在 TCP 中有一个配置选项——最大分段大小(MSS),它规定了 TCP 报文段能传输的数据字段最大长度,小于它的分组我们都叫做『小分组』。

为了更高效地传递数据,减少网络中的小分组,TCP 引入了 Nagle 算法,它的核心思想是将一些小分组进行合并,变为一个更大的分组,从而有效减少网络的拥塞。

它要求 TCP 连接上只能存在一个未被确认的小分组,其余的分组在该分组 ACK 未到达前不能发送。这些小分组会被收集起来,在 ACK 到来时合并为一个更大的分组发送出去。

这样,比如我们连续发送 1 个字节的小分组,若网络中已经存在一个未被确认的小分组,则它会把这些 1 字节的分组合并起来,一起进行发送。

延迟 ACK

TCP 还提供了一种延迟 ACK 机制,它在收到对方的报文时,不会立即发送 ACK,而是进行了一段时间的延迟。

在这段延迟的时间中,如果接收方有数据要发送给发送方,则将要发送的数据与 ACK 一块发送。

而如果该接收方接收到了更多对方发来的数据,则只需要将 ACK 一并发送(确认最后一个),而不需要发送多个 ACK。

延迟 ACK 需要注意的一个问题是这个延迟时间不能无限延迟,否则发送方会认为发生了超时而进行数据的重发,得不偿失。

当 Nagle 算法遇上延迟 ACK

如果发送方发送了多个小分组给接收方,接收方第一次接收到分组的时候,会对 ACK 进行延迟,而发送方在这个 ACK 到达之前会收集其他小分组等待其到达。

这就造成了发送方必须等到接收方的 ACK 时延到达时才能进行后续合并的分组的发送,这就导致了一个较大的时延。

因此 Nagle 算法配合延迟 ACK 的效果不是很理想

粘包问题(TCP 使用的坑)

首先要明确一个概念:粘包问题属于应用层的问题,不是 TCP 协议的问题,TCP 协议的任务仅仅是负责将应用层的数据正确的传输。这往往是使用 TCP 时应用不当而导致的问题。

但由于使用 TCP 使用过程中可能出现这个问题,因此在这里讨论一下。粘包核心原因在于由于 TCP 协议是面向字节流的,因此它的数据并没有边界

我们先看看什么是粘包问题。我们预想中,应用层通过 TCP 发送两个数据 Data1Data2,应该是这样的效果:

这种情况下并不会发生粘包问题,因为 Data1Data2 在接收方都是完整的,并且它们之间有分隔,因此可以很好地区分开来。

但是依然是发送 Data1Data2,如果出现了下面这种情况:

由于 TCP 发送的数据没有边界,是一个字节流,因此可能就导致了两个 Data 的头尾相接,『粘』在了一起。这时我们就无法对其进行区分了,因为我们无法找到一个很好的边界来分隔开两个 Data.

解决方法

  1. 使用标识了数据长度以及数据头的应用层协议,服务端获取消息时解析出消息长度,然后向后读取对应长度内容(如 HTTP 协议的 Content-Length 字段)。
  2. 将消息设置为定长,若不足则补上固定分隔符。
  3. 加入消息边界,服务端按消息边界对消息内容进行划分(如 HTTP 协议的 \r\n\r\n)

参考资料

《计算机网络:自顶向下方法》

《TCP/IP 详解卷 1》

TCP-IP详解:Nagle算法

点赞

发表评论

电子邮件地址不会被公开。必填项已用 * 标注

%d 博主赞过: