【Linux】TCP协议

​🌠 作者:@阿亮joy.

🎆专栏:《学会Linux》

🎇 座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根

目录

👉TCP协议👈

TCP 协议(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。那 TCP 协议如何对数据传输进行控制的呢?接下来我们一起来探讨。

TCP协议段格式

TCP 报文由首部和数据两部分组成。首部一般由 20 到 60 字节(Byte)构成,长度可变。其中前 20 个字节格式固定,后 40 个字节为可选。

TCP 报文中每个字段的含义如下:

  • 源端口和目的端口字段:源端口(Source Port)是源计算机上的应用程序的端口号,目的端口(Destination Port)是目标计算机的应用程序端口号,都占 16 位。
  • 序列号字段:序列号(Sequence Number)占 32 位。它表示本报文段所发送数据的第一个字节的编号。
  • 确认号字段:确认号(Acknowledgment Number,ACK Number)占 32 位。它表示接收方期望收到发送方下一个报文段的第一个字节数据的编号。
  • 数据偏移字段(4 位首部长度):数据偏移是指 TCP 报文中的有效载荷(数据)相对于 TCP 报文起始位置的字节偏移量,占 4 位。
  • 保留字段:保留(Reserved)占 6 位。为 TCP 将来的发展预留空间,目前不需要关心该字段。
  • 标志位字段:包括 URG、ACK、PSH、RST、SYN、FIN 等标志位。
    • URG:紧急指针是否有效
    • ACK:确认号是否有效
    • PSH:提示接收端应用程序立刻从 TCP 缓冲区把数据读走
    • RST:对方要求重新建立连接; 我们把携带 RST 标识的称为复位报文段
    • SYN:请求建立连接,我们把携带 SYN 标识的称为同步报文段
    • FIN:通知对方,本端要关闭了,我们称携带 FIN 标识的为结束报文段
  • 窗口大小字段:窗口大小(Window Size)占 16 位。它表示从 Ack Number 开始还可以接收多少字节的数据量,也表示当前接收端的接收窗口还有多少剩余空间。
  • TCP 校验和字段:校验位(TCP Checksum)占 16 位。它用于确认传输的数据是否有损坏。校验和由发送端填充,CRC校验,接收端校验不通过则认为数据有问题. 此处的检验和不光包含 TCP 首部,也包含 TCP 数据部分。
  • 紧急指针字段:紧急指针(Urgent Pointer)仅当前面的 URG 控制位为 1 时才有意义。它指出本数据段中为紧急数据的字节数,占 16 位。
  • 可选项字段:选项(Option)长度不定,但长度必须是 4 字节的整数倍。

4 位首部长度的意义

4 位首部长度是用来表示 TCP 报头的长度的,也能表示数据相对于 TCP 报文起始位置的偏移量,其单位是 4 字节,所以其能表示的范围是 0 到 60 字节。因为 TCP 固定的报头长度是 20 字节,那么 4 位首部长度的最小值就是 5 了。

TCP 是如何解包并将有效载荷(数据)向上交付的呢?

首先提取 TCP 报文的前 20 个字节的固定报头,根据固定报头中的 4 位首部长度来判断报文中是否有可选项字段,如果有可选项字段,也需要将其提取出来。如果 4 位首部长度等于 5,则说明报文中没有可选项字段;如果 4 位首部长度不等于 5,则说明报文中的可选项字段占 (4 位首部长度大小 * 4 - 20) 个字节。将报头提取完后,剩下的就是有效载荷了。那么再根据固定报头中的目的端口,就可以向上交付数据给特定的进程了。

TCP 报文中是没有整个报文的大小或者有效载荷的大小,那是如何得知有效载荷的大小的呢?

当 TCP 报文被封装在 IP 报文中传输时,IP 首部中有一个字段叫做总长度,它表示整个 IP 报文的长度。由于 IP 报文包括 IP 首部和 TCP 报文,因此可以通过减去 IP 首部长度来得到 TCP 报文的总长度。然后再减去 TCP 首部长度,就可以得到 TCP 有效载荷的长度了。

确认应答机制

我们都知道 TCP 协议是可以保证可靠性的协议,那我有两个问题就是:是什么原因导致传输的不可靠的呢?TCP 协议是如何可靠性的呢?

是什么原因导致传输的不可靠呢?

其实传输的不可靠单纯就是因为传输的距离变长了。就好比,两个人相隔百米互相喊话,那么这两个的通信就无法保证可靠,无法保证自己说的话已经被对法接收到了。

那 TCP 协议是如何保证可靠性的呢?

TCP 保证可靠性的一个重要的机制就是确认应答机制。当发送端发送数据时,它会等待接收端的确认应答(ACK)。如果接收端成功接收到数据,它会返回一个确认应答给发送端。如果发送端在一定时间内没有收到确认应答,它会认为数据丢失,并重新发送数据。那么这种机制就称为 TCP 协议的确认应答机制,而这种机制可以保证数据的可靠传输。

深入了解 TCP 的确认应答机制

客户端向服务端请求时,实际并不是只发送一个请求(注:只发送一个请求,效率过低),可能是多个请求,而客户端也可能会给客户端回复多个应答。需要注意的是,客户端一次向服务端发送多个请求时,发送的顺序不一定是服务端接收的顺序,也就是报文乱序问题。那么 TCP 是如何解决以上这两个问题的呢?想要知道这个,我们需要了解 TCP 报头中的序号和确认序号。

序号和确认序号

序号是发送方给每个发送的报文分配的一个编号(TCP 将每个字节的数据都进行了编号),用来标识该报文在数据流中的位置。接收方可以根据序号对接收到的报文进行排序,以确保数据按顺序传输。注:这里的报文指的是携带完整 TCP 报头的 TCP 报文。

确认序号是接收方返回给发送方的一个编号,表示该编号前的的所有报文都已经接收到了,也可以用来表示接收方期望接收的下一个数据包的序号。发送方可以根据确认序号判断哪些数据包已经被接收方成功接收,哪些数据包需要重新发送。

通过序号和确认序号,请求和应答就可以一一对应起来了。因为确认序号的含义是该序号前的所有报文都已经接收到了,所以就会出现部分确认应答和不给应答两种情况。

部分应答应答:假设客户端给服务端发送序号为 1000、2000 和 3000 三个报文,服务端也成功接收到了这三个报文,那服务端就可以给客户端回复一个确认序号为 3001 的报文,表示该序号前的报文都接收到了,而不需要回复 1001 或 2001 的报文,这就是部分确认应答。

不给应答:假设客户端给服务端发送序号为 1000、2000 和 3000 三个报文,服务端只接受到序号为 1000 和 3000 的报文,没有接收到序号为 2000 的报文。那么服务端只能给客户端回复一个确认序号为 10001 的报文,尽管服务端已经接收到了序号为 3000 的报文,这就是不给应答的情况。而这种情况就可能会涉及报文的重传了,稍后会介绍到。

只用序号一个字段就可以表示序号和确认序号两个字段,那么为什么只用序号一个字段呢?

TCP 协议是全双工的通信发送,也就是说 TCP 协议中的通信双方既可以收数据,也可以发数据。那么就可能会出现这种情况:服务端在给客户端应答的时候,也想给客户端发送数据。发送数据就需要有序号,那么这就要求了 TCP 报头中要有序号和确认序号两个字段。只有序号一个字段无法区分该序号究竟是序号还是确认序号,也无法实现全双工的通信方式。

窗口大小

TCP 的接收缓冲区和发送缓冲区

TCP 本身是具有接收缓冲区和发送缓冲区的:

  • 接收缓冲区用来暂时保存接收到的数据
  • 发送缓冲区用来暂时保存还未发送的数据
  • 这两个缓冲区都是在 TCP 传输层内部实现的
  • TCP 发送缓冲区当中的数据是由上层应用层程序进行写入的。当上层调用 write / send 这样的系统调用接口时,就会将应用层的数据拷贝到 TCP 的发送缓冲区中,并不是将数据直接发送到网络中。
  • TCP 接收缓冲区当中的数据也是有上层应用层程序来进行读取的。当上层调用 read / recv 这样的系统调用接口时,就会将 TCP 接收缓冲区的数据拷贝到应用层中,并不是直接从网络中获取数据。
  • 当数据拷贝到 TCP 中的发送缓冲区中后,对应的 write / send 函数就能够返回,无须关系数据如何发送以及如何发送,这些问题都是 TCP 协议来解决的。

TCP 发送缓冲区和接收缓冲区存在的意义

发送缓冲区和接收缓冲区的作用:

  • 数据在网络传输的过程中可能会遇到一些错误,导致对端无法接收到数据,这时就需要进行数据重传,因此 TCP 必须提供一个发送缓冲区来暂时保存发送出去但未收到确认应答的数据。当收到对端的确认应答后,发送缓冲区中的数据才能够被覆盖。
  • 接收端接收数据的速度是有限的,为了保证没来得及接收的数据不会被迫丢弃,因此 TCP 提供了一个接收缓冲区来保存接收到但未来得及读取的数据。因为数据的传输是非常耗时,我们不能够随意丢弃经过网络传输过来的数据。

经典的生产者消费者模型:

  • 对于发送缓冲区来说,上层应用不断将数据拷贝到发送缓冲区中,下层网络层不断从发送缓冲区中拿出数据进行进一步的封装。在这个过程中,上层应用充当着生产者的角色,下层网络层充当着消费者的角色,而发送缓冲区就是交易场所。
  • 对于接收缓冲区来说,下层网络层不断将解包后的数据写入到接收缓冲区中,上层应用不断从接收缓冲区中拿出数据进行进一步的处理。在这个过程中,下层网络层充当着生产者的角色,上层应用充当着消费者的角色,而接收缓冲区就是交易场所。
  • 引入接收缓冲区和发送缓冲区就相当于引入了两个生产者消费者模型,生产者消费者模型将上层应用与底层通行细节进行了解耦,同时也支持并发来提高通信的效率。

窗口大小

当发送端将数据发送给对端时,其本质就是将自己发送缓冲区中的数据发送到对端的接收缓冲区中。但是缓冲区是有大小的,当接收端处理数据的速度小于发送端发送数据的数据,缓冲区就有可能会被填满。这时候,发送端再发送过来的数据就无法放入到接收缓冲区中导致数据丢失,从而引发数据重传等连锁反应。

为了解决这个问题,TCP 报头中包含了 16 位窗口大小,16 位窗口大小中填充的是自己的接收缓冲区剩余空间的大小,也就是通过 16 位窗口大小告知对方自己的接收能力。

接收端在对发送端发送过来的数据进行响应时,可以通过 TCP 的报头中的 16 位窗口大小来告知发送端自己当前接收缓冲区剩余空间的大小。此时,发送端就可以根据这个窗口大小来调整自己发送数据的速度。

  • 窗口大小字段越大,说明接收端处理数据的能力越强,发送端可以适当地提高数据的发送速度。
  • 窗口大小字段越小,说明接收端处理数据的能力越弱,发送端需要适当地降低数据的发送速度。
  • 当窗口大小字段等于 0 时,说明接收端的接收缓冲区已经没有剩余空间了,发送端应该停止发送数据直到接收端处理完一些数据,接收缓冲区中有空间剩余。
  • 通过 16 位窗口大小告知对方自己的接收能力,这样就可以做到流量控制了。

理解本质:

  • 在进行套接字编程时,我们调用 write / send 函数向套接字中写入数据时,可能会因为套接字的发送缓冲区已经被写满而被阻塞住了,其本质就是 TCP 中的发送缓冲区已经被写满了,所以 write / send 函数就需要阻塞到发送缓冲区有足够的空间来存储数据。
  • 我们调用 read / recv 函数从套接字中读取数据时,可能会因为套接字中的接收缓冲区中没有数据而被阻塞住了,其本质就是 TCP 中的接收缓冲区中没有数据,所以 read / recv 就需要阻塞到接收缓冲区中有一定数量的数据。
  • 调用 write / send 和 read / recv 函数会被阻塞注,本质就是生产者消费者模型中的临界资源没有就绪,需要阻塞等待直到条件满足。

六个标记位

为什么需要多个标记位?

  • TCP 报文的种类是多种多样的,除了正常通信的常规报文,还有建立连接时发送的建立连接报文以及断开连接时发送的断开连接报文等等。
  • 服务端接收到大量的不同类型的报文时,服务端就要根据报文的类型来进行相应的处理。例如:服务端接收到正常通信的常规报文时,会将该报文放入到接收缓冲区中等待上层应用来读取。当服务端接收到建立连接和断开连接的报文时,操作系统会在 TCP 层内进行三次握手和四次挥手的动作。
  • 正是因为不同类型的报文需要对应不同的处理逻辑,我们就需要通过某种策略来区分报文的类型。而 TCP 就是通过报头中的六个标记位来区分报文的类型,这六个标记位都只占一个比特位。其中,1 表示真,0 表示假。例如:如果 TCP 报头中的 ACK 的比特位为 1,说明该报文是一个确认报文。

SYN

  • SYN(Synchronize Sequence Numbers)表示同步序列号,是建立连接时使用的握手信号。
  • 如果 TCP 报头中的 SYN 被设置为 1,则说明该报文是一个请求建立连接的报文。
  • ACK 只会在建立连接阶段被设置为 1,在正常通信阶段时均被设置为 0。

FIN

  • FIN(Finish)是断开连接时使用的结束信号。
  • 如果 TCP 报头中的 FIN 被设置为 1,则说明该报文是一个请求断开连接的报文。
  • FIN 只会在断开连接阶段被设置为 1,在正常通信阶段时均被设置为 0。

ACK

  • ACK(Acknowledge Character)是确认应答的标记位,该标记位被设置为 1,表示对收到的报文进行确认应答。
  • 大部分的 TCP 报文中的 ACK 标记位都会被设置为 1,只有建立和断开连接的报文的 ACK 标记位不会被设置为 1。因为在正常网络通信中,自己发送出去的数据本身对对方发送的数据就有一定的应答能力,因此大多数 TCP 报文的 ACK 都会被设置为 1。而建立和断开连接会分别因为之前没有消息需要应答和不再进行数据通信,因为这两个类型的报文的 ACK 标记位都不会被设置为 1。注:除了有些报文可能是 FIN + ACK 报文。

RST

  • RST(Reset)是用于非正常关闭连接的标记位,该标记位被设置为 1 时,表示该 TCP 连接出现异常,需要关闭该连接。
  • 在 TCP 连接中,RST 数据包通常是由接收方发送的,用于表示接收方无法处理接收到的数据包或者表示接收方已经关闭了连接。当发送方收到 RST 数据包时,会立即关闭连接并且不会发送任何数据。
  • 进行三次握手时,如果客户端收到服务端发送的 SYN + ACK 数据包后,向服务端发送 ACK 数据包并认为连接已经建立好了。但是 ACK 数据包丢失了,服务端无法接收到 ACK 数据包。那么服务端就会认为自己发送的 SYN + ACK 数据包丢失了,需要进行重传。这就会存在一种情况:服务端还没来得及进行 SYN + ACK 数据包的重传,客户端就向服务端发送数据,而服务端会认为怎么连接还没有建立好就给我发送数据呢,这时服务端会向客户端发送 RST 数据包表示关闭该连接并重新进行三次握手建立连接。
  • RST 攻击:攻击者可以伪造一个 TCP 数据包,将 RST 标志位设置为 1,然后发送给另一端,这样就可以终止连接。攻击者可以通过这种方式来终止正常的连接,从而使另一端无法正常工作。

PSH

  • PSH(Push)是用来告知对方是否立即读取缓冲区的数据的标记位,如果 PSH 标记位被设置为 1,则表示发送端要求接收端立即读取缓冲区中的数据。
  • 接收端给发送端应答时会在 TCP 报头中填充窗口大小,告诉发送端自己接收缓冲区剩余空间的大小。如果窗口大小为 0,则说明接收缓冲区没有剩余空间,那么发送端会过一段时间再给接收端发送一个 TCP 报头来得知接收端接收缓冲区剩余空间的大小。如果缓冲区剩余空间多次为 0,发送端会发送 PSH 数据包要求接收端立即从接收缓冲区中读取数据。

URG

双方使用 TCP 协议进行网络通信时,TCP 协议是保证报文按序发送和按序到达的,即便到达接收端的接收缓冲区的顺序和发送时的顺顺序不一样,也可以根据序列号来排序,从而实现按序到达。

一般情况下,TCP 按序到达是我们所希望的,要求接收方从接收缓冲区中按序读取数据。但是有些特殊情况,发送方会给接收方发送一些紧急数据,要求接收方优先读取这些数据。那该怎么办呢?

此时就需要用到 TCP 报头中的 URG 标记位和 16 位紧急指针了。

  • URG(Urgent)是标记紧急指针是否有效的标记位,如果 URG 标记位被设置为 1,则说明该报文的紧急指针有效,该报文是紧急报文。
  • 16 位紧急指针的大小表示紧急数据在有效载荷中的偏移量,紧急指针所指向的位置的一个字节的数据就是紧急数据,需要上层优先读取该数据。

连接管理机制

如何理解连接

TCP 协议是一种面向连接的可靠的通信协议。使用 TCP 协议通信前,客户端和服务器之间需要建立一个连接,并维护这个连接的状态,才能进行数据的传输。

客户端和服务端使用 TCP 协议通信前需要建立连接,是因为 TCP 的各种可靠性(如:超时重传、流量控制和拥塞控制等)都是建立在连接的基础之上的。因此,保证传输数据的可靠性的前提就是先建立号连接。

每个客户端将来都有可能连接同一个服务端,那么服务端中一定会存在大量的连接,此时操作系统就需要对这些链接进行管理。

  • 操作系统通过先描述再组织的方式来管理连接。在操作系统内部里一定会有一个描述连接的结构体,结构体有着各种字段来描述着连接的各种属性,最终所有定义出来的连接结构体会以某种数据结构的管理起来。此时,操作系统对连接的管理就转换成对该数据结构的增删查改了。
  • 建立连接,实际上就是在操作系统内部用该结构体定义一个结构体变量,然后填充该结构体变量的各种属性字段,然后将变量插入到数据结构中。
  • 断开连接,实际上将该连接对应的结构体变量从数据结构中移除,然后释放该连接占用的各种资源。
  • 操作系统对连接进行管理是有成本的,这个成本包括管理连接的时间成本和存储连接的空间成本。

三次握手

三次握手的过程

客户端和服务端进行 TCP 通信前需要建立好连接,而建立的连接的过程就称之为三次握手。

以客户端和服务端为例,客户端想要和服务端进行通信。客户端主动向服务端发起建立连接的请求,然后客户端和服务端的 TCP 层就自动三次握手建立好连接。

  • 第一次握手:客户端发送一个 SYN 标记位为 1 的数据包给服务端,其中包含了客户端随机生成的初始序列号。
  • 第二次握手:服务端收到客户端的 SYN 数据包后,向客户端发送一个 SYN 和 ACK 标记位都为 1 的数据包,表示确认收到了客户端的 SYN 数据包并同意建立连接。SYN + ACK 数据包中包含了服务器随机生成的初始序列号和确认序号(客户端的初始序列号 + 1)。
  • 第三次握手:客户端收到服务端的 SYN + ACK 数据包后,会向服务端发送一个 ACK 标记位为 1 的数据包,表示确认收到了服务端的 SYN + ACK 数据包并且客户端认为建立连接成功,ACK 数据包中包含了确认序号(服务端的初始序列号 + 1)。
  • 三次握手的状态变化:
    • 客户端向服务器发送 SYN 包,客户端进入 SYN_SENT 状态。
    • 服务器收到 SYN 包后,向客户端发送SYN + ACK 包,服务器进入 SYN_RCVD 状态。
    • 客户端收到 SYN + ACK 包后,向服务器发送 ACK 包,客户端进入 ESTABLISHED 状态。
    • 服务器收到 ACK 包后,服务器进入 ESTABLISHED 状态。

三次握手完成后,客户端和服务端之间的连接就建立起来了,从此客户端和服务端就可以通过该连接进行通信,直至其中一方断开连接。

三次握手一定能保证成功吗?

三次握手不一定能够保证成功,因为在网络传输中可能会出现各种各样的问题,如:网络延迟、丢包、服务端关机等等。以第三次握手为例,如果服务端发出的 SYN + ACK 数据包超过一段的时间没有收到应答,服务端会认为该数据包丢失并进行数据包重传。重传次数根据 /proc/sys/net/ipv4/tcp_synack_retries 来指定,默认是 5 次。如果重传次数超过了这个值,服务端就会认为连接建立失败,关闭连接。

为什么是三次握手呢?而不是一次、两次、四次等次数呢?

  • 如果是一次握手的话,服务端接收到客户端的 SYN 包就认为建立连接成功的话,这样会带来 SYN 洪水攻击问题。SYN 洪水攻击是攻击者向被攻击主机发送大量的伪造的 TCP 连接请求,从而使得被攻击主机服务器的资源耗尽(CPU 满负荷或内存不足)的攻击方式。
  • 如果是两次握手的话,也无法避免 SYN 洪水攻击。因为服务端收到客户端的 SYN 数据包后,会给客户端发送 SYN + ACK 数据包,但服务端无法保证该数据包一定能够被客户端接收到,所以服务端只能在发出 SYN + ACK 数据包后就把连接建立好。如果黑客向服务端发送大量的 SYN 数据包,服务端就只能把连接建立好,进而导致资源耗尽。
  • 如果采取的是一次握手和两次握手,服务端中肯定会存在大量的连接,而客户端可能没有什么连接,这样会存在 SYN 洪水攻击。而如果采取的是三次握手,只有当服务端接收到客户端的 ACK 数据包才会进行连接的建立,而客户端在发出 ACK 数据包时就把连接建立好了。此时,如果客户端对服务端进行 SYN 洪水攻击,那么客户端中也会存在大量的连接,它也是要付出代价的。
  • 三次握手可以将连接建立异常的成本嫁接到客户端,一定程度上保证了服务端的安全。除了这个作用,三次握手还有验证全双工的作用。客户端通过发送 SYN 数据包和接收 SYN + ACK 数据包来证明自己即能收也能发,服务端通过发送 SYN + ACK 数据包和接收 ACK 数据包来证明自己即能收又能发,而一次握手和两次握手都无法验证 TCP 是全双工的。
  • 偶数次握手会将连接建立异常的成本嫁接到服务端,因此不会采取偶数次的握手。而三次以上的奇数次握手会浪费时间和资源,因为三次握手已经能够将连接建立成功了。

四次挥手

客户端和服务端结束通信时需要断开连接,断开连接的过程就是四次挥手。

以客户端主动断开连接为例:

  • 第一次挥手:客户端主动向服务端发送 FIN 报文,请求断开连接,表明客户端不会再向服务端发送数据了,但可以接收服务端发送过来的数据。

  • 第二次挥手:服务端接收到客户端发送的 FIN 报文后,会给客户端发送 ACK 报文,表明服务端收到了客户端的 FIN 报文。而服务端可能还有数据需要进行处理和发送,连接并没有真正关闭。

  • 第三次挥手:服务端处理完数据后,便向客户端发送 FIN 报文。

  • 第四次挥手:客户单收到服务端的 FIN 报文后,向服务端发送 ACK 报文,表明确认关闭连接。服务端收到该 ACK 报文时,也就关闭了连接。

  • 注:如果收到客户端的 FIN 报文时,服务端没有数据需要进行处理,那么 ACK 和 FIN 会在同一个报文中同时设置为 1,此时四次挥手就变成了三次挥手。

  • 四次挥手的状态变化:

    • 客户端向服务端发送 FIN 报文后,进入 FIN_WAIT_1 状态。
    • 服务端向客户端响应 ACK 报文后,立马进入 CLOSE_WAIT 状态。
    • 客户端收到服务端发送的 ACK 报文后,客户端的状态会从 FIN_WAIT_1 变成 FIN_WAIT_2。
    • 服务端向客户端发送 FIN 报文后,服务端的状态会从 CLOSE_WAIT 变成 LAST_ACK。
    • 客户端收到服务端发送的 FIN 报文后,客户的状态会从 FIN_WAIT_2 变成 TIME_WAIT,并给服务端发送 ACK 报文。
    • 服务端收到客户端发送的 ACK 报文后,服务端的状态会从 LAST_ACK 变成 CLOSED。
    • 主动关闭连接的一方进行第四次挥手后,需要维持 TIME_WAIT 状态一段时间,这个时间的大小是 2 MSL(Max Segment Lifetime, 报文最大生存时间),然后才能进入 CLOSE 状态。

四次挥手一定能够成功吗?

四次挥手不能够保证一定成功。四次挥手可能会失败的情况包括:客户端或服务器发送的 FIN 数据包丢失,客户端或服务器发送的 ACK 应答报文丢失。如果出现这些情况,客户端或服务器会重传丢失的数据包,直到连接断开或达到最大重传次数。

在什么情况下,服务端会存在大量 CLOSE_WAIT 状态呢?

服务端会在被动关闭连接的情况下,在接收到 FIN 数据包但尚未发送自己的 FIN 数据包时,进入 CLOSE_WAIT 状态。通常,CLOSE_WAIT 状态在服务器上的停留时间应该很短。但是,如果服务器上出现大量的 CLOSE_WAIT 状态,那么可能意味着被动关闭的一方没有及时发出 FIN 数据包,也就是说应用层没有调用 close 函数关闭文件描述符。

验证 CLOSE_WAIT 状态

Sock.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>

class Sock
{
private:
    const static int gbacklog = 20;

public:
    Sock() {}
    int Socket()
    {
        int listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock < 0)
        {
            exit(2);
        }
        int opt = 1;
        // setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        return listensock;
    }
    void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(3);
        }
    }
    void Listen(int sock)
    {
        if (listen(sock, gbacklog) < 0)
        {
            exit(4);
        }

    }

    int Accept(int listensock, std::string *ip, uint16_t *port)
    {
        struct sockaddr_in src;
        socklen_t len = sizeof(src);
        int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
        if (servicesock < 0)
        {
            return -1;
        }
        if(port) *port = ntohs(src.sin_port);
        if(ip) *ip = inet_ntoa(src.sin_addr);
        return servicesock;
    }
    bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port)
    {
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(server_port);
        server.sin_addr.s_addr = inet_addr(server_ip.c_str());

        if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0) return true;
        else return false;
    }
    ~Sock() {}
};

