tcp 协议详解

时间:2024-03-30 17:37:39

什么是 TCP 协议

TCP全称为 “传输控制协议(Transmission Control Protocol”). 人如其名, 要对数据的传输进行一个详细的控制。TCP 是一个传输层的协议。

如下图:

在这里插入图片描述

我们接下来在讲解 TCP/IP 协议栈的下三层时都会先解决这两个问题:

  1. 报头与有效载荷如何分离?
  2. 有效载荷应该交付给上层的哪个协议?

TCP 协议段格式

img

数据偏移

数据偏移占 TCP 协议段的 4 个字节!数据偏移字段是用来表示 TCP 头部的长度,以确保在 TCP 报文中正确地定位到数据的开始位置。

你可能会有疑问:数据偏移字段只有 4 个字节,表示的范围也就是: [ 0 , 15 ] [0, 15] [0,15],连 TCP 协议段的 20 个字节的固定首部都表示不了,如何能用来表示 TCP 头部长度呢?

这是因为:数据偏移在计算的时候是有基本单位的,数据偏移的单位是 4 字节,这就意味着数据偏移字段能表示的字节数范围是: [ 0 , 60 ] [0, 60] [0,60] 字节。

数据偏移最大表示的 60 个字节,减去 20 个字节的固定首部长度,我们可以得出选项部分的长度范围是: [ 0 , 40 ] [0, 40] [0,40] 字节。

我们现在就能解决这个问题啦:TCP 报文的报头与有效载荷是如何分离的呢?

首先,获得一个 TCP 报文之后,直接读取固定长度 20 个字节,然后再读取数据偏移。由数据偏移计算出 TCP 报文的头部长度。然后就可以将报头与有效载荷分离啦!

这个选项是什么东西呢?

在TCP协议报头中,选项字段用于提供额外的功能和灵活性,通常用于特定的需求或性能优化。选项字段是可选的,不是每个TCP报文都会包含选项。具体的选项有哪些你可以在网上搜搜,我们这里不做讲解,因为不是重点。

源端口号与目的端口号

我们在写 TCP 网络套接字编程的客户端代码时,不是要绑定服务器的端口号嘛。在客户端给服务端发送请求的时候,我们绑定的服务器端口号就是目的端口号。源端口号就是客户端进程绑定的端口号,客户端的端口号我们不会手动绑定,而是操作系统随机分配的。

有了源端口号,就知道数据是从哪个进程来, 到哪个进程去。那么,TCP 报文中有效载荷交付给上层的那个协议的问题也就解决了!

通过目的端口号,我们就知道 TCP 报文的有效载荷该交给上层的哪个协议!

  • 如果目的端口号是 80,那么就是交给应用层的 http 协议。
  • 如果目的端口号是 443,那么就是交给应用层的 https 协议。

窗口

根据 TCP 协议段的图,我们可以看出窗口大小是 16 位的。

img

在客户端与服务端使用 TCP 协议进行通信的时候,他不能像 UDP 协议那样,一有数据就发送。TCP 内部是维护了发送缓冲区和接收缓冲区的!既然 TCP 协议不能像 UDP 协议那样一有数据就发送,那 TCP 协议是如何做的呢?

这里需要提到流量控制,现在只是提一下,等会儿会细讲的,因为 TCP 的知识点具有很强的关联性,无法一个一个地拎出来讲。

流量控制:在 TCP 协议中,不能让客户端使劲儿地往服务端发数据(当然反过来也一样,CS 在互相通信嘛),如果将服务端的接收缓冲区干满了,那么服务端就会将报文丢弃,出现丢包问题(TCP 报文丢失,即服务端没有收到)。尽管说 TCP 协议有丢包重传(通信一方发的报文对方没有收到,会进行重传)机制,但是这样做显然会造成传输效率下降。因此,可以看出流量控制在通信过程中的必要性!

我们都知道 TCP 是可靠传输的?TCP 凭什么保证可靠性呢?最基本的特点:==确认应答机制。==客户端向服务端发送了一条消息,服务端收到客户端的消息之后,也会向客户端发送一条消息,告诉客户端,你发的消息我收到了。

那么,基于这个确认应答机制,客户端被要求数据发送地慢一点,客户端的判断依据是什么呢?

显然是由服务端接收缓冲区的剩余空间大小来决定的!当客户端给服务端发送消息,服务端对客户端发来的消息做应答的时候,窗口字段填充的内容就是自身接收缓冲区的剩余空间大小。

16 位窗口大小填充的就是自身接收缓冲区的剩余空间大小。

序号与确认序号

这个世界上有 100% 可靠的网络协议吗?

答案显然是没有的!

  • 假设客户端给服务端发送了一条数据,如果客户端收到了服务端的应答,那么就可以保证客户端发送给服务端的数据是可靠的。
  • 因此,没有应答的数据,TCP 协议是没有办法保证可靠性的。所以,客户端与服务端通信的最新一条数据,一定是没有应答的,即我们无法保证通信双方发出的数据是 100% 可靠的。

