当前位置: 首页 > article >正文

【网络】4万字细品TCP协议

文章目录

  • TCP协议
    • 关于UDP和TCP的优缺点
  • TCP协议格式
    • 4位首部长度
    • 可靠性的理解
    • 确认应答的工作方式
    • 序号和确认序号
      • 如何保证报文的顺序呢
      • 确认序号的特点:
      • 为什么有两套序号
      • 总结:
      • 序列号的原理
    • 16位窗口大小
      • TCP的缓冲区
        • 为什么TCP叫做传输控制协议
        • 缓冲区存在的意义
      • 窗口大小
    • 6个标记位
      • 为什么要有不同种类的标志位
      • 三次握手
        • 具体过程如下:
        • 需要注意的点:
        • 三次握手时的状态变化
        • 套接字接口函数和三次握手之间的关系:
        • 问:客户端和服务端以什么情况认为自己的连接建立好了?
        • 建立连接的本质
        • 系统内核如何管理连接
      • 为什么是三次握手
        • SYN洪水问题:
      • 三次握手的意义
      • 四次挥手
        • 具体过程如下:
        • 四次挥手时的状态变化
        • 套接字函数和四次挥手之间的关系
        • TIME_WAIT状态
          • 验证
        • bind error
        • CLOSE_WAIT状态
  • **超时重传机制**
    • 丢包问题
    • 超时重传的等待时间
    • 注意:
  • 连接管理机制
  • 滑动窗口
    • 滑动窗口和重传机制
    • 滑动窗口的移动
    • 如何实现滑动窗口
    • 丢包问题
  • 流量控制
  • 拥塞控制
  • 延迟应答
  • 捎带应答
  • 面向字节流
  • 粘包问题
  • TCP异常情况
    • 进程终止
    • 机器重启
    • 机器掉电/网线断开
  • TCP小结
  • 小实验:
  • CLOSE_WAIT状态
  • TIME_WAIT状态
    • bind error
        • setsockopt函数
  • 理解listen的第二个参数
    • 全连接队列
  • TCP与UDP对比
  • 用UDP实现可靠传输(经典面试题)

补充知识:端口号是如何被交付的?

1)系统中,进程要被OS管理起来,就是把进程的PCB通过双向链表(数据结构)管理起来, 网络里面有端口号,进程要和端口号进行绑定

2)根据报文的目的端口号来找到主机上对应的进程,其实就是根据一个整数找到一个进程,用的是哈希的策略,根据目的端口号做哈希,就可以找到目标进程的PCB,

3)PCB找到了,那么这个进程打开的网络文件就可以被找到了,那么我们就可以知道这个网络文件的缓冲区,收到报文后,把这个报文拷贝到这个进程的网络缓冲区


TCP协议

TCP全称为“传输控制协议(Transmission Control Protocol)”,TCP协议是当今互联网当中使用最为广泛的传输层协议,没有之一

TCP协议被广泛应用,其根本原因就是提供了详尽的可靠性保证,基于TCP的上层应用非常多,比如HTTP、HTTPS、FTP、SSH等,甚至MySQL底层使用的也是TCP


为什么网络会存在不可靠的情况

如果要进行通信的各个设备相隔千里,那么连接各个设备的“线”就会变得非常长,传输数据时出现错误的概率也会大大增高,此时要保证传输到对端的数据无误,就必须引入可靠性,而TCP就是在此背景下诞生的,TCP就是一种保证可靠性的协议,

网络中存在不可靠的根本原因就是:

  • 长距离数据传输所用时间和太长了,数据在长距离传输过程中就可能会出现各种各样的问题

完成一件事可以分为两个步骤:决策和执行, 类比到网络协议栈中就是

  • 传输层做具体决策,网络层和数据链路层做具体执行

关于UDP和TCP的优缺点

为什么还要有UDP协议呢?

TCP协议是一种可靠的传输协议,使用TCP协议能够在一定程度上保证数据传输时的可靠性,而UDP协议是一种不可靠的传输

这里的不可靠和可靠是两个中性词,它们描述的都是协议的特点

1)TCP协议是可靠的协议,也就意味着TCP协议需要做更多的工作来保证传输数据的可靠,并且引起不可靠的因素越多,保证可靠的成本(时间+空间)就越高

  • 比如数据在传输过程中出现了丢包,乱序,检验和失败等,这些都是不可靠的情况

TCP要想办法解决数据传输不可靠的问题,因此TCP使用起来一定比UDP复杂,并且维护成本特别高,

2)UDP协议是不可靠的协议,也就意味着UDP协议不需要考虑数据传输时可能出现的问题,因此UDP无论是使用还是维护都足够简单

3)需要注意的是,虽然TCP复杂,但TCP的效率不一定比UDP低**,TCP当中不仅有保证可靠性的机制,还有保证传输效率的各种机制**


如何选择呢?

UDP和TCP没有谁最好,只有谁最合适,网络通信时具体采用TCP还是UDP完全取决于上层的应用场景,如果应用场景严格要求数据在传输过程中的可靠性,那么就必须采用TCP协议,如果应用场景允许数据传输出现少量丢包,那么肯定优先选择UDP协议,因为UDP协议足够简单


TCP协议格式

image-20220913084054565

源/目的端口号

表示数据是从哪个进程来,到发送到对端主机上的哪个进程

问:TCP端口号有几个?

TCP 用一个 16 位端口号来标识一个端口,可允许有 65536 ( 2的16次方) 个不同的端口号,范围在 0 ~ 65535 之间


4位首部长度

4位首部长度表示的是报头的长度,4位首部长度描述的基本单位是4字节

4位首部长度的取值范围是0000 ~ 1111, 所以TCP报头最大长度为15*4 = 60字节,因为TCP标准报头的长度是20字节,所以报头中选项字段的长度最多是40字节

  • TCP报头标准长度是20个字节,也就是不带选项和数据的最小报头长度

因为TCP标准报头长度是20字节,而首部长描述的基本单位是4字节, 所以报头当中的4位首部长度的值就为20 ÷ 4 = 5,也就是0101


TCP是如何将报头与有效载荷进行分离

当TCP从底层获取到一个报文后,TCP此时并不知道报头的具体长度,但是TCP的标准报头长度是20个字节,并且这20字节当中涵盖了4位的首部长度

因此TCP是这样分离报头与有效载荷的:

  • 1)当TCP获取到一个报文后,首先读取报文的前20个字节,并从中提取出4位的首部长度,此时便获得了TCP报头的大小,记为size, 注意:size的值不可能小于20字节, 因为TCP的标准报头长度是20个字节, size>=20
  • 2)如果size的值大于20字节,则需要继续从报文当中读取size-20个字节的数据, 这部分数据其实就是TCP报头当中的选项字段
  • 3)取完TCP的基本报头和选项字段后,剩下的内容就是有效载荷了

TCP协议是如何做到向上交付的呢(将有效载荷交付给上层的哪一个协议)

首先我们要知道:我们之前写应用层的每一个网络进程时都必须绑定一个端口号

  • 服务端进程必须显示绑定一个端口号
  • 客户端进程由系统动态绑定一个端口号

TCP的报头中涵盖了目的端口号,因此TCP可以提取出报头中的目的端口号,找到对应的应用层进程,进而将有效载荷交给对应的应用层进程进行处理

说明: 内核中用哈希的方式维护了端口号与进程ID之间的映射关系,因此传输层可以通过端口号快速找到其对应的进程ID,进而找到对应的应用层进程


可靠性的理解

理解TCP的可靠性,其最核心的机制应为其基于序号的确认应答机制

什么是可靠?

在进行网络通信时,一方发出的数据后,它不能保证该数据能够成功被对端收到,因为数据在传输过程中可能会出现各种各样的错误,只有当收到对端主机发来的响应消息后,该主机才能保证上一次发送的数据被对端可靠的收到了,这就叫做真正的可靠

可靠性不仅要判定对方已经收到,也要判断对方没有收到, TCP的可靠性体现的是对历史数据的可靠性,对于当前最新数据不关心


image-20220913091245271

这条响应的消息就称为确认应答,确认应答的意义在于让确定刚刚发送的消息对端100%收到了.就这样一来一回,就能保证上一条消息一定被对端收到,就保证了通信的可靠性.


注意:应答只能保证上一条消息被对方100%收到,但是总会存在最新的一条消息不会被应答,所以TCP并不是100%可靠的

1)TCP要保证的是双方通信的可靠性,虽然此时主机A能够保证自己上一次发送的数据被主机B可靠的收到了

2)但主机B也需要保证自己发送给主机A的响应数据被主机A可靠的收到了,因此主机A在收到了主机B的响应消息后,还需要对该响应数据进行响应

3)但此时又需要保证主机A发送的响应数据的可靠性…,这样就陷入了一个死循环

只有当一端收到对方的响应消息后,才能保证自己上一次发送的数据被对端可靠的收到了,但双方通信时总会有最新的一条消息,因此无法百分之百保证可靠性