Test.cc

cpp 复制代码
#include "Sock.hpp"

int main()
{
    Sock sock;
    int listensock = sock.Socket();
    sock.Bind(listensock, 8080);
    sock.Listen(listensock);

    while(true)
    {
        std::string clientip;
        uint16_t clientport;
        int sockfd = sock.Accept(listensock, &clientip, &clientport);
        if(sockfd > 0)
        {
            std::cout << "[" << clientip << ":" << clientport << "]# " << sockfd << std::endl;;
        }
    }
    return 0;
}

验证流程:

  • 启动服务端,使用 netstat 查看服务器状态。
  • 使用 telnet 工具充当客户端连接服务端后再使用 netstat 来查看服务器状态。
  • 客户端输入 quit 断开连接后,使用 netstat 来查看 CLOSE_WAIT 状态。

验证 TIME_WAIT 状态

cpp 复制代码
#include "Sock.hpp"

int main()
{
    Sock sock;
    int listensock = sock.Socket();
    sock.Bind(listensock, 8080);

    sock.Listen(listensock);

    while (true)
    {
        std::string clientip;
        uint16_t clientport;
        int sockfd = sock.Accept(listensock, &clientip, &clientport);
        if (sockfd > 0)
        {
            std::cout << "[" << clientip << ":" << clientport << "]# " << sockfd << std::endl;
            ;
        }
        sleep(10);
        close(sockfd);
        std::cout << sockfd << " closed" << std::endl;
    }
    return 0;
}