虽然我们不能保证双方通信的数据 100% 可靠,但是只要收到应答的数据就一定是可靠的,因此 TCP 协议能保证局部数据的可靠性。这个是题外话哈!

我们现在来讲序号与确认序号的作用。由TCP 协议段格式图可以看出序号与确认序号都是 32 位的哈!客户端给服务端发送数据时,按照一定的顺序将数据发出,可是服务端收到数据的顺序会和客户端发送数据的顺序一样嘛?

显然无法保证服务端收到数据的顺序与客户端发送数据顺序的一致,这就是数据包乱序问题。你 TCP 协议不是可靠传输的协议嘛!怎么能容忍数据包乱序的问题呢?因此 32 位序号的核心作用之一:保证数据的按序到达!

什么是序号呢?

TCP 协议将每个字节的数据都进行了编号,即为序列号。

img

如上图 TCP 发送缓冲区中的第一字节的其实位置处就是序号 1,依次类推。有了序号这个东东,接收方只需要对接收到的数据按照序号进行排序就能保证数据的顺序不会紊乱啦!

我们在写 TCP 协议的网络套接字编程代码时,调用 write 接口往 fd 里面写入,当时我们认为调用 write 就是在向对方发送数据了!其实不然,调用 write 系统调用的本质是将用户层的数据拷贝到了 TCP 的发送缓冲区,至于数据什么时候发送,发送多少,全是由 TCP 说了算。因此 TCP 才被叫做传输控制协议嘛!因此我们在自定义协议的时候,才需要将 read 读到的报文做拼接,提取的过程嘛!因为 read 读上来的并不一定是一个完整的报文啊!

用户调用 write 拷贝到 TCP 发送缓冲区的数据是有序的,并且每个字节都有自己的编号,客户端在发送数据的时候,会填充 TCP 报头的 32 位序列号,填充的内容就是发送数据块的最后一个字节的序号!例如,如上图,假设客户端某一次发送数据时发送了 [ 1 , 1000 ] [1, 1000] [1,1000] 字节的数据,那么这个 32 位的序列号填充的就是 100。

服务端不是要对客户端发送的数据做应答嘛,服务端在确认应答的报文中会填充 32 位确认序号,其值为收到报文的 32 位序列号加 1。例如,假设服务端收到了客户端发来的 [1, 1000] 字节的,服务端在做应答时,32 位确认序号就会填充为 1001。

那么为什么是收到报文的 32 为序列号加 1 呢?

首先,确认序号的意义是:确认序号之前的数据,我已经全部收到了,下一次发送数据,请从确认序号指定的序号处开始发送。你品,你细品,确认序号之前的数据,我已经全部收到了哦!

我们先回到确认应答机制上来,客户端发送消息,服务端确认应答(当然反过来也是一样的,通信的过程是双方都在进行的嘛),然后再发送下一个消息。你不觉得这样串行的方式效率太低了嘛?

实际上客户端是有可能一次给服务端发送多个报文的,这个确认序号的意义在这里就能凸显出来啦。如上图:假设客户端一次性向服务端发送了三个 TCP 报文,数据分别是: [ 1 , 1000 ] [1, 1000] [1,1000] 字节, [ 1001 , 2000 ] [1001, 2000] [1001,2000] 字节, [ 2001 , 3000 ] [2001, 3000] [2001,3000] 字节,服务端收到这三个报文之后,需要对每一个报文都做应答吗?显然是不需要的,只需要一个应答,在应答报文的确认序号中填写 3001,表示 3001 序号之前的数据我都已经收到了哦,下次请从序号 3001 开始发送数据。

因此,我们允许应答有少量的缺失。

事实上,服务端在应答的时候,也是能给客户端发送数据的,因为一次应答也是发送一个完整的报文嘛,既然是一个完整的报文,为什么不能将应答与数据同时发送给客户端呢?(当然前提是服务端有数据需要向客户端发送),我们将应答与数据经由同一个报文发送给通信另一方的机制称为:捎带应答。

这里你就可能会有以疑问了:为什么要有序号和确认序号,一个序号不可以吗?

  1. 首先,应答也可能携带数据,即捎带应答。
  2. 服务端与客户端互发消息,序号(发送数据)与确认序号(应答),两个都要使用!