互联网通信当中是不存在百分之百的可靠性的,因为双方通信时总有最新的一条消息得不到响应,但实际没有必要保证所有消息的可靠性,我们只要保证双方通信时发送的每一个核心数据都有对应的响应就可以了,而对于一些无关紧要的数据(比如响应数据,我们没有必要保证它的可靠性,因为对端如果没有收到这个响应数据,会判定上一次发送的报文丢失了,此时对端可以将上一次发送的数据进行重传


确认应答的工作方式

上述的这种策略在TCP当中就叫做确认应答机制,通过应答机制来保证,上一条信息被对方百分之百的收到了.它体现的是历史消息的可靠性.但是这种方式是有局限性的,从而使得TCP通信不是完全可靠的,因为最新的消息是没有应答的

确认应答机制不是保证双方通信的全部消息的可靠性,而是只要一方收到了另一方的应答消息,就说明它上一次发送的数据被另一方可靠的收到了,如果没收到应答就认为上一条消息对方没收到.这样就能保证通信双方向的可靠性.

如何理解确认应答

  • 客户端向服务器发送数据,服务器会返还一个应答,这样就保证了客户端到服务器单向通信的可靠性.
  • 同理,客户端也可以给服务器应答,也可以保证服务器到客户端可靠性.

序号和确认序号

客服端向服务器一次发送一批数据,只要保证每一条数据都有应答,就说明数据被可靠的收到了, 但是注意:被收到了!不代表被按顺序收到了

  • 连续发送多个报文时,由于各个报文在进行网络传输时选择的路径可能是不一样的,因此这些报文到达对端主机的先后顺序也就可能和发送报文的顺序是不同的

乱序的数据是存在问题的,可靠性的含义除了保证被对方收到,也要保证按序到达


如何保证报文的顺序呢

TCP报头中的32位序号的作用之一实际就是用来保证报文的有序性的

接收端在收到一批报文时可以将报文按序排好再交付上层使用, 同时接受端需要给发送端的报文以确认,所以确认报文也必须带有序号 ,TCP报文中的32位确认序号字段就表明了该报文是对哪一个的报文的确认

比如:确认序号为13,表示的是序号13之前的所有报文都收到了,告知发送方下一次请从13号报文开始发送.

image-20220913093656047

确认序号的特点:

  • 确认序号填充的是被确认的报文的序号值+1,客户端收到确认报文后,可以通过确认序号来辨别是哪个报文的确认.
  • 确认序号可以表明确认序号值之前的所有报文都已收到,可以防止之前的确认报文丢失对方没收到.所以确认序号可以理解为对历史所有报文的应答
    • TCP报头当中的32位确认序号是告诉对端,我当前已经收到了哪些数据,你的数据下一次应该从哪里开始发
  • 确认序号可以认为是接收端期待将要收到的数据的编号,这个理解对之后重传机制的理解很有帮助

注意: 无论是发送的消息还是确认的消息,都必须是一个完整的TCP报文.而不可能是一个数字或者其他什么,图中每一个箭头都是一个完整的报文,只是为了突出重点,才简化其他内容.


为什么有两套序号

为什么一个报文中既有序号又有确认序号 (为什么要用两套序号机制)

如果通信双方只是一端发送数据,另一端接收数据,那么只用一套序号就可以了

  • 发送端在发送数据时,将该序号看作是32位序号
  • 接收端在对发送端发来的数据进行响应时,将该序号看作是32位确认序号

但实际TCP却没有这么做,根本原因就是因为TCP是全双工的,双方可能同时想给对方发送消息,一个报文既可以向对端发送数据又可以作为应答响应对方的报文.

  • 双方发出的报文当中,不仅需要填充32位序号来表明自己当前发送数据的序号
  • 还需要填充32位确认序号,对对方上一次发送的数据进行确认,告诉对方下一次应该从哪一字节序号开始进行发送

因此在进行TCP通信时,双方都需要有确认应答机制,此时一套序号就无法满足需求了,因此需要TCP报头当中出现了两套序号


总结:

  • 32位序号的作用是,保证数据的按序到达,同时这个序号也是作为对端发送报文时填充32位确认序号的根据,
  • 32位确认序号的作用是,告诉对端当前已经收到的数据有哪些,该序号之前的数据我已经收到了,对端下一次发送数据时应该从哪一序号开始进行发送,
  • 序号和确认序号是确认应答机制的数据化表示,确认应答机制就是由序号和确认序号来保证的,
  • 通过序号和确认序号可以判断某个报文是否丢失

序列号的原理

TCP将发送出去的每个字节数据都进行了编号,这个编号叫做序列号

我们要知道: TCP本身是有接收缓冲区和发送缓冲区的

  • 发送数据,调用send函数的时候,实际上是把应用层数据拷贝到TCP的发送缓冲区,然后接下来的事情不需要我们管, 如果双方进行通信,TCP把发送缓冲区的数据直接发送给对端的接收缓冲区里面就可以了
  • 当上层调用reda/recv函数读取的时候,并不是从网络里读取数据,而是在TCP的接收缓冲区读取对应的数据,读取到上层

image-20220914225342306

  • 所以我们有时候会见到:读数据的时候,如果没用数据会堵塞住,说明不是网络里面没数据,而是TCP缓冲区里面没有数据

TCP是面向字节流的,我们可以把TCP的缓冲区看成一个庞大的的数组,而上层拷贝数据进来是按字节存放的.

  • 此时上层应用拷贝到TCP发送缓冲区当中的每一个字节数据天然有了一个序号,这个序号就是字符数组的下标,只不过这个下标不是从0开始的,而是从1开始往后递增的

  • 双方在通信时,本质就是将自己发送缓冲区当中的数据拷贝到对方的接收缓冲区当中,

  • 发送方发送数据时报头当中所填的序号,实际就是发送的若干字节数据当中,首个字节数据在发送缓冲区当中对应的下标

    • 即: 我们以要发送的一批数据的最后一个字符的下标作为本次发送报文的序号
  • 接收方接收到数据进行响应时,响应报头当中的确认序号实际就是,接收缓冲区中接收到的最后一个有效数据的下一个位置所对应的下标,

  • 对端应答报文中的确认序号正好就是下一次发送数据的起始位置,故下一次直接从该确认序号位置起发送即可

例子:发送端要发送3000字节的数据,如果发送端每次发送1000字节

1)此时需要用三个TCP报文来发送这3000字节的数据

2)这三个TCP报文当中的32位序号填的就是发送数据中首个字节的序列号,因此分别填的是1,1001和2001

image-20220915092500100

3)接收端收到了这三个TCP报文后,就可以根据TCP报头当中的32位序列号对这三个报文进行顺序重排(该动作在传输层进行)

  • 在进行报文重排时,可以根据当前报文的32位序号与其有效载荷的字节数,进而确定下一个报文对应的序号

4)重排后将其放到TCP的接收缓冲区当中,此时接收端这里报文的顺序就和发送端发送报文的顺序是一样的了


在上述情况下,如果发生了报文丢失的情况怎么处理?

首先: 主机A发送了三个报文给主机B,其中每个报文的有效载荷都是1000字节,这三个报文的32位序号分别是1,1001,2001

假设这三个报文在网络传输过程中出现了丢包,最终只有序号为1和2001的报文被主机B收到了:

  • 当主机B在对报文进行顺序重排的时候,就会发现只收到了1-1000和2001-3000的字节数据
  • 此时主机B在对主机A进行响应时,其响应报头当中的32位确认序号填的就是1001
    • 告诉主机A下次向我发送数据时应该从序列号为1001的字节数据开始进行发送
    • 特别要注意: 此时主机B在给主机A响应时,其32位确认序号不能填3001,因为1001-2000是在3001之前的,如果直接给主机A响应3001,就说明序列号在3001之前的字节数据全都收到了,因此主机B只能给主机A响应1001
  • 当主机A收到该确认序号后就会判定序号为1001的报文丢包了,此时主机A就可以选择进行数据重传

16位窗口大小

TCP的缓冲区

TCP是具有缓冲区的 : 接收缓冲区和发送缓冲区, 可以理解为TCP自身动态开辟的两段内存

其中:

  • 接收缓冲区用来暂时保存接收到的数据 发送缓冲区用来暂时保存还未发送的数据

要注意的是:这两个缓冲区都是在TCP传输层内部实现的

image-20220915093619675

对应的收发函数send/recv, write/read本质上也是拷贝函数,是将数据在进程和缓冲区之间拷贝

  • TCP发送缓冲区当中的数据由上层应用应用层进行写入,当上层调用write/send这样的系统调用接口时,实际不是将数据直接发送到了网络当中,而是将数据从应用层拷贝到了TCP的发送缓冲区当中
  • TCP接收缓冲区当中的数据最终也是由应用层来读取的,当上层调用read/recv这样的系统调用接口时,实际也不是直接从网络当中读取数据,而是将数据从TCP的接收缓冲区拷贝到了应用层而已
  • 调用read和write进行文件读写时,并不是直接从磁盘读取数据,也不是直接将数据写入到磁盘上,而对文件缓冲区进行的读写操作

image-20220915094634314


当调用write/send函数把数据写入到TCP的发送缓冲区后,对应的上层就可以返回了

  • 至于发送缓冲区当中的数据具体什么时候发,怎么发等问题实际都是由TCP决定的

为什么TCP叫做传输控制协议

因为最终数据的发送和接收方式,如何发,何时发, 发多少以及传输数据时遇到的各种问题应该如何解决,都属于传输控制相关的策略, 都是由TCP自己决定的

  • 用户只需要将数据拷贝到TCP的发送缓冲区,以及从TCP的接收缓冲区当中读取数据即可

缓冲区存在的意义

1)缓冲区能够提高效率

  • 如果不存在缓冲区,直接把数据发送到对端主机上,就像调用printf直接将数据打印到显示器上,直接把数据写到磁盘文件中,等价于调用fwrite的时候把数据写到文件里面, 直接把数据从内存刷新到磁盘,效率比较慢
  • 数据在网络中传输时可能会出现某些错误,此时就可能要求发送端进行数据重传,因此TCP必须提供一个发送缓冲区来暂时保存发送出去的数据,以免需要进行数据重传,只有当发出去的数据被对端可靠的收到后,发送缓冲区中的这部分数据才可以被覆盖掉,
  • 接收端处理数据的速度是有限的,为了保证没来得及处理的数据不会被迫丢弃,因此TCP必须提供一个接收缓冲区来暂时保存未被处理的数据,因为数据传输是需要耗费资源的,我们不能随意丢弃正确的报文,此外,TCP的数据重排也是在接收缓冲区当中进行的,

2)缓冲区将应用层和底层作一定的解耦

  • 有缓冲区的话,应用层只需要把数据拷贝到发送缓冲区当中,应用层就可以直接返回了,至于数据什么时候发,怎么发, 应用层不关心, 这是TCP协议干的事情, 应用层只关心数据如何拷贝到TCP缓冲区,拷贝完成应用层就可以返回了
  • 只有OS中的TCP协议知道网络,乃至对端的状态明细,所以只有TCP协议能处理如何发,什么时候发,发多少,出错了怎么办等细节问题

小例子:寄快递

我们就相当于客户端,只需要填写一个快递单,剩下的事情我们就不要管了,至于快递如何发,什么时候发,发多少个人的快递,中间快递丢了怎么办,这些事情不需要我们来进行处理


这里的缓冲区实际可以看成是生产消费模型:

1)对于发送缓冲区来说,上层应用不断往发送缓冲区当中放入数据,下层网络层不断从发送缓冲区当中拿出数据准备进一步封装,此时上层应用扮演的就是生产者的角色,下层网络层扮演的就是消费者的角色,而发送缓冲区对应的就是“交易场所”,

2)对于接收缓冲区来说,上层应用不断从接收缓冲区当中拿出数据进行处理,下层网络层不断往接收缓冲区当中放入数据,此时上层应用扮演的就是消费者的角色,下层网络层扮演的就是生产者的角色,而接收缓冲区对应的就是“交易场所”

3)引入发送缓冲区和接收缓冲区相当于引入了两个生产者消费者模型,该生产者消费者模型将上层应用与底层通信细节进行了解耦,此外,生产者消费者模型的引入同时也支持了并发和忙闲不均


窗口大小

窗口大小可以理解为本机用来接收对方数据的“窗口”的大小.

为什么要有窗口大小这个概念?

1)当发送端要将数据发送给对端时,本质是把自己发送缓冲区当中的数据发送到对端的接收缓冲区当中,但缓冲区是有大小的,

2)如果对方大量发送数据,导致我们的接收缓冲区已所剩无几甚至满了.没有能力接受数据,那再发的数据就会被丢掉,

3)如果不对发送方提示自身的数据接受能力,任由对方发送的话,就会造成资源浪费.这种因大量发送,导致对方无法接收,进而导致数据丢弃资源浪费的现象,称为没有做流量控制而导致对方丢包的问题.


解决办法就是: TCP报文中的16位窗口大小字段就是用来表示自身接受缓冲区的剩余空间

具体做法就是**: 接收端在应答报文中填充自己的接收容量,相当于通告对方自身的接受能力**,对端就可以动态调整自己的发送策略,这就是流量控制.

需要注意的是:这个16位窗口大小当中填的是自身接收缓冲区中剩余空间的大小,也就是当前主机接收数据的能力

为什么填的是自己缓冲区的剩余空间大小?

因为我要把我自己的接收能力告诉给对方,让对方知道我的情况,对方就可以根据我的接收能力来动态调整发送数据的多少问题

流量控制是为了让数据量发送的速度变得合理,而不是一味的快或者一味的慢


具体步骤:

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

  • 当窗口大小的字段越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度,否则:说明接收端接收数据的能力越弱,此时发送端可以减小发送数据的速度
  • 当窗口大小的字段为0的时候,说明接收端的接收缓冲区已经满了,此时发送端不能再继续发送数据