如果想让服务器能够立即重新启动,可以使用 setsocketopt 函数的 SO_REUSEADDR 选项。SO_REUSEADDR 选项允许在同一端口上快速重新启动服务器,而不必等待 TIME_WAIT 状态的套接字释放。

cpp 复制代码
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
  • sockfd:要设置选项的套接字描述符。
  • level:指定所要设置的选项的协议层,常用的协议层包括SOL_SOCKET、IPPROTO_TCP、IPPROTO_IP等,通常设置为 SOL_SOCKET 表示Socket选项。
  • optname:要设置的选项名称,具体的选项可以是以下任意一个:
    • SO_REUSEADDR:允许重用本地地址;
    • SO_KEEPALIVE:启用 TCP 的 KeepAlive 机制。如果启用了此选项,则在套接字上的连接空闲一段时间后,将自动发送一个探测报文以检测对端是否仍然存在。
    • TCP_NODELAY:禁用 Nagle 算法。如果启用了此选项,则当套接字收到数据时,它将立即发送,而不是等待大量数据一起发送。这在某些情况下可以提高性能。
    • SO_SNDBUF:设置发送缓冲区大小;
    • SO_RCVBUF:设置接收缓冲区大小;
    • SO_ERROR:获取socket上的错误信息;
    • SO_BROADCAST:允许广播传输;
    • SO_LINGER:控制关闭 socket 的行为;
    • SO_REUSEPORT:允许多个进程或线程绑定同一个 IP 地址和端口号。
  • optval:指向包含选项新值的缓冲区的指针。
  • optlen:缓冲区长度。