TCP 报头的 6 个标志位

  1. URG(紧急):
    • URG 标记位用于指示 TCP 报文段中的紧急数据。当 URG 标记位被设置为 1 时,表示 TCP 报文段中的某些数据被标记为紧急数据,需要优先处理。
    • TCP 报头中,紧急指针表示的就是紧急数据在 TCP 有效载荷中的偏移量,单位是字节。
    • URG 可以在什么场景使用呢?比如服务器压力很大,现在无法处理某些客户端的请求,客户端具体也不知道服务端是什么情况,就可以通过将 URG 标志位设为 1,携带紧急数据来询问服务器现在的情况。
  2. ACK(确认):
    • ACK 标记位用于确认接收方已经成功接收到了发送方发送的数据。当 ACK 标记位被设置为1时,表示 TCP 报文段中的确认序号有效,接收方已经成功接收到了发送方发送的数据。
  3. PSH(推送):
    • PSH 标记位用于提示接收方应该立即将收到的数据推送给应用层。当 PSH 标记位被设置为 1 时,表示 TCP 报文段中的数据应该被立即传输给上层应用,而不应该被缓存或等待更多数据。只有紧急情况才会将 PSH 置 1。
  4. RST(复位):
    • RST 标记位用于强制中断 TCP 连接。当 RST 标记位被设置为 1 时,表示 TCP 连接出现了异常情况,需要立即中断连接。RST 标记位通常用于处理一些异常情况,如连接超时、协议错误等。
  5. SYN(同步):
    • SYN 标记位用于发起 TCP 连接的请求。当 SYN 标记位被设置为 1 时,表示发送方希望建立一个新的 TCP 连接。
  6. FIN(结束):
    • FIN 标记位用于结束 TCP 连接。当 FIN 标记位被设置为 1 时,表示发送方已经完成了数据的发送,并且希望关闭TCP连接。FIN 标记位通常用于连接的正常关闭过程中,表明发送方不再有数据发送。

保留字段

TCP 协议头部中,有一些字段被标记为“保留字段”。这些字段在 TCP 协议的当前版本中没有被使用,被保留供未来使用或扩展时所需。保留字段的存在有以下几个目的和作用:

  1. 兼容性: 保留字段的存在确保了 TCP 协议的向后兼容性。即使在当前版本中没有使用,但保留字段的存在使得未来版本能够通过修改和扩展这些字段来引入新的功能和特性,而不会破坏已有的实现和部署。
  2. 协议扩展: 保留字段为 TCP 协议的未来扩展提供了可能性。随着网络技术的发展和应用需求的变化,TCP 协议可能需要引入新的功能和选项。保留字段可以用于扩展协议头部,以支持新的特性或选项。
  3. 预留用途: 保留字段可以作为临时的占位符,用于暂时未确定具体用途的情况。在协议制定或升级过程中,有时会留出一些字段作为预留字段,以备将来可能需要引入的新功能或选项。
  4. 标准化: 保留字段的存在有助于标准化 TCP 协议的演进过程。通过在协议规范中明确保留字段的存在和用途,可以为未来的协议扩展和版本更新提供一个统一的框架和方向。

校验和

TCP 报头中的检验和字段(Checksum)用于检测 TCP 报文在传输过程中是否发生了损坏或错误。TCP 的检验和算法通过对 TCP 报文段中的各个字段进行计算生成一个校验和值,并将该值存储在 TCP 报头中的检验和字段中。接收端在接收到 TCP 报文时会重新计算校验和,并将计算得到的校验和值与报文中的校验和字段进行比较,以确定报文的完整性。

检验和字段的作用包括:

  1. 数据完整性验证: 检验和字段用于验证 TCP 报文在传输过程中是否受到了损坏或篡改。接收端会重新计算报文的校验和,并将计算得到的校验和值与发送端发送的校验和字段进行比较。如果两者不一致,则说明报文在传输过程中发生了错误,接收端会丢弃该报文并请求重传。
  2. 数据可靠性保证: 检验和字段可以帮助确保 TCP 报文的可靠传输。通过校验和的验证,接收端可以检测到传输过程中发生的任何错误或损坏,从而及时发现并处理错误,保证数据的可靠传输。
  3. 拒绝冒充攻击: 检验和字段可以防止恶意主机对 TCP 报文进行篡改或冒充攻击。由于检验和字段是通过对 TCP 报文内容进行计算得到的,攻击者无法在不知道校验和算法的情况下有效地篡改 TCP 报文而不被检测到。

TCP 的确认应答机制

其实这个机制在前面都讲得差不多了,这里来总结一下吧:
TCP 的确认应答机制是一种用于确保数据可靠传输的重要机制。它通过在数据传输过程中的确认应答来确认数据的成功传输,从而保证数据的可靠性。

  1. 序号和确认号: TCP 协议中的每个数据段都包含一个序号字段和一个确认号字段。发送方使用序号字段对发送的数据进行编号,接收方通过确认号字段向发送方发送确认消息,确认接收到的数据。确认号字段表示接收方期望接收到的下一个序号,即已成功接收的最后一个字节的序号加 1。

TCP 的超时重传机制

首先我们来看看有哪些情况会触发 TCP 的超时重传机制:

  1. 主机 A 发送数据给 B 之后, 可能因为网络拥堵等原因, 数据无法到达主机 B,如果主机A在一个特定时间间隔内没有收到 B 发来的确认应答, 就会进行重发。

img

  1. 主机 A 未收到 B 发来的确认应答, 也可能是因为 ACK 丢失了。在这种情况下接收方可能会收到重复的报文,显然报文重复也是不可靠传输的一种表现形式,因此,必须进行相同报文的去重!去重可以根据 32 位的序列号嘛!

img