重新回忆之前的现象:

  • 在编写TCP套接字时,我们调用read/recv函数从套接字当中读取数据时,可能会因为套接字当中没有数据而被阻塞住,本质是因为TCP的接收缓冲区当中没有数据了,我们实际是阻塞在接收缓冲区当中了,
  • 而我们调用write/send函数往套接字中写入数据时,可能会因为套接字已经写满而被阻塞住,本质是因为TCP的发送缓冲区已经被写满了,我们实际是阻塞在发送缓冲区当中了,
  • 在生产者消费者模型当中,如果生产者生产数据时被阻塞,或消费者消费数据时被阻塞,那么一定是因为某些条件不就绪而被阻塞,

6个标记位

为什么要有不同种类的标志位

首先我们要知道有这么一个现象:一个大型服务端程序,可能在任意时刻都有可能存在成百上千个客户端发来的报文,服务端就必须首先要区分各个报文的类别

  • TCP报文的种类多种多样,除了正常通信时发送的普通报文,还有建立连接时发送的请求建立连接的报文,以及断开连接时发送的断开连接的报文等等,
  • 收到不同种类的报文时完美需要对应执行动作,比如正常通信的报文需要放到接收缓冲区当中等待上层应用进行读取,而建立和断开连接的报文本质不是交给用户处理的,而是需要让操作系统在TCP层执行对应的握手和挥手动作,
  • 也就是说不同种类的报文对应的是不同的处理逻辑,所以我们要能够区分报文的种类,而TCP就是使用报头当中的六个标志字段来进行区分的,这六个标志位都只占用一个比特位,为0表示假,为1表示真,

TCP报头中的标志位就是用于表明报头的类别的,比如SYN就是请求连接的报文,FIN就是断开链接的报文.表明自身报文的类别只需将标志位的比特位置1即可.

经常使用的6个标记位:

image-20220915195220737

初步认识每一个标志位的用途:

标志位对应含义解释
ACKacknowledge确认表明自身是个确认报文,并填充确认序号就可以,为了捎带应答,大部分报文的ACK都会设置
SYNsynchronize同步表明自身是个请求连接的报文,此时就会进行三次握手
RSTreset重置表明自身是个重置连接的报文,此时通信双方会重新建立连接
PSHpush提交若接收端读取过慢导致窗口大小吃紧,发送方就可在报文中携带PSH,催促对端尽快将缓冲区内容提交至应用层
URGurgent紧急表示报文内紧急指针有效,紧急指针指向数据中某一处,提示对端优先读取该处数据,该功能不常见
FINfinish结束表明自身是个结束连接的报文,提示对端开始关闭连接

接下来我们对每一个标记位都做简单的描述:

关于ACK标记位

  • 报文当中的ACK被设置为1,表明该报文可以对收到的报文进行确认,
  • 一般除了第一个请求报文没有设置ACK以外,其余报文基本都会设置ACK,因为发送出去的数据本身就对对方发送过来的数据具有一定的确认能力,因此双方在进行数据通信时,可以顺便对对方上一次发送的数据进行响应

几乎在所有的TCP通信的过程中,ACK都会被设置


关于SYN标记位:

  • 报文当中的SYN被设置为1,表明该报文是一个连接建立的请求报文,
  • 只有在连接建立阶段,SYN才被设置,正常通信时SYN不会被设置,

关于RST标记位:

  • 报文当中的RST被设置为1,表示需要让对方重新建立连接,
  • 在通信双方在连接未建立好的情况下,一方向另一方发数据,此时另一方发送的响应报文当中的RST标志位就会被置1,表示要求对方重新建立连接,
  • 在双方建立好连接进行正常通信时,如果通信中途发现之前建立好的连接出现了异常也会要求重新建立连接

场景:TCP三次挥手

我们并不担心第1,第2次的报文丢了, 我们担心第3次丢了,原因如下:

  • 因为第1和第2次有应答,第3次没有应答, 我们并不知道对方是否收到了该报文,有被丢失的风险

如果正巧第三次握手的报文丢包了,此时服务端并没有建立连接,而客户端认为自己可以向对方发消息了(只要最要一个ACK发送过去,客户端就认为连接已经建立好了), 此时如果客户端已经将数据发来了,此时服务端就认为链接都没建立好,你怎么能给我发数据呢? 服务端就给客户端响应一个标记位携带RST的报文, 客户端一旦识别到了这个报文, 就意识到这个链接建立失败了, 以重置连接


关于FIN标记位:

  • 报文当中的FIN被设置为1,表明该报文是一个连接断开的请求报文,
  • 只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置,

关于PSH标记位:

  • 报文当中的PSH被设置为1,是在告诉对方尽快将你的接收缓冲区当中的数据交付给上层

  • 当报文当中的PSH被设置为1时,实际就是在告知对方操作系统,尽快将接收缓冲区当中的数据交付给上层, 这可能会导致我们使用read/recv函数读取数据时,期望读取的字节数和实际读取的字节数是不一定吻合的


关于URG标记位

TCP按序到达本身也是我们的目的,此时对端上层在从接收缓冲区读取数据时也必须是按顺序读取的,但是有时候发送端可能发送了一些“紧急数据”,这些数据需要让对方上层提取进行读取,此时应该怎么办呢?

此时我们就可以对URG标记位进行设置:URG标记位一般配合着紧急指针使用

  • 当URG标志位被设置为1时,需要通过TCP报头当中的16位紧急指针来找到紧急数据,否则一般情况下不需要关注TCP报头当中的16位紧急指针,
  • 16位紧急指针代表的就是紧急数据在报文中的偏移量,
  • 因为紧急指针只有一个,它只能标识数据段中的一个位置,因此tcp的紧急指针只能传输一个字节,

三次握手

TCP协议是面向连接的,通信双方连接的时候一方需要connect建立链接,另一方需要accept获取链接

建立连接的这个过程我们称之为三次握手,三次握手实际上就是三次交换报文.


以服务器和客户端为例,当客户端想要与服务器进行通信时,需要先与服务器建立连接,此时客户端作为主动方会先向服务器发送连接建立请求,然后双方TCP在底层会自动进行三次握手

具体过程如下:

image-20220915212341250

1)第一次握手:客户端向服务器发送的报文当中的SYN位被设置为1,表示请求与服务器建立连接

  • 客户端向服务端发送携带SYN标志位的报文,表示请求建立连接.

2)第二次握手:服务器收到客户端发来的连接请求报文后,紧接着向客户端发起连接建立请求并对客户端发来的连接请求进行响应,此时服务器向客户端发送的报文当中的SYN位和ACK位均被设置为1

  • 服务端收到请求后,返回携带SYN和ACK标志位的报文,表示请求建立连接并作客户端消息的应答
  • 服务端收到SYN标志位的报文,也要返回一个带有SYN标志位的报文,以告知客户端可以来进行连接

3)第三次握手:客户端收到服务器发来的报文后,得知服务器收到了自己发送的连接建立请求,并请求和自己建立连接,最后客户端再向服务器发来的报文进行响应

  • 客户端收到服务端返回建立连接的报文后,发送携带ACK标志位的报文作应答

需要注意的点:

1)客户端向服务器发起的连接建立请求,是请求建立从客户端到服务器方向的通信连接,而TCP是全双工通信,因此服务器在收到客户端发来的连接建立请求后**,服务器也需要向客户端发起连接建立请求,请求建立从服务器到客户端方法的通信连接**

2)三次握手是可能失败的,三次握手只是用较大概率建立连接成功的一种方式

  • 通信双方在进行三次握手时,其中前两次握手能够保证被对方收到,因为前两次握手都有对应的下一次握手对其进行响应,但第三次握手是没有对应的响应报文的,
  • 如果第三次握手时客户端发送的ACK报文丢失了,那么连接建立就会失败
image-20220915213518786

虽然客户端发起第三次握手后就完成了三次握手,但服务器却没有收到客户端发来的第三次握手,此时服务器端就不会建立对应的连接,所以建立连接时不管采用几次握手,最后一次握手的可靠性都是不能保证的


三次握手时的状态变化

image-20220915214223970

1)最开始双方都处于CLOSED状态

2)服务器为了能够接收客户端发来的连接请求,需要由CLOSED状态变为LISTEN状态

3)客户端就向服务器发起三次握手了,当客户端发起第一次握手后,状态变为SYN_SENT状态

4)处于LISTEN状态的服务器收到客户端的连接请求后,将该请求连接放入内核等待队列中,并向客户端发起第二次握手,此时服务器的状态变为SYN_RCVD

5)当客户端收到服务器发来的第二次握手后,紧接着向服务器发送最后一次握手,此时客户端的连接已经建立,状态变为ESTABLISHED

6)服务器收到客户端发来的最后一次握手后,连接也建立成功,此时服务器的状态也变成ESTABLISHED

至此三次握手结束,通信双方可以开始进行数据交互了


套接字接口函数和三次握手之间的关系:

顺序解释状态变化
0最初双方都处于CLOSED状态CLOSED
1服务端调用listen函数,进入监听状态等待客户端连接CLOSED -> LISTEN
2客户端调用connect函数,发送同步请求报文CLOSED -> SYN_SENT
3服务端监听到同步请求后,就创建连接,并向对端发送同步响应报文LISTEN -> SYN_REVD
4客户端收到服务器同步响应后,就立即认为连接成功SYN_SENT -> ESTABLISHED
5服务端收到客户端第三次握手应答报文后,才认为连接成功SYN_REVD -> ESTABLISHED
  • 在客户端发起连接建立请求之前,服务器需要先进入LISTEN状态,此时就需要服务器调用对应listen函数,
  • 当服务器进入LISTEN状态后,客户端就可以向服务器发起三次握手了,此时客户端对应调用的就是connect函数,
  • 需要注意的是,connect函数不参与底层的三次握手,connect函数的作用只是发起三次握手,当connect函数返回时,要么是底层已经成功完成了三次握手连接建立成功,要么是底层三次握手失败,
  • 如果服务器端与客户端成功完成了三次握手,此时在服务器端就会建立一个连接,但这个连接在内核的等待队列当中,服务器端需要通过调用accept函数将这个建立好的连接获取上来,
  • 当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了

如下图所示,listen后就已经能够建立连接了,accept只是将内核中的连接提取到应用层.

image-20220920202317761


问:客户端和服务端以什么情况认为自己的连接建立好了?

客户端发送第三次握手报文后,就认为连接已经建立好了.服务端收到第三次握手报文后,才认为连接已经建立好


建立连接的本质

双方在三次握手成功,建立好连接之后,一定要在双方的OS内,为维护连接创建对应的数据结构!

系统内核如何管理连接

服务器中存在大量的连接,服务器一定要管理这些连接,此时服务端是如何做管理的呢?

六字真言: 先描述再组织

  • 在C语言上,就是将连接用结构体描述,然后使用合适的数据结构维护这些连接结构体
  • 所以双方进行维护连接是需要时间和空间上的成本的

为什么是三次握手

因为我们要确定两个事情: 对方是否就绪,网络连接是否正常

三次握手可以保证:

  • 确认双方主机是否健康
  • 验证全双工 (三次握手是能看到双方都有收发的最小的次数)

只要能验证全双工,也就能一并校验对方的主机是健康的,主机是没有问题的,甚至可以验证主机的通信信道,不管是发出还是接收都是没有问题的

确认主机的健康,更多的是确认:1.主机状态好着 没,OS有没有挂掉 2.双方的IO状况


理由:

对于客户端来说: 第一次握手:证明客户端具有发送数据的能力, 第二次握手:证明客户端具有收数据的能力,前两次握手就证明了客户端可以收和发数据

对于服务器端: 当他收到了一个携带SYN报文(第一次握手),证明自己可以收数据, 当他收到ACK(第三次握手)证明自己可以发数据

双方以三次握手的方式,这样双方就能够以最小的成本次数去验证全双工


问: 一次握手行不行? 即客户端只需要发送SYN报文