如果想让服务器能够立即重新启动,可以设置 SO_REUSEADDR 选项,并将 optval 参数设置为一个 int 类型的指针,指向一个值为 1 的整数,表示开启地址重用功能。代码示例如下:

cpp 复制代码
int optval = 1;
if(setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) 
{
    perror("setsockopt");
    exit(EXIT_FAILURE);
}

这将允许在服务器退出后立即重新启动,而无需等待TIME_WAIT 状态的套接字释放。请注意,使用 SO_REUSEADDR 选项可能会导致套接字地址重用,因此应谨慎使用。


为什么要保持 TIME_WAIT 状态 2 MSL 的时间呢?

  • MSL 是 Max Segment Lifetime,报文最大生存时间,报文从一端到另一端所需时间的最大值,它是任何报文在网络上存在的最长时间,超过这个时间报文将被抛弃。
  • TIME_WAIT 状态保持两倍的 MSL 时间,是因为防止历史连接中的数据被相同四元组(源 IP、源端口、目的 IP、目的端口)的连接错误的接收。如果在关闭连接之前的某个报文被网络延迟了没有送到对端,可能这个报文已经被重传过了,然后客户端和服务端经过一段时间后关闭连接。但是客户端和服务端立即建立了四元组相同的连接,曾经被网络延迟的报文刚好达到对端且刚好在对端的接收窗口内,对端会正常接收这个报文,但是这个数据报文是上一个连接残留下来的,这样就会导致数据错乱等严重问题。因此,TIME_WAIT 状态需要至少保持 2 MSL 的时间,保证历史的数据完全从网络中消消失。
  • TIME_WAIT 状态保持两倍的 MSL 时间的另一个原因是保证被动关闭连接的一方能够正确地关闭连接。如果客户端(主动关闭放)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么根据 TCP 可靠性原则,服务端(被动关闭方)会重传 FIN 报文。假设客户端(主动关闭连接放)没有 TIME_WAIT 状态,而是在发完最后一次 ACK 报文就直接进入 CLOSED 状态。如果该 ACK 报文丢失了,服务端则重传 FIN 报文,而此时客户端已经进入关闭状态了,在收到服务端重传的 FIN 报文后,就会回复 RST 报文。


  • 服务端收到这个 RST 报文并将其解释为一个错误(Connection Reset By Peer),这对于一个可靠的协议来说不是一种优雅的断开连接的方式。为了防止以上情况的出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK 报文。如果服务端没有收到 ACK 报文,那么服务端就会重传 FIN 报文,这样一去一来刚好两个 MSL 的时间。注:客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待施加你会被重置回 2 MSL。如果服务端重传次数超过规定的次数时,服务端会自动关闭连接。

注:MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同,在 Centos7 上默认配置的值是 60s,可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看 MSL 的值。

超时重传

TCP 的超时重传机制是指在 TCP 协议中,当发送数据的一方在规定的时间内未收到接收方的确认信号时,就会触发超时重传机制。具体来说,TCP 将每个发送的数据包都标记一个时间戳,如果在规定的时间内未收到确认信号,就会重新发送该数据包。这个时间戳是根据之前的数据包的发送时间、传输距离以及网络拥塞程度等因素计算得出的。

超时重传机制可以保证数据的可靠传输,但也会影响网络的传输效率。因此,TCP 协议的实现通常会根据网络状况动态地调整超时时间,以达到最佳的传输效率和可靠性。

需要注意的是,TCP 的超时重传机制是通过代码逻辑来实现的,并不是 TCP 报头来实现的。当发送方发送数据包时,会启动一个定时器,如果在规定的时间内没有收到接收方的确认消息,发送方会认为该数据包已经丢失,并重新发送该数据包。超时重传机制的实现需要在代码中设置一个合适的超时时间,以及对超时事件的处理逻辑。

超时重传的两种情况

超时重传分为两种情况:一种是发送方发送的报文丢失了,此时发送方在一定的时间内无法收到对应的应答,会对该报文进行重传。

超时重传的另一种情况是:接收方收到了发送方的报文,但是接收方给发送方的应答报文丢失了,此时发送方也会因为一定时间内没有收到对应的应答,进而对该报文进行重传。

  • 当出现超时重传时,发送方无法判别是因为发送的报文丢失了,还是因为接收方发送的应答报文丢失了。但是发送方也不关心这个,只要发送方在一定的时间内没有对应的应答,发送方就会对该报文进行重传。
  • 如果是因为接收方发送的应答报文丢失而引起超时重传的话,那么接收方就收到多个重复的报文。但是这不需要担心,可以根据报文的序列号来对报文进行去重。
  • 当发送缓冲区中的数据被发送出去后,操作系统并不会立即将刚发送出去的数据从缓冲区中移除或者覆盖掉。只有当接收方回复了对应的应答后,我们才能认为对方收到了该数据,否则该数据必须保留在发送缓冲区中等待超时重传。

超时重传的时间

  • 如果超时重传的时间过长,会导致丢包后发送方长时间收不到对应的应答而一直在等待,影响了整体重传的效率。
  • 如果超时重传的时间过短,会导致发送方频繁地重传数据。因为应答报文有可能正在网络中传输,并没有丢包,但是超时重传的时间过短,此时发送方就开始重传数据了,这样会导致发送方重传了大量的重复数据,浪费网络资源,降低了网络传输的效率。

最理想的情况就是找到一个最小的时间,保证确认应答一定能在这个时间内返回。但是这个时间是不固定的,其应该根据网络状况和数据传输的要求进行调整。当网络状况好的时候,超时重传的时间可以设置成短一点,提高网络传输的效率;而当网络状况差时,超时重传的时间可以设置成长一点,降低重传的概率和避免网络拥塞。

TCP 为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。

  • Linux 中(BSD Unix 和 Windows 也是如此),超时以 500ms 为一个单位进行控制,每次判定超时重发的超时时间都是 500ms 的整数倍。
  • 如果重发一次之后,仍然得不到应答,等待 2 * 500ms 后再进行重传。
  • 如果仍然得不到应答,等待 4 * 500ms 进行重传。依次类推,以指数的形式进行递增。
  • 当累计到一定的重传次数时,TCP 认为网络或者对端主机出现异常,进而强制关闭连接。

流量控制

接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此 TCP 支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)。

  • 接收端将自己接收缓冲区剩余空间的大小放入 TCP 报头中的窗口大小字段,通过 ACK 报文通知发送端。
  • 窗口大小字段越大,说明网络的吞吐量越高。
  • 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。发送端接受到这个窗口之后,就会减慢自己的发送速度。
  • 如果接收端缓冲区满了,就会将窗口大小置为 0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。还有一种策略就是,接收端的缓冲区剩余空间更新后,接收端会给发送端发送窗口更新通知来告知对方接收缓冲区剩余空间的大小。
  • TCP 是全双工的,可以在两个方向上进行流量控制。
  • 三次握手时,会通过 TCP 报头中的窗口大小字段来告知对方自己的接收缓冲区剩余空间的大小。那么在正式进行网络通信时,就已经得知对方的接收能力了,可以根据对方的接收能力来发送数据。
  • 16位窗口字段最大能表示的值是 65535,那么 TCP 窗口最大就是 65535 字节么?并不是,实际上,TCP 报头 40 字节选项中还包含了一个窗口扩大因子 M,实际窗口大小是窗口字段的值左移 M 位,M 的取值范围是 0 到 14。

滑动窗口

我们都知道 TCP 是每发送一个数据,都要进行一次应答。当上一个数据收到了应答,再发送下一个数据。这样的模式有非常明显的缺点就是性能较差。当数据往返时间越长时,网络的吞吐量会越低。

一次发送多条数据

双方在进行 TCP 通信时一次可以想对方发送多条数据,这样可以将等待多个响应的时间重叠在一起,进而提高通信效率。

需要注意的是,虽然双方在进行通信时可以一次向对方发送多条数据,但这样也是要考虑对方的接收能力的,发送数据的总量不能超过对方接收缓冲区剩余空间的大小。

发送方给对方发送多条数据时,这么多条数据当中会有部分数据是没有收到移动的。我们可以将发送缓冲区中的数据分为三部分:

  • 已经发送并且已经收到 ACK 确认的数据
  • 已经发送但未收到 ACK 确认的数据
  • 未发送的数据