发送方对于发出去的报文是否丢失无法进行判定!必须通过规定来确认是否需要进行报文的重传。TCP 选择使用超时重传来实现。那么这个时间是如何计算的嘞?

  • 最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”。
  • 但是这个时间的长短, 随着网络环境的不同, 是有差异的。
  • 如果超时时间设的太长, 会影响整体的重传效率。
  • 如果超时时间设的太短, 有可能会频繁发送重复的包。

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

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

TCP 的连接管理机制

想必在学习 TCP 之前你就已经听过 TCP 的三次握手和四次挥手了吧!

img

我们之前就提到过,TCP 协议是面向连接的,这个连接的过程包含建立连接和断开连接两个部分哈!也就是我们常说的三次握手和四次挥手。

三次握手

TCP 的三次握手是建立 TCP 连接的过程,用于确保通信双方的状态同步和数据传输的可靠性。三次握手的过程如下:

  1. 客户端发送连接请求:
    • 客户端首先向服务器发送一个连接请求报文段(SYN),其中包含了客户端的初始序列号(Seq=X)和 TCP 头部中的 SYN 标志位被置为1,表示客户端希望建立连接。
  2. 服务器确认连接请求并发送响应:
    • 服务器收到客户端的连接请求后,会向客户端发送一个确认报文段(SYN+ACK)。在这个确认报文段中,服务器将确认号设置为客户端的初始序列号加 1(ACK=X+1),同时在 TCP 头部中将 SYN 和 ACK 标志位都置为 1,表示服务器已经收到了客户端的连接请求,并且愿意建立连接。
  3. 客户端发送确认响应:
    • 客户端收到服务器的确认响应后,会向服务器发送一个确认报文段(ACK),以确认服务器的确认响应。在这个确认报文段中,客户端将确认号设置为服务器的初始序列号加1(ACK=Y+1),同时在 TCP 头部中将 ACK 标志位置为 1,表示客户端已经收到了服务器的确认响应,连接建立成功。

其实 3 次握手也可以看作是 4 次握手。服务端的 SYN 和 ACK 如果分开发送就是 4 次握手啦,只不过实际肯定不会这样做,捎带应答机制确保了绝大多数情况下是 3 次握手而不是 4 次握手。

一次握手行不行?

显然不行。进行三次握手,通信双方都进行了一次可靠的发送数据和接收数据,从而验证了全双工通路是没有问题的!如果只进行一次握手,即客户端向服务器发送一次连接请求,客户端就认为与服务端建立好了连接。

  • 数据可靠性得不到保障,如果只进行一次握手,服务器并没有确认客户端的连接请求,那么在传输数据时可能会出现丢失或错误的情况,因为没有建立可靠的通信通道。即没有验证全双工通路是没有问题的。
  • 序列号无法同步: 在 TCP 的三次握手过程中,双方会交换各自的初始序列号,以便后续的数据传输中可以正确地进行序列号的同步和确认。如果只进行一次握手,那么无法确保双方的序列号是一致的,可能会导致后续的数据传输中出现混乱或错误。
  • 服务器资源消耗增加:一次握手会导致服务器在接收到客户端的连接请求后立即建立连接,即使服务器并不确定客户端是否真的需要建立连接。这样会增加服务器维护连接的成本,尤其是在面对大量短暂连接的情况下,服务器可能会因为连接资源耗尽而无法处理正常的连接请求。服务器会浪费资源在维护这些无用的连接上,从而降低了服务器的性能和可用性。
  • 容易受到恶意攻击: 由于一次握手会导致服务器立即建立连接,即使是来自未经身份验证的恶意客户端的连接请求,服务器也会进行响应。这样容易受到恶意攻击,如拒绝服务攻击(DoS)或洪泛攻击(SYN Flood)等,从而影响服务器的正常运行。

两次握手行不行?

显然也不行,存在和一次握手相同的问题。

  • 两次握手,服务器一定是将链接先建立好的哪一方,如果客户端不管服务端的 ACK 就和一次握手完全一样了!
  • 因此优先让服务器建立连接是不理智的行为,服务器本身是一对多的,如果服务端挂掉了,会影响更多的客户端,连接方面的成本不应由服务端来承受。
  • 相反,三次握手如果失败,连接失败的成本是在客户端,而不是在服务端!奇数次握手,可以确保一般情况下握手失败的成本主要在客户端!
  • 由上图可知,客户端收到服务端的 SYN + ACK 会建立,而服务端收到客户端的 ACK 才会建立连接。如果连接失败,即客户端 ACK 丢失等问题,那么连接失败的成本主要是在客户端!
  • 因此 3 次握手是验证通信全双工的最小次数。

img