绝对不可以!

1)因为此时客户端没法证明自己具有收和发的能力,客户端把数据发出去就什么都不管了, 此时一次握手有没有成功都是不确定的(因为收到了服务端的响应才证明第一次握手是成功的),

2)服务端尽管收到了这个报文,也就只是验证了自己具有收的能力,发的能力没有验证

两次握手行不行?

1)此时可以验证客户端的收和发数据的能力

2)此时服务器只能收到一条客户端发来的请求连接的报文消息,来证明自己具有接收数据的能力,但是无法验证服务端发送的消息是否被服务器收到 ,所以就没办法验证自己的全双工当中发数据的能力


SYN洪水问题:

**对于一次握手:**如果客户端发送一条消息,连接就建立好了,那么存在以下的问题

1)对于服务器端:一旦建立好链接,OS需要为了维护链接创建对应的相关的数据结构

2)维护链接是有成本的,此时如果客户端发送海量的携带请求的SYN报文的时候,会让服务器的资源很快就被消耗完了

  • 客户端发送大量的SYN请求的时候,此时服务器就立马把链接结构建立好了,但是客户端又不和我通信, 此时就浪费服务器的资源 服务器受到攻击的成本太低了

3)此时客户端只需要发送ACK报文即可构成连接,是没有太多的成本的


**对于两次握手:**如果客户端发送请求,服务器响应,此时链接就建立好了,那么存在以下的问题

1)客户端收到响应报文就认为链接建立好了,而服务器发送响应报文的时候,认为自己链接建立好了,

2)但是可能客户端压根就没有收到服务器的响应报文,或者响应报文丢失了,但是服务端此时也认为链接建立好了,

3)服务器照样会维护连接创建对应的数据结构,并且认为当前是健康连接 ,服务端维护连接是有成本的,而客户端发送ACK报文是没有成本的


如果是三次握手呢?

首先要明确的是: 三次握手其实也不一定能预防这个问题

1)如果是三次握手,客户端是不可能通过只发送大量的SYN来达到对服务端进行海量攻击的情况,因为如果第二次握手的时候,客户端不响应(没有第三次握手),那么服务端就不会为它维护连接

2)如果客户端响应了,那么客户端和服务器是等量成本消耗,此时双方都要维护连接所创建对应的数据结构,并且此时是正常链接

3)此时在应用层就能拿到当前的链接,做各种安全策略

实际上:客户端发送SYN的时候(第一次握手), 服务器也会维护链接,此时这种行为称为半连接,此时的服务器成本是非常低的


总结:

1)一次握手和两次握手容易被别人发送海量的SYN报文而消耗完服务器上的连接资源,客户端发送大量SYN的请求我们称之为:SYN洪水

2)一次握手和两次握手并不能很好的预防SYN洪水攻击

3)建立连接成功形成共识,是大家都完成三次握手

4)最后一个报文发出是有时间的差别的,所以客户端和服务端认为连接建立好,双方形成共识是有时间差的


三次握手的意义

  • 三次握手可以互相确认对方的状态、网络的状态是否良好.
  • 三次握手是验证全双工即验证通信双方是否具有收发能力的最小次数
    • 客户端收到第二次握手时就验证自身具有收发能力,服务端接收到第三次握手时就验证自身的收发能力
    • 少于三次无法验证全双工,大于三次浪费资源
  • 少于三次握手就建立连接,双端连接资源消耗不对等,容易造成SYN洪水攻击,如果三次握手成功才建立连接的话,服务器第三次握手前只占用少量资源,三次握手后对端和服务器承受等量资源,对端还会暴露自身IP

相较之下,三次握手更加安全省事


为什么要3次握手?

通信视角:双方三次握手建立成功不是目的,连接建立的时候去验证双方的主机和双方的路上的各种设备是否健康,这才是3次握手的目的, 3次握手是唯一一个以最少次数去验证双方通信信道的一个机制,以最少的次数验证了双方都有一次IO的过程,证明了对方主机是健康的,对方主机在系统层面是可以发出SYN,对方向我发送消息时,信道是没有通畅的, 同样的,对于我来说,能给别人应答,给别人SYN+ACK,别人就可以确认 我的主机是正常的,给我发消息的信道是通畅的 ,验证主机是OK的, 验证中间通信网络是OK的

4次可以嘛? 可以,但是3次已经可以了,为什么要4次

安全视角: 如果只有一次/两次握手, 客户端只要发送SYN就可以建立好连接,此时服务器很容易受到攻击


四次挥手

当双方TCP通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手

  • 一般而言,建立连接的是客户端,但断开链接通信的双方随时都有可能.

断开连接的本质:双方达成连接都应该断开的共识,本质就是一个通知对方的机制

  • 通俗理解就是:我只要告诉你,我要和你断开连接了,你只要告诉我,你要和我断开连接了,双方知道并同意即可

4次挥手本质是为了断开连接,断开连接的本质是双方的主机上的OS回收资源


具体过程如下:

image-20220915221703682

当客户端与服务器通信结束后,需要与服务器断开连接,此时就需要进行四次挥手

1)第一次挥手:客户端向服务器发送的报文当中的FIN位被设置为1,表示客户端请求与服务器断开连接,

2)第二次挥手:服务器收到客户端发来的断开连接请求后对其进行响应,

3)第三次挥手:服务器收到客户端断开连接的请求,且已经没有数据需要发送给客户端的时候,服务器就会向客户端发起断开连接请求

4)第四次挥手:客户端收到服务器发来的断开连接请求后对其进行响应

四次挥手结束后双方的连接才算真正断开,以4次挥手的方式,达成连接关闭的一致认识,4次挥手是协商断开连接的最少次数


四次挥手时的状态变化

image-20220915222214611

四次挥手时的状态变化如下:

  • 在挥手前客户端和服务器都处于连接建立后的ESTABLISHED状态,
  • 客户端为了与服务器断开连接主动向服务器发起连接断开请求,此时客户端的状态变为FIN_WAIT_1,
  • 服务器收到客户端发来的连接断开请求后对其进行响应,此时服务器的状态变为CLOSE_WAIT,
  • 当服务器没有数据需要发送给客户端的时,服务器会向客户端发起断开连接请求,等待最后一个ACK到来,此时服务器的状态变为LASE_ACK,
  • 客户端收到服务器发来的第三次挥手后,会向服务器发送最后一个响应报文,此时客户端进入TIME_WAIT状态,
  • 当服务器收到客户端发来的最后一个响应报文时,服务器会彻底关闭连接,变为CLOSED状态,
  • 而客户端则会等待一个2MSL(Maximum Segment Lifetime,报文最大生存时间)才会进入CLOSED状态,

套接字函数和四次挥手之间的关系

顺序解释状态变化
0最初双方都处于连接建立(ESTABLISHED状态)ESTABLISHED
1客户端主动调用close函数,向服务器发送结束报文ESTABLISHED -> FIN_WAIT_1
2服务器收到结束报文,向客户端返回应答报文ESTABLISHED -> CLOSE_WAIT
3客户端收到应答报文FIN_WAIT_1 -> FIN_WAIT_2
4服务端调用close函数,向客户端发送结束报文CLOSE_WAIT -> LAST_ACK
5客户端收到结束报文FIN_WAIT_2 -> TIME_WAIT
6客户端向服务端返回应答报文TIME_WAIT -> CLOSED
7服务端收到应答报文LAST_ACK -> CLOSED
  • 客户端发起断开连接请求,对应就是客户端主动调用close函数,
  • 服务器发起断开连接请求,对应就是服务器主动调用close函数,
  • 一个close对应的就是两次挥手,双方都要调用close,因此就是四次挥手

**为什么是4次挥手? **

本质也是以最小成本的方式让双方建立一个断开连接的共识


四次挥手中 挥手丢包时的解决方法

  • 第一次挥手丢包:客户端收不到服务器的应答,进而进行超时重传
  • 第二次挥手丢包:客户端收不到服务器的应答,进而进行超时重传,
  • 第三次挥手丢包:服务器收不到客户端的应答,进而进行超时重传
  • 第四次挥手丢包:服务器收不到客户端的应答,进而进行超时重传

如果客户端在发出第四次挥手后立即进入CLOSED状态,此时服务器虽然进行了超时重传,但已经没有客户端的响应了,因为客户端已经将连接关闭了

image-20220920194623422

服务器在经过若干次超时重发后得不到响应,最终也一定会将对应的连接关闭,但在服务器不断进行超时重传期间还需要维护这条废弃的连接,这样对服务器是不好的

为了避免这种情况,因此客户端在四次挥手后没有立即进入CLOSED状态,而是进入到了TIME_WAIT状态进行等待,此时要是第四次挥手的报文丢包了,客户端也能收到服务器重发的报文然后进行响应


TIME_WAIT状态

主动断开连接的一方,即便四次挥手完成也不能立马释放自己的连接,而必须要维持一段时间,这个时候所处的状态就是TIME_WAIT状态

TIME_WAIT的重要性:

1)较大概率保证最后一个ACK被对端收到

  • 客户端在进行四次挥手后进入TIME_WAIT状态,如果第四次挥手的报文丢包了,客户端在一段时间内仍然能够接收服务器重发的FIN报文并对其进行响应,能够较大概率保证最后一个ACK被服务器收到

2)保证双方通信信道上的数据在网络中尽可能的消散

  • 客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方,因此四次挥手后进入TIME_WAIT状态,可以保证双方通信信道上的数据在网络中尽可能的消散

实际第四次挥手丢包后,可能双方网络状态出现了问题,尽管客户端还没有关闭连接,也收不到服务器重发的连接断开请求,此时客户端TIME_WAIT等若干时间最终会关闭连接,而服务器经过多次超时重传后也会关闭连接,这种情况虽然也让服务器维持了闲置的连接,但毕竟是少数,引入TIME_WAIT状态就是争取让主动发起四次挥手的客户端维护这个成本, 因此TCP并不能完全保证建立连接和断开连接的可靠性,TCP保证的是建立连接之后,以及断开连接之前双方通信数据的可靠性,


验证

最初成功建立连接:

image-20220920202823939

服务端主动断开连接,进入TIME_WAIT状态

image-20220920202840993


关于TIME_WAIT的等待时长

  • 等待时间太长会让等待方维持一个较长的时间的TIME_WAIT状态,在这个时间内等待方也需要花费成本来维护这个连接,这也是一种浪费资源的现象
  • 等待时间太短可能没有达到我们最初目的,没有保证ACK被对方较大概率收到,也没有保证数据在网络中消散,此时TIME_WAIT的意义也就没有了

TCP协议规定,主动断开连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(Maximum Segment Lifetime,报文最大生存时间)的时间才能进入CLOSED状态

MSL

MSL在RFC1122中规定为两分钟,比如在我们的服务器Centos7 默认配置的值是60s

image-20220920195134323

为什么TIME_WAIT的等待时间要设置成两个MSL

  1. MSL是TCP报文的最大生存时间,因此TIME_WAIT状态持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失, 即: 尽量保证历史发送的网络数据在网络中消散

2)同时也是在理论上保证最后一个报文可靠到达的时间


bind error

出现这种情况一定是因为你是断开连接的主动一方, 主动断开连接的一方进入TIME_WAIT,实际上在它看来,已经把四次挥手做完了,无非就是等网络数据消散和最后一个ACK被对方收到, 比如说是服务器, 此时这个连接并没有被释放,连接还在->说明这个端口还在被占用着,虽然没有在用它了,此时再去绑定,就是一个端口被另一个进程再绑定, 而端口号只能够被一个进程绑定,所以就会出现bind error情况

应用层连接已经结束,传输层还保留该连接,因为无法保证对端是否收到第四次挥手. 所以进程退出后,无法重新绑定该端口

如何解决?

在创建套接字代码处使用接口setsockopt,可以解决上述情况.让服务立马重新启动

static int Socket()
{
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    {
        cerr << "socket error" << endl;
        exit(2);
    }
    int opt = 1;
    setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
    return sock;
}

此时当我们终止进程的时候,可以重新打开对于的端口进行使用,TIME_WAIT状态仍然存在,但是不影响我们重新启动

image-20220920203057427

如果无法立即重启服务,可能会造成经济损失.


CLOSE_WAIT状态

对端主动调用close退出连接,但自己不退出,则后两次挥手就不会发生,自身就一直保持CLOSE_WAIT状态.

  • 双方在进行四次挥手时,如果只有客户端调用了close函数,而服务器不调用close函数,此时服务器就会进入CLOSE_WAIT状态,而客户端则会进入到FIN_WAIT_2状态,
  • 但只有完成四次挥手后连接才算真正断开,此时双方才会释放对应的连接资源,如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少,
  • 因此如果不及时关闭不用的文件描述符,除了会造成文件描述符泄漏以外,可能也会导致连接资源没有完全释放,这其实也是一种内存泄漏的问题,
  • 因此在编写网络套接字代码时,如果发现服务器端存在大量处于CLOSE_WAIT状态的连接,此时就可以检查一下是不是服务器没有及时调用close函数关闭对应的文件描述符

启示:1.一个文件描述符被用完,一定要记得关闭, 2.文件描述符是有限的,小心文件描述符泄露的问题


超时重传机制

概念

双方在进行网络通信时,发送方发出去的数据在一个特定的事件间隔内如果得不到对方的应答,此时发送方就会进行数据重发,这就是TCP的超时重传机制


注意:TCP保证双方通信的可靠性,一部分是通过TCP的协议报头体现出来的,还有一部分是通过实现TCP的代码逻辑体现出来的

  • 比如超时重传机制实际就是发送方在发送数据后开启了一个定时器,若是在这个时间内没有收到刚才发送数据的确认应答报文,则会对该报文进行重传,这就是通过TCP的代码逻辑实现的,而在TCP报头当中是体现不出来的

丢包问题

问:当我们发送完对应的报文,如果发送方没有收到接收方的ACK响应, 那么接收方一定没有收到对应的报文吗?

不是的!这里的丢包有两种情况:

  • 一种情况是:报文真的丢了,接收方并没有收到

    • 此时发送端在一定时间内收不到对应的响应报文,就会进行超时重传
  • 另一种情况是:接收方发送的应答丢了,实际上接收方已经收到了发送方的报文

    • 此时发送端也会因为收不到对应的响应报文,而进行超时重传

image-20220920195829674


当出现丢包时,发送方是无法辨别是发送的数据报文丢失了,还是对方发来的响应报文丢失了,因为这两种情况下发送方都收不到对方发来的响应报文,此时发送方就只能进行超时重传

  • 不管是数据丢了还是ACK响应丢了,都重传就行,

如果数据真的丢了,那没问题,如果是确认应答丢了,对方已经收到了数据,此时超时重传又给对方发一份相同的数据, 当对方收到了重复的数据,本身也是不可靠的的表现,所以怎么保证收到的数据不是重复的呢?

,因为每个报文都有序号, 既然是重传的报文,这个报文的序号没有变化,接收方可以根据报头当中的32位序号来判断曾经是否收到过这个报文,从而达到报文去重的目的,所以不担心报文被对方重复收到 所以去重也是可靠性的一种保证!


超时重传的等待时间

超时重传的时间不能设置的太长也不能设置的太短

  • 超时重传的时间设置的太长,会导致丢包后对方长时间收不到对应的数据,进而影响整体重传的效率
  • 超时重传的时间设置的太短,会导致对方收到大量的重复报文,可能对方发送的响应报文还在网络中传输而并没有丢包,但此时发送方就开始进行数据重传了,并且发送大量重复报文会也是对网络资源的浪费

所以我们要找到一个最小的时间,保证“确认应答一定能在这个时间内返回”,但这个时间的长短,是与网络环境有关的,网好的时候重传的时间可以设置的短一点,网卡的时候重传的时间可以设置的长一点,也就是说超时重传设置的等待时间一定是上下浮动的,因此这个时间不可能是固定的某个值

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

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

注意:

1.网络状态是变化的,网络通信的效率也是变化的,发送数据得到的ACK响应的时间也是浮动的, 所以结论是:超时重传的时间一定是浮动的

2.当发送缓冲区当中的数据被发送出去后,操作系统不会立即将该数据从发送缓冲区当中删除或覆盖,而会让其保留在发送缓冲区当中,以免需要进行超时重传,直到收到该数据的响应报文后,发送缓冲区中的这部分数据才可以被删除或覆盖

连接管理机制

TCP的其中一个特点:TCP是面向连接的

1)TCP的各种可靠性机制实际都不是从主机到主机的,而是基于连接的,与连接是强相关的.比如一台服务器启动后可能有多个客户端前来访问:

  • 如果TCP不是基于连接的,也就意味着服务器端只有一个接收缓冲区,此时各个客户端发来的数据都会拷贝到这个接收缓冲区当中,此时这些数据就可能会互相干扰

2)我们在进行TCP通信之前需要先建立连接,因为TCP的各种可靠性保证都是基于连接的,要保证传输数据的可靠性的前提就是先建立好连接

三次握手是双方OS中TCP协议自动完成的,用户层完全不参与, 所以在TCP中,不要认为用户的发送行为会直接影响TCP的发送逻辑 (因为有缓冲区做解耦)


操作系统对连接的管理

面向连接是TCP可靠性的一种,只有在通信建立好连接才会有各种可靠性的保证,而一台机器上可能会存在大量的连接,此时操作系统就不得不对这些连接进行管理

  • 操作系统在管理这些连接时需要“先描述,再组织”,在操作系统中一定有一个描述连接的结构体,该结构体当中包含了连接的各种属性字段,所有定义出来的连接结构体最终都会以某种数据结构组织起来,此时操作系统对连接的管理就变成了对该数据结构的增删查改,
  • 建立连接,实际就是在操作系统中用该结构体定义一个结构体变量,然后填充连接的各种属性字段,最后将其插入到管理连接的数据结构当中即可,
  • 断开连接,实际就是将某个连接从管理连接的数据结构当中删除,释放该连接曾经占用的各种资源,
  • 因此连接的管理也是有成本的,这个成本就是管理连接结构体的时间成本,以及存储连接结构体的空间成本,

滑动窗口

实际上报文发送并不是串行的效率太低,双方在进行TCP通信时可以一次向对方发送多条数据,让传输等待时间重叠进而提高数据通信的效率

image-20220920213957292

虽然双方在进行TCP通信时可以一次向对方发送大量的报文,但不能将自己发送缓冲区当中的数据全部打包发送给对端,在发送数据时还要考虑对方的接收能力


滑动窗口可以类比报头中的窗口大小,但16位窗口大小描述的是接收缓冲区的剩余空间,滑动窗口可以形象地理解成套在发送缓冲区上的一个窗口. 滑动窗口就是在发送缓冲区当中

发送方可以一次发送多个报文给对方,此时也就意味着发送出去的这部分报文当中有相当一部分数据是暂时没有收到应答的,

其实可以将发送缓冲区当中的数据分为三部分:

  • 已经发送并且已经收到ACK的数据
  • 已经发送还但没有收到ACK的数据
    • 发大量数据暂时不用应答就可以立马发下一个,并不是说这些报文不需要被应答,而是每个报文在没有收到应答的时候也可以发下一个报文 每个报文还是需要ACK的
  • 还没有发送的数据

image-20220920214354265

滑动窗口可能向左滑动吗?

不可能! 因为左侧的是已经发送且已经收到确认

如何得知对方的接收能力呢?

在对方给我发送的TCP的报头当中,里面有16位窗口大小,里面记录的就是接收方的接收缓冲区的剩余空间的大小, 接受能力知道了,窗口大小怎么初始化了,后序在通信时,对方会不断通告它的接收能力,窗口大小就可以进行无误的调整


关于这个窗口大小:

  • 该窗口的大小就是发送一批报文的数量,准确的说是无需等待确认应答而可以继续发送数据的最大值.
  • 这个窗口从头开始不断的向后运动,只有窗口内的数据才会被发送;
  • 窗口前面的数据都是被发送过的,窗口后的数据是未被发送的,也可能暂时就没有数据.
  • 窗口的大小也是不固定的,可以根据对方的接收能力动态调整.
  • 滑动窗口描述的就是,发送方不用等待ACK一次所能发送的数据最大量,
image-20220920215903601

滑动窗口存在的最大意义就是可以提高发送数据的效率:

  • **滑动窗口的大小等于对方窗口大小与自身拥塞窗口大小的较小值,**因为发送数据时不仅要考虑对方的接收能力,还要考虑当前网络的状况,
  • 我们这里先不考虑拥塞窗口,并且假设对方的窗口大小一直固定为4000,此时发送方不用等待ACK一次所能发送的数据就是4000字节,因此滑动窗口的大小就是4000字节,(四个段)
    • 现在连续发送1001-2000,2001-3000,3001-4000,4001-5000这四个段的时候,不需要等待任何ACK,可以直接进行发送,
    • 当收到对方响应的确认序号为2001时,说明1001-2000这个数据段已经被对方收到了,此时该数据段应该被纳入发送缓冲区当中的第一部分,而由于我们假设对方的窗口大小一直是4000,因此滑动窗口现在可以向右移动,继续发送5001-6000的数据段,以此类推,
    • 当发送方发送出去的数据段陆陆续续收到对应的ACK时,就可以将收到ACK的数据段归置到滑动窗口的左侧,并根据当前滑动窗口的大小来决定,是否需要将滑动窗口右侧的数据归置到滑动窗口当中
  • 滑动窗口越大,则网络的吞吐率越高,同时也说明对方的接收能力很强,

滑动窗口和重传机制

TCP的重传机制要求暂时保存发出但未收到确认的数据,而这部分数据实际就位于滑动窗口当中,只有滑动窗口左侧的数据才是可以被覆盖或删除的,因为这部分数据才是发送并被对方可靠的收到了,所以滑动窗口除了限定不收到ACK而可以直接发送的数据之外,滑动窗口也可以支持TCP的重传机制

滑动窗口的移动

对方接收能力可能不断在变化,从而滑动窗口也会随之不断变宽或者变窄,例如:如果上层一直不把数据读取走,那么滑动窗口就会越来越窄

如何实现滑动窗口

TCP接收和发送缓冲区都看作一个字符数组,而滑动窗口实际就可以看作是两个指针限定的一个范围,例如:start指向滑动窗口的左侧,end指向的是滑动窗口的右侧 [start,end)范围上的内容就是滑动窗口的内容

  • 当发送端收到对方的响应时,如果响应当中的确认序号为x,窗口大小为win
    • 此时就可以将start更新为x ,end更新为win+start

image-20220920220524801

当然不必担心越界的问题,缓冲区可以是环形队列,我们把缓冲区画成线性的只是为了好理解


丢包问题

当发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种

case1: 数据包已经抵达,ACK丢包

在发送端连续发送多个报文数据时,部分ACK丢包并不要紧,此时可以通过后续的ACK进行确认

image-20220920221027742

比如图中1001~2000和2001-3000的数据包对应的ACK丢失了:

  • 但只要发送端收到了最后3001~4000数据包的响应,此时发送端也就知道1001~2000和2001-3000的数据包实际上被接收端收到了的 (确认序号的含义是对端已经收到了序号为该确认序号之前的所有报文) ,
  • 因为如果接收方没有收到1001~2000和2001-3000的数据包是没办法设置确认序号为4001的,确认序号为4001的含义就是序号为1-4000的字节数据我都收到了,你下一次应该从序号为4001的字节数据开始发送
  • 所以只要最后一个确认报文被收到,那么之前面的确认报文没有收到也无所谓.如果后面的确认报文没有被收到,那就超时重传即可
  • 只有收到ACK确认报文后,滑动窗口的才会右移,所以如果没有收到ACK,数据还会保存在滑动窗口中

所以TCP协议是允许少量的丢包的, TCP不是100%每个报文都要收到,正如上述的ACK丢了也不影响

  • TCP允许丢包的情况就是:允许在内部的某些ACK丢失,只要最新的报文的确认序号被主机A收到,主机A就能确认之前的报文已经被主机B收到

case2: 数据包丢了

如果数据包没有收到,那么自然没有该序号的确认报文.那么之后收到的数据,其确认报文的确认序号都是最后一个收到的报文的确认序号.

如果发送端收到三次以上的具有重复确认序号的报文,就认为该序号的报文已丢失需要重传,会立即补发对应序号的报文,这就是快重传机制.

例子:

image-20220920221412553

  • 当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,就是在提醒发送端“下一次应该从序号为1001的字节数据开始发送”,
  • 如果发送端连续收到三次确认序号为1001的响应报文,此时就会将1001-2000的数据包重新进行发送,
  • 此时当接收端收到1001-2000的数据包后,就会直接发送确认序号为4001的响应报文,因为2001-4000的数据接收端其实在之前就已经收到了,

上述的这种策略被称为“高速重发控制”,也叫做“快重传”

注意:快重传需要在大量的数据重传和个别的数据重传之间做平衡,

  • 上述这个例子当中发送端并不知道是1001-2000这个数据包丢了,当发送端重复收到确认序号为1001的响应报文时,理论上发送端应该将1001-7000的数据全部进行重传
  • 但这样可能会导致大量数据被重复传送,所以发送端可以尝试先把1001-2000的数据包进行重发,然后根据重发后的得到的确认序号继续决定是否需要重发其它数据包

滑动窗口中的数据一定都没有被对方收到吗

滑动窗口当中的数据是可以暂时不用收到对方确认的数据,而不是说滑动窗口当中的数据一定都没有被对方收到,滑动窗口当中可能有一部分数据已经被对方收到了,但可能因为滑动窗口内靠近滑动窗口左侧的一部分数据,在传输过程中出现了丢包等情况,导致后面已经被对方收到的数据得不到响应,

例子:

image-20220920221802290

例如图中的1001-2000的数据包如果在传输过程中丢包了,此时虽然2001-5000的数据都被对方收到了,此时对方发来的确认序号也只能是1001,当发送端补发了1001-2000的数据包后,对方发来的确认序号就会变为5001,此时发送缓冲区当中1001-5000的数据也会立马被归置到滑动窗口的左侧


有了快重传,为什么还要有超时重传?

  • 快重传和超时重传的触发条件不同,超时重传的条件是超出特定时间后未收到应答,快重传是收到三次以上重复确认序号的应答.
    • 快重传是能够快速进行数据的重发,当发送端连续收到三次相同的应答时就会触发快重传,而不像超时重传一样需要通过设置重传定时器,在固定的时间后才会进行重传,
  • 虽然快重传能够快速判定数据包丢失,但快重传并不能完全取待超时重传,因为有时数据包丢失后可能并没有收到对方三次重复的应答,此时快重传机制就触发不了,而只能进行超时重传,
  • 超时重传必须得存在,因为是给我们兜底的,而快重传是一个在能重传的前提条件下,为我们提高效率

流量控制

TCP支持根据接收端的接收数据的能力来决定发送端发送数据的速度,这个机制叫做流量控制

首先要明确的是:接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,此时发送端继续发送数据,就会造成丢包,进而引起丢包重传等一系列连锁反应,

因此接收端可以将自己接收数据的能力告知发送端,让发送端控制自己发送数据的速度:

  • 接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK通知发送端
    • 窗口大小字段越大,说明网络的吞吐量越高
  • 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端,发送端接收到这个窗口之后,就会减慢自己发送的速度
  • 如果接收端缓冲区满了,就会将窗口值设置为0,这时发送方不再发送数据,但需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端

窗口大小为0的时候,怎么处理

如果接收端缓冲区满了,就会将窗口置为0

当发送端得知接收端接收数据的能力为0时会停止发送数据,此时发送端会通过以下两种方式来得知何时可以继续发送数据,

  • 等待接收方告知,接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个TCP报文,主动将自己的窗口大小告知发送端,发送端得知接收端的接收缓冲区有空间后就可以继续发送数据了
  • 发送方主动询问,发送端每隔一段时间向接收端发送报文,该报文不携带有效数据,只是为了询问发送端的窗口大小,直到接收端的接收缓冲区有空间后发送端就可以继续发送数据了

小例子:

1)如果发送端主机A收到了窗口大小为0的报文,就知道了主机B没有能力接收数据了,接收缓冲区已经被弄满了,主机A就不发数据了,因为有流量控制,所以就停下来, 主机A会进行等待,

2)此时主机A是可以发送一些管理报文过去的, 主机A可以向主机B发送一个PSH报文,这个报文可以不携带任何的有效数据,只有报头,发过去之后并不占用对方接收缓冲区的空间,

3)此时主机A给主机B发了消息,主机B需要做应答,一旦主机B应答了,就再通告自己的窗口大小, 所以当主机B的窗口大小为0了,主机A会等待一段时间,然后会给对方发送一个窗口探测的报文,可以认为是携带PSH的报文(没有数据),对方一旦应答就会告诉我它的窗口大小, 如果此时窗口大小还是为0,此时主机A只能轮询的向对方发送窗口探测,

4)一旦主机B的上层把数据取走了,此时主机B可以立马向主机A同步一个报文,这个报文可以不携带数据,只是个报头,告诉主机A 当前我的窗口大小已经更新了,可以发送消息了

5)当窗口大小为0: 主机A在等着发,主机B在等着上层来取, 要通告双方的窗口大小,所以有两种策略:

一种是主机A去轮询,一种是主机B一旦更新了,会主动通知主机A 在TCP当中,这两种策略都会被使用


16位表示的最大表示范围是0~ 65535,那TCP窗口最大就是65535吗?

理论上确实是这样的,但实际上TCP报头当中40字节的选项字段中包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位得到的,


何时协商窗口大小 (什么时候知道双方的接收能力)

1)双方在进行TCP通信之前需要先进行三次握手建立连接,而双方在握手时除了验证双方通信信道是否通畅以外,还进行了其他信息的交互,其中就包括告知对方自己的接收能力

  • 双方在进行三次握手的时候不携带任何数据,此时可以顺手携带一下窗口大小,通过此来设置各自滑动窗口初始值,因此双方还没有正式开始通信之前就已经知道了对方接收数据能力,所以双方在发送数据时是不会出现缓冲区溢出的问题的

2)只要报文没有有效载荷,就不会占用接收缓冲区,因为报头被TCP协议吸收,不提交至上层


拥塞控制

前面的超时重传和连接管理和流量控制等机制都只是考虑通信双方两台主机的事情.而**拥塞控制考虑的是整个网络和网络中的所有主机的事情. ** 拥塞控制是群体主机都要遵守的规则

为什么会有拥塞控制

1)两个主机在进行TCP通信的过程中,出现个别数据包丢包的情况是很正常的,此时可以通过快重传或超时重发对数据包进行补发,但如果双方在通信时出现了大量丢包,此时就不能认为是正常现象了

2)双方网络通信时出现少量的丢包TCP是允许的,但一旦出现大量的丢包,此时量变引起质变,这件事情的性质就变了,此时TCP就不再推测是双方接收和发送数据的问题,而判断是双方通信信道网络出现了拥塞问题,可能是当前网络状态比较拥堵

3)TCP不仅考虑了通信双端主机的问题,同时也考虑了网络的问题,在不清楚当前网络状态的情况下,大量主机贸然发送大量的数据很有可能引起网络瘫痪


如何解决拥塞问题

1)网络出现问题一定是网络中大部分主机共同作用的结果

2)如果网络中的主机在同一时间节点都大量向网络当中塞数据,此时位于网络中某些关键节点的路由器下就可能排了很长的报文,最终导致报文无法在超时时间内到达对端主机,此时也就导致了丢包问题,

3)当网络出现拥塞问题时,通信双方虽然不能提出特别有效的解决方案,但双方主机可以做到不加重网络的负担,

4)双方通信时如果出现大量丢包,不应该立即将这些报文进行重传,而应该少发数据甚至不发数据,等待网络状况恢复后双方再慢慢恢复数据的传输速率

需要注意的是,网络拥塞时影响的不只是一台主机,而几乎是该网络当中的所有主机,此时所有使用TCP传输控制协议的主机都会执行拥塞避免算法

  • 因此拥塞控制看似只是谈论的一台主机上的通信策略,实际这个策略是所有主机在网络崩溃后都会遵守的策略
  • 一旦出现网络拥塞,该网络当中的所有主机都会受到影响,此时所有主机都要执行拥塞避免,这样才能有效缓解网络拥塞问题,通过这样的方式就能保证雪崩不会发生,或雪崩发生后可以尽快恢复

拥塞控制

虽然滑动窗口能够高效可靠的发送大量的数据,但如果在刚开始阶段就发送大量的数据,就可能会引发某些问题,因为网络上有很多的计算机,有可能当前的网络状态就已经比较拥塞了,因此在不清楚当前网络状态的情况下,贸然发送大量的数据,就可能会引起网络拥塞问题,

因此TCP引入了慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据


  • TCP除了有窗口大小和滑动窗口的概念以外,还有一个窗口叫做拥塞窗口,拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞
    • 发送数据包时,将拥塞窗口和接收端的窗口大小做比较,取二者小值作为滑动窗口的大小.
  • 刚开始发送数据的时候拥塞窗口大小定义以为1,每收到一个ACK应答拥塞窗口的值就加一,
  • 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小,

每收到一个ACK应答拥塞窗口的值就+1,此时拥塞窗口就是以指数级别进行增长的,如果先不考虑对方接收数据的能力,那么滑动窗口的大家就只取决于拥塞窗口的大小, 但指数级增长是非常快的,因此“慢启动”实际只是初始时比较慢,但越往后增长的越快,如果拥塞窗口的值一直以指数的方式进行增长,此时就可能在短时间内再次导致网络出现拥塞,

慢启动指的是初始时慢后期快,既可以在前期探测网络又可以在后期加大传输力度.但如果单纯的加倍,很快拥塞窗口就会失去意义,因此引入慢启动的阈值.

  • 最初拥塞窗口大小为1,随着传输次数加大,窗口值呈指数级增长

  • 拥塞窗口增长到慢启动阈值时,由指数级转变为线性增长

  • 为了避免短时间内再次导致网络拥塞,因此不能一直让拥塞窗口按指数级的方式进行增长,

  • 此时就引入了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长,

  • 当TCP刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值,

  • 在每次超时重发的时候,慢启动阈值会变成当前拥塞窗口的一半,同时拥塞窗口的值被重新置为1,如此循环下去,

image-20220921152637004