如上图所示,发送缓冲区第二部分数据所占的空间就是滑动窗口的空间。滑动窗口的大小是指无需等待 ACK 应答而可以继续发送数据的数量的最大值。

滑动窗口存在的最大意义就是提高通信效率:

  • 滑动窗口的大小等于对方窗口大小与自身拥塞窗口大小之间的较小值,拥塞窗口需要根网络状况有关,暂时不需要考虑拥塞窗口,可以认为滑动窗口的大小就等于对方窗口的大小。
  • 不考虑拥塞窗口的硬性,假设滑动窗口的大小固定为 4000,也就是说发送方不需要等待对方的应答而一次向其发送 4000 字节的数据。
  • 现在发送方可以直接向对方连续发送 1001 到 2000、2001 到 3000、3001 到 4000 和 4001 到 5000 这四个段的数据,不需要等待对方的应答。
  • 当收到对方发送的确认序号为 2001 的报文时,说明 1001 到 2000 这个段的数据已经被对方收到了,那么此时滑动窗口可以向右移动。因为滑动窗口的大小固定为 4000(对方窗口的大小),所以 5001 到 6000 这段数据可以直接向对方发送,不需要等待其他数据段的应答,以此类推。
  • 滑动窗口越大,则说明网络的吞吐率越高,也说明对方的接收能力越好。

当发送方发送的数据陆陆续续地收到对应的应答时,此时可以将应答所对应的数据段移出滑动窗口,置于滑动窗口的左侧。而窗口是否会向右移动则取决于对方的接收能力(窗口大小)。如果对方可以接收更多的数据,那么窗口可以向右移动将位于窗口右侧的数据包含进窗口中,进行数据的发送。

TCP 超时重传机制要求发送缓存区暂时保存发送未被对方确认的数据,而这部分数据就是在滑动窗口当中。位于滑动窗口左侧的数据都是已经被对方确认收到的数据,这些数据能够被操作系统删除或者覆盖。因此,滑动窗口不仅能够保证能向对方一次性发送多条数据,而且还保证了数据的超时重传。

滑动窗口主要是解决数据传输的效率问题,顺带保证超时重传的可靠性。

滑动窗口的本质

滑动窗口的本质就是指针或者下标,而滑动窗口的移动就是指针或者下标增加。

滑动窗口一定能够向右移动吗?滑动窗口如果一直向右移动会造成越界问题吗?

滑动窗口不一定能够向右移动。因为滑动窗口的大小是受对方窗口大小的限制的,如果对方应用层没有从接收缓冲区中拿出数据,这样对方的窗口会越来越小,因此滑动窗口会越来越小,无妨向右移动。

滑动窗口并不一定是整体向右移动的,可能是窗口的左边界进行移动而右边界不移动,因为对方的窗口大小是不固定的,随时在变化,所以滑动窗口的大小也不是固定的,也是随时都在变化。

滑动窗口一直向右移动并不会造成越界问题。因为发送缓存区是被看成环形队列的,当滑动窗口向右移动时,如果超出了缓冲区的范围,那么就会进行模除运算,重新回到缓冲区的起始位置进行向右移动。

滑动窗口的大小可以为零吗?

滑动窗口的大小可以为零。当对方一直不从接收缓冲区中拿取数据,会导致发送方的滑动窗口越来越小直至为零。

丢包问题

情况一:数据已经被接收方接收,但是 ACK 丢失了

这种情况下,部分 ACK 丢了并不要紧,因为可以通过后续的 ACK 进行确认。

情况二:数据直接丢失了,未被接收方接收到

  • 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的 ACK,就像是在提醒发送端我想要的是 1001 一样。
  • 如果发送端主机连续三次收到了同样一个 1001 这样的应答,就会将对应的数据 1001 - 2000 重新发送。
  • 这个时候接收端收到了 1001 之后,再次返回的 ACK 就是7001了,因为 2001 - 7000 的数据接收方之前就已经收到了,被放到了接收的接收缓冲区中。

这种重传机制就是快重传机制。快速重传机制的实际方式是:当发送方收到三个相同的 ACK 时,就认为是前一个数据包已经丢了,并立即重传。这个机制提高重传的效率。

快重传 VS 超时重传

  • 快重传能够进行数据的快熟重传,当发送方连续收到三个相同的 ACK 时,就会触发快重传机制。而快重传机制不需要向超时重传那样通过设置重传定时器,在一定的时间后进行重传。
  • 虽然快重传机制能够实现数据的快速重传,但是触发这个机制是有条件的,就是连续收到三个相同的 ACK。这也是快重传无法取代超时重传的原因,超时重传也是一种兜底的策略。如果接收方发送的 ACK 报文一直丢失的话,就无法触发快重传了,这时候就需要超时重传。

拥塞控制

双方通过 TCP 进行通信时,出现丢包问题是非常正常的,此时就通过超时重传或者快重传进行数据的重传。但是如果双方进行通信时出现了大量的丢包,此时就不能认为这是正常现象了。

TCP 协议不仅考虑到了两个主机端到端的可靠性问题,还考虑了网络状态的问题。

当出现大量丢包问题时,就可能出现网络拥塞问题。当出现网络拥塞问题,就不能立即将这些数据进行重传。因为一个网络是多个主机进行共用的,并且这样主机使用的都是 TCP / IP 协议,你重传了,那别的主机要不要重传呢。所以当网络出现问题时,不应该再向网络中发送大量数据。

网络拥塞影响的不只是一台主机,影响的是该网络下的所有主机,此时所有使用 TCP 协议的主机都需要执行拥塞避免算法来缓解网络拥塞的状态,使得网络状态慢慢得以恢复。

拥塞控制

虽然 TCP 协议有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然地发送大量的数据是很有可能引起雪上加霜的。

TCP 协议引入了慢启动机制,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。

为了解决网络拥塞问题,就引入了一个概念拥塞窗口。

  • 拥塞窗口是可能引起网络拥塞的上限值,如果一次性发送的数据量超过了拥塞窗口的大小,就有可能引起网络拥塞。
  • 刚开始发送数据时,拥塞窗口的大小被定义成 1,。每收到一个 ACK 应答,拥塞窗口的大小就增加一。
  • 每次发送数据的视乎,会将拥塞窗口的大小和接收方主机返回的窗口大小进行比较,取较小值作为滑动窗口的大小。

每收到一个 ACK 应答,拥塞窗口的大小就增加一,那么拥塞窗口的大小是以指数的形式进行增长的。指数形式增长只是初始时增长较慢,也就是所谓的慢启动,但是越往后增长就后越快,这时候就有可能短时间内又造成了网络拥塞的问题。

  • 为了避免短时间内有造成网络拥塞的问题,那么就不能让拥塞窗口大小一直以指数的形式增长下去。
  • 此时就引入了一个慢启动的阈值,当拥塞窗口的大小超过这个阈值时,拥塞窗口大小就不在意指数的形式进行增长了,而是以线性的形式进行增长。
  • 当 TCP 刚开始启动的时候,慢启动的阈值被设置为对方窗口大小的最大值。
  • 在每次超时重传的时候,慢启动的阈值会变成当前拥塞窗口大小的一般,同时拥塞窗口的大小变成一,重新开始增长。