我们将 TCP 网络套接字编程中使用的函数与三次握手的过程串起来理解:

  • 当客户端向服务端发送连接请求,即第一次握手。实际上就是调用 connect 函数向服务端发起连接请求,调用该函数的进程会阻塞在 connect 函数中,等待服务器对连接请求进行应答。

  • TCP 的三次握手过程中,listen函数的调用时机是在服务器调用bind函数绑定了地址和端口之后,以及调用accept函数之前。以下是listen函数在三次握手过程中的角色:

    1. 设置套接字为监听状态: 调用listen函数将套接字设置为监听状态,告诉操作系统该套接字将用于接受客户端的连接请求。在调用listen函数之前,服务器必须先调用bind函数将套接字绑定到一个特定的IP地址和端口上。
    2. 设置全连接队列的大小: listen函数还可以指定全连接队列的最大长度,即同时等待处理连接请求的最大数量。如果全连接队列已满,新的连接请求将被拒绝。这个参数通常称为 backlog
    3. 开始监听连接请求: 调用listen函数后,服务器就开始监听连接请求了。此时,服务器处于等待状态,等待客户端发送连接请求。

    listen 的第二个参数:backlog

    • 已经建立好的连接,会被放在队列之后,而 accept 函数只负责从这个连接队列中拿取一个连接,与特定的 fd 关联起来返回给上层用户。因此,连接建立成功和上层的 accept 函数没有关系,三次握手是在双方操作系统内部完成的。

    • 如果将全连接队列填满了,还有客户端来请求连接,服务端会处于 SYN_RECV 状态,而客户端会处于 ESTABLISHED 状态。此时客户端的连接会被放入半连接队列,半连接队列中的节点不像全链接队列中的节点那样会长时间维护,因此资源消耗并不大!

      • 当客户端向服务器端发送 SYN 报文请求建立 TCP 连接时,服务器端收到 SYN 请求后会将该连接请求放入半连接队列中,并发送一个 SYN+ACK 响应给客户端。此时连接处于半打开状态,即连接的一半已经建立(客户端到服务器端的连接),但另一半尚未建立(服务器端到客户端的连接)。一旦服务器接收到客户端的 ACK 确认报文,即完成了三次握手,连接将从半连接队列中移出,进入到全连接队列中,变为完全打开状态(Established)。

        半连接队列的作用包括:

        1. 管理连接建立过程: 半连接队列用于管理正在进行的连接建立过程,即处于半打开状态的连接请求。它可以帮助服务器跟踪和处理连接建立的过程,确保连接的正确建立和管理。
        2. 防止SYN洪泛攻击: 半连接队列可以防止 SYN 洪泛攻击。当服务器收到大量的 SYN 请求时,如果半连接队列已满,新的连接请求将被拒绝,从而有效地阻止了恶意攻击者通过发送大量的 SYN 请求消耗服务器资源。
        3. 优化连接处理效率: 半连接队列可以帮助服务器在连接建立过程中进行优化处理,提高连接建立的效率和速度。通过合理设置半连接队列的大小,可以避免过多的连接请求排队等待,减少连接建立的延迟。
      • 实验的现象可以看到服务器处于 SYNC_RECV 状态说明,服务器可能将客户端的 ACK 应答丢弃了,因为全连接队列已经满了嘛!无法将连接放入全链接队列。

    • listen 函数的第二个参数 backlog 为什么不能没有呢?这就是维护全连接队列的好处啦!

      • 提供连接的排队和管理机制: 全连接队列提供了一个排队机制,可以让服务器按顺序处理连接请求。当服务器忙于处理其他连接或资源有限时,新的连接请求会被放置在全连接队列中,等待服务器处理。这种排队机制可以确保连接请求被有序地处理,避免过载或资源竞争。
      • 控制服务器负载和资源使用: 维护全连接队列可以帮助服务器控制负载,防止过多的连接请求导致服务器资源耗尽或性能下降。通过限制全连接队列的大小,可以限制服务器同时处理的连接数量,避免过多的连接导致服务器负载过高。
      • 提高系统稳定性和可靠性: 维护全连接队列可以提高服务器的稳定性和可靠性。当服务器忙于处理其他任务或遇到突发的高负载时,全连接队列可以帮助服务器缓解压力,避免因过多的连接请求导致系统崩溃或服务不可用。
      • 保护系统免受拒绝服务攻击: 全连接队列可以帮助服务器抵御拒绝服务(DoS)攻击或洪泛攻击等恶意行为。通过限制全连接队列的大小或实施其他连接管理策略,可以有效地防止恶意攻击导致服务器资源耗尽或服务不可用。

好的,理论讲了这么多,可现实是不是这个样子呢?实践才是检验真理的唯一标准:

我们使用 TCP 网络套接字编程编写一个服务端,然后分别在相同的操作系统的不同版本运行,看看是什么效果哈!服务端是单进程版本,只能处理一个客户端的连接,我们将 backlog 设置为 0,也就是说全连接队列的长度为 1。这就意味着,当第三个客户端连接服务器的时候,全连接队列已经满了!

  • centos 7 环境下测试:

    我们看到,在客户端有 3 个已经建立好的连接,他们的状态都是 ESTABLISHED。在服务端有两个连接是 ESTABLISHED,还有一个连接是 SYN_RECV 状态。这跟我们上面讲的,全连接队列满了的时候,还有客户端请求连接的情况吻合!

img

  • centos 8 环境测试:

​ 我们看到当服务端的操作系统是 centos 8 时,情况就有所不同啦!当全连接队列满了的时候,还有客户端向服务端请求连接的时候,这个客户端会处于 SYN_SENT 的状态,并且在服务端查询不到 SYN_RECV 状态的进程!这说明在 centos 8 的环境下,当全连接队列满了的时候,服务端会拒绝客户端的连接!即第二次握手都不会发生!

img

如果面试官问起这个地方的细节,你就可以回答这会根据操作系统版本的区别而有所差距哈!因为不同的操作系统版本,内核的实现都是有差别的!对应的网络协议栈具体的实现也是有差别的!

四次挥手

img

下面是 TCP 四次挥手的过程:

  1. 第一步(FIN 和 ACK):
    • 客户端发送一个FIN(结束)报文给服务器,表示客户端不再发送数据,并请求关闭连接。
    • 服务器收到FIN后,发送一个 ACK(确认)报文给客户端,表示已经收到了客户端的关闭请求。
  2. 第二步(关闭发送):
    • 服务器确认客户端的关闭请求后,服务器通常会继续发送尚未传输完的数据,等待数据传输完成。
  3. 第三步(服务器关闭):
    • 当服务器的数据传输完成后,会发送一个 FIN 报文给客户端,表示服务器不再发送数据,请求关闭连接。
  4. 第四步(客户端确认):
    • 客户端收到服务器的FIN报文后,发送一个ACK报文给服务器,表示已经收到了服务器的关闭请求,并确认关闭连接。

你可能就会问了,这里能不能将服务端的 FIN 和 ACK 合并呢?只能说是有这种情况发生的,当客户端请求关闭连接的时候,服务端收到了客户端的 FIN,同时,服务端也没有数据需要向客户端发送了,并且服务端也要和客户端断开连接。只有在这种情况下,服务端的 FIN 和 ACK 才会合并在一起发送给客户端!也就是只有在很少数的情况下,四次握手才会变成 3 次握手。

挥手的次数少于 4 次行不行?

一方想要断开连接,表示这一方已经没有数据给对方发送了!但是,发送数据是通信双方都可以进行的!这一方不发送数据了,可是架不住对方还要发送数据!因此连接的断开就必须进行两次。一次 FIN 一次 ACK,一次 FIN 一次 ACK。

挥手的次数大于 4 次显然就没必要啦!4 次已经足够啦!


在上图中,假设左侧是客户端,那么就是客户端先请求断开连接,我们发现先断开连接的一方,在收到对方的 FIN 之后会进入一个叫做 TIME_WAIT 的状态。在上图中,客户端(先断开连接的一方)进入 TIME_WAIT 状态之后,并没有立刻释放掉连接,而是等待了一段时间,才会释放连接进入 CLOSED 状态!

问题来了,为什么要进行 TIME_WAIT?

  1. 确保最后的 ACK 被对方正常接收:在四次挥手的最后一步,客户端发送了 ACK,表示确认关闭连接,但这个 ACK 有可能在传输过程中丢失,导致服务器无法收到确认,从而认为连接没有被正常关闭。TIME_WAIT 状态的存在可以确保最后的 ACK 在网络中的所有副本都被正确处理,避免了这种问题。
  2. 避免新连接与旧连接混淆:在 TIME_WAIT 状态期间,客户端的 IP 地址和端口号仍然被保留,这样可以确保在该时间段内的任何延迟的数据包都不会被误认为是新的连接。这是因为,如果服务器收到了来自相同 IP 地址和端口号的新连接请求,而这个 IP 地址和端口号刚好是之前的连接的对方信息,那么服务器会错误地认为这是旧连接的重复数据包,导致出现数据混淆或安全问题。

img

学到这里,我们就能解决之前遗留的一个问题啦:为什么终止掉服务器的服务进程之后,无法绑定同一端口继续启动服务进程?

  • 如上图,服务端在与客户端通信的时候,直接 ctrl + c 终止掉服务进程,然后立刻使用命令,./server 9999 重启服务进程,我们发现会报错:bind error,address already in use。
  • 我们使用命令 netstat -nalp 查看网络状态,发现绑定 9999 端口号的进程还存在,并且处于 TIME_WAIT 状态。
  • 在这个例子中,服务器是主动断掉连接的一方,因此他会处于 TIME_WAIT 状态,一段时间之后才会将连接释放!既然这个连接都还没有释放如何能够绑定相同的端口号呢?

怎么解决这个问题呢?

  1. 可以等待 TIME_WAIT 状态过去了,进入 CLOSED 状态再绑定 9999 端口号,让服务器启动。显然这不是一个好办法,因为服务器是用来提供服务的,一秒钟无法提供服务,这其中的损失还是相当大的!

  2. 可不可以换端口号呢?作为一个已经上线的服务器,客户端访问你的这个服务器端口号都是固定的,换端口,怎么敢的!

  3. 使用函数解决,允许端口可以被重复绑定。

    • int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

    • sockfd:套接字描述符。

    • level:选项所在的协议层,对于 TCP 可以是 SOL_SOCKET

    • optname:选项名,比如 SO_REUSEADDR

    • optval:指向存有选项值的缓冲区的指针。

    • optlenoptval 的长度。

    • SO_REUSEADDR 是一个套接字选项,用于告诉内核允许重用处于 TIME_WAIT 状态的地址。

    • SO_REUSEPORT 是一个套接字选项,用于告诉内核允许重用处于 TIME_WAIT 状态的端口。

    • 所以,我们在 TCP 服务端的时候只要加上这两行代码就可以解决这个问题啦!

      int opt = 1;
      setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));
      

还有一个问题就是这个 TIME_WAIT 持续的时间是多少呢?

TIME_WAIT 状态的等待时间通常是两倍的最大报文生存时间(Maximum Segment Lifetime,MSL)。

centos 查看 MSL 时长:

cat /procsys/net/ipv4/tcp_fin_timeout

这个不是 MSL 时间哈: M S L = t c p _ f i n _ t i m e o u t ∗ 2 MSL = tcp\_fin\_timeout * 2 MSL=tcp_fin_timeout2

我的机器上 cat 出来的时间是 60 秒,也就是说 TIME_WAIT 等待的时间是 240 秒

流量控制

在前面将 TCP 协议段的时候,我们就提到了流量控制的概念!流量控制用于控制数据的发送速率,确保发送方不会向接收方发送过多的数据,从而避免接收方因处理不及时而导致数据丢失或缓冲区溢出的情况。

流量控制的具体过程是怎么样的呢?

  1. 接收方通告窗口大小: 接收方会在 TCP 报文中的窗口字段中通告自己的接收窗口大小,表示自己当前可接收的数据量。这就是我们之前讲的 16 位窗口大小嘛!
  2. 发送方根据接收窗口调整发送速率: 发送方根据接收方通告的窗口大小来调整自己的发送速率。如果接收窗口较小,表示接收方的缓冲区已经满了或者处理能力有限,发送方会减缓发送速率;如果接收窗口较大,表示接收方有足够的缓冲区空间或者处理能力,发送方可以增加发送速率。
  3. 动态调整窗口大小: TCP 协议中的滑动窗口机制(滑动窗口等会儿就讲哈)允许发送方和接收方动态调整窗口大小,以适应网络条件的变化。发送方可以根据接收方通告的窗口大小来调整自己的发送窗口大小,从而控制发送速率;而接收方则可以根据自己的处理能力和缓冲区空间来调整自己的接收窗口大小。

不知道你会不会有疑问?第一次发送数据的时候,怎么保证发送数据的大小是合理的呢?

我们不要认为 3 次握手的过程只是单纯的 3 次握手哈!在握手的过程中,双方是交换了报文的,在交换报文的过程中也会做一些其他的事情:

  1. 初始化序列号(Sequence Number): 在握手过程中,客户端和服务器会交换彼此的初始序列号,以便后续的数据传输中可以正确地进行序列号的同步和确认。
  2. 设置初始窗口大小(Initial Window Size): 在握手过程中,客户端和服务器会协商初始窗口大小,以确定初始数据传输的窗口大小。初始窗口大小影响了数据传输的速率和效率。
  3. 协商TCP选项: 在握手过程中,客户端和服务器还可以协商一些 TCP选项,如最大报文段长度(Maximum Segment Size,MSS,就是去掉 TCP 报头之后的数据长度)、窗口扩大因子(Window Scaling Factor, 16 位窗口大小可能并不满足需要,可以进行加倍,例如窗口扩大因子为 1,表示窗口字段值左移 1 位)等,以提高数据传输的性能和效率。

在之前我们提到过,某些报文的应答是可以缺失的,通过 32 位确认序号减少了应答的次数!可是如果应答的次数变少了,应答报文的丢失可能会造成发送方无法得知对方接收缓冲区的大小,导致通信出现问题!因此,窗口大小字段的发送不能完全依赖于应答报文!

  1. 窗口探测(Window Probing):当发送方的发送窗口变得空闲时,为了确定接收方是否仍然可以接收数据,发送方可能会发送一个小的探测段(通常是一个字节),这个探测段不包含实际的数据,而只是用来触发接收方发送窗口更新。如果接收方确实可以接收数据,它将发送一个窗口更新,告知发送方可以发送更多数据。窗口探测机制有助于减少发送方在发送窗口空闲时的等待时间,从而提高数据传输的效率。
  2. 窗口更新(Window Update):接收方在接收缓冲区有足够空间时,会发送窗口更新通知给发送方,告知发送方可以增加发送窗口的大小,以便发送更多数据。窗口更新通常包含在确认段中发送给发送方。通过窗口更新,发送方可以动态调整发送窗口的大小,以适应接收方的接收能力,从而实现更高效的数据传输。

这两个机制是 TCP 中用于优化流量控制和提高传输效率的重要手段。窗口探测确保发送方及时得知接收方的接收能力,而窗口更新则允许接收方动态地通知发送方其接收缓冲区的状态,从而使数据传输更加顺畅和高效。通信双方都有一定策略能通知对方,能一定程度上防止因网络问题导致窗口大小更新报文的丢包问题。

滑动窗口

TCP 的滑动窗口是一种流量控制机制,用于管理发送方和接收方之间的数据传输。滑动窗口允许发送方在不等待确认的情况下连续发送多个数据段,同时确保不会超出接收方的处理能力。

都说 TCP 是可靠传输,既然 TCP 有超时重传机制,那么说明已经发出去的暂时还没有收到应答的报文,需要被 TCP 暂时保存起来,同时这种歌性质的报文显然可能存在多个,那么问题就来了,这些个报文被保存到哪里的呢?

这就不得不提到发送缓冲区的逻辑结构啦:在逻辑上发送缓冲区被分成了四个区域

img

  1. 已发送已确认:表示这个部分的数据已经发送成功,并且已经收到了接收方的应答,因此这个区域是可以被覆盖的!
  2. 已发送未确认:这部分包含了两种数据哈,一种是已经发送但是还没有收到应答的数据;另一种是可以立即发送但是还没有发送的数据,这部分就是我们的滑动窗口啦!
  3. 待发送:因为接收方缓冲区大小,网络状况等原因,这个区域里面的数据是不能直接发送的!
  4. 空闲区域:发送缓冲区中尚未被使用的区域!

这么看来已发送但是暂时还没有收到应答的报文还是在发送缓冲区的!完全没有必要单独拷贝一份出来哦!

如何理解发送缓冲区的区域划分?

全域划分的本质其实就是通过指针或者下标来区分发送缓冲区的不同区域,因为发送缓冲区本质也是数组嘛,通过定义相关指针或者下标就能做到区域划分啦!

滑动窗口,滑动窗口,那肯定是要滑动的呀!理解了区域划分的本质,窗口的滑动,本质上就是指针的移动嘛!因为有了滑动窗口,发送方才可以一次向对方发送大量的 TCP 报文。在目前我们认为,滑动窗口的大小,不能超过接收方的接收缓冲区的大小,实际上滑动窗口的大小会受到网络状况的限制,等我们等会儿讲拥塞控制的时候,会补全滑动窗口大小的概念。

这么看来的话,滑动窗口越大,网络的吞吐率就越高呢!

滑动窗口中可发的数据为啥不可以一次给对方发送过去,而是要一段一段的发送呢?

这个问题与 IP 协议有关,现在不能完全解释原因!一次给对方发送过去,如果发生了丢包,超时重传的代价会增加!当然还要考虑到网络拥塞的问题,这个等会儿就讲。滑动窗口中一个 TCP 有效载荷的大小是在 TCP 三次握手的时候就协商好了的!即 MSS,最大报文段长度!

如果网络通信的过程中丢包了,我们该如何理解滑动窗口呢?

  1. 应答丢失:这种情况下, 部分 ACK 丢了并不要紧, 因为可以通过后续的 ACK 进行确认;

img

  1. 数据包丢失:

    • 如下图:当 [ 1 , 1000 ] [1, 1000] [1,1000] 报文段丢失之后, 发送端会一直收到 1001 这样的 ACK, 就像是在提醒发送端 “我想要的是 1001” 一样
    • 如果发送端主机连续三次收到了同样一个 “1001” 这样的应答, 就会将对应的数据 [ 1001 , 2000 ] [1001, 2000] [1001,2000] 重新发送 (快重传机制)。
    • 这个时候接收端收到了 1001 之后, 再次返回的ACK就是 7001 7001 7001 了(因为 [ 2001 , 7000 ] [2001, 7000] [2001,7000])接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中

img

滑动窗口怎么移动的?移动的时候大小会变化吗?怎么变化的?会为 0 吗?

  • 滑动窗口原则上是不能向左移动的,只能向右移动。
  • 滑动窗口在移动的时候,窗口大小会动态变化,取决于接收方接收缓冲区的大小,发送方的发送缓冲区大小,以及网络状况(拥塞控制部分详讲)。
  • 滑动窗口的 start 会根据接收方发送的确认序号进行设置,滑动窗口的 e n d = m i n ( 接收方的窗口大小,发送方的有效数据,拥塞窗口 ) end = min(接收方的窗口大小,发送方的有效数据,拥塞窗口) end=min(接收方的窗口大小,发送方的有效数据,拥塞窗口) 前两个你应该现在就能理解了,至于拥塞窗口是什么,会在拥塞控制的时候详解,到时候你在回来看看就能理解了!

滑动窗口会在发送缓冲区中越界嘛?

发送缓冲区本质是一个数组嘛,而滑动窗口一直是向右移动的,这么来看好像会越界哈!其实是不会的,因为 TCP 采用了类似环形队列那种的环形算法。保证滑动窗口不会在发送缓冲区中越界!

补充一个小小的知识点:

在进行三次握手的时候,通信双方会进行起始序号的协商:一般来说通信双方会都随机出来一个起始序号,通信的起始序号就是双方随机出来的较小序号。

起始序号 + 发送缓冲区的数组下标 = 序号 ( 32 位序号中的序号 ) 起始序号 + 发送缓冲区的数组下标 = 序号(32