图示说明:

  • 指数增长,刚开始进行TCP通信时拥塞窗口的值为1,并不断按指数的方式进行增长,
  • 加法增大,慢启动的阈值初始时为对方窗口大小的最大值,图中慢启动阈值的初始值为16,因此当拥塞窗口的值增大到16时就不再按指数形式增长了,而变成了的线性增长,
  • 乘法减小,拥塞窗口在线性增长的过程中,在增大到24时如果发生了网络拥塞,此时慢启动的阈值将变为当前拥塞窗口的一半,也就是12,并且拥塞窗口的值被重新设置为1,所以下一次拥塞窗口由指数增长变为线性增长时拥塞窗口的值应该是12,

主机在进行网络通信时,实际就是在不断进行指数增长,加法增大和乘法减小,

需要注意的是,不是所有的主机都是同时在进行指数增长,加法增大和乘法减小的,每台主机认为拥塞窗口的大小不一定是一样的,即便是同区域的两台主机在同一时刻认为拥塞窗口的大小也不一定是完全相同的,因此在同一时刻,可能一部分主机正在进行正常通信,而另一部分主机可能已经发生网络拥塞了


流量控制&&滑动窗口&&拥塞窗口

  • 流量控制:考虑的是对端接收缓冲区的接收能力,进而控制发送方发送数据的速度,避免对端接收缓冲区溢出
  • 滑动窗口:考虑的是发送端不用等待ACK一次所能发送的数据最大量,进而提高发送端发送数据的效率
  • 拥塞窗口:考虑的是双方通信时网络的问题,如果发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞

延迟应答

如果接收端接收数据后立即确认应答,上层可能还未及时将读取缓冲区,所以此时应答报文中的窗口大小可能比较小.如果稍等一下,可能应用层直接清空了缓冲区,此时的窗口大小就会更大

例子 :

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

需要注意的是:

1)延迟应答的目的不是为了保证可靠性,而是留出一点时间让接收缓冲区中的数据尽可能被上层应用层消费掉,此时在进行ACK响应的时候报告的窗口大小就可以更大,从而增大网络吞吐量,进而提高数据的传输效率,

2)延迟应答就是让接收端等待特定的时间,比如200ms后在发送应答报文,单纯地为了提升效率


延迟应答也不是一定使用,一定是建立在用户层积极读取缓冲区的前提下,另外:不是所有的数据包都可以延迟应答

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

捎带应答

捎带应答其实是TCP通信时最常规的一种方式,实际上一般不会有单独的ACK应答报文

为什么几乎所有的报文都会携带ACK?

在向对方发送数据的同时,也是对 对方的上一条报文作应答确认

例子1:

主机A给主机B发送了一条消息,当主机B收到这条消息后需要对其进行ACK应答,但如果主机B此时正好也要给主机A发生消息,此时这个ACK就可以搭顺风车,而不用单独发送一个ACK应答,此时主机B发送的这个报文既发送了数据,又完成了对收到数据的响应,这就叫做捎带应答

例子2:

三次握手的第二次握手就是将SYN和ACK结合起来发

image-20220922154521885


捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不用再发送单纯的确认报文了,

此外,由于捎带应答的报文携带了有效数据,因此对方收到该报文后会对其进行响应,当收到这个响应报文时不仅能够确保发送的数据被对方可靠的收到了,同时也能确保捎带的ACK应答也被对方可靠的收到了


面向字节流

当创建一个TCP的socket时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区,不管是send/write还是recv/read都是在和缓冲区交互数据

  • 调用write函数就可以将数据写入发送缓冲区中,此时write函数就可以进行返回了,接下来发送缓冲区当中的数据就是由TCP自行进行发送的,
  • 如果发送的字节数太长,TCP会将其拆分成多个数据包发出,如果发送的字节数太短,TCP可能会先将其留在发送缓冲区当中,等到合适的时机再进行发送,
  • 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,可以通过调用read函数来读取接收缓冲区当中的数据,
  • 而调用read函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取,

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

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

实际对于TCP来说**,它并不关心发送缓冲区当中的是什么数据,在TCP看来这些只是一个个的字节数据**,它的任务就是将这些数据准确无误的发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,这就叫做面向字节流,

  • TCP不关心数据的基本格式,只关心数据量,发送次数和接收次数没有任何的关联性,这就叫做面向字节流

粘包问题

什么叫粘包

首先要明确,粘包问题中的“包”,是指的应用层的数据包,所谓粘包,就是数据包与包之间没有分隔,读取数据的时候可能会少读或者多读,存在数据和前后的包粘在一起

  • 因为TCP是面向字节流的,所以报文和报文之间是没有边界的, 在TCP的协议头中,没有如同UDP一样的“报文长度”这样的字段,**站在传输层的角度,**TCP是一个一个报文过来的,按照序号排好序放在缓冲区中,站在应用层的角度,看到的只是一串连续的字节数据, 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包

UDP存在粘包问题嘛?

1)UDP是面向数据报, 对比UDP数据报格式,必须以一个完整的报文为单位进行读写,UDP报头中就含有整个报文长度的字段,UDP是一个一个把数据交付给应用层的,有很明确的数据边界,所以不存在粘包问题

2)站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现“半个”的情况

因此UDP是不存在粘包问题的,根本原因就是UDP报头当中的16位UDP长度,记录的UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界


如何解决粘包问题

要解决粘包问题,本质就是要明确报文和报文之间的边界,

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

应用层协议的存在,可以指导如何发送数据,如何接收数据 粘包问题不是TCP刻意为之,而是应用层协议没有定制好,是用户的锅


TCP异常情况

进程终止

当客户端正常访问服务器时,如果客户端进程突然崩溃了,此时建立好的连接会怎么样?

1)文件描述符的生命周期随进程, 当一个进程退出时,该进程曾经打开的文件描述符都会自动关闭

2)因此当客户端进程退出时,相当于自动调用了close函数关闭了对应的文件描述符,此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源

3)也就是说,进程终止时会释放文件描述符,TCP底层仍然可以发送FIN,和进程正常退出没有区别


机器重启

当客户端正常访问服务器时,如果将客户端主机重启,此时建立好的连接会怎么样?

当我们选择重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源


机器掉电/网线断开

当客户端正常访问服务器时,如果将客户端突然掉线了,此时建立好的连接会怎么样?

当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的

  • 服务器会定期客户端客户端的存在状况,检查对方是否在线,如果连续多次都没有收到ACK应答,此时服务器就会关闭这条连接
  • 此外,客户端也可能会定期向服务器“报平安”,如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭

其中服务器定期询问客户端的存在状态的做法,叫做基于保活定时器的一种心跳机制,是由TCP实现的

此外,应用层的某些协议,也有一些类似的检测机制

  • 例如基于长连接的HTTP,也会定期检测对方的存在状态

TCP小结

TCP协议这么复杂就是因为TCP既要保证可靠性,同时又尽可能的提高性能

可靠性的保证

检验和,序列号,确认应答,超时重传,连接管理,流量控制,拥塞控制,

  • 流量控制是为了控制数据量,目的是不要让对方来不及接收导致丢包
  • 拥塞控制 防止出现网络大面积丢包的情况

提高性能

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


需要注意的是,TCP的这些机制有些能够通过TCP报头体现出来的,但还有一些是通过代码逻辑体现出来的

理解传输控制协议

TCP的各种机制实际都没有谈及数据真正的发送,这些都叫做传输数据的策略,TCP协议是在网络数据传输当中做决策的,它提供的是理论支持

  • 比如TCP要求当发出的报文在一段时间内收不到ACK应答就应该进行超时重传
  • 而数据真正的发送实际是由底层的IP和MAC帧完成的,

TCP做决策, IP+MAC做执行,我们将它们统称为通信细节,它们最终的目的就是为了将数据传输到对端主机,而传输数据的目的是什么则是由应用层决定的,因此应用层决定的是通信的意义,而传输层及其往下的各层决定的是通信的方式,


小实验:

编写简单的代码来演示上述的情况:我们只需要编写服务器端的代码,而采用一些网络工具来充当客户端向我们的服务器发起连接请求

我们可以把套接字的相关接口进行封装:

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

using namespace std;

class Sock
{
public:
    static int Socket()
    {
        int sock = socket(AF_INET, SOCK_STREAM, 0);
        if (sock < 0)
        {
            cerr << "socket error" << endl;
            exit(2);
        }
        int opt = 1;
        setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
        return sock;
    }

    static void Bind(int sock, uint16_t port)
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            cerr << "bind error!" << endl;
            exit(3);
        }
    }

    static void Listen(int sock)
    {
        if (listen(sock, 5) < 0)
        {
            cerr << "listen error !" << endl;
            exit(4);
        }
    }

    static int Accept(int sock)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int fd = accept(sock, (struct sockaddr *)&peer, &len);
        if(fd >= 0){
            return fd;
        }
        return -1;
    }

    static void Connect(int sock, std::string ip, uint16_t port)
    {
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));

        server.sin_family = AF_INET;
        server.sin_port = htons(port);
        server.sin_addr.s_addr = inet_addr(ip.c_str());

        if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0)
        {
            cout << "Connect Success!" << endl;
        }
        else
        {
            cout << "Connect Failed!" << endl;
            exit(5);
        }
    }
};

服务器端的代码

服务器的初始化需要进行套接字的创建、绑定以及监听,然后主线程就可以通过调用accept函数从底层获取建立好的连接了.获取到连接后主线程创建新线程为该连接提供服务,而新线程只需执行一个死循环逻辑即可

#include"Sock.hpp"

void Usage(std::string s)
{
    std::cout <<"Usage: "<<s <<"port"<<std::endl;
}
void* Routine(void* arg)
{
	pthread_detach(pthread_self());//线程分离
	int fd = *(int*)arg;
	delete (int*)arg;
	while (1)
    {
		std::cout << "Runing"<<std::endl;
		sleep(2);
	}
	return nullptr;
}

//./server port
int main(int args,char* argv[])
{
    if(args!=2)
    {
        Usage(argv[0]);
        exit(1);
    }
    int listen_sock = Sock::Socket();//创建监听套接字
    Sock::Bind(listen_sock,atoi(argv[1]));//绑定
    Sock::Listen(listen_sock);//监听

    for(;;)
    {
        int sock = Sock::Accept(listen_sock);//获取新连接
        std::cout << "get a new link: " << sock << std::endl;
        int* p = new int(sock);
		pthread_t tid;//创建新线程提供服务
		pthread_create(&tid, nullptr, Routine, (void*)p);
    }
    return 0;
}

CLOSE_WAIT状态

如果当服务器收到客户端的FIN请求后,服务器端不调用close函数关闭对应的文件描述符:

  • 服务器就不会给客户端发送FIN请求,相当于只完成了四次挥手当中的前两次挥手
  • 此时客户端和服务器的连接状态分别会变为FIN_WAIT_2和CLOSE_WAIT

image-20220920204421253


代码编写完毕后运行服务器,并用telnet工具连接我们的服务器,此时通过以下监控脚本就可以看到两条状态为ESTABLISHED的连接,我们这里使用的端口号是8080

while :; do sudo netstat -ntp|head -2&&sudo netstat -ntp | grep 8080; sleep 1; echo "##################"; done

这两条连接当中,一条是客户端到服务器的连接,另一条就是服务器到客户端的连接

image-20220920210733119


现在我们让telnet退出,就相当于客户端向服务器发起了连接断开请求,但此时服务器端并没有调用close函数关闭对应的文件描述符,所以当telnet退出后,客户端维护的连接的状态会变为FIN_WAIT_2.而服务器维护的连接的状态会变为CLOSE_WAIT

image-20220921150111440


TIME_WAIT状态

当客户端和服务器在进行TCP通信时,客户端调用close函数关闭对应的文件描述符,如果服务器收到后也调用close函数进行了关闭,那么此时双方将正常完成四次挥手

但主动发起四次挥手的一方在四次挥手后,不会立即进入CLOSED状态,而是进入短暂的TIME_WAIT状态等待若干时间,最终才会进入CLOSED状态

  • 主动断开连接的一方会进入TIME_WAIT状态

继续使用上述的代码:

由于telnet退出后服务器端没有调用close关闭对应的文件描述符,因此客户端维护的客户端维护连接的状态停留在了FIN_WAIT_2状态,而服务器维护连接的状态停留在了CLOSE_WAIT状态

要让客户端和服务器继续完成后两次挥手,就需要服务器端调用close函数关闭对应的文件描述符.虽然服务器代码当中没有调用close函数,但因为文件描述符的生命周期是随进程的,当进程退出的时候,该进程所对应的文件描述符会自动关闭

因此只需要在telnet退出后让服务器进程退出就行了,此时服务器进程所对应的文件描述符会自动关闭,此时服务器底层TCP就会向客户端发送FIN请求,完成剩下的两次挥手


我们可以发现: 服务端主动断开连接, 四次挥手后客户端维护的连接就会进入到TIME_WAIT状态,而服务器维护的连接则会立马进入到CLOSED状态

image-20220921150841564

bind error

在该连接处于TIME_WAIT期间,如果服务器想要再次重新启动,就会出现绑定失败的问题

image-20220921151134138

因为在TIME_WAIT期间,这个连接并没有被完全释放,也就意味着服务器绑定的端口号仍然是被占用的,此时服务器想要继续绑定该端口号启动,就只能等待TIME_WAIT结束


但当服务器崩溃后最重要实际是让服务器立马重新启动,如果想要让服务器崩溃后在TIME_WAIT期间也能立马重新启动,需要让服务器在调用socket函数创建套接字后,继续调用setsockopt函数设置端口复用,这也是编写服务器代码时的推荐做法

setsockopt函数

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

参数说明:

  • sockfd:需要设置的套接字对应的文件描述符.
  • level:被设置选项的层次.比如在套接字层设置选项对应就是SOL_SOCKET.
  • optname:需要设置的选项.该选项的可取值与设置的level参数有关.
  • optval:指向存放选项待设置的新值的指针.
  • optlen:待设置的新值的长度.

返回值说明

设置成功返回0,设置失败返回-1,同时错误码会被设置


我们这里要设置的就是监听套接字,将监听套接字在套接字层设置端口复用选项SO_REUSEADDR.该选项设置为非零值表示开启端口复用

static int Socket()
{
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    {
        cerr << "socket error" << endl;
        exit(2);
    }
    int opt = 1;
    setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
    return sock;
}

此时当服务器崩溃后我们就可以立马重新启动服务器,而不用等待TIME_WAIT结束:

image-20220921151357443


连接是由TCP管理的

从上面的实验中可以看到,即便通信双方对应的进程都退出了,但服务器端依然存在一个处于TIME_WAIT状态的连接,这也更加说明了进程管理和连接管理是两个相对独立的单元.连接是由TCP自行管理的,连接不一定会随进程的退出而关闭.


理解listen的第二个参数

回忆

在编写TCP套接字的服务器代码时,在进行了套接字的创建和绑定之后,需要调用listen函数将创建的套接字设置为监听状态,此后服务器就可以调用accept函数获取建立好的连接了.其中listen函数的第1个参数就是需要设置为监听状态的套接字,而listen的第二个参数我们一般设置为5 这个参数有什么含义呢?


listen函数

int listen(int sockfd, int backlog);

listen的第二个参数backlog,用来指定TCP建立正常连接的个数为backlog+1 但这个并不是服务器能维护的连接个数,而是连接建立好但没有被上层accept提取的连接的最大个数


例子:

假设listen的第二个参数设置为1,那么只能有两个链接是正常的ESTABLISHED,除此之外都是SYN_RECV状态 (意味着服务器没有对刚才客户端发来的连接请求进行响应)

image-20220921201320357

而对于状态为SYN_RCVD的连接,由于服务器长时间不对其进行应答,三次握手失败后该连接会被自动释放

总结一下:

  • listen的第二个参数为:backlog 无论有多少客户端向服务器发起连接请求,最终在服务器端最多只有backlog+1个连接会建立成功
  • 当发来第backlog +2 个连接请求时,服务器只是收到了该客户端发来的SYN请求,但并没有对其进行响应
  • 当发来更多的连接请求时,服务器会直接拒绝这些连接请求

实际TCP在进行连接管理时会用到两个连接队列

  • 连接没有完全建立好,也就是三次握手没有完成的连接,叫做半连接,保存在内核中的半连接队列
  • 连接建立好但没有被上层提取的连接叫做全连接,保存在内核中的全连接队列

全连接队列的长度实际会受到listen第二个参数的影响,一般TCP全连接队列的长度就等于listen第二个参数的值+1


半连接的长度

半连接队列的长度有操作系统自行有一套控制算法,不同系统实现不同.可有效预防SYN攻击导致正常客户无法建立连接

半链接队列:用来维护一些处于3次握手过程之中的链接,只有握手成功了,才可能进入到全链接队列

全连接队列

限制底层任何一个时刻最多能够建立成功的链接个数就是listen的第二个参数 + 1

为什么要维护全连接队列

等待区就是全连接队列,有了等待队列就能最大化利用系统的连接资源,不会出现空闲浪费的情况.

一般当服务器压力较大时连接队列的作用才会体现出来,如果服务器压力本身就不大,那么一旦底层有连接建立成功,上层就会立马将该连接读走并进行处理.

  • 服务器端启动时一般会预先创建多个服务线程为客户端提供服务,主线程从底层accept上来连接后就可以将其交给这些服务线程进行处理.
  • 如果向服务器发起连接请求的客户端很少,那么连接一旦在底层建立好就被主线程立马accept上来并交给服务线程处理了.
  • 但如果向服务器发起连接请求的客户端非常多,当每个服务线程都在为某个连接提供服务时,底层再建立好连接主线程就不能获取上来了,此时底层这些已经建立好的连接就会被放到连接队列当中,只有等某个服务线程空闲时,主线程就会从这个连接队列当中获取建立好的连接.
  • 如果没有这个连接队列,那么当服务器端的服务线程都在提供服务时,其他客户端发来的连接请求就会直接被拒绝.
  • 但有可能正当这个连接请求被拒绝时,某个服务线程提供服务完毕,此时这个服务线程就无法立马得到一个连接为之提供服务,所以一定有一段时间内这个服务线程是处于闲置状态的,直到再有客户端发来连接请求.
  • 而如果设置了连接队列,当某个服务线程提供完服务后,如果连接队列当中有建立好的连接,那么主线程就可以立马从连接队列当中获取一个连接交给该服务线程进行处理,此时就可以保证服务器几乎是满载工作的.

就像是商场中的餐饮饭馆,馆内没有空桌的话,再来的客人就要坐在门口的等待区,待馆内出现空桌后排队进入其中消费,这样能最大化利用桌子这个资源,不会浪费出现空闲的情况.

为什么这个队列不能太长

全连接队列不能太长,系统一般设置为5, 虽然维护连接队列能让服务器处于几乎满载工作的状态,但连接队列也不能设置得太长.

  • 如果队列太长,也就意味着在队列较尾部的连接需要等待较长时间才能得到服务,此时客户端的请求也就迟迟得不到响应.
  • 此外,服务器维护连接也是需要成本的,连接队列设置的越长,系统就要花费越多的成本去维护这个队列.
  • 但与其与其维护一个长连接,造成客户端等待过久,并且占用大量暂时用不到的资源,还不如将部分资源节省出来给服务器使用,让服务器更快的为客户端提供服务.

因此虽然需要维护连接队列,但连接队列不能维护的太长.


TCP与UDP对比

TCP协议

TCP协议叫做传输控制协议(Transmission Control Protocol).TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议.

TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输.其次.TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况.TCP协议都有对应的解决方法.

UDP协议

UDP协议叫做用户数据报协议(User Datagram Protocol).UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议.

使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况.UDP协议本身是不知道的.

TCP/UDP对比

TCP协议虽然是保证可靠性的协议,但不能说TCP就一定比UDP好,因为TCP保证可靠性也就意味着TCP需要做更多的工作,而UDP不保证可靠性也就意味着UDP足够简单.

  • TCP常用于可靠传输的情况,应用于文件传输,重要状态更新等场景.
  • UDP常用于对高速传输和实时性较高的通信领域,例如早期的QQ、视频传输等.另外UDP可以用于广播.

也就是说.UDP和TCP没有谁最好,只有谁最合适,网络通信时具体采用TCP还是UDP完全取决于上层的应用场景.


用UDP实现可靠传输(经典面试题)

当面试官让你用UDP实现可靠传输时,你一定要立马想到TCP协议,因为TCP协议就是当前比较完善的保证可靠性的协议,面试官让你用UDP这个不可靠的协议来实现可靠传输,无非就是让你在应用层来实现可靠性,此时就可以参考TCP协议保证可靠性的各种机制

例如:

  • 引入序列号,保证数据按序到达.
  • 引入确认应答,确保对端接收到了数据.
  • 引入超时重传,如果隔一段时间没有应答,就进行数据重发.

但TCP保证可靠性的机制太多了,当你被面试官问到这个问题时,最好与面试官进一步进行沟通,比如问问这个用UDP实现可靠传输的应用场景是什么.因为TCP保证可靠性的机制太多了,但在某些场景下可能只需要引入TCP的部分机制就行了,因此在不同的应用场景下模拟实现UDP可靠传输的侧重点也是不同的

TCP太重了, UDP简单快速, + 使用一点点的可靠性



http://www.kler.cn/news/17796.html

相关文章:

  • yolov8 OpenCV DNN 部署 推理报错
  • 科大版中国版ChatGPT来啦!抢先体验
  • 还能这么玩?清华给 ChatGPT 做逆向,发现了 ChatGPT 的进化轨迹!
  • 记一次产线打印json导致的redis连接超时
  • 【算法】Check If Word Is Valid After Substitutions 检查替换后的词是否有效
  • MySQL高频面试题
  • 多通道振弦传感器无线采集仪通过短信和FTP文件修改参数
  • 设计原则之【接口隔离原则】
  • 22.Java多线程
  • SpreadJS 16.1 EN + SpreadJS 16.1 CN Crack
  • 【Linux】linux进程间通信netlink socket(用户与内核通信)
  • PBDB Data Service:Special parameters(特殊参数)
  • 公司新来的00后真是卷王,工作没2年,跳槽到我们公司起薪18K都快接近我了
  • JAVA原生语言开发多学校Saas模式校园管理系统
  • LT8471IFE#PBF-ASEMI代理亚德诺LT8471IFE#PBF原厂芯片
  • 文件操作和IO
  • 机器视觉工程师,听我一句劝,别去外包,干了三年,废了....对女人没了兴趣
  • 【Unity编辑器】拓展Project视图
  • 复兴号列车司机室
  • Midjourney之logo设计(建议收藏)
  • 杂乱之Android的字体相关类Typeface
  • 一道2023年数学分析真题
  • 【Linux】Linux安装Nexus(图文解说详细版)
  • 基于numpy的鸢尾花数据获取、处理等操作。
  • Android14新权限机制
  • 2023-05-04 LeetCode每日一题(摘水果)
  • 行为型模式-解释器模式
  • 了解进程控制
  • 错题汇总03
  • 顺序表和链表优缺点以及区别