上图说明:

  • 指数增长:刚开始发送数据时,拥塞窗口大小被设置为 1,并且以指数形式进行增长。因为指数前期增长较慢,可以避免网络拥塞问题。而中后期时指数增长快,此时网络也恢复了,可以尽快恢复双方的通信效率。
  • 线性增长:慢启动的阈值初始时被设置为对方窗口大小的最大值,上图中的慢启动阈值为 16。当拥塞窗口的大小增长到慢启动阈值时就不再以指数形式进行增长,而采用线性增长的方式。注意:线性增长阶段时,还未发生网络拥塞问题。
  • 乘法减小:当拥塞窗口大小增长到 24 时,发生了网络拥塞问题,此时慢启动阈值就会变成当前拥塞窗口大小的一半,同时拥塞窗口大小变成一。然后拥塞窗口再次以指数的形式进行增长,周而复始。

少量的丢包,我们仅仅是触发超时重传或快重传。大量的丢包,我们就认为网络拥塞。当 TCP 通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。拥塞控制,归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

延迟应答

如果接收数据的主机接收到数据立刻返回 ACK 应答,此时返回的窗口可能比较小。

  • 假设接收端缓冲区为 1M,一次收到了 500K 的数据。如果立刻进行应答,那么返回的窗口大小就是 500K。
  • 但实际上接收方处理数据的速度可能会很快,10ms 之内就可以把 500K 数据从缓冲区消费掉了。
  • 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
  • 如果接收端稍微等一会再应答,比如等待 200ms 再应答,那么这个时候接收方返回的窗口大小就是 1M。

接收方在接收到数据后,并不会立即发送 ACK 应答,而且是等候一段时间(一般是200ms),等接收方上层处理完数据后再给发送方发送一个更大的窗口大小,这种机制就是 TCP 协议的延迟应答机制。延迟应答机制并不是为了保证可靠性的,而是为了提高效率的一种策略。

有了延迟应答机制,发送给对方的窗口大小就会越大,网络吞吐量就越大,传输效率就越高。延迟应答机制的目标是在保证网络不拥塞的情况下尽量提高传输效率。

需要注意的是,并不是所有的数据包都可以延迟应答。

  • 数量限制:每隔 N 个包就应答一次。
  • 时间限制:超过最大延迟时间就应答一次(这个时间不会导致超时重传)。

具体的数量和超时时间,依操作系统不同也有差异。一般 N 取2,超时时间取 200ms。

捎带应答

捎带应答是双方进行 TCP 通信时最常见的一种方式。TCP 协议的捎带应答机制就是发送的同一个 TCP 数据包中即包含数据又包含 ACK 应答的一种机制。

就好比,主机 A 给主机 B 发送一条消息,主机 B 收到该消息后就回复给出 ACK 应答。但如果主机 B 也有消息需要给主机 A 发送,那么这个 ACK 应答就可以和该消息放在同一个报文中,而不需要再单独发送一个 ACK 应答了。此时,主机 B 发送的这个既完成了对收到的数据的应答,又完成了自己数据的发送。

捎带应答机制很明显能够减小网络通信的开销,因为通信双方不再单独地发送 ACK 应答了。

面向字节流

创建一个 TCP 的套接字时,会在内核中创建一个发送缓冲区 和一个接收缓冲区。

  • 调用 write 或 send 函数时,数据会先考到到发送缓冲区中。
  • 如果要发送的数据的字节数太多,会被拆分成多个 TCP 的数据包发出。
  • 如果要发送的数据的字节数太少,就会先发送缓冲区中保存着。等到缓冲区长度差不多了,或者其他合适的时机再发送出去。
  • 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,然后应用程序可以调用 read 或 recv 函数从接收缓冲区拿取任意字节长度的数据。
  • TCP 的一个连接既有发送缓冲区,也有接收缓冲区。那么对于这一个连接,既可以读数据,也可以写数据,这个概念叫做全双工。

由于缓冲区的存在,TCP 程序的读和写不需要一一匹配。

  • 写 100 个字节数据时,可以调用一次 write 函数写 100 个字节,也可以调用 100 次 write 函数,每次写一个字节。
  • 读 100 个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100 个字节,也可以一次 read 一个字节, 重复 100 次。

对应 TCP 来说,根本就不关心发送缓冲区中存储的是什么数据,在它看来就是一个个字节的数据,它只需要将这些数据可靠地发送到对方的接收缓冲区中就行了。而对方的上层也不会关心接收缓冲区的数据是什么数据,只需要将其一个字节一个字节地读取上来就行了。像这种不关心数据格式的数据通信流程,就被称为面向字节流。

而 UDP 的面向数据报就是将上层应用层交下来的数据看做一个独立的报文,既不进行拆分,也不进行合并,添加上 UDP 报头后向下交付给网络层处理。而上层应用层收到数据时,就认为该数据是一个完整的报文,可以直接对其进行处理。面向数据包就好像是我们平时收快递的样子,当我们收到一个快递时,我们就知道对方发送了一个快递。当我们收到五个快递时,对方就发送了五个快递。这种数据通信流程就被称为面向数据报。

粘包问题

什么是粘包?

  • 首先要明确的是,粘包问题中的 "包",是指的应用层的数据包。
  • 在 TCP 的协议报头中,没有向 UDP 协议一样的 "报文长度" 这样的字段,但是有一个序号这样的字段。
  • 站在传输层的角度,TCP 协议是一个一个报文传输过来的,并且按照序号排好序放在缓冲区中。
  • 但是站在应用层的角度,看到的只是一串连续的字节数据。
  • 那么应用程序看到了这么一连串的字节数据,并不知道从哪个部分开始到哪个部分是一个完整的应用层数据包,这就是所谓的粘包。

如何解决粘包问题呢?

解决粘包问题的本质就是明确两个包之间的边界,只要知道包与包之间的边界,就能够正确读取一个数据包了。

  • 对于定长的包,保证每次都按固定大小读取即可。
    对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如 HTTP 报头使用 Content-Length 字段来表示正文的长度。
  • 对于变长的包,还可以在包和包之间使用明确的分隔符。应用层协议可以由程序员自己来定制的,只要保证分隔符不和正文冲突即可。

UDP 协议是否存在粘包问题呢?

  • 对于 UDP协议,如果还没有向上层交付数据,UDP 的报文长度仍然存在。同时 UDP 协议是一个一个把数据交付给应用层,就有很明确的数据边界。
  • 站在应用层的角度,使用 UDP 协议进行通信的时,要么是收到完整的 UDP 报文,要么就是没有收到 UDP 报文,不会出现读取到半个 UDP 报文的情况。

因此,UDP 协议是不存在粘包问题的,根本原因就是 UDP 报头中有 16 位 UDP 长度字段来明确数据之间的边界。而 TCP 是基于字节流,没有明确的数据边界,需要应用层定制协议来明确数据与数据之间的边界。

TCP异常情况

进程崩溃 / 进程退出

当客户端和服务端正常通信时,客户端进程突然崩溃了,那么建立好的连接会怎么办呢?

TCP 的连接信息是有内核维护的,所以当客户端进程崩溃后,内核需要挥手该进程的 TCP 连接资源。于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核中完成的,并不需要进程的参与。所以即使客户端进程退出了,还是能与服务器完成 TCP 四次挥手释放连接的。

机器重启

当客户端和服务端正常通信时,客户端机器重启,那么建立好的连接会怎么办呢?

当客户端选择机器重启时,操作系统会把正在运行的所有进程杀掉再进行机器重启,那么机器重启的情况就和进程崩溃、进程退出的情况一样了。操作系统自动帮客户端与服务器进行四次挥手,正确释放连接。

机器掉电 / 网线断开

当客户端和服务端正常通信时,客户端机器掉电或网线断开,那么建立好的连接会怎么办呢?

当客户端机器掉电或网线断开时,客户端的连接会自动关闭掉,但是服务端是无法感觉客户端已经关闭连接了,因此服务端还会保持连接。但是这个连接并不会一直保持,因为 TCP 协议具有保活机制。

  • 保活机制:定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用。每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少。如果连续发送几个探测报文都没有得到应答,则认为当前连接已经死亡,系统内核会将错误信息通知上层应用程序,进而关闭连接。
  • 客户端长期掉线,服务端会发送探测报文来检查客户端的状态。如果发送多个探测报文都没有收到应答,那么服务端会将该连接进行关闭。
  • TCP 的保活机制是基于定时器实现的,如果对方能够正常应答,那么定时器将会被重置。

应用层的某些协议,也有一些这样的检测机制。例如 HTTP 长连接中,也会定期检测对方的状态。例如 QQ,在 QQ断线之后,也会定期尝试重新连接。

TCP小结

TCP 协议之所以设计得这么复杂,是因为既要保证可靠性,同时也要尽可能地提高性能。

可靠性:

  • 校验和
  • 序列号
  • 确认应答
  • 超时重发
  • 连接管理
  • 流量控制
  • 拥塞控制

提高性能:

  • 滑动窗口
  • 快速重传
  • 延迟应答
  • 捎带应答

其他:

  • 超时重传定时器:用于实现可靠传输。当一个 TCP 数据包被发送出去后,如果在一定时间内没有收到对方的确认应答,则会触发超时重传机制,即重传该数据包。而超时时间则是通过定时器来控制的。
  • 保活定时器:TCP 协议中的保活定时器用于检测 TCP 连接是否已经失效,如果失效则断开连接。
  • TIME_WAIT 定时器:主动断开连接的一方需要保持 TIME_WAIT 状态 2MSL 的时间,确保被动关闭连接放能够正确关闭连接。

基于TCP应用层协议

基于 TCP 协议的应用层协议有很多,常见的协议如下:

  • HTTP:超文本传输协议,用于万维网上的数据传输。
  • HTTPS:超文本传输安全协议,基于 HTTP 协议之上的安全协议,用于万维网上的数据传输。
  • SSH:安全外壳协议,用于远程登录主机和文件传输
  • Telnet:远程终端协议,用于远程登录主机。
  • FTP:文件传输协议,用于在网络上进行文件传输。
  • SMTP:简单邮件传输协议,用于电子邮件的发送。
  • DNS:域名系统,用于将域名转换为 IP 地址。

当然,也包括我们自己写 TCP 程序时自定义的应用层协议。

TCP与UDP的对比

TCP 协议和 UDP 协议的区别主要有一下几个方面:

  • 连接:TCP 是面向连接的协议,而 UDP 是无连接的协议。
  • 可靠性:TCP 协议提供可靠的数据传输,即数据包在传输过程中会进行确认、重传和流量控制等机制,而 UDP 协议则不提供可靠性保证。
  • 传输效率:由于 TCP 协议提供了许多可靠性保证机制,因此其传输效率相对较低;而 UDP 协议则没有这些机制,因此传输效率相对较高。

TCP 协议和 UDP 协议没有明显的好坏之分,我们需要根据具体的应用场景来选择合适的协议。TCP 协议和 UDP 协议的具体应用场景:

  • TCP 协议适用于需要可靠传输保障的应用场景,如:文件传输、电子邮件和网页访问等等。
  • UDP 协议适用于要求传输效率高、实时性高的应用场景,如:实时音视频传输、游戏直播等等。

用UDP实现可靠传输

可以参考 TCP 协议保证可靠性的做法,在应用层实现类似的功能。例如:

  • 引入序列号:为每个数据包分配一个唯一的序列号,接收方可以使用序列号来检测数据包的丢失和顺序错误,并进行必要的处理。
  • 引入确认应答:发送方在发送数据包后等待一段时间,如果没有收到确认响应,就会认为数据包丢失,然后重新发送。接收方在收到数据包后发送确认响应。
  • 引入超时重传:发送方在发送数据包后启动一个计时器。如果在一定时间内未收到确认响应,就认为数据包丢失,然后进行重传。
  • ...

👉TCP相关实验👈

理解listen的第二个参数

accept 函数不参与三次握手的过程,而是当底层建立好连接后,然后 accept 从底层获取已经建立好的连接。也就是说。就算我不调用 accept 函数,底层的连接也可以建立好。

那如果上层来不及调用 accept 函数,并且对端还来了大量的连接请求,难道所有的连接都应该先建立好吗?如果上层都来不及获取连接了,那么就说明服务器现在已经很繁忙了。如果现在系统还有建立连接的话,这就会导致服务器的资源更加吃紧甚至挂掉。

那系统是如何解决这个问题的呢?系统为 TCP 连接管理维护了两个连接队列,来解决这个问题。而这两个队列不能没有,也不能太长。如果没有队列的话,服务器闲下来的话,没有连接可以获取,这样就无法使服务器的资源充分发挥处理。而如果队列太长的话,对端等的时间就会变长,并且这些资源可以划分给服务器,这样服务器就可以处理更多的连接请求,更能提高效率。

当对端来了大量的连接请求时,并不是所有的连接都会建立好。如果没有超过连接队列的长度,连接就会建立好,在队列中等待上层获取。而如果超过连接队列的长度,连接请求就会被拒绝。而连接队列的长度与 listen 函数的第二个参数有关。

现在我们把上面代码中的 gbacklog 从 20 改成 1,并且服务端不进行 accept 获取新连接。如下图所示:





listen 的第二个参数决定了底层全连接队列的长度,其长度等于 listen 第二个参数加一,而全连接队列就是用来保存处于 ESTABLISHED 状态,但是上层没有调用 accept 获取的连接请求。全连接队列也被称为 accept 队列。

还有一个队列就是半连接队列,用来保存处于 SYN_SENT 和 SYN_RECV 状态的连接请求。半连接队列有明显的生命周期,一段时间后,服务端还是没有向客户端发送 SYN+ACK 包,那么系统将该连接请求移除半连接队列。而如果服务端收到了客户端的 ACK 包后,系统会将连接请求移入到全连接队列。而全连接队列能够将连接长时间的维持在 ESTABLISHED 状态,等待上层来获取连接。当全连接队列满了时,就无法继续让当前连接的状态进入 ESTABLISHED 状态了。

👉总结👈

本篇博客从 TCP 协议报头讲起,讲解了 TCP 协议的确认应答机制、窗口大小、六个标记位、连接管理机制、超时重传机制、流量控制、滑动窗口、拥塞控制、延迟应答、捎带应答、面向字节流、粘包问题等等。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!💖💝❣️

相关推荐
安於宿命1 分钟前
【Linux】简易版shell
linux·运维·服务器
黄小耶@13 分钟前
linux常见命令
linux·运维·服务器
叫我龙翔14 分钟前
【计网】实现reactor反应堆模型 --- 框架搭建
linux·运维·网络
古驿幽情16 分钟前
CentOS AppStream 8 手动更新 yum源
linux·运维·centos·yum
BillKu17 分钟前
Linux(CentOS)安装 Nginx
linux·运维·nginx·centos
BillKu21 分钟前
Linux(CentOS)yum update -y 事故
linux·运维·centos
a2663789626 分钟前
解决yum命令报错“Could not resolve host: mirrorlist.centos.org
linux·运维·centos
不爱学习的YY酱1 小时前
【计网不挂科】计算机网络期末考试——【选择题&填空题&判断题&简述题】试卷(4)
网络·计算机网络
装睡的小5郎1 小时前
家庭宽带如何开启公网ipv4和ipv6
网络
yfs10241 小时前
压缩Minio桶中的文件为ZIP,并通过 HTTP 响应输出
网络·网络协议·http