【Linux】传输层协议:UDP和TCP

文章目录

  • 一、UDP协议
  • 二、TCP协议
    • 1.理解TCP报头+某些TCP的策略
      • [1.1 TCP报头字段(TCP的黏包问题)](#1.1 TCP报头字段(TCP的黏包问题))
      • [1.2 网络协议栈和linux系统的联系(以port为键值的开散列哈希表,哈希桶存储port对应的PCB的地址)](#1.2 网络协议栈和linux系统的联系(以port为键值的开散列哈希表,哈希桶存储port对应的PCB的地址))
      • [1.3 从代码层面理解TCP报头(结构体数据)](#1.3 从代码层面理解TCP报头(结构体数据))
      • [1.4 确认应答机制(序号和确认序号,TCP面向字节流的特点)](#1.4 确认应答机制(序号和确认序号,TCP面向字节流的特点))
      • [1.5 流量控制(16位窗口大小)](#1.5 流量控制(16位窗口大小))
      • [1.6 TCP报文段的类型(6个标志位:详解URG和RST)](#1.6 TCP报文段的类型(6个标志位:详解URG和RST))
      • [1.7 超时重传机制(数据包在超时时间窗口内没有收到应答,则判定为丢包进行重传)](#1.7 超时重传机制(数据包在超时时间窗口内没有收到应答,则判定为丢包进行重传))
    • 2.连接管理机制
      • [2.1 为什么要三次握手?(最小成本验证全双工通信+防止产生单主机对服务器进行SYN洪水攻击的漏洞)](#2.1 为什么要三次握手?(最小成本验证全双工通信+防止产生单主机对服务器进行SYN洪水攻击的漏洞))
      • [2.2 双方四次挥手时,状态的变化(理解CLOSE_WAIT,TIME_WAIT状态)](#2.2 双方四次挥手时,状态的变化(理解CLOSE_WAIT,TIME_WAIT状态))
        • [2.2.1 四次挥手的详细过程](#2.2.1 四次挥手的详细过程)
        • [2.2.2 测试TIME_WAIT状态](#2.2.2 测试TIME_WAIT状态)
        • [2.2.3 主动断开连接的一方为什么要维持2MSL时间的TIME_WAIT状态?](#2.2.3 主动断开连接的一方为什么要维持2MSL时间的TIME_WAIT状态?)
        • [2.2.4 解决服务器立即重启无法bind原来端口号的问题](#2.2.4 解决服务器立即重启无法bind原来端口号的问题)
    • 3.TCP的高效性
      • [3.1 滑动窗口(批量化发送数据段+支持超时重传机制)](#3.1 滑动窗口(批量化发送数据段+支持超时重传机制))
      • [3.2 拥塞控制](#3.2 拥塞控制)
        • [3.2.1 拥塞控制和超时重传机制(大面积的丢包:拥塞控制,小面积的丢包:超时重传)](#3.2.1 拥塞控制和超时重传机制(大面积的丢包:拥塞控制,小面积的丢包:超时重传))
        • [3.2.2 拥塞窗口的引入,MSS和SMSS](#3.2.2 拥塞窗口的引入,MSS和SMSS)
        • [3.2.3 慢启动算法+拥塞避免算法(阈值前指数增长,阈值后线性增长)](#3.2.3 慢启动算法+拥塞避免算法(阈值前指数增长,阈值后线性增长))
      • [3.3 延迟应答 && 捎带应答](#3.3 延迟应答 && 捎带应答)
    • 4.TCP相关问题和相关实验
      • [4.1 TCP异常情况](#4.1 TCP异常情况)
      • [4.2 用UDP实现可靠性传输](#4.2 用UDP实现可靠性传输)
      • [4.3 理解listen的第二个参数backlog(全连接长度=backlog+1)](#4.3 理解listen的第二个参数backlog(全连接长度=backlog+1))

一、UDP协议

1.端口号

在网络通信中,通信的本质实际就是两台主机上的进程在网络环境中进行通信,也就是数据的传输,而我们总说TCP/IP协议栈,这两个协议分别解决了两个重要的问题,即一台主机如何在网络环境中标定自己的唯一性,一台主机中的某个进程如何在主机内部标定自己的唯一性,实际就是通过网络层协议IP地址和传输层协议端口号port来解决这两个问题的。

端口号一般可以分为知名端口号和操作系统动态分配的端口号,知名端口号的范围是0-1023,例如使用HTTP,HTTPS,SSH,FTP等应用层协议的进程bind的端口号都是知名端口号,他们的端口号都是固定的,1024-65535是操作系统动态分配的端口号,我们自己在写服务器时要避免bind知名端口号,因为这些端口号早已被成熟的应用层协议占用了。
所以我们之前在写socket套接字编程的时候,无论是UDP服务器还是TCP服务器,bind的端口号都是1024-65535范围的port,不会占用知名端口号。

一个进程是否可以bind多个端口号呢?可以的,一个进程可以同时提供多个应用层网络服务,比如一个进程内部可以创建出多个thread,每个thread都可以调用socket接口,创建struct sockaddr_in 结构体,然后再将sockfd和结构体进行bind,这样就可以在一个进程内部bind多个端口号,使得一个进程可以提供多种应用层网络服务了。
一个端口号是否可以被多个进程bind呢?一个端口号一定是不可以被多个进程bind的,因为端口号需要标识进程的唯一性,如果多个进程bind了同一端口号,那数据包从传输层在向上交付时,该将数据包交付给哪个进程呢?我们知道数据包是通过端口号来向上交付给特定的进程的,所以一个端口号是不能被多个进程bind的,端口号到进程必须是具有唯一性的。

接下来介绍两个工具,一个用于查看网络中连接状态的命令行工具netstat,一个是用于快速查看服务器进程id的命令行工具pidof

2.理解UDP报头

UDP协议的报头格式如下,因为UDP不需要保证可靠性,所以UDP报头的字段内容也会比较少,所以UDP通信起来比较简单。无论是学习什么协议,都要能够做到解包和分用,像我们之前自己制定应用层协议时,进行应用层报头和有效载荷分离时,我们是通过特殊分隔符\r\n的方式来分隔报头和有效载荷。而在UDP这里其实是通过固定报头长度的方式来进行有效载荷和报头的分离的,进行分用时,只要通过16位目的端口号就可以将数据向上交付给特定的应用层进程。
还有一个字段16位UDP长度,用于表示报文的整体大小。校验和我们一般不关心,如果校验和正确则证明该报文在传输过程中没有发生损坏,对端正常接收该报文即可,如果校验和出错,例如发生比特位翻转等问题,则对端直接丢弃该报文,该报文视为无效报文。

对UDP报头仅仅理解到上面那种层次是绝对不够的,上面仅仅是从逻辑层面的理解,我们需要将理解深入到代码层面才能更好的认识UDP报头。
传输层和网络层都是在linux内核中实现的,而linux内核是用C语言实现的,那UDP报头实际就是一个结构体,结构体成员变量实际就是UDP报头中的各个字段值,所以在分用时,只需要让指针指向数据包的前8个字节,然后将指针类型强转成结构体类型,然后读取里面成员变量的值,以此来实现分用。
在C语言中,即使是结构体数据,他其实也是二进制的字节流,如果想要将报头和有效载荷粘连在一块,我们可以开辟一大块char数组,然后将结构体数据按照字节流的方式拷贝到char数组中,然后紧接着再将有效载荷拷贝到里面,想要读取UDP报头进行分用时,可以直接将指针强转成结构体类型,然后进行成员选择,读取UDP报头的内容。

3.UDP的特点(面向数据报,全双工)

UDP最典型的特点就是面向数据报,创建socket时,内核会在传输层建立两个内核缓冲区,一个是发送缓冲区,一个是接收缓冲区,UDP在发送数据时,可以理解为不使用发送缓冲区,而是直接将数据包向下交付,这点和TCP面向字节流是极大不同的,面向字节流会使用发送缓冲区来提升发送效率,比如使用滑动窗口等策略。
数据报之间是有明确边界的,所以应用层在读取UDP传输层缓冲区中的数据时,只可能读取到一个完整的报文或者压根没有读取到报文(报文丢失),不会出现读取1个多报文,或者半个报文的情况,这点与字节流也是不同的,在TCP中,接收缓冲区中会包含多个数据包的有效载荷,这些有效载荷是按照字节流的方式被应用层读取的,所以应用层就可能会读取1个多报文的内容,也可能读取半个报文的内容,因为他是字节流的,报文和报文之间是没有明确边界的。
换种形象点的说法,UDP通信就像寄信一样,发送方将一个一个的信封寄到对端,对端接收的时候就会一个一个的接收信封,然后打开信封看里面的内容,而TCP通信就像把所有的信的内容写到一张大A4纸上,然后直接将这个A4纸发给对端,对端接收时,需要自己判断A4纸上的内容,从哪到哪是一封信的内容,从哪到哪是下一封信的内容。

并且从UDP和TCP通信时,使用的套接字编程API的不同,我们也可以看出UDP面向数据报和TCP面向字节流之间的差异,UDP是无连接的,所以UDP每次在发送数据时,都需要指定对端的socket地址,同样每次在接收数据时,也需要指定发送端的socket地址,保证在每次发送时,将一整个数据报都发送给对方,对方接收时,也是直接接收一整个数据报,所以sendto调用几次,recvfrom就会相应的调用几次。
而TCP是有连接的,TCP在发送数据时,首先send会将应用层数据拷贝到socket发送缓冲区中,而实际发送的时候,TCP有自己的发送策略,因为TCP叫做传输控制协议,比如滑动窗口策略,拥塞控制等等,所以一个完整的数据报在socket发送缓冲区可能并不会完整的发送到对端,而是会经过拆分或和其他报文组装之后再发送给对端,因为这样能够提升TCP数据传输的效率,这也是字节流的特点。所以send调用几次和recv调用几次,他们之间并没有必然的联系,可以调用send100次将100个字节的数据进行发送,对端可以调用recv一次读取100字节的数据,这就是字节流。

无论是UDP还是TCP,在创建socket时,内核会相应的创建出发送和接收缓冲区,而缓冲区其实有点像生产消费模型里面的环形队列,它可以使得通信双方解耦,应用层只要将数据拷贝到内核socket缓冲区之后,应用层就可以作别的事情了,至于数据什么时候穿过网络协议栈进行发送,发送过程中数据丢失怎么办?这些问题都是传输层来解决,应用层根本不关心!
所以我们说缓冲区是可以实现通信双方解耦的问题的,同时对于TCP来讲,发送缓冲区还可以提升TCP数据传输的效率,对于UDP而言,UDP并没有真正意义上的发送缓冲区,但实际他是有的,只不过这个缓冲区没有什么用,内核收到该数据后,会直接封装报头讲数据向下交付给网络层进行后续处理。

无论是UDP还是TCP,他们都是全双工的,因为双方都有一套发送和接收的缓冲区,这使得在一个时间点上,client既可以给server发送数据,server又可以给client发送数据,这极大提高了网络中通信的效率。缓冲区就像一个超市的存在,client的应用层就像producer,server的应用层就像consumer,这就是典型的生产消费模型,支持忙闲不均,使通信双方解耦。

另一个需要说明的点是,我们学习的所有网络级别的系统调用接口,并不是网络数据发送和接收的接口,而是拷贝接口!例如你在调用send sendto write时,实际上是把数据从你在应用层定义的缓冲区中拷贝到内核中的接收缓冲区,当数据继续向下贯穿协议栈时,传输层自己会将内核中的缓冲区的数据提取出来,然后添加上报头,向下交付给网络层,同样当你调用recv recvfrom read时,实际上也是把数据从内核缓冲区中拷贝到应用层的缓冲区,应用层的缓冲区实际就是我们定义出来的 char buffer,所以我们说网络IO接口实际就是网络拷贝接口。
值得注意的是内核socket缓冲区中存放的是传输层的有效载荷,是不包含UDP报头或TCP报头的。

UDP的报头中有一个16位UDP长度的字段值,所以UDP报文的最大长度就是2^16次方大小,也就是65536字节,如果应用层的报文长度超过65536-20的长度,则应用层需要自己手动的分包,分为多个报文进行网络数据传输,接收端自己收到数据后需要手动进行拼装。
做法其实很简单,因为UDP是面向数据报的,而且通过16位UDP长度这个字段值,在传输层可以轻松得到有效载荷的字节大小,所以在发送端应用层可以在拆分的报文中增加一个序号,比如在应用层的有效载荷的第一个字节的位置增加一个序号,然后再将拆分的各个报文向下交付,接收端在接收时,从传输层将有效载荷拿到应用层时,可以读取完应用层报头后,读取有效载荷时,判定该报文的序号,根据序号来重新组装。
(实际我说的还是有点麻烦了,我们可以将应用层报文的序号直接放到应用层报头里面,对端读取时,只要读完应用层报头就可以知道当前这个报文的序号是多少了。)
从UDP报头中的16位UDP长度,可以得到有效载荷的大小,我们也可以发现UDP面向数据报的端倪,为什么说UDP不存在黏包问题呢?因为接收方在自己的传输层,就可以通过UDP报头得到有效载荷的大小,每一个报文的有效载荷大小都可以确定,所以接收方在读取的时候是可以做到精准的只读取一个报文的,不存在黏包问题(多个应用层报文在内核缓冲区中,互相之间没有边界,引用层读取时,不知道当前读取的数据是一个数据包还是多个数据包黏在一块的)。

二、TCP协议

1.理解TCP报头+某些TCP的策略

1.1 TCP报头字段(TCP的黏包问题)

TCP全称为transmission control protocol,传输控制协议,人如其名,TCP对于数据的传输有着详细的控制,既能够通过多种策略保证他耀眼的可靠性,又能通过其他策略同时保证他的高效性。
学习任何一个协议,我们都需要考虑该协议如何做到报头与有效载荷分离,如何将分离后的有效载荷向上交付给上层,TCP协议报头有自己的标准长度20字节,如果报头中携带了其他的TCP头部选项,则报头就不止20字节,其中4位首部长度代表着TCP报头的大小,4位首部长度的值×单位5byte等于TCP报头的长度,所以TCP报头的长度在20字节-60字节范围之间。
拆分TCP报文时,只需要先读取标准头部长度20字节的内容,读到4位首部长度这个字段值,然后×5,这样就可以获得完整的TCP报文的长度,而剩余部分则为有效载荷,向上交付也非常简单,标准头部中有16位目的端口号,通过端口号就可以准确的将有效载荷交付给特定的应用层协议(使用该协议的process)


2.
现在有一个问题,TCP报头里面只有表征TCP报头长度的字段,却没有表征有效载荷大小的字段,那接收方在应用层读取TCP缓冲区中的有效载荷时,如何才能确定读取的是一个报文中的有效载荷呢?以达到没有多读或者少读的情况。
其实这也是TCP面向字节流的特点,这点与UDP面向数据报是完全不同的,TCP没道理解决你应用层能不能读取到一个完整报文的问题,TCP没有这个责任与义务,因为TCP是面向字节流的,TCP缓冲区中的数据就是一个个字节存放着的,并不像UDP那样面向数据报,从而有效载荷(传输层)之间有着明确的边界,这也正是字节流的特点。
那这个问题(其实就是黏包问题)谁解决呢?当然是应用层来解决,应用层需要定好协议,以便接收方在读取报文时,能够完整的读取一个报文,其实这个问题我们当时在写网络版本计算器时就解决过这个问题,我们当时通过\r\n作为特殊字符分隔符,来明确两个包之间的边界,应用层报头和报文之间有特殊分隔符,报文的末尾也有特殊字符分隔符。

1.2 网络协议栈和linux系统的联系(以port为键值的开散列哈希表,哈希桶存储port对应的PCB的地址)

前期我们在说端口号的时候,我们说传输层通过协议报头中的16位目的端口号,将数据交付给使用应用层协议的特定进程,我们这么理解确实没问题,但理解到这个程度还是不够深刻,我们需要将这个过程细化,将网络协议栈和Linux的文件系统联系起来,从而更好的理解传输层向上交付有效载荷这个过程。
实际上内核中会维护一个以port端口号为键值的开散列哈希表,哈希桶中会存放指向PCB结构体的指针,传输层通过port来向上将有效载荷交付给特定进程时,通过哈希表就可以快速找到特定的进程结构体了,所以向上交付不是一句空话,而是需要通过特定的数据结构来完成的。
除此之外,调用socket接口返回的sockfd其实就是文件描述符,该文件描述符其实就是fd_array数组的下标,该下标对应的位置中会存放指向文件结构体的指针,文件结构体内部会维护创建sockfd时,内核同时创建出来的接收缓冲区和发送缓冲区的指针,该指针名为struct sk_buff * sk_receive_queue/sk_send_head,所以收发缓冲区也不简单是一个数组,而是一个结构体。
网络在收到数据时,对端主机会先将数据放到用于通信的sockfd对应的文件结构体内部的接收缓冲区中,这样应用层就能以文件描述符的方式来读取网络数据,等上层调用recv recvfrom read等网络IO接口时,实际就是把sockfd对应的文件结构体内部的接收缓冲区中的数据拷贝到应用层缓冲区

1.3 从代码层面理解TCP报头(结构体数据)

理解TCP的报头和UDP报头一样,他们其实都是linux内核里面的结构体,当向网络层交付报文时,TCP会把发送缓冲区中的数据和TCP报头粘连在一起,然后统一向下交付。
我在从代码层面理解TCP报文统一向下交付的过程时,内核将结构体数据TCP报头拷贝到一个大的char buffer里面,然后紧接着在将TCP有效载荷拷贝到这个大buffer里面,这点其实很容易做到。下面的截图示范了如何将结构体数据拷贝到char数组里面,然后在读取数组内容时,完整的将结构体成员变量值给解释出来,其实就是将指针类型做一下强制类型转换就可以。
当然,内核是不是这样做的,我也不清楚,我这个水平还看不懂内核源码,单单从逻辑层面上理解,其实是可以这样理解的,并且顺带还能复习一下C语言的指针和结构体这方面的知识。


2.
我们只需要将结构体数据看成二进制流就可以了,其实也就是字节流,然后我们可以调用memcpy将结构体数据拷贝到char数组里面,需要注意的是在接收端接收到报文时,进行读取报头的时候,需要将指针强转为结构体类型,以此来对char数组前一部分的数据进行解释,拿到原本的二进制流语义,以此来读到报头中的各个字段。

1.4 确认应答机制(序号和确认序号,TCP面向字节流的特点)

接下来我们来讨论保证TCP可靠性的其中的一个机制:确认应答机制。
实际上为什么网络传输中会存在不可靠问题呢?本质原因还是因为传输距离过长
比如我在内蒙给广东的网友发送消息,那数据包其实是要经过很多的路由器结点进行数据包转发,穿过很多的局域网,在局域网内部经过双绞线(以太网技术常用的物理介质)传输,还要经过运营商的基站,数据包在如此之长的传输距离中很有可能会丢失,数据里面的比特位翻转,又或是数据包中的字节乱序,又或是数据包重复发送给我的广东网友(发送方可能以为数据包丢失了)。
TCP应该如何解决网络传输时的不可靠问题呢?需要确认应答acknowledgement
虽然数据包在网络中传输的距离过长,但只要我发送给我网友的消息有回复,有应答,那我就能判断我发的数据一定到达了我网友的主机上,比如我问我网友,你TCP/IP学的怎么样啊最近?我网友给我回复说,我最近正学TCP的确认应答机制呢!那我立马就可以肯定我发送的数据经过网络传输后,我的网友一定收到了,因为网友对我发送的消息做出了回复。同样的,如果我没回复我的网友,那网友也不敢确定他说的话,我一定收到了,因为我还没有给他发送的消息做出回复呢!而这就是典型的确认应答机制!
但其实你可以发现,我和我的网友在发消息时,总会有最后一条消息是没有被确认的,无论最后一条消息是他发还是我发,所以我们可以得出结论,TCP并没有绝对的可靠性,只有相对的可靠性!
所以TCP的可靠性永远不谈最新的消息,只谈论历史的消息,因为一定存在最新的一条消息是没有被应答的。

我们在学习TCP时,最常见的图就是左边的,但实际TCP的工作模式是右边的,发送消息时,会一次性发送一批数据段,确认应答时,也会一次性发送一批确认数据段。如果一个数据段不包含任何有效载荷,只有ACK标志位被置为1,那我们称这个报文段为单纯的确认数据段,不包含任何消息。

3.
谈论确认应答机制,绝对离不开TCP报头中的序号和确认序号,在TCP中,每一个被发送的数据段都有自己的序号或确认序号,而序号和确认序号的值其实就在TCP的报头中,在发送时只要将数据段报头中的相应序号或确认序号的值填充上即可。
序号比较好理解,相当于每一个数据段都有一个数字来唯一标识自己,但确认序号的定义却是这样的,确认序号的值表示接收方收到了ack序号之前的所有报文,而且是连续的报文,比如ack确认序号的值是11,那就代表接收方收到了10号及之前所有序号的报文,发送端下次从第11序号开始发送报文就可以了,所以确认序号的值从发送方的角度来理解,可以理解为发送方下一次发送报文时,报文的序号的值。
如果server收到了10 12 13序号的报文,则server向client发送确认数据段时,确认序号只能是11号,因为11号报文server没有收到,即使收到了12 13也不能ack14,因为必须保证ack确认序号之前的所有序号的报文都被收到才可以。

为什么确认序号要这么定义呢
其实这样定义是有原因的,后面讲滑动窗口时,就能知道确认序号的精妙所在了,它可以在某些情况下提高网络数据传输的效率。
client批量化发送数据段时,数据段到达server的顺序和发送时的顺序一定是一样的吗
这是不一定的,但数据到达server之后,数据段乱序其实也是长距离网络传输不可靠的一种表现,所以TCP在收到一批数据段之后,首先会根据数据段的序号进行排序,以此来保证数据段到达时的有序性,
为什么要有两个序号,一个序号不可以吗
因为TCP是全双工的,所以需要两个序号,一个报文段可以有双重身份,既可以有确认序号,又可以有序号,兼具确认应答和发送网络数据的作用。比如client发送10号报文段,内容是server你学完TCP了吗?server发送20号报文段,同时确认序号为11(通信双方发送的报文段序号是具有全局唯一性的,同时序号和确认序号可以协商,不会发生冲突的),内容是我学完TCP了。此时server发送的报文段就具有全双工的作用,既是给client发送报文段的确认应答,又是自己要发送给client的含有网络数据的报文段。这就是全双工通信。

实际上只要将应用层缓冲区的数据拷贝到传输层缓冲区中,天然的每个字节的数据就都有自己的序列号,这也就是字节流的特点,有效载荷之间无明显边界。
发送报文段时,报文段的序列号为,有效载荷的首个字节的数据在缓冲区中的序列号。
确认序号告诉发送者,发送者下次从哪里发送数据,以及接收方接收到了哪些数据。

1.5 流量控制(16位窗口大小)

流量控制可以说是既能保证TCP的可靠性,又能保证TCP的高效性,谈论流量控制,也一定离不开TCP报头中的16位窗口大小这一字段。
如果数据发送的太快,对端可能由于接收缓冲区一下子被打满,从而丢弃一些数据段。如果数据发送的太慢,对端的应用层调用recv阻塞式读取接收缓冲区中的数据时,可能会影响对端上层的业务处理,因为recv默认是阻塞式读取。所以在TCP这里,发送数据过快也不行,过慢也不行,速度必须要合适。
所以双方在发送数据时,必须要知道对方对于数据的接收能力大小,从而控制自己在给对方发送数据时的速度大小,而16位窗口大小表示的就是自身接收缓冲区中剩余空间的大小,告知对方自己对数据的接收能力,所以双方在通信时,每一次发送报文都会携带自己的16位窗口大小,交换双方自己对于数据的接收能力,以此来达到流量控制的目的。

现在有一个问题,发送方怎么在第一次发送数据的时候,就知道对方的接收能力是多少呢?
其实早在三次握手的阶段,双方就已经互相交换了各自的接收能力,握手期间就可以得知对方报头中的16位窗口大小的值,从而控制自己发送数据的速度大小。窗口大小的字段越大,网络对于数据的吞吐量就越高。
如果接收方的缓冲区被打满,16位窗口大小值为0,发送端得知对方缓冲区已经没有剩余空间后,会停止发送报文,但会每隔一段时间发送一个窗口探测报文,接收方会返回一个窗口更新通知,当接收方的缓冲区有剩余空间之后,双方又会继续完成通信。
除此之外,如果你想扩大16位窗口的大小,在TCP40字节的头部选项中有一个窗口扩大因子选项,扩大因子位M,M的范围是0-14,修改过后,实际窗口大小会扩大2^M倍数,值得注意的是,窗口扩大因子选项只能出现在同步报文段中,如果出现在其他通信报文段,则该选项将被忽略。当连接建立好之后,窗口扩大因子会固定不变。这个选项了解一下即可,用的不多。

1.6 TCP报文段的类型(6个标志位:详解URG和RST)

TCP报头中还有6个标志位,不同的标志位代表不同类型的报文段,服务器会收到来自不同的大量的客户端的报文段,而每个报文段都会有自己的类型。
SYN:同步报文段,用于TCP三次握手建立连接时的连接请求
ACK:确认报文段,对历史报文段进行确认应答
URG:表示紧急指针是否有效
FIN:结束报文段,用于断开连接,进行TCP四次挥手
RST:复位报文段reset,表示重新建立连接
PSH:催促接收方应用层尽快从传输层接收缓冲区中取走数据,为后续到来的数据腾出空间。

16位紧急指针表示的是紧急数据在有效载荷中的偏移量,TCP规定死紧急数据只能有1字节,当URG标志位被置为有效时,紧急指针就会派上用场,接收方在读取TCP报头之后,会首先从紧急指针表示的偏移量处读取1字节的紧急数据,然后再重新从有效载荷的起始位置读取完成剩余的数据,这1字节的紧急数据我们一般称为带外数据。
实际上紧急指针和URG标志位我们在99.99%的情景下都用不到,如果真要是用带外数据,可能运维的一些人员会用到,比如服务器现在压力非常大,可以发送1字节的带外数据用于询问当前服务器的状态怎么样,有没有恢复到健康状态,服务器可以返回1字节的带外数据,用1字节的数据来对应状态码,返回服务器是因为什么原因而导致过载,因为带外数据不用经过冗长的数据流,可以直接在应用层读取。
所以带外数据实际上并不在正常的数据流中,一般用带外数据的也就是UDP和TCP协议了。如果想要读取带外数据,可以将recv的flags标志位按位或上MSG_OOB,这样就可以读取带外数据了。

三次握手建立连接并不一定能够成功建立连接,没人说三次握手一定能够成功,同样四次挥手也一样,就算连接建立成功了,那也是有可能断开的,比如单方面的将服务器主机电源拔掉,那连接不就会自动断开吗?等服务器重启的时候,服务器不认为连接建立成功,但client还认为连接存在着,所以client就会给服务器一直发消息,服务器就会感觉很奇怪,连接都已经不存在了,你为什么还要和我通信呢?所以此时服务器就会给client发送一个复位报文段,其报头中的RST标志位被置为1,告诉client说,你别再给我发消息了,连接早就异常断开了,你再重新发起三次握手,重新和我建立连接吧。
所以复位标志位用于通信双方中,任何一方认为建立连接不一致时,认为连接异常的一方会发送复位报文段,告知对方我们需要重新建立连接。

下面这样的场景很多人应该遇到过吧,其实就是因为服务器压力过大,无法承载更多的连接,导致连接被重置,连接异常,而服务器向我们的浏览器发送的报文段就是复位报文段,让客户端重新和服务器建立连接。

1.7 超时重传机制(数据包在超时时间窗口内没有收到应答,则判定为丢包进行重传)

我们之前谈论过网络传输中不可靠问题之一:丢包,超时重传就是为了解决丢包问题。
丢包分为两种情况,一种是数据段真的丢了,一种是数据段发送过去了,但ACK报文段丢了,其实这两种情况,发送方是无法分辨出来的,发送方只能规定在一个时间段内,如果发出去的报文没有收到确认应答,则该报文就被认定为丢包,就好比某些家长的孩子找不见了,这个孩子可能仅仅是走丢了,也可能失去life了,也有可能被人贩子拐跑了,家长其实无法确定孩子到底去哪了,只能从时间维度上来判断,如果1年或更长的时间孩子都没有回来,那家长就认为孩子丢了。

如果是第二种丢包情况,那接收方会收到两个一模一样的报文,此时,接收方会对报文按照序号进行排序+去重,保证收到的报文是可靠的,因为重复报文也是不可靠表现的一种。


3.
我们知道发送的数据段是有可能没有收到ACK的,所以被发出的数据不应该立马被移除(计算机上的移除其实就是数据覆盖),应该先保存一段时间,如果发送的数据丢包了,则可以将保存的数据再重新发送,而像这样已经发送但没有收到ACK的数据,其实是存放在滑动窗口里面的,这个后面会讲,现在先提一下。

超时的时间怎么定呢?其实这个时间应该是随着网络情况动态变化的,如果网络情况好,超时时间设定的非常长,这其实就会影响网络传输的效率,因为数据包发送的速度非常快,可能数据包来回一次共需要50ms,但你将超时时间设定为500ms,那中间的450ms的时间就会被平白无故浪费掉,如果网络情况特别差,超时时间设定的非常短,那更离谱了,数据包正在传输的过程当中就被判定为丢包了,这同样也会影响数据传输的效率。
所以最理想的情况,就是找出一个最短的时间,保证绝大部分的网络情况下,数据包都可以在这个最短的时间窗口内,发送过去,同时ACK报文能够发回来。

在linux(unix和windows也一样)中,超时实际上是以500ms作为基本单位来进行控制的,如果第一次重发后,还没有得到确认,则会以2的指数幂×500ms的方式来逐渐增大超时的时间窗口,累计达到一定重传次数,则TCP会强制关闭双方建立的连接。

2.连接管理机制

2.1 为什么要三次握手?(最小成本验证全双工通信+防止产生单主机对服务器进行SYN洪水攻击的漏洞)

三次握手是TCP建立连接的过程,客户端先给服务器发送一个SYN报文段,表示客户端想和服务器建立连接,然后服务器确认应答客户端的连接请求,同时服务器也想和客户端建立连接请求,所以服务器会发送一个捎带应答的报文段,报文段既是服务器想和客户端建立连接的SYN报文段,同时兼具确认应答的作用,当客户端收到来自服务器对他自己发送的SYN报文段的确认应答后,客户端则会认为连接已经建立成功了,客户端收到来自server的SYN报文段后,客户端也会向server发送一个ACK确认报文段,当server收到ACK报文段之后,server则也会认为连接建立成功了,当双方各自都认为连接建立成功后,那么双方实际上就可以完成通信了,这就是三次握手的整个过程。

我们以前谈论过TCP只能保证历史消息的可靠性,永远不谈最新一条消息的可靠性,这句话没问题,比如三次握手的最后一次握手,这个消息的确就是不可靠的,因为客户端发送的ACK报文段,server是否收到,客户端是不知道的!但这没有关系,就算ACK报文段丢失了,那server不会认为连接建立成功,此时如果client给server发送消息,则server会感觉很奇怪,既然连接不建立成功,你还给我发消息,那就说明我们双方产生了认为连接建立不一致的情况,那server就会给client发送复位报文段,请求重新三次握手,重新建立连接,因为我们现在连接的建立是不一致的,client认为连接建立成功,但server不认为成功。或者还有另一种情况,server发送的SYN报文段超时没有确认应答,则server就会进行超时重传,当client收到重复的捎带应答报文段时,client就会意识到自己给server发送的确认应答报文段可能丢失了,此时client就会重发ACK报文段。
所以,三次握手的最后一个报文段无论出现什么样的不可靠行为,我们都不用担心,TCP会有相应的策略解决不可靠问题,比如超时重传,双方认为连接建立不一致时,认为连接不存在的一方可发送复位报文段,重新建立连接。
还需要说明的一点是,三次握手建立连接是站在双方各自的视角,不是站在上帝的视角认为连接建立成功,那就成功了。比如client只要收到了server的ACK报文段,那client就会认为连接已经建立成功了,也就是三次握手的第二次握手完成之后,client就已经认为连接建立成功了,所以只要双方自己认为连接建立成功,那就算连接建立成功。

所以没人说三次握手一定会成功,但其实在良好的网络环境下,99.99%的三次握手都会成功,只能说三次握手大概率都会成功,我们最担心的其实是第三次握手的报文段丢失,因为他是最新的消息,他是不可靠的,不过TCP对于这样不可靠消息是有解决方案的,所以也不用过多的担心。
另外服务器是会收到来自大量的不同的客户端的连接请求的,所以服务器是需要将这些大量的连接管理好的,因为我们知道传输层是在服务器主机的OS内部实现的,而操作系统需要管理连接,那采用的方式就一定是先描述,再组织,所以所谓的连接在OS中,一定是需要相关的结构体来描述的,然后再通过某种数据结构将大量的连接对象(连接结构体)管理起来,则可以得出结论,维护一个连接是有成本(时空成本)的。

谈论完三次握手的详细过程之后,我们来谈谈为什么一定是三次握手?
首先一次握手肯定不行,因为单个client主机给server发送一堆SYN报文,那么server就需要维护好建立好的连接,而我们知道建立连接是有成本的,所以server在单主机情况下就会遭受SYN洪水攻击,使服务器挂掉。
两次握手也不行,服务器依旧会遭受到SYN洪水攻击,client给server发送SYN报文段,server那就需要维护连接,同时server也会给client发送SYN+ACK,但client可以忽略掉这个报文段,直接把他丢弃掉,那client就不用维护连接,所以两次握手也是不可以的。因为在单个client主机攻击的情况下,服务器都遭不住了,更别说大量的client主机了。
那三次握手为什么可以呢
a.三次握手能够以最小成本验证全双工通信 。全双工通信指的是server和client各自都能够进行数据的接收和发送,发送和接收是解耦的,client可以发送SYN报文段,也能接收来自server的ACK报文段,server可以发送SYN报文段,也能接收来自client的ACK报文段,话说回来,四次握手可以验证全双工通信吗?当然也可以,但既然三次握手行,为什么要四次握手呢?第四次握手不是平白无故的消耗网络资源吗?所以三次握手是以最小成本来验证全双工通信的。
b.三次握手可以防止产生单主机对服务器SYN洪水攻击的漏洞 。其实服务器受到了攻击,这本身就不应该是TCP来解决的问题,这是网络安全的话题,我TCP只负责进行数据传输的控制,但如果因为你TCP建立连接的机制有明显的被攻击的漏洞,那这就是你TCP的问题了,我们知道一次握手和两次握手都存在明显的被SYN洪水攻击的漏洞,那三次握手就不存在吗?答案是不存在。
三次握手中,server建立连接的前提是client已经建立好连接了,所以client和server是同等成本的消耗,想要让server建立连接,首先client你自己就得先建立好连接,而大部分情况下服务器的配置要比client高很多,所以如果双方在不停的以相同成本进行消耗,那也一定是client先扛不住,而不是server,所以单主机的情况下,client想要SYN洪水攻击服务器,这是不现实的!(这就有点像,你朋友给你吃一个很酸或很辣的捉弄你的零食,但你说必须你先吃,我才会吃,这就是双方进行同等成本的消耗)
四次握手,五次握手可以吗
前面我们说过三次握手已经可以最小成本验证全双工通信了,那四次,五次握手肯定也可以。但四次握手也存在SYN洪水攻击的漏洞,最后一次握手是server发送给client的,让client来建立连接的,但client可以忽略掉这个报文段,只让server自己建立连接,去承担维护连接需要的成本。同时五次握手由于最后一次是client发送给server的,所以server建立连接之前,client也会建立连接,双方是同等成本的消耗,则可以避免单主机的SYN洪水攻击。所以三次握手之后的其他握手,偶数次依旧存在SYN洪水攻击,奇数次不存在SYN洪水攻击,但他们都不是最小成本验证全双工通信信道,所以使用三次握手,而不是其他握手!

实际上,如果真想搞掉服务器也很简单,就是让多个肉鸡同时连接一个服务器,所谓的肉鸡其实只是一种网络说法,肉鸡就是客户端,只要服务器接收来自大量的客户端连接,那服务器就要为此承担很多成本,最后的结果就是服务器过载严重,无法接收来自其他正常客户端的连接请求,这样的攻击称为ddos攻击,即服务拒绝式攻击。其实只要肉鸡足够多,什么服务器都能够打的下来,最高端的攻击往往采用最朴素的方式,2001年,我们国家和外国的黑客进行网络攻防大战时,我们国家的红客就打下过白宫的服务器,采用的方式其实就是SYN洪水攻击,当时由于美国对我们国家做出的种种欺压行为,我国红客在5月4日青年节的同一时间段内,8w台主机同时访问白宫官网,并在首页挂上五星红旗,以及让美国舰艇归回去的愤慨之词。

现在对于SYN洪水攻击,有了更多的安全机制,例如黑名单,白名单,防火墙,SYN cookie机制等等安全策略,能够缓解SYN洪水攻击。
以前的12306由于使用人数过多,尤其在逢年过节时服务器压力非常大,服务器很容易就会挂掉,直到弹性云服务器的出现,才解决了12306负载严重的问题。

2.2 双方四次挥手时,状态的变化(理解CLOSE_WAIT,TIME_WAIT状态)

2.2.1 四次挥手的详细过程

建立连接是由一方主动发起,大部分都是客户端主动向服务器发起连接请求,但断开连接可以由任意一方主动发起,发起的上层条件,其实就是调用close()关闭套接字文件描述符sockfd,当client发送完毕消息之后,可以发送一个结束报文段FIN,服务器发送ACK应答,确认client断开连接的请求,同样服务器也可以不给client发送消息了,他也可以发送一个结束报文段FIN,客户端发送ACK应答,确认server断开连接的请求。
我们这里所说的不发送消息,指的是不发应用层的数据了,并不代表传输层自己不能发送该层的管理报文段,例如FIN,ACK等等报文段。


2.
有一个问题,为什么TCP是有连接的呢?是因为TCP要保证可靠性,但为什么TCP连接能够保证可靠性呢?其实连接并不能直接保证可靠性,但他能够间接的保证可靠性。
当连接建立好之后,内核会创建连接结构体,用于管理连接,而正是连接结构体的建立,才能够更好的完成TCP保证可靠性的种种策略,例如超时重传(结构体内部维护定时器),确认应答(结构体内部有序号和ack序号字段),流量控制(结构体内部有16位窗口大小),所以TCP连接结构体是TCP保证可靠性的数据结构基础。

可以看到下面连接在四次挥手时有很多的状态:FIN_WAIT_1,FIN_WAIT_2,TIME_WAIT,CLOSED,LAST_ACK,CLOSE_WAIT,这些状态其实就是连接结构体内部的一个属性字段,就像int status;一样,而这些大写英文的状态其实就是宏,不同的宏代表不同的连接状态。这么多状态最重要的就是CLOSE_WAIT和TIME_WAIT状态,要好好理解。
如果客户端先调用close(sockfd)主动断开连接,向服务器发送FIN报文段,则客户端进入FIN_WAIT_1状态,服务器收到报文段之后,会返回一个ACK报文段,同时服务器进入CLOSE_WAIT状态,服务器调用close(sockfd)断开连接时,向客户端发送FIN报文段,同时服务器进入LAST_ACK状态,客户端收到FIN报文段之后,向服务器返回一个ACK报文段,同时客户端进入TIME_WAIT状态,当服务器收到ACK报文段之后,服务器进入CLOSED状态,客户端进入TIME_WAIT状态时,需要等待2MSL(Max Segment Life报文最大生存时间)的时间才会进入CLOSED状态。

所以,主动断开连接的一方最终状态是TIME_WAIT状态,被动断开连接的一方,完成最初的两次挥手后,状态变为CLOSE_WAIT状态。
这个实验很好做,我们只需要让建立好连接的client率先终止进程,断开连接,则通过netstat便可以查看出服务器连接的状态,server的TCP连接处于CLOSE_WAIT状态,client的TCP连接处于FIN_WAIT2状态,由于客户端和服务器是在一台云服务器上作测试的,所以查看网络状态时,我们分别能够看到client和server的连接状态。
如果我们的服务器出现了大量的CLOSE_WAIT状态,一般有两种情况,一种是服务器存在bug,服务器代码中没有close(sockfd),这会导致服务器无法完成最后的两次挥手。另一种情况是服务器现在压力可能比较大,比如忙着向client推送消息,导致来不及执行close(sockfd),不过这种情况只是暂时的,等服务器缓过来之后,是能够完成后两次挥手的。

如果要测试CLOSE_WAIT状态,则可以把HandlerHttp(sockfd);下面的close代码屏蔽掉,这样当客户端断开连接时,服务器并不会调用close(sockfd),也就是不会完成后两次挥手,则服务器状态会一直持续CLOSE_WAIT状态。

如果要测试TIME_WAIT状态,就是下面第二张图片那样,一个正常的服务器会在客户端退出之后,自己也会退出,调用close(sockfd)完成后两次挥手,使得连接完全断开。服务器自己退出时,其实就是read读到0,代表client已经退出了,服务器此时也应该退出。

2.2.2 测试TIME_WAIT状态

下面是测试TIME_WAIT状态的过程,可以看到服务器代码中,建立好一个连接之后,会和客户端进行一次通信,也就是执行HandlerHttp方法,但只要服务器执行完这个方法,服务器就会执行close(sockfd),所以只要让服务器是被动断开连接的一方,并且四次挥手全部完成,服务器最终状态就会是TIME_WAIT状态,在动图中可以看到,客户端是主动断开连接的一方,服务器被动断开连接。

2.2.3 主动断开连接的一方为什么要维持2MSL时间的TIME_WAIT状态?

为什么四次挥手结束之后,主动断开连接的一方要维持一段时间的TIME_WAIT状态呢?而且这个时间是2MSL(maximum segment life)
理由1:保证主动断开连接的一方,最后一次挥手发送的ACK报文尽可能的让对方收到,因为主动断开连接的一方还可能会补发最后一个ACK报文 。(书上的概念:可靠的终止TCP连接)
MSL指的是一个报文段从左到右或从右到左的最大生存时间,注意是最大生存时间,这样的最大生产时间,即使网络情况很差,在一个MSL内,报文段都可以到达对方主机了,如果网络情况较好,都不需要一个MSL时间就可以到达对方主机。
如果最后一个ACK报文丢失,则服务器首先会没有收到ACK报文,然后服务器会重发FIN报文段,客户端再次收到FIN报文段时,客户端就会意识到最后一个ACK报文段可能丢失了,则客户端会补发最后一个ACK报文段,而服务器判断ACK报文段丢失+自己重发FIN报文段,这两个时间最大就是2MSL,所以持续2MSL能够有效的保证最后一个ACK报文即使丢失了,主动断开连接的一方依旧能够有能力再次补发最后一个ACK报文段。
理由2:双方在断开连接的时候,网络中可能还存在滞留的报文,保证滞留的报文消散 。(书上的概念:保证让迟来的TCP报文段有足够的时间被识别并丢弃)
有可能有一种情况是,当服务器发送FIN报文段后,有一个携带信息的报文段(比如6号报文段)晚于FIN报文段发送给客户端,如果客户端处于TIME_WAIT状态,则可以有足够的时间来识别6号报文段并把他丢弃掉,也就是让网络中可能存在的滞留的报文彻底消散。
而持续2MSL的时间,可以让两个传输方向上尚未被接收到的,迟来的报文段都能够消散,例如左边给右边发送一个迟来的报文段,右边收到后同样会返回一个迟来的报文段,而这一来一回的时间最多就是2MSL

MSL的时间大小,可以在下面的路径中查出来,默认为60s

2.2.4 解决服务器立即重启无法bind原来端口号的问题

以前在socket套接字编程的时候,我们会遇到服务器有时立即重启无法bind原来的端口号,但有时却又可以bind原来的端口号,其实就是因为TIME_WAIT状态。
如果你先关闭客户端,后关闭服务器,则服务器最终状态是CLOSED状态,此时你立即重启服务器bind原来的端口号,就不会出现bind error的问题,因为端口号并没有被占用着。
如果你先关闭服务器,则服务器的最终状态是TIME_WAIT状态,此时你立即重启服务器bind原来的端口号,这一定是bind不成功的,因为原来的服务器进程还占用着8080端口号(拿8080举例),你现在重启服务器,又bind8080号端口,当然就会报错bind error了。因为一个端口号只能被一个进程绑定,一个进程是可以bind多个端口号的,因为一个进程可以打开多个文件描述符sockfd。

服务器立即重启无法bind原来端口号是一个很严重的问题,比如京东618期间,服务器挂满了大量的连接,如果由于连接数的不断增多,服务器不小心挂掉了,那服务器是需要立马重启的,如果此时服务器无法bind原来的端口号,而导致被迫等待2MSL的时间,也就是120s,那所有的用户此时就无法进行购物,京东无法提供服务,618期间,1s就是上百万的营业额,要是等120s,公司得亏损多少啊,所以服务器必须能够立即重启且能够bind原来的端口号,大型公司的服务器他们bind的基本都是知名端口号,在公司内部一旦一个服务器bind了一个端口号,轻易是不会换端口号的。

解决的方式也并不困难,只需要设置sockfd选项为重用本地地址SO_REUSEADDR,即使服务器(主动断开连接)的sockfd对应的连接结构体处于TIME_WAIT状态,与sockfd绑定的socket地址(struct sockaddr_in local)也可以立即被重用,这样就可以实现服务器立即重启依旧能bind原来的端口号了。

除了设置SO_REUSEADDR选项外,还可以修改内核参数/proc/sys/net/ipv4/tcp_tw_recycle来快速回收被关闭的sockfd,回收其相关的所有资源,例如连接结构体,socket地址等等,从而使得主动关闭TCP连接的一方根本就不进入TIME_WAIT状态,进而允许服务器进程能够立即重用本地socket地址。摘自:《Linux高性能服务器编程》

3.TCP的高效性

3.1 滑动窗口(批量化发送数据段+支持超时重传机制)

之前我们在谈论确认应答的时候,对每一个发送的数据段,都要有一个ACK确认应答,收到ACK之后,再发送下一个数据段,这样的工作效率是非常低的,实际当中是批量化的发送一批报文,确认应答时,可能只会返回一个ACK报文段,而实现批量化发送其实就是依靠接收缓冲区中的滑动窗口,批量化的数据段可以在一个时间点上直接发送,大大提高了TCP的传输效率。
除提高效率外,滑动窗口其实还支持了超时重传机制,我们知道数据段在发送的时候,是可能存在丢包问题的,而解决丢包就需要重传数据段,所以未收到ACK的数据段不应该立马被移除,而应该先暂存一段时间,而这样未收到ACK但已发送的报文段,其实就是在滑动窗口中暂存着的。


2.
其实我们可以在逻辑上将发送缓冲区分为4个部分,从左到右依次为:已经发送同时被ACK的数据(这部分数据可以被新数据覆盖),已经发送但没有被ACK的数据(这部分数据不能被新数据覆盖),尚未被发送的数据(刚刚从应用层缓冲区中拷贝下来的数据),剩余的没有数据的空间(其实开辟空间时,有初始化的数据)
所以,随着滑动窗口的右移,右边的数据就会被逐渐发送,左边的数据会被应答,如果需要重传数据段,则重传的就是滑动窗口中的数据段。

我们可以将缓冲区看作一个大的buffer,而实际上所谓的滑动窗口,其实就是buffer中win_start和win_end两个数组下标之间构成的空间,滑动窗口移动,其实就是win_start++和win_end++,当滑动窗口内的数据被ACK确认应答了,滑动窗口就会右移。
但我们对滑动窗口的理解就止步于此了吗?当然不是!这仅仅只是一个开始而已!


4.
接下来我们要提出许多问题,通过解答这些问题来加深对滑动窗口的理解。
(1)滑动窗口的大小是怎么设定的?未来大小又是怎么变化的?
滑动窗口的大小要始终和对方的接收能力挂钩,因为滑动窗口的大小=一次批量化发送数据段的多少,我们知道TCP有流量控制,而一次批量化发送数据段的多少,其实是由对方的16位窗口大小和网络的拥塞情况(下面的拥塞控制就会讲到,现在先提一嘴)共同决定的,所以滑动窗口的大小=min(16位窗口大小,拥塞窗口大小),如果随着网络情况变好,同时对方接收能力也提升上来,那滑动窗口自然就会变大,而无论是网络情况还是对方接收能力,只要有一个下降,滑动窗口自然就会变小,因为滑动窗口大小是取两者的最小值。
初始时,win_start=0,win_end=win_start+tcp_win(对方的接收能力),这么认为其实是不对的,因为还要考虑网络的拥塞情况,不过暂时这样理解是可以的,大部分情况下网络都是良好的
(2)窗口一定会向右滑动吗?能不能向左滑动呢?
滑动窗口不一定向右滑动,有可能保持不动,比如发送端发送的报文都丢包了,或者发送报文的ACK丢包了,这两种情况滑动窗口都会保持不动。当然一般正常情况下,滑动窗口都会右移的,比如最左侧报文段收到ACK,那滑动窗口就是会右移的。
但滑动窗口一定不会向左滑动,左边的数据都是已经被ACK的,而滑动窗口内的数据是未被ACK的,向左滑动是不合理的!
(3)滑动窗口大小会保持不变吗?会变大吗?会变小吗?变化的依据又是什么?
滑动窗口大小会保持不变,比如上面我们说的两种丢包的情况,滑动窗口的大小和位置都会保持不变。
滑动窗口是会变大的,比如对方应用层将socket缓冲区内的数据全部拿走,缓冲区的剩余空间一下子增多,同时网络情况也一直很良好,那么滑动窗口就可以增大,发送数据时就可以一次批量化的发送更多的数据了。
滑动窗口也是会变小的,比如对方的应用层就是不拿走传输层的数据,则随着对方接收缓冲区不断接收数据段,则对方的接收能力就会下降,而此时滑动窗口也会跟着下降。
(4)收到ACK的报文如果不是窗口左侧的报文,而是中间的,或者是右侧的,该怎么办?窗口还要滑动吗?
收到ACK的报文是中间的(右侧的也一样),一般有两种情况,一种是左侧报文段丢失了,另一种是左侧报文段没丢失,但对应的ACK报文段丢失了。
对于第一种情况,在滑动窗口中,假设丢失报文段的序号是1000,发送成功的报文段的序号是2000,所以丢失的其实就是1000序号-1999序号这1000字节的数据,而后面发送的报文段都得到了ACK,但值得注意的是这些ACK报文段的确认序号是什么呢?我们之前学习确认应答机制的时候,知道确认序号表示的是,ack序号之前的所有数据都已经收到了,所以这些返回的ACK报文段的确认序号就全部是1000,此时发送端就知道1000号报文段在传输过程中丢包了!那就会触发超时重传机制。
对于第二种情况,如果仅仅只是ACK报文段丢失了,那后面发送成功的报文段对应返回的ACK报文段的确认序号就会是正常的,而这种情况并不会产生任何问题,滑动窗口正常右移即可。
(5)滑动窗口会变为0吗?
会的,如果对方接收能力为0,则滑动窗口也会为0,比如对方缓冲区被打满,其上层还不取走缓冲区中的数据,则此时接收能力也就是16位窗口大小的值就会为0
(6)滑动窗口一直向右滑动吗?如果剩余空间不够了该怎么办?

实际上发送缓冲区被内核维护成了一个环形结构,所以滑动窗口确实会一直向右滑动,而所谓的环形结构其实就是通过模运算来实现的,当滑动窗口的位置发生变化时,win_start和win_end会随之增大或不变,在变化之后可以让win_start和win_end%=缓冲区的大小,防止下标越界,这样其实就维护好一个环形队列了。

3.2 拥塞控制

3.2.1 拥塞控制和超时重传机制(大面积的丢包:拥塞控制,小面积的丢包:超时重传)

之前我们谈论的所有TCP策略和机制,其实都是在谈通信两端,没有谈论中间网络数据传输的环节,丢包除了因为双方的问题,还有可能因为中间环节网络出现了问题,而由于网络异常或压力过大导致的丢包,需要TCP进行拥塞控制。所以滑动窗口的大小不仅需要考虑对方的接收能力大小,还要考虑中间环节网络的情况如何。
TCP引入了许多的机制来保证网络数据传输的可靠性,例如,流量控制,超时重传,确认应答,连接管理,同时也引入了滑动窗口,拥塞控制等机制来保证网络数据传输的高效性,只不过TCP的可靠性过于耀眼,导致很多人忽略了TCP的高效性,但实际上TCP也是非常高效的。
所以你敢说UDP一定比TCP更高效吗?虽然网上有很多人这么说,但我不敢这么说。

如果clien给server发送一批数据段,只有几个数据段丢失了,那client并不会觉得怎么样,直接超时重传即可,但如果丢失了非常多的数据段,则client会认为此时是网络出现问题了,因为在流量控制机制的管理下,发送的一批数据段一定是符合对方接收能力的,此时如果出现大面积的丢包,则一定是网络环境出现了问题,而如果网络出现了问题,就需要TCP的拥塞控制来缓解网络压力。
所以TCP的可靠性不仅仅考虑了通信双方可能出现的问题,同时还考虑了中间环节网络可能出现的问题。

如果此时发生了大面积的网络丢包,那TCP还能采取超时重传的策略吗?
需要知道的是,网络中通信的可不止你和服务器这两台主机,还要其他主机也在通信,如果TCP采取超时重传策略,那所有的主机都会采取超时重传策略,本来网络的压力就已经够大了,结果所有的主机还在不停的向网络中疯狂的塞报文,那造成的结果就是又一次的大面积丢包,因为此时网络环境已经出问题了,比如带宽太窄,网络数据拥塞。所以重传只会加剧网络故障问题。
正确的做法应该是让所有的主机都遵守停下来的机制,网络是有自我恢复的能力的,只要所有主机都暂时不发送报文,或者仅仅只发送少量的报文,则等待网络恢复之后,再继续通信,这才是正解。而当网络中出现大面积丢包,所有主机停下来(只是形象化的说法,实际是让主机只发送少量的探测报文),等待网络恢复的机制其实就是拥塞控制。

3.2.2 拥塞窗口的引入,MSS和SMSS

当网络出现拥塞时,发送端会发送拥塞窗口大小的探测报文段,用于探测网络状况如何。
而拥塞窗口大小的单位其实就是SMSS(Segment Maximum Segment Life 分段最大段大小),SMSS是在一个TCP连接中,发送端可以发送的最大数据段的大小。SMSS的值是根据网络条件和对方接受能力共同考虑得出的估计值,SMSS用于优化数据传输和拥塞控制,即使在非常差的网络条件下,SMSS也大概率能够发送到对端主机。
SMSS并不是在TCP三次握手阶段通过选项字段来协商决定的,而是在建立连接之后,实际进行通信时,发送方根据网络和接收方的反馈动态调整的,所以拥塞窗口的单位大小就是SMSS,例如发生网络拥塞时,主机会按照2的指数幂的大小发送具体个数的SMSS报文。


2.
MSS(Maximum Segment Size)是TCP三次握手阶段,通过kind=2最大报文段长度选项来协商的,MSS指的是TCP模块发送报文时最大支持的报文段长度,通常这个值是MTU-40,因为MTU(Maximum Transmission Unit)是数据链路层最大的传输单元,减去的40字节包括TCP头部20字节和IP头部20字节,假设TCP和IP头部都不包含选项字段,并且这也是一般情况。
所以控制TCP最大报文段为MTU-40,可以有效避免IP层分片的情况,因为IP分片与组装会平白无故增加很多的网络消耗,如果我们能够避免分片,那我们就应该尽量这么去做。

总结一下,MSS是在三次握手阶段协商出来的TCP层最大报文段大小,如果在TCP头部选项设置了MSS的大小,则在实际通信时,无论你发送什么报文段,都不能超过这个大小。
而SMSS是在三次握手成功之后,实际在通信时,发送方根据网络和对方接收能力反馈,动态调整的报文段大小,其实就是拥塞窗口的单位大小。

3.2.3 慢启动算法+拥塞避免算法(阈值前指数增长,阈值后线性增长)

慢启动是拥塞控制的一个非常好的策略,当网路发生拥塞时,TCP会进行慢启动,以2的指数幂递增的方式来发送SMSS大小的报文段,我们知道指数增长其实是爆炸式增长,前期可能增长的比较慢,但只要增长起来,后面增长的就会越来越快。
而这正好适合拥塞控制,前期网络状况不好,正好可以慢启动,发送少量的SMSS大小的报文段,正好可以让网络状况快速的恢复,后期网络状况恢复好了,同时拥塞窗口也增长到很大了,正好可以支持大批量的数据段传输,让网络数据传输的效率快速提升上来。

拥塞窗口实际上是有一个动态调整的阈值ssthresh(slow start threshold),当拥塞窗口前期以2的指数幂方式不断增大时,一旦超过了这个阈值,则拥塞窗口会线性增长(每次+1),当网络再次出现拥塞时,拥塞窗口重新被置为1,同时阈值重新调整为发生网络拥塞时,拥塞窗口大小值的一半,阈值的初始值为窗口最大值65535byte,上图中拥塞窗口以SMSS为单位来显示CWND,但实际上他是以字节为单位的,同时横坐标以RTT(Round-Trip Time 往返时延)为单位,图中所画的初始ssthresh大小为16SMSS,但实际是65535byte。

拥塞窗口既保证了可靠性,发生大量丢包时,进行慢启动等待网络状况恢复,同时也保证了高效性,拥塞窗口不断变化,这也能同时调整滑动窗口的大小,保证数据传输的高效性,即照顾了网络状况,同时又能够使数据传输的效率提升上来。

3.3 延迟应答 && 捎带应答

延迟应答是提高数据传输效率的一种手段,如果接收方立马返回ACK应答,返回的窗口可能比较小,但如果延迟一段时间,在这段时间里,可能接收方的上层拿走了缓冲区中的数据,此时在返回ACK应答,ACK应答里面的窗口大小就会比较大了,发送方下次发送的数据量就可以更多了,这就可以提高数据传输的效率。
值得注意的是,并不是只要延迟了,上层就一定会在这段时间内拿走缓冲区中的数据,这是概率性事件,只是说大概率上层会拿走,如果拿走,那恰巧就可以提高效率,没拿走,那也就只能没拿走呗,这个世界上没有绝对的事情,任何事情都是概率性的,只不过分为大概率和小概率,延迟应答也一样,只不过他是较大概率的事件。

一定要记得,窗口越大,网络吞吐量就越高,传输效率也就会越高(一次传输的数据更多了嘛),TCP提高效率的机制就是保证在网络不拥塞的前提下,尽可能提升传输效率。
所有的包都可以延迟应答吗?其实是有限制的。
数量限制:每隔N个包就应答一次,一般N取2(不同version的OS可能会有差异)
时间限制:超过最大延迟时间就应答一次,一般最大延迟时间取200ms(不同version的OS可能会有差异)

捎带应答也是TCP提高数据传输效率的一种手段,这个我们很早之前就见过了,比如三次握手的第二次握手阶段,server也想和client建立连接,则server会向client发送一个SYN报文段,而这个SYN报文段就可以捎带上ACK,应答上一个client给server发送的SYN报文段,所以捎带应答很简单,只要将这个报文段的ACK和SYN标志位都置为有效即可。

4.TCP相关问题和相关实验

4.1 TCP异常情况

如果client或server进程挂掉了,那之前TCP建立的连接怎么办?如果是电脑关机呢?
进程终止,OS会释放进程相关的所有资源,包括套接字文件描述符,释放sockfd,仍然可以发送FIN报文段,触发四次挥手,所以和正常关闭没有什么区别。前面我们测试TIME_WAIT状态的时候,其实就看到过这个现象了,客户端进程终止,连接其实是正常断开的,所以进程终止和直接调用close(sockfd),从作用效果上来讲没什么区别。
电脑关机同样也是如此,我们平常在电脑关机时,如果有未关闭的进程,电脑应该是有提示的,会告诉你现在有部分应用未关闭,是否要选择强制关机?如果我们强制关机,其实就是操作系统手动杀死掉了正在运行的进程,作用效果其实和进程终止一样。
拔电源或拔网线,曾经的连接怎么办呢?
拔电源或拔网线,物理隔绝其实是最致命的,连网线都没有了,你怎么发送FIN报文段呢?不过也不用担心,客户端由于拔电源或网线异常后,但服务器还认为连接是正常的,而服务器会有自己对于连接的保活策略,会定期发送探测报文段,询问客户端是否在线,当发送累计到一定次数时,服务器会自动断开连接。

4.2 用UDP实现可靠性传输

其实用UDP实现可靠性传输,是有对应的方案的,因为摆在我们面前的TCP就是这个世界上最优秀的可靠性传输协议,而UDP也想要实现,其实就是在应用层仿照内核层TCP的机制来实现。
例如在应用层引入序列号,保证数据的有序性。引入确认应答,确保对端收到数据。引入超时重传,发生丢包时,能够补发数据包。

4.3 理解listen的第二个参数backlog(全连接长度=backlog+1)

以前我们在写tcp的socket编程时,监听连接到来的接口listen的第二个参数backlog,当时直接无脑设置为5的大小,但其实他是有原因的,实际表示的是内核监听队列的最大长度。

平常生活中,我们在吃海底捞的时候,总会见到海底捞外面排着一些等待的队伍,为什么海底捞要有这种排队等待的机制呢?其实就是为了提高资源的利用率,从而达到赚钱的目的。海底捞中就餐的位置是有限的,而常常出现的情况就是座位全都满了,外面会有一批人在等待着,其实就是为了当某些座位出现空闲的时候,这些空闲的座位又能够立马重新投入使用,保证座位大部分时间都有人在使用,那么海底捞餐厅的营业额就会增长的更多,所以排队的本质其实就是让我们的资源在空闲的时候,能够立马让资源投入使用,以提高资源的利用率,而这个资源对于海底捞来说,其实就是就餐的座位,对于计算机来说,其实就是时间和内存空间。那这个排队的队列的长度该怎么定呢?如果海底捞外面排了50个人,那我今天还会吃吗?可能还没到吃呢,我就交代在这儿了,如果队伍排短一些,我或许还会排队试试看,如果非常长我一定不会排的。
而在内核中,OS其实会为TCP维护一个内核监听队列,该队列里面的连接都是全连接,表示他们已经完成三次握手,只要accept把这些连接拿上去,这些连接就立马能够投入使用,进行TCP通信,有可能服务器进程忙着做别的事情,没有调用accept接口,但此时服务器已经监听到许多TCP的连接请求了,此时服务器会将这些连接放到内核等待队列中,当服务器执行accept代码时,服务器会从内核等待队列中,拿走一个全连接,然后用accept返回的用于TCP通信的sockfd来进行后续的数据传输。

所以,accept是不参与TCP三次握手的,accept只负责从内核等待队列中将全连接拿上来,然后开始后续的网络通信工作。真正的TCP三次握手是由connect和listen系统调用发起和完成的。
connect调用时,客户端会向服务器发送一个SYN报文段,同时客户端进入SYN_SENT状态,而服务器早由于调用listen,从而进入了LISTEN状态,当服务器收到SYN报文段时,服务器会返回一个SYN+ACK的报文段,然后服务器进入SYN_RCVD状态,当客户端收到ACK的时候,客户端就已经认为连接建立成功了,从而进入ESTABLISHED状态,客户端看到SYN报文段时,也会向服务器返回一个ACK报文段,服务器收到后也就会进入ESTABLISHED状态,整个三次握手的过程中,是没有accept参与的。

我把backlog设置为2,同时服务器初始化好之后,在启动时不会调用accept把任何全连接从内核等待队列中拿出来,这样所有和服务器完成三次握手建立成功的连接,就会全部呆在内核等待队列里面。
从下面代码我们也能看出,如果你想实现一个多线程的服务器,只要保证一个线程始终能够从全连接队列中拿取连接,然后由其他线程来为拿取上来的连接提供通信服务,这就是一个多线程的服务器。至于listen接口,早在服务器初始化时候就调用过了,服务器启动时无须管listen接口了就,只需要调用accept即可。

从实验结果可以看出,当出现4个客户端向服务器连接时,第四个客户端的连接情况有些许不同,服务器不认为连接建立成功,状态为SYN_RECV,而客户端认为连接是建立成功的,状态为ESTABLISHED,但在一段时间过后,服务器会把连接关闭,所以netstat就无法查到了。第四个客户端的连接请求到来时,服务器不会受理客户端发来的最后一次握手的ACK报文段,不会让三次握手建立成功,仅仅只是半连接状态。

从实验结果可以看出TCP的内核等待队列最多只能允许backlog+1个全连接,后续来的连接只能是半连接,如果不能尽快完成握手,server会自动关闭掉半连接。
值得注意的是,全连接队列的长度和server能够建立多少个连接是没有关系的,只是说当server没有调用accept拿取连接时,如果连接到来了,那就暂时把连接先放到等待队列中,而这个等待队列最多只能容纳backlog+1个连接,等server执行到accept接口的时候,再把连接从队列中拿上来。

Linux内核协议栈其实为管理一个TCP连接,使用了两个队列,一个是半连接队列,一个是全连接队列,当全连接队列满了的时候,服务器无法再继续受理新到来的连接,只会维持一小段时间的半连接。

相关推荐
正在努力的小河40 分钟前
Linux设备树简介
linux·运维·服务器
荣光波比42 分钟前
Linux(十一)——LVM磁盘配额整理
linux·运维·云计算
墨雨听阁1 小时前
8.18网络编程——基于UDP的TFTP文件传输客户端
网络·网络协议·学习·udp
LLLLYYYRRRRRTT1 小时前
WordPress (LNMP 架构) 一键部署 Playbook
linux·架构·ansible·mariadb
轻松Ai享生活1 小时前
crash 进程分析流程图
linux
大路谈数字化3 小时前
Centos中内存CPU硬盘的查询
linux·运维·centos
xie_pin_an3 小时前
网络原理与编程实战:从 TCP/IP 到 HTTP/HTTPS
网络·tcp/ip·http
luoqice4 小时前
linux下查看 UDP Server 端口的启用情况
linux
倔强的石头_5 小时前
【Linux指南】动静态库与链接机制:从原理到实践
linux
赏点剩饭7785 小时前
linux中的hostpath卷、nfs卷以及静态持久卷的区别
linux·运维·服务器