目录
TCP协议
与系统相关联
文件与套接字的关系
C语言的多态
谈谈可靠性
TCP协议格式
目的端口号
4位首部长度
16位窗口大小
序号与确认序号
32位序号
32位确认序号
标志位
TCP连接
三次握手
四次挥手
三次握手状态变化
四次挥手状态变化
流量控制
滑动窗口
拥塞控制
延迟应答
捎带应答
面向字节流
粘报问题
TCP连接异常问题
TCP小结
TCP协议
TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议,广泛应用于互联网。
TCP协议被广泛应用,其根本原因就是提供了详尽的可靠性保证,基于TCP的上层应用非常多,比如HTTP、HTTPS、FTP、SSH等,甚至MySQL底层使用的也是TCP。
TCP可以说是传输层中最重要的协议,几乎没有之一,多数应用层协议底层都使用TCP协议。
与系统相关联
看似这些网络的系统调用接口是发送函数,其实是一种拷贝函数,实质是将用户缓冲区的数据拷贝到TCP传输层的发送缓冲区中,将接收缓冲区中的数据拷贝到用户缓冲区!
我们在操作系统的文件IO中已经学过,语言级缓冲区数据调用 write 会刷新到文件的内核缓冲区,而文件缓冲区数据何时、如何写入磁盘,这是操作系统进行管理的,我们无需多虑。同样的,在网络层面,应用层的用户缓冲区(例如我们定义的inbuffer数组)数据write写入套接字的文件描述符中,其实就是内核传输层的缓冲区,内核缓冲区的数据也是经由操作系统管理,由TCP协议决定何时写入网卡,这两者的区别就是一个向硬件磁盘中写入,一个向硬件网卡中写入。
这个缓冲区实际是一段内存空间,在OS中,打开一个磁盘文件就会创建一个struct file 结构体,结构体内有多个属性字段(一个缓冲区、inode编号、方法函数表中包含write、read磁盘的函数指针等)。同样的,在网络中,我们打开的是网卡文件,OS会自动创建一个struct socket结构体,struct file 有指针指向struct socket结构体,这个结构体内还有结构体指针,这个结构体内就对应了传输层的缓冲区!
服务端、客户端都使用TCP协议,也就是将客户端的发送缓冲区数据拷贝到服务端的接收缓冲区中;同理,服务端也可以将发送缓冲区数据拷贝到客户端的接收缓冲区中,这就是为什么一个文件文件描述符既可以读数据、又可以写数据,这是因为TCP的一个文件描述符有两个缓冲区,这就是全双工通信,
这里就体现了网络分层,应用层只需要进行字符串处理,将数据写入内核缓冲区,而数据如何传、何时传、出错了怎么办,这都是有传输层来负责的,TCP自主决定。
同时传输层为下层的网络层提供了可靠传输的策略,如果网络层发送失败,传输层会使用超时重传等策略保证可靠传输。
那么我们之前在网络中read的时候可能会发生阻塞,如今来看,这就是因为我们的接收缓冲区没有数据!所以进程阻塞,等待资源就绪!
文件与套接字的关系
如果应用层使用了传输层的TCP协议,那么应用层打开的套接字文件是网卡文件,生成对应的struct file结构体,结构体内有内核传输层TCP提供的发送缓冲区和接收缓冲区吗
如果应用层使用了传输层的 TCP 协议,那么:
-
不是直接打开网卡文件: 应用层不是直接打开网卡文件来创建套接字。 应用层程序通过套接字 API (例如
socket()
,bind()
,listen()
,connect()
) 来请求操作系统创建一个套接字。操作系统内核负责与网卡等底层硬件交互。 -
创建 struct file 结构体: 当应用层程序创建一个套接字时,内核会创建一个对应的
struct file
结构体。struct file
是 Linux 内核中用于表示打开文件的通用数据结构。 在套接字的情况下,它表示一个打开的套接字。 这个struct file
结构体是连接应用层和内核网络协议栈的关键。 -
struct file
结构体关联套接字结构体: 这个struct file
结构体会指向一个套接字结构体 (通常是struct socket
)。struct socket
结构体包含了套接字的所有状态信息,例如本地地址、远程地址、连接状态、接收和发送缓冲区等等。struct socket
结构体和struct file
结构体是关联的,struct file
提供了一组标准的 I/O 操作接口 (例如read()
,write()
,close()
),而struct socket
负责实际的网络通信。
-
接收缓冲区和发送缓冲区:
- 是,
struct socket
结构体中包含用于 TCP 连接的发送缓冲区和接收缓冲区。 这些缓冲区是在内核空间中分配的。 - 这些缓冲区用于临时存储发送和接收的数据。 当应用程序调用
send()
或write()
发送数据时,数据会首先被复制到发送缓冲区,然后由 TCP 协议栈负责将数据发送到网络上。 当应用程序调用recv()
或read()
接收数据时,TCP 协议栈会从网络上接收数据,并将数据存储到接收缓冲区,然后应用程序从接收缓冲区读取数据。 - 应用层并不能直接访问这些缓冲区,而是通过操作系统提供的 API 进行读写。
- 是,
+---------------------+ +---------------------+ +-------------------------+
| 应用层程序 | <--> | struct file | <--> | struct socket |
| (例如:浏览器) | | (文件描述符) | | (套接字) |
+---------------------+ +---------------------+ +-------------------------+| | - f_op (指向套接字操作)| | - 状态信息 (地址,端口) || | - ... | | - 接收缓冲区 || send()/recv() | | | - 发送缓冲区 || | | | - ... |V | | +-------------------------+
+---------------------+ +---------------------+
| 内核 TCP 协议栈 |
+---------------------+|V网络设备 (网卡)
记住,套接字是操作系统提供的一种机制,允许应用程序通过网络进行通信。 应用程序并不直接操作网卡等底层硬件,而是通过套接字 API 与内核交互,由内核负责处理底层的网络通信细节。
文件与套接字的关系
应用层使用套接字API接口例如 socket() 时 OS 会自动创建一个 struct socket 结构体,当应用层使用 write、send系统调用时,write、send 不是直接将数据写入到网卡发送给对方,而是根据打开的套接字返回的 fd 找到对应的 struct file 结构体,struct file 结构体内有一个指针 private_data ,如果该进程打开的是一个网络文件--套接字,那么 private_data 就会指向 struct socket 结构体(struct socket结构体内也有struct file指针,会回指 struct file 结构体),这个 struct socket 结构体就是记录套接字各种状态、信息的集合,并且 struct socket 中又有一个 struct sock 类型的 sk 指针,struct sock结构体内存放的有发送缓冲区 sk_write_queue 与 sk_receive_queue 接收缓冲区,此时根据我们创建套接字时传入的参数(SOCK_STREAM、SOCK_DGRAM等参数)来判断应该建立struct tcp_sock 还是 struct udp_sock,然后将struct sock 类型的 sk 指针指向对应的结构体。
并且由于结构体类型不同,struct sock 类型的 sk 指针只能操作属于自己空间内的数据,如果想操作例如对应的 tcp_sock 结构体内的数据,就将指针强制类型转换即可(struct tcp_sock*)sk -> ??? 这就体现了C语言的多态!注意,能强制类型转换而实现多态需要结构体套结构体时,结构体成员放在第一个成员位置,这样才能地址相同从而进行类型转换。
注意:struct file 中有 f_op 指针,它指向了一个方法集,这个方法集是面对上层用户的,提供统一的write、read、recv等接口,而struct file - > struct socket - > struct sock - > ops 指针它也指向了一个方法集(listen、bind、accept、setsockopt等),而这个方法集是面向下层协议栈的,是网络中的方法集。
一台机器中一定会收到很多来自不同机器的报文,而报文是需要被管理的,那么OS如何对报文进行管理?
先描述、再组织。用结构体 sk_buff 描述报文,结构体内有head、data、tail、end四个指针,分别指向报文头部空间起始地址、数据(有效载荷)起始地址、数据尾部地址、报文尾空间地址,用双向链表组织这个结构体,所以在OS中对报文的管理就成为了对链表的增删查改。
在网络协议栈中每一层协议对报文的封装和解封装就成为了对报文结构体内指针的移动,并不需要将一份报文的数据在协议栈的上下层中拷贝传递,而是直接在报文结构体内操作即可,效率更高。
所以组织报文的双向链表向上每经过一个协议栈,就需要将各个节点内的头指针进行移动,直到传输层时,根据报头的目的端口号区分上层进程,然后将整个链表内的报文结构体分发到各个进程对应的struct file - > struct socket - > struct sock - > sk_receive_queue(接收缓冲区)!
所以TCP的发送缓冲区其实并不是一个数组,而是一个描述报文的链表形式组织的数据结构,只不过我们认为是数组就可以了。
- 一个进程可以建立多个TCP连接,一个连接有一对缓冲区!
- 建立连接是有成本的,struct file、struct socket、struct tcp_sock等结构体,这都是需要时间和内存成本
C语言的多态
C语言如何实现多态?
我们在之间的系统学习部分就已经了解了
- 属性的多态实际上是结构体套结构体套结构体...,要使用哪一个结构体就将当前结构体指针强制类型转换为那个类型指针,之后就可以使用强制类型转换后的那个结构体的成员变量
- 方法的多态可以体现在结构体内的方法集内的函数指针。
谈谈可靠性
为什么网络中会存在不可靠?
现代的计算机大部分都是基于冯诺依曼体系结构的。
虽然这里的输入设备、输出设备、内存、CPU都在一台机器上,但这几个硬件设备是彼此独立的。如果它们之间要进行数据交互,就必须要想办法进行通信,因此这几个设备实际是用“线”连接起来的,其中连接内存和外设之间的“线”叫做IO总线,而连接内存和CPU之间的“线”叫做系统总线。由于这几个硬件设备都是在一台机器上的,因此这里传输数据的“线”是很短的,传输数据时出现错误的概率也非常低。
但如果要进行通信的各个设备相隔千里,那么连接各个设备的“线”就会变得非常长,传输数据时出现错误的概率也会大大增高,此时要保证传输到对端的数据无误,就必须引入可靠性。
总之,网络中存在不可靠的根本原因就是,长距离数据传输所用的“线”太长了,数据在长距离传输过程中就可能会出现各种各样的问题,而TCP就是在此背景下诞生的,TCP就是一种保证可靠性的协议。
思维扩展:
- 实际单独的一台计算机可以看作成一个小型的网络,计算机上的各种硬件设备之间实际也是在进行数据通信,并且它们在通信时也必须遵守各自的通信协议,只不过它们之间的通信协议更多是描述一些数据的含义。
为什么会存在UDP协议?
TCP协议是一种可靠的传输协议,使用TCP协议能够在一定程度上保证数据传输时的可靠性,而UDP协议是一种不可靠的传输协议,那既然有了可靠的TCP,UDP协议这种不可靠的协议存在有什么意义呢?
不可靠和可靠是两个中性词,它们描述的都是协议的特点。
- TCP协议是可靠的协议,也就意味着TCP协议需要做更多的工作来保证传输数据的可靠,并且引起不可靠的因素越多,保证可靠的成本(时间+空间)就越高。比如数据在传输过程中出现了丢包、乱序、检验和失败等,这些都是不可靠的情况。
- 由于TCP要想办法解决数据传输不可靠的问题,因此TCP的设计一定比UDP复杂,并且维护成本特别高。
- UDP协议是不可靠的协议,也就意味着UDP协议不需要考虑数据传输时可能出现的问题,因此UDP无论是使用还是维护都足够简单。
需要注意的是,虽然TCP复杂,但TCP的效率不一定比UDP低,TCP当中不仅有保证可靠性的机制,还有保证传输效率的各种机制。
UDP和TCP没有谁最好,只有谁最合适,网络通信时具体采用TCP还是UDP完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,那么就必须采用TCP协议,如果应用场景允许数据传输出现少量丢包,那么肯定优先选择UDP协议,因为UDP协议足够简单。
是否存在100%可靠的网络协议?
不存在,因为永远都会有一个最新消息没有应答!所以就没办法保证这个最新消息被对方收到,除非对方又发了一次对这个消息的应答,但是很显然,这个应答又会成为一个最新的消息,对方没办法保证这个消息是否被我们接受
TCP协议格式
TCP协议格式如下:
TCP报头当中各个字段的含义如下:
- 源/目的端口号:表示数据是从哪个进程来,到发送到对端主机上的哪个进程。
- 32位序号/32位确认序号:分别代表TCP报文当中每个字节数据的编号以及对对方的确认,是TCP保证可靠性的重要字段。
- 4位TCP报头长度:表示该TCP报头的长度,以4字节为单位。
- 6位保留字段:TCP报头中暂时未使用的6个比特位。
- 16位窗口大小:保证TCP可靠性机制和效率提升机制的重要字段。
- 16位检验和:由发送端填充,采用CRC校验。接收端校验不通过,则认为接收到的数据有问题。(检验和包含TCP首部+TCP数据部分)
- 16位紧急指针:标识紧急数据在报文中的偏移量,需要配合标志字段当中的URG字段统一使用。
- 选项字段:TCP报头当中允许携带额外的选项字段,最多40字节。
目的端口号
我们在UDP一文中已经讲过,分析协议有两大方面:
- 如何将报头与有效载荷分离?
- 如何区分并分发给上层?
TCP如何决定将有效载荷交付给上层的哪一个协议?
应用层的每一个网络进程都必须绑定一个端口号。
- 服务端进程必须显示绑定一个端口号。
- 客户端进程由系统动态绑定一个端口号。
而TCP的报头中涵盖了目的端口号,因此TCP可以提取出报头中的目的端口号,找到对应的应用层进程,进而将有效载荷交给对应的应用层进程进行处理。
说明一下: 内核中用哈希的方式维护了端口号与进程ID之间的映射关系,因此传输层可以通过端口号快速找到其对应的进程ID,进而找到对应的应用层进程。
4位首部长度
用来分割TCP报头与有效载荷的字段。
注意:首部长度虽然只有4个bit位,表示范围只有【0,15】,但是首部长度表示的数值具有单位,它的单位是4字节,又因为TCP首部长度固定为20个字节,所以首部长度能表示的字节大小范围是【20,60】。
当TCP从底层获取到一个报文后,虽然TCP不知道报头的具体长度,但报文的前20个字节是TCP的基本报头,并且这20字节当中涵盖了4位的首部长度。
因此TCP是这样分离报头与有效载荷的:
- 当TCP获取到一个报文后,首先读取报文的前20个字节,并从中提取出4位的首部长度,此时便获得了TCP报头的大小size。
- 如果size的值大于20字节,则需要继续从报文当中读取size−20字节的数据,这部分数据就是TCP报头当中的选项字段。
- 读取完TCP的基本报头和选项字段后,剩下的就是有效载荷了。
如果TCP报头当中不携带选项字段,那么TCP报头的长度就是20字节,此时报头当中的4位首部长度的值就为20 ÷ 4 = 5 20\div4=520÷4=5,也就是0101。
16位窗口大小
TCP的接收缓冲区和发送缓冲区
TCP本身是具有接收缓冲区和发送缓冲区的:
- 接收缓冲区用来暂时保存接收到的数据。
- 发送缓冲区用来暂时保存还未发送的数据。
- 这两个缓冲区都是在TCP传输层内部实现的。
-
TCP发送缓冲区当中的数据由上层应用应用层进行写入。当上层调用 write/send 这样的系统调用接口时,实际不是将数据直接发送到了网络当中,而是将数据从应用层拷贝到了TCP的发送缓冲区当中。
-
TCP接收缓冲区当中的数据最终也是由应用层来读取的。当上层调用 read/recv 这样的系统调用接口时,实际也不是直接从网络当中读取数据,而是将数据从TCP的接收缓冲区拷贝到了应用层而已。
-
就好比调用read和write进行文件读写时,并不是直接从磁盘读取数据,也不是直接将数据写入到磁盘上,而对文件缓冲区进行的读写操作。
当数据写入到TCP的发送缓冲区后,对应的 write/send 函数就可以返回了,至于发送缓冲区当中的数据具体什么时候发,怎么发等问题实际都是由TCP决定的。
我们之所以称TCP为传输层控制协议,就是因为最终数据的发送和接收方式,以及传输数据时遇到的各种问题应该如何解决,都是由TCP自己决定的,用户只需要将数据拷贝到TCP的发送缓冲区,以及从TCP的接收缓冲区当中读取数据即可。
需要注意的是,通信双方的TCP层都是一样的,因此通信双方的TCP层都是既有发送缓冲区又有接收缓冲区。
TCP的发送缓冲区和接收缓冲区存在的意义
发送缓冲区和接收缓冲区的作用:
- 数据在网络中传输时可能会出现某些错误,此时就可能要求发送端进行数据重传,因此TCP必须提供一个发送缓冲区来暂时保存发送出去的数据,以免需要进行数据重传。只有当发出去的数据被对端可靠的收到后,发送缓冲区中的这部分数据才可以被覆盖掉。
- 接收端处理数据的速度是有限的,为了保证没来得及处理的数据不会被迫丢弃,因此TCP必须提供一个接收缓冲区来暂时保存未被处理的数据,因为数据传输是需要耗费资源的,我们不能随意丢弃正确的报文。此外,TCP的数据重排也是在接收缓冲区当中进行的。
- 16位窗口大小是流量拥塞控制的实现原理,因为client、server的接收缓冲区与发送缓冲区的空间都是有限的,如果超出了空间大小,则新收到的TCP报文都会被丢弃,这就表明TCP协议不可靠!所以TCP在设计时考虑到了这一点,为了TCP的可靠性,在TCP的报头中添加了16位的窗口大小字段,告知对方自己的接收缓冲区还能容纳多少空间,让对方调整发送的数据大小。
经典的生产者消费者模型:
- 对于发送缓冲区来说,上层应用不断往发送缓冲区当中放入数据,下层网络层不断从发送缓冲区当中拿出数据准备进一步封装。此时上层应用扮演的就是生产者的角色,下层网络层扮演的就是消费者的角色,而发送缓冲区对应的就是“交易场所”。
- 对于接收缓冲区来说,上层应用不断从接收缓冲区当中拿出数据进行处理,下层网络层不断往接收缓冲区当中放入数据。此时上层应用扮演的就是消费者的角色,下层网络层扮演的就是生产者的角色,而接收缓冲区对应的就是“交易场所”。
- 因此引入发送缓冲区和接收缓冲区相当于引入了两个生产者消费者模型,该生产者消费者模型将上层应用与底层通信细节进行了解耦,此外,生产者消费者模型的引入同时也支持了并发和忙闲不均。
- 在网络中,我们把这叫做拥塞控制,在系统中,这就是一种生产消费模型的生产者与消费者的同步关系!因为应用层会从接收缓冲区读数据,OS为向接收缓冲区写入数据,实际上OS写入的数据是来自发送端的应用层,所以这实际上就是应用层的发送者进程与接受者进程的同步关系!
窗口大小
当发送端要将数据发送给对端时,本质是把自己发送缓冲区当中的数据发送到对端的接收缓冲区当中。但缓冲区是有大小的,如果接收端处理数据的速度小于发送端发送数据的速度,那么总有一个时刻接收端的接收缓冲区会被打满,这时发送端再发送数据过来就会造成数据丢包,进而引起丢包重传等一系列的连锁反应。
因此TCP报头当中就有了16位的窗口大小,这个16位窗口大小当中填的是自身接收缓冲区中剩余空间的大小,也就是当前主机接收数据的能力。
接收端在对发送端发来的数据进行响应时,就可以通过16位窗口大小告知发送端自己当前接收缓冲区剩余空间的大小,此时发送端就可以根据这个窗口大小字段来调整自己发送数据的速度。
- 窗口大小字段越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度。
- 窗口大小字段越小,说明接收端接收数据的能力越弱,此时发送端可以减小发送数据的速度。
- 如果窗口大小的值为0,说明接收端接收缓冲区已经被打满了,此时发送端就不应该再发送数据了。
理解现象:
- 在编写TCP套接字时,我们调用read/recv函数从套接字当中读取数据时,可能会因为套接字当中没有数据而被阻塞住,本质是因为TCP的接收缓冲区当中没有数据了,我们实际是阻塞在接收缓冲区当中了。
- 而我们调用write/send函数往套接字中写入数据时,可能会因为套接字已经写满而被阻塞住,本质是因为TCP的发送缓冲区已经被写满了,我们实际是阻塞在发送缓冲区当中了。
- 在生产者消费者模型当中,如果生产者生产数据时被阻塞,或消费者消费数据时被阻塞,那么一定是因为某些条件不就绪而被阻塞。
序号与确认序号
- 序号:发送方的发送缓冲区发送出的一组数据的最后一个下标。(暂时认为发送缓冲区是一个数组)
- 确认序号:是收到的报文的序号+1,表明确认序号之前的数据我方都已经收到了!
什么是真正的可靠?
在进行网络通信时,一方发出的数据后,它不能保证该数据能够成功被对端收到,因为数据在传输过程中可能会出现各种各样的错误,只有当收到对端主机发来的响应消息后,该主机才能保证上一次发送的数据被对端可靠的收到了,这就叫做真正的可靠。
但TCP要保证的是双方通信的可靠性,虽然此时主机A能够保证自己上一次发送的数据被主机B可靠的收到了,但主机B也需要保证自己发送给主机A的响应数据被主机A可靠的收到了。因此主机A在收到了主机B的响应消息后,还需要对该响应数据进行响应,但此时又需要保证主机A发送的响应数据的可靠性…,这样就陷入了一个死循环。
因为只有当一端收到对方的响应消息后,才能保证自己上一次发送的数据被对端可靠的收到了,但双方通信时总会有最新的一条消息,因此无法百分之百保证可靠性。
所以严格意义上来说,互联网通信当中是不存在百分之百的可靠性的,因为双方通信时总有最新的一条消息得不到响应。但实际没有必要保证所有消息的可靠性,我们只要保证双方通信时发送的每一个核心数据都有对应的响应就可以了。而对于一些无关紧要的数据(比如响应数据),我们没有必要保证它的可靠性。因为对端如果没有收到这个响应数据,会判定上一次发送的报文丢失了,此时对端可以将上一次发送的数据进行重传。
这种策略在TCP当中就叫做确认应答机制。需要注意的是,确认应答机制不是保证双方通信的全部消息的可靠性,而是只要一方收到了另一方的应答消息,就说明它上一次发送的数据被另一方可靠的收到了。
32位序号
如果双方在进行数据通信时,只有收到了上一次发送数据的响应才能发下一个数据,那么此时双方的通信过程就是串行的,效率可想而知。
因此双方在进行网络通信时,允许一方向另一方连续发送多个报文数据,只要保证发送的每个报文都有对应的响应消息就行了,此时也就能保证这些报文被对方收到了。
但在连续发送多个报文时,由于各个报文在进行网络传输时选择的路径可能是不一样的,因此这些报文到达对端主机的先后顺序也就可能和发送报文的顺序是不同的。但报文有序也是可靠性的一种,因此TCP报头中的32位序号的作用之一实际就是用来保证报文的有序性的。也有去重的作用,如果TCP在网络中丢失了但是没有完全丢,当发送端超时重传并且接收端已经收到了这个报文后,这个丢失的报文又到达目的主机了,此时就会因为序列号已经存在,接收端就会丢弃报文。
TCP将发送出去的每个字节数据都进行了编号,这个编号叫做序列号。
- 比如现在发送端要发送3000字节的数据,如果发送端每次发送1000字节,那么就需要用三个TCP报文来发送这3000字节的数据。
- 此时这三个TCP报文当中的32位序号填的就是发送数据中最后一个数据所在发送缓冲区的下标(先这么认为),因此分别填的是1000、2000和3000。
此时接收端收到了这三个TCP报文后,就可以根据TCP报头当中的32位序列号对这三个报文进行顺序重排(该动作在传输层进行),重排后将其放到TCP的接收缓冲区当中,此时接收端这里报文的顺序就和发送端发送报文的顺序是一样的了。
接收端在进行报文重排时,可以根据当前报文的32位序号与其有效载荷的字节数,进而确定下一个报文对应的序号。
序号实际上并不是发送缓冲区的数组下标
一方面发送缓冲区并不是数组,他是一个循环的双向链表,每个节点都是sk_buff结构体类型。
另一方面,在TCP建立连接时的三次握手阶段,双方会随机生成一个序列号,并以最小的序列号为准,以后双方发送的报文是以发送数据的下标 + 协商的序列号为序列号!
在TCP建立连接时的三次握手阶段,双方会随机生成一个序列号,这是因为连接建立前可能有其他连接,上一个连接中它们发送的报文可能在网络中一直阻塞住了,所以如果上一个连接断开后,阻塞的报文到达接收端,那么这就回扰乱当前连接的通信,所以用一个随机的序列号可以更大概率的防止上一次连接中阻塞报文的干扰,因为序列号很大概率是不一致的。
32位确认序号
TCP报头当中的32位确认序号是告诉对端,我当前已经收到了哪些数据,你的数据下一次应该从哪里开始发。
以刚才的例子为例,当主机B收到主机A发送过来的32位序号为1000的报文时,由于该报文当中包含1000字节的数据,因此主机B已经收到序列号为1-1000的字节数据,于是主机B发给主机A的响应数据的报头当中的32位确认序号的值就是序号值加一,即1001。
- 一方面是告诉主机A,序列号在1001之前的字节数据我已经收到了。
- 另一方面是告诉主机A,我期待你下次发送数据时从序列号为1001的字节数据开始进行发送。
之后主机B对主机A发来的其他报文进行响应时,发给主机A的响应当中的32为确认序号的填法也是类似的道理。
注意:
- 确认应答与其他报文一样,也是一个完整的TCP报文,尽管该报文可能不携带有效载荷,但至少包含完整的TCP报头。
报文丢失怎么办?
还是以刚才的例子为例,主机A发送了三个报文给主机B,其中每个报文的有效载荷都是1000字节,这三个报文的32位序号分别是1000、2000、3000。
如果这三个报文在网络传输过程中出现了丢包,最终只有序号为 1000 和 3000 的报文被主机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,其实这是从我们的角度来看,实际上接收端在接收到1000序列号的数据之后,它会通知对方ACK,表明它期待1001之后的数据,即使对方发送了3000序列号的数据,但是接收端依旧回应ACK确认序号为1001,超过三次就会触发快重传机制,我们下文会讲解。
因此发送端可以根据对端发来的确认序号,来判断是否某个报文可能在传输过程中丢失了,这点会在下文的滑动窗口中详细讲解。
面试题:client向server发送TCP报文(报头+有效载荷),如果报头中的序号是1000(表明传输的数据是1-1000),那么server收到报文之后,明明可以直接复用这个报文,将序号的位置直接加一,然后返回这个报头给client即可,即让一个32位的序号充当两个作用,那么为什么TCP报头设计的时候还需要多一个32位的确认号呢?
原因很简单,因为TCP是全双工的,双方地位对等,不仅仅只有 client 向 server 发送TCP报文,server 也需要向 client 发送报文!并且,在确认应答的报文中,我们不仅仅只会发送报头,还可能会携带信息!也就是一个TCP报文可能有确认应答+发送信息这两件事(捎带应答),所以server也需要序号来指明自己发送的数据范围,用确认序号来回应client表明自己收到了信息!
为什么要用两套序号机制?
如果通信双方只是一端发送数据,另一端接收数据,那么只用一套序号就可以了。
- 发送端在发送数据时,将该序号看作是32位序号。
- 接收端在对发送端发来的数据进行响应时,将该序号看作是32位确认序号。
但实际TCP却没有这么做,根本原因就是因为TCP是全双工的,双方可能同时想给对方发送消息。
- 双方发出的报文当中,不仅需要填充32位序号来表明自己当前发送数据的序号。
- 还需要填充32位确认序号,对对方上一次发送的数据进行确认,告诉对方下一次应该从哪一字节序号开始进行发送。
因此在进行TCP通信时,双方都需要有确认应答机制,此时一套序号就无法满足需求了,因此需要TCP报头当中出现了两套序号。
总结一下:
- 32位序号的作用是,保证数据的按序到达,还有去重功能,同时这个序号也是作为对端发送报文时填充32位确认序号的根据。
- 32位确认序号的作用是,告诉对端当前已经收到的字节数据有哪些,对端下一次发送数据时应该从哪一字节序号开始进行发送。
- 序号和确认序号是确认应答机制的数据化表示,确认应答机制就是由序号和确认序号来保证的。
- 此外,通过序号和确认序号还可以判断某个报文是否丢失。
标志位
我们都知道TCP有三个阶段
- 建立连接,三次握手
- 正常数据通信
- 断开连接,四次挥手
为什么会存在标志位?
- TCP在通信时,发送的都是完整的报文,可以没有数据,但是报头一定是完整的。那么根据TCP的这三个阶段,我们可以知道TCP的报文也是有类型的!
- 因为client :server = n :1,服务器会与多个用户建立TCP连接,所以不同用户的报文类型也就指明了server要做不同的动作,所以TCP的报头中一定存在着区分TCP报文类型的字段——6个标志位。
ACK:
- 值为1表示确认号有效,也就表明该TCP报文有确认应答的属性,至于是否是携带应答,就要看报文内是否有数据。
- 一般除了第一个请求报文没有设置ACK以外,其余报文基本都会设置ACK,因为发送出去的数据本身就对对方发送过来的数据具有一定的确认能力,因此双方在进行数据通信时,可以顺便对对方上一次发送的数据进行响应。
SYN:
- 请求建立连接的标志位,报文当中的SYN被设置为1,表明该TCP报文是用来建立TCP连接的,是一个连接建立的请求报文。
- 只有在连接建立阶段,SYN才被设置,正常通信时SYN不会被设置。
FIN:
- 报文当中的FIN被设置为1,表明该报文是一个连接断开的请求报文。
- 只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置。
URG:
双方在进行网络通信的时候,由于TCP是保证数据按序到达的,即便发送端将要发送的数据分成了若干个TCP报文进行发送,最终到达接收端时这些数据也都是有序的,因为TCP可以通过序号来对这些TCP报文进行顺序重排,最终就能保证数据到达对端接收缓冲区中时是有序的。
TCP按序到达本身也是我们的目的,此时对端上层在从接收缓冲区读取数据时也必须是按顺序读取的。但是有时候发送端可能发送了一些“紧急数据”,这些数据需要让对方上层提取进行读取,此时应该怎么办呢?
此时就需要用到URG标志位,以及TCP报头当中的16位紧急指针。
紧急指针是否有效,0表示16位紧急指针无效,1表示16紧急指针有效,说明报文携带了紧急数据,要求被高优先级处理。16位紧急指针是一个偏移量,在报文内携带的数据偏移一个偏移量后的那一个字节就是这个紧急数据!
因为TCP要保证可靠传输,所以他发送的数据都是有编号的,都是按照编号排列在接收缓冲区中,所以即使有紧急情况,也只能在缓冲区里面排队,所以引入了URG和紧急指针,来提高数据优先级。
小结:
- 当URG标志位被设置为1时,需要通过TCP报头当中的16位紧急指针来找到紧急数据,否则一般情况下不需要关注TCP报头当中的16位紧急指针。
- 16位紧急指针代表的就是紧急数据在报文中的偏移量。
- 因为紧急指针只有一个,它只能标识数据段中的一个位置,因此紧急数据只能发送一个字节,而至于这一个字节的具体含义这里就不展开讨论了。
带外数据
recv函数的第四个参数flags有一个叫做MSG_OOB的选项可供设置,其中OOB是带外数据(out-of-band)的简称,带外数据就是一些比较重要的数据,因此上层如果想读取紧急数据,就可以在使用recv函数进行读取,并设置MSG_OOB选项。
与之对应的send函数的第四个参数flags也提供了一个叫做MSG_OOB的选项,上层如果想发送紧急数据,就可以使用send函数进行写入,并设置MSG_OOB选项。
PSH:
报文当中的PSH被设置为1,是在通知接收端应用层应用程序立即从TCP缓冲区把数据读走。
场景:当发送者发送缓冲区与接收端的接收缓冲区都满了,而接收端一直不读走数据,持续一段时间,那么发送端就会发送带有PSH标志的TCP报文,通知对方立即读走数据,如果还不读走,那一般就是你程序有问题,不然怎么那么长时间都不read呢?此时就可能断开连接了。
我们一般认为:
- 当使用read/recv从缓冲区当中读取数据时,如果缓冲区当中有数据read/recv函数就能够读到数据进行返回,而如果缓冲区当中没有数据,那么此时read/recv函数就会阻塞住,直到当缓冲区当中有数据时才会读取到数据进行返回。
实际这种说法是不太准确的,其实接收缓冲区和发送缓冲区都有一个水位线的概念。
- 我们假设TCP接收缓冲区的水位线是100字节,那么只有当接收缓冲区当中有100字节时才让read/recv函数读取这100字节的数据进行返回。
- 如果接收缓冲区当中有一点数据就让read/recv函数读取返回了,此时read/recv就会频繁的进行读取和返回,进而影响读取数据的效率(在内核态和用户态之间切换也是有成本的)。
- 因此不是说接收缓冲区当中只要有数据,调用read/recv函数时就能读取到数据进行返回,而是当缓冲区当中的数据量达到一定量时才能进行读取。
当报文当中的PSH被设置为1时,实际就是在告知对方操作系统,尽快将接收缓冲区当中的数据交付给上层,尽管接收缓冲区当中的数据还没到达所指定的水位线。这也就是为什么我们使用read/recv函数读取数据时,期望读取的字节数和实际读取的字节数是不一定吻合的。
RST:
虽然TCP保证可靠性,但是它允许连接的三次握手失败!所以如果连接建立失败,就可以在TCP报文中设置RST,通知对方重新建立连接。
一种连接的异常情况:对于客户端来讲,只要它把第三次握手的报文发出去,它就认为连接已经建立!而服务端可能没有收到第三次的ACK报文,所以此时双方没有建立连接。
那么发送端就发送数据了,但是服务器没有建立连接,当服务端收到没有连接的数据后,就会设置RST标志位,构建TCP报文发送给发送端,要求对方重新建立连接。
TCP连接
维护连接是有成本的
服务器操作系统中存在大量的TCP连接,那么操作系统要对连接做管理,即先描述、再组织。用结构体描述(序号、确认序号等),用链表组织,所以OS对连接的管理就变为了对链表的增删查改,所以维护连接是需要结构体,需要占据一定了内存空间的!
注意:我们在图中标注的SYN、ACK不是只发送了这单个信息,而是发送的具有报头的一整个TCP报文(没有数据),并在报文中的6个标志位中对SYN、ACK进行了赋值!
三次握手
三次握手的过程
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手。
以服务器和客户端为例,当客户端想要与服务器进行通信时,需要先与服务器建立连接,此时客户端作为主动方会先向服务器发送连接建立请求,然后双方TCP在底层会自动进行三次握手。
- 第一次握手:客户端向服务器发送的报文当中的SYN位被设置为1,表示请求与服务器建立连接。
- 第二次握手:服务器收到客户端发来的连接请求报文后,紧接着向客户端发起连接建立请求并对客户端发来的连接请求进行响应,此时服务器向客户端发送的报文当中的SYN位和ACK位均被设置为1。
- 第三次握手:客户端收到服务器发来的报文后,得知服务器收到了自己发送的连接建立请求,并请求和自己建立连接,最后客户端再向服务器发来的报文进行响应。
需要注意的是,客户端向服务器发起的连接建立请求,是请求建立从客户端到服务器方向的通信连接,而TCP是全双工通信,因此服务器在收到客户端发来的连接建立请求后,服务器也需要向客户端发起连接建立请求,请求建立从服务器到客户端方法的通信连接。
面试题:TCP为什么设计为三次握手和四次挥手?
- 可靠的验证全双工。实际上这就是为了TCP的可靠性。因为不管是建立连接还是断开连接,双方都会发送一次信息,所以为了可靠性,对方需要对此应答,所以建立连接的三次握手实质上应该是四次握手,即client发送SYN、server发送ACK、server发送SYN、client发送ACK,只不过在第二次握手时,server捎带应答了SYN!将携带SYN的TCP报文和ACK的TCP报文进行了合并。因为建立阶段双方都会发一次报文、应答一次报文,这可以看做是对全双工的通路进行检测是否流畅的过程。
- 连接建立失败代价转移。奇数次握手可以保证一般情况下的握手失败的代价会让client承受,而server不会被握手失败影响,因为维持连接是有代价的!服务器与客户端是1对多的关系,如果连接失败(也就是服务端认为建立链接了,但是客户端不认为建立连接了,这是1次握手2次握手的弊端),那么服务端会保留异常连接的结构体一段周期,但是这个周期是致命的,因为服务器可能同一时间维持了几万个异常连接,导致服务器崩溃,对所有用户都会产生影响,所以奇数次握手会让最后一次ACK由client发送,一旦发送失败影响的只有client,因为server只会在第三次握手成功后才建立连接,如果没有收到第三次握手信息,那么server认为连接建立失败。奇数次握手能验证全双工的最小握手次数就是3,1次握手会由SYN洪水问题,2次握手会导致连接失败的代价让服务端承受。
因此,这里给出两个建立连接时采用三次握手的理由:
- 三次握手是验证双方通信信道的最小次数,能够让能建立的连接尽快建立起来。
- 三次握手能够保证连接建立时的异常连接挂在客户端(风险转移)。
对于第二个原因,有人可能疑惑“那么两次握手不就能验证通路流畅吗?client发送的SYN服务端收到了,服务端发送的ACK客户端收到了,这不就证明了通路流畅吗?”
这个问题是站在了client端而没有考虑server端的结果,因为server端并不知道它的ACK对方是否收到了!server只能确定对方能发来SYN的这个单向通路是流畅的。
那么为什么几乎所有的教科书都将握手简化为3次握手,而不是实质上的四次呢?
因为服务器面对连接请求是无条件接受的,并不会出现犹犹豫豫的行为,所以服务器一旦收到了连接请求,就会很热情的立即回发SYN并告知对方 “server端刚刚收到了你发送的连接请求“”故TCP报文携带ACK(其实应该是ACK的报文携带SYN标志位)。所以服务器为了效率将四次握手简化为三次握手。
既然三次握手已经能够验证双方通信信道是否正常了,那么三次以上的握手当然也是可以验证的,但三次已经能验证了就没有必要再进行更多次的握手了,会浪费网络资源。
四次挥手
四次挥手的过程
由于双方维护连接都是需要成本的,因此当双方TCP通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手。
还是以服务器和客户端为例,当客户端与服务器通信结束后,需要与服务器断开连接,此时就需要进行四次挥手。
- 第一次挥手:客户端向服务器发送的报文当中的FIN位被设置为1,表示请求与服务器断开连接。
- 第二次挥手:服务器收到客户端发来的断开连接请求后对其进行响应。
- 第三次挥手:服务器收到客户端断开连接的请求,且已经没有数据需要发送给客户端的时候,服务器就会向客户端发起断开连接请求。
- 第四次挥手:客户端收到服务器发来的断开连接请求后对其进行响应。
四次挥手结束后双方的连接才算真正断开。
为什么是四次挥手?
- 由于TCP是全双工的,建立连接的时候需要建立双方的连接,断开连接时也同样如此。在断开连接时不仅要断开从客户端到服务器方向的通信信道,也要断开从服务器到客户端的通信信道,其中每两次挥手对应就是关闭一个方向的通信信道,因此断开连接时需要进行四次挥手。
- 需要注意的是,四次挥手当中的第二次和第三次挥手不能合并在一起,因为第三次握手是服务器端想要与客户端断开连接时发给客户端的请求,而当服务器收到客户端断开连接的请求并响应后,服务器不一定会马上发起第三次挥手,因为服务器可能还有某些数据要发送给客户端,只有当服务器端将这些数据发送完后才会向客户端发起第三次挥手。
简而言之,四次挥手之所以不是三次挥手,是因为第二次确认应答之后,服务端可能还想发送一些数据,所以此时它的断开连接意愿不明确,不像三次握手时,服务器的“来者不拒”,可以捎带应答,挥手时服务端意愿不明确,所以不能合并为三次挥手。
四次挥手之所以没有简化为三次挥手,这是因为双方的意愿可能不统一!当client主动发送FIN表示client已经把信息发送完了,所以client请求断开链接(但是之后还可以发送管理报文,例如ACK),然后server收到后回进行确认应答ACK,但是此时server可能还有话(信息)没说完,所以他不会急着断开连接,还会发送一些数据,所以让server的第二次挥手ACK与第三次挥手FIN结合具有这件事具有偶然性,不能强制合并,所以是四次挥手。
三次握手状态变化
三次握手时的状态变化如下:
- 最开始时客户端和服务器都处于CLOSED状态。
- 服务器为了能够接收客户端发来的连接请求,需要由CLOSED状态变为LISTEN状态。
- 此时客户端就可以向服务器发起三次握手了,当客户端发起第一次握手后,状态变为SYN_SENT状态。
- 处于LISTEN状态的服务器收到客户端的连接请求后,将该连接放入内核等待队列中,并向客户端发起第二次握手,此时服务器的状态变为SYN_RCVD。
- 当客户端收到服务器发来的第二次握手后,紧接着向服务器发送最后一次握手,此时客户端的连接已经建立,状态变为ESTABLISHED。
- 而服务器收到客户端发来的最后一次握手后,连接也建立成功,此时服务器的状态也变成ESTABLISHED。
至此三次握手结束,通信双方可以开始进行数据交互了。
套接字和三次握手之间的关系
- 在客户端发起连接建立请求之前,服务器需要先进入LISTEN状态,此时就需要服务器调用对应listen函数。
- 当服务器进入LISTEN状态后,客户端就可以向服务器发起三次握手了,此时客户端对应调用的就是connect函数。
- 需要注意的是,connect函数不参与底层的三次握手,connect函数的作用只是发起三次握手。当connect函数返回时,要么是底层已经成功完成了三次握手连接建立成功,要么是底层三次握手失败。
- 如果服务器端与客户端成功完成了三次握手,此时在服务器端就会建立一个连接,但这个连接在内核的等待队列当中,服务器端需要通过调用accept函数将这个建立好的连接获取上来。
- 当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了。
listen的第二个参数
- 连接建立成功和上层是否accept没有关系,因为三次握手是connect发出的,但是双方OS自动完成的!即使 tcpserver.cc 中没有 accept 代码(只有创建套接字、bind、listen),client与server 仍然能建立连接,可以手动验证。
- listen 的第二个参数值 +1 ,表示的是OS允许的全连接的最大长度,也就是这个链表最大节点数。一旦超过最大节点数,OS就不会再建立任何一个全链接,新的连接状态只能为SYN_RECVD
- 如果新链接超过全连接队列长度最大值,那么服务端会直接丢掉或忽视第三次握手的ACK,因为服务端的全连接队列已经满了。(三次握手建立的连接称为全连接,二次握手建立的连接称为半连接)
三次握手建立全连接是第三次握手之后才能建立,client只要发出去第三次握手的ACK,client就认为它建立连接成功了,但是server需要收到这个ACK才能确认全连接建立成功,并且建立全连接之前要建立半连接,也就是第二次握手,第二次握手后服务器就会对这个半连接进行维护(OS中维护了一个半连接的队列,也有最大长度限制),如果client发送了第三次ACK,但是此时server端的全连接数(listen的第二个参数+1)已经达到最大值,那么就表明server不会再建立任何一个全连接!所以此时server收到了这个ACK也会选择丢弃或忽略,并维持一小段时间的半连接,之后OS就会直接释放半连接,为半连接的队列空出位置。
这个半连接的队列也是有长度限制的,这个长度由OS设置,我们无需关心。从中又引发了一个问题,还是SYN洪水,因为OS会为半连接进行管理!所以只要client发送第一次握手,那么OS就会在半连接队列中维护一个半连接,但是此时并不会与我们之前讨论的一次握手一样把服务器搞崩情况一样,因为半连接队列有长度限制,超过最大长度后就不会再对半连接进行维护管理!
但是问题不在这里,问题在于如果黑客恶意发送大量的SYN,那么服务器的半连接队列被占满,这会导致如果有正常用户想要和server建立TCP全连接也无法成功,因为建立全连接之前先是半连接,也就是三次握手前,肯定要有二次握手!所以会导致正常用户无法与server建立连接,也就无法与服务器交互信息!就如同铁路抢票、班级选课,虽然服务器并没有崩溃(因为有人能正常获得server服务),但是我们却不能访问网页,这是因为全连接、半连接队列都已经满了,新的连接无法建立导致的!
面试题:为什么listen的第二个参数不能设置太长,也不能没有?也就是说全连接的队列为什么不能维护的很长
因为这属于典型的生产消费模型,半连接队列向全连接队列生产数据,上层使用accept从全连接队列消费数据,如果上层已经忙的处理不了任何新的全连接,而生产者还一直向全连接队列发数据,那么在上层很忙来不及accept的时候,OS中还维护着这么一堆占着内存资源还用不到没有价值的全连接,雪上加霜!全连接队列维护时间高于半连接队列。将全连接队列长度限制到合理级别,可将更多的资源(内存)供上层使用,提高效率。
不能没有,是因为一旦上层处理完连接任务,忙完了,空出了一个连接位置,那么此时全连接队列没有了(相当于缓冲区没了),那么上层的资源势必会暂时没人使用,这就会导致效率降低,无法让服务器资源得到充分利用。举个例子:一个饭店如果十分火热,新顾客都会排号在店外等待,那么全连接队列就相当于店外的一些坐在凳子上的人,如果店内空出了一桌位置,那么店外凳子上的人可以立马进去享受店内服务,如果店外没有人等待,那么如果店内空出了一桌位置,不会立即得到人员补充,可能半个小时都没顾客,这就会导致营销额降低,即效率降低。
四次挥手状态变化
四次挥手时的状态变化如下:
- 在挥手前客户端和服务器都处于连接建立后的ESTABLISHED状态。
- 客户端为了与服务器断开连接主动向服务器发起连接断开请求,此时客户端的状态变为FIN_WAIT_1。
- 服务器收到客户端发来的连接断开请求后对其进行响应,此时服务器的状态变为CLOSE_WAIT。
- 当服务器没有数据需要发送给客户端的时,服务器会向客户端发起断开连接请求,等待最后一个ACK到来,此时服务器的状态变为LASE_ACK。
- 客户端收到服务器发来的第三次挥手后,会向服务器发送最后一个响应报文,此时客户端进入TIME_WAIT状态。
- 当服务器收到客户端发来的最后一个响应报文时,服务器会彻底关闭连接,变为CLOSED状态。
- 而客户端则会等待一个2MSL(Maximum Segment Lifetime,报文最大生存时间)才会进入CLOSED状态。
至此四次挥手结束,通信双方成功断开连接。
套接字和四次挥手之间的关系
- 客户端发起断开连接请求,对应就是客户端主动调用close函数。
- 服务器发起断开连接请求,对应就是服务器主动调用close函数。
- 一个close对应的就是两次挥手,双方都要调用close,因此就是四次挥手。
CLOSE_WAIT
- 双方在进行四次挥手时,如果只有客户端调用了close函数,而服务器不调用close函数,此时服务器就会进入CLOSE_WAIT状态,而客户端则会进入到FIN_WAIT_2状态。
- 但只有完成四次挥手后连接才算真正断开,此时双方才会释放对应的连接资源。如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少。
- 因此如果不及时关闭不用的文件描述符,除了会造成文件描述符泄漏以外,可能也会导致连接资源没有完全释放,这其实也是一种内存泄漏的问题。
- 因此在编写网络套接字代码时,如果发现服务器端存在大量处于CLOSE_WAIT状态的连接,此时就可以检查一下是不是服务器没有及时调用close函数关闭对应的文件描述符。
TIME_WAIT
四次挥手中前三次挥手丢包时的解决方法:
- 第一次挥手丢包:客户端收不到服务器的应答,进而进行超时重传。
- 第二次挥手丢包:客户端收不到服务器的应答,进而进行超时重传。
- 第三次挥手丢包:服务器收不到客户端的应答,进而进行超时重传。
- 第四次挥手丢包:服务器收不到客户端的应答,进而进行超时重传。
- 如果客户端在发出第四次挥手后立即进入CLOSED状态,此时服务器虽然进行了超时重传,但已经得不到客户端的响应了,因为客户端已经将连接关闭了。
服务器在经过若干次超时重发后得不到响应,最终也一定会将对应的连接关闭,但在服务器不断进行超时重传期间还需要维护这条废弃的连接,这样对服务器是非常不友好的。
为了避免这种情况,因此客户端在四次挥手后没有立即进入CLOSED状态,而是进入到了TIME_WAIT状态进行等待,此时要是第四次挥手的报文丢包了,客户端也能收到服务器重发的报文然后进行响应。
TIME_WAIT状态存在的必要性:
- 客户端在进行四次挥手后进入TIME_WAIT状态,如果第四次挥手的报文丢包了,客户端在一段时间内仍然能够接收服务器重发的FIN报文并对其进行响应,能够较大概率保证最后一个ACK被服务器收到。
- 客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方。因此客户端四次挥手后进入TIME_WAIT状态,还可以保证双方通信信道上的数据在网络中尽可能的消散。
实际第四次挥手丢包后,可能双方网络状态出现了问题,尽管客户端还没有关闭连接,也收不到服务器重发的连接断开请求,此时客户端TIME_WAIT等若干时间最终会关闭连接,而服务器经过多次超时重传后也会关闭连接。这种情况虽然也让服务器维持了闲置的连接,但毕竟是少数,引入TIME_WAIT状态就是争取让主动发起四次挥手的客户端维护这个成本。
因此TCP并不能完全保证建立连接和断开连接的可靠性,TCP保证的是建立连接之后,以及断开连接之前双方通信数据的可靠性。
TIME_WAIT的等待时长是多少?
TIME_WAIT的等待时长既不能太长也不能太短。
- 太长会让等待方维持一个较长的时间的TIME_WAIT状态,在这个时间内等待方也需要花费成本来维护这个连接,这也是一种浪费资源的现象。
- 太短可能没有达到我们最初目的,没有保证ACK被对方较大概率收到,也没有保证数据在网络中消散,此时TIME_WAIT的意义也就没有了。
TCP协议规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(Maximum Segment Lifetime,报文最大生存时间)的时间才能进入CLOSED状态。
MSL在RFC1122中规定为两分钟,但是各个操作系统的实现不同,比如在Centos7上默认配置的值是60s。我们可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout命令来查看MSL的值。
TIME_WAIT的等待时长设置为两个MSL的原因:
- MSL是TCP报文的最大生存时间,因此TIME_WAIT状态持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。
- 同时也是在理论上保证最后一个报文可靠到达的时间。
setsockopt
之前我们会经常遇到服务器重启失败,这是因为服务器如果主动断开连接,进行四次挥手,那么服务器的这个链接不会立即消失,而是会进入TIME_WAIT状态,会维持大概30-60s,所以在这个时间短内,服务器的上一个进程的对应的port依旧被占用,所以如果重启服务器时还使用上一次的端口号,就会导致不同进程绑定使用相同的port,所以重启失败。
而如果想直接使用上一次的ip:port则使用setsockopt系统调用,因为上一个连接进入的TIME_WAIT状态不会影响之后的连接 。
客户端是经常先断开连接的一方,为什么它不会重启失败?
因为客户端每次启动的端口号都是随机的,而服务器就不一样了,服务器的端口号不能改变,否则用户使用之前的port就访问不到服务器进程
为什么TIME_WAIT要维持一段时间?一般为2MSL时间。
- 让通信时的历史数据(可能阻塞在了网络中)到达时可以有时间处理丢弃,不是处理!因为如果阻塞在网络中直到连接断开时才到,那么双方早就超时重传了,等到连接断开时收到这个报文已经没有用了,反而会影响干扰下一个连接的通信,所以需要时间将历史报文进行丢弃处理。
- 保证对方收到了第四次挥手的ACK,如果没收到,对方会补发FIN,所以TIME_WAIT时间是在等待对方超时重传FIN的时间,如果这段时间没有收到FIN,则认为连接断开完毕;如果收到了FIN,则补发ACK。当然了,如果client一直收不到第三次挥手的FIN,server也一直收不到第四次挥手的ACK,那么谁也怪不了,这是网络的问题,他们会自动断开连接
流量控制
TCP支持根据接收端的接收数据的能力来决定发送端发送数据的速度,这个机制叫做流量控制(Flow Control)。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,此时发送端继续发送数据,就会造成丢包,进而引起丢包重传等一系列连锁反应。
因此接收端可以将自己接收数据的能力告知发送端,让发送端控制自己发送数据的速度。
- 接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK通知发送端。
- 窗口大小字段越大,说明网络的吞吐量越高。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
- 发送端接收到这个窗口之后,就会减慢自己发送的速度。
- 如果接收端缓冲区满了,就会将窗口值设置为0,这时发送方不再发送数据,但需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
当发送端得知接收端接收数据的能力为0时会停止发送数据,此时发送端会通过以下两种方式来得知何时可以继续发送数据。
- 等待告知。接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个TCP报文,主动将自己的窗口大小告知发送端,发送端得知接收端的接收缓冲区有空间后就可以继续发送数据了。
- 主动询问。发送端每隔一段时间向接收端发送报文,该报文不携带有效数据,只是为了询问发送端的窗口大小,直到接收端的接收缓冲区有空间后发送端就可以继续发送数据了。
16为数字最大表示65535,那TCP窗口最大就是65535吗?
理论上确实是这样的,但实际上TCP报头当中40字节的选项字段中包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位得到的。
第一次向对方发送数据时如何得知对方的窗口大小?
双方在进行TCP通信之前需要先进行三次握手建立连接,而双方在握手时除了验证双方通信信道是否通畅以外,还进行了其他信息的交互,其中就包括告知对方自己的接收能力,因此在双方还没有正式开始通信之前就已经知道了对方接收数据能力,所以双方在发送数据时是不会出现缓冲区溢出的问题的。
既体现了可靠性又体现了效率。虽然窗口大小是16为的,也就是64kb,但是缓冲区大小可以更改,在报头的选项中可以指明数字,然后16位窗口就会左移。
流量控制的实现就是控制滑动窗口的大小,流量控制不仅仅是控制窗口减小,也会控制窗口变大。
滑动窗口
根据流量控制,发送端可以一次发送多个报文,当然了之后也会收到多个确认应答,但是在发出报文之后,收到确认应答之前,我们不能将TCP发送缓冲区的数据清理掉,因为对方可能没有收到发送的报文,从而引起发送端超时重传,那么此时就需要将之前发过的数据再补发一次,所以我们需要将发送出去的报文数据暂时保存起来,以备超时重传。
连续发送多个数据
双方在进行TCP通信时可以一次向对方发送多条数据,这样可以将等待多个响应的时间重叠起来,进而提高数据通信的效率。
需要注意的是,虽然双方在进行TCP通信时可以一次向对方发送大量的报文,但不能将自己发送缓冲区当中的数据全部打包发送给对端,在发送数据时还要考虑对方的接收能力。
发送方可以一次发送多个报文给对方,此时也就意味着发送出去的这部分报文当中有相当一部分数据是暂时没有收到应答的。
那么暂时没有收到确认应答的多个报文会被保存在哪里呢?
没有被特意的拷贝保存!因为数据一直都在发送端的发送缓冲区中,发送缓冲区向网卡写入数据也只是一种拷贝!所以数据一直都在发送缓冲区,那么我们就需要对发送缓冲区进行分类管理!缓冲区数据分为三部分:
- 第一部分数据为:已发送、已确认应发。
- 第二部分数据为:已发送 / 未发送、未确认应发。
- 第三部分数据为:待发送。
所以第一部分数据可以被丢弃,被覆盖,上层需要发送数据时,直接将数据write写入到发送缓冲区的第一部分即可。
因为第二部分数据还没有被应答,所以该区域数据需要维持,不能被修改,当被应答之后,将数据移动至第一部分区域即可,之后就可以被覆盖。
而滑动窗口描述的就是,发送方不用等待ACK一次所能发送的数据最大量。
因此滑动窗口在向右移动的过程中并不一定是整体右移的,因为对方接收能力可能不断在变化,从而滑动窗口也会随之不断变宽或者变窄。
那么如何实现滑动窗口,滑动窗口的大小是多少呢?
- 滑动窗口就在发送缓冲区,是发送缓冲区的一部分
- 滑动窗口的范围大小,是对方的接收窗口
- 区域划分就是通过指针/下标来进行划分的
TCP接收和发送缓冲区都看作一个字符数组,而滑动窗口实际就可以看作是两个指针限定的一个范围,比如我们用 start 指向滑动窗口的左侧,end指向的是滑动窗口的右侧,此时在 start 和 end 区间范围内的就可以叫做滑动窗口。
当发送端收到对方的响应时,如果响应当中的确认序号为x,窗口大小为win,此时就可以将start更新为x,而将end更新为start+win。
窗口的大小与流量控制有关,是对方的能接收窗口大小,只需要两个数组下标维护一个窗口接口,win_start、win_end,在win_start之前的是第一部分数据,在win_start、win_end之间的就是第二部分,在win_end之后的就是第三部分,可以根据对方接收缓冲区的剩余空间自动调整滑动窗口大小。
丢包超时问题
当发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种。
情况一: 数据包已经抵达,ACK丢包。
在发送端连续发送多个报文数据时,部分ACK丢包并不要紧,此时可以通过后续的ACK进行确认。
比如图中2001-3000和4001-5000的数据包对应的ACK丢失了,但只要发送端收到了最后5001-6000数据包的响应,此时发送端也就知道2001-3000和4001-5000的数据包实际上被接收端收到了的,因为如果接收方没有收到2001-3000和4001-5000的数据包是设置确认序号为6001的,确认序号为6001的含义就是序号为1-6000的字节数据我都收到了,你下一次应该从序号为6001的字节数据开始发送。
情况二: 数据包丢了。
- 当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,就是在提醒发送端“下一次应该从序号为1001的字节数据开始发送”。
- 如果发送端连续收到三次确认序号为1001的响应报文,此时就会将1001-2000的数据包重新进行发送。
- 此时当接收端收到1001-2000的数据包后,就会直接发送确认序号为6001的响应报文,因为2001-6000的数据接收端其实在之前就已经收到了。
这种机制被称为“高速重发控制”,也叫做“快重传”。
需要注意的是,快重传需要在大量的数据重传和个别的数据重传之间做平衡,实际这个例子当中发送端并不知道是1001-2000这个数据包丢了,当发送端重复收到确认序号为1001的响应报文时,理论上发送端应该将1001-7000的数据全部进行重传,但这样可能会导致大量数据被重复传送,所以发送端可以尝试先把1001-2000的数据包进行重发,然后根据重发后的得到的确认序号继续决定是否需要重发其它数据包。
滑动窗口中的数据一定都没有被对方收到吗?
滑动窗口当中的数据是可以暂时不用收到对方确认的数据,而不是说滑动窗口当中的数据一定都没有被对方收到,滑动窗口当中可能有一部分数据已经被对方收到了,但可能因为滑动窗口内靠近滑动窗口左侧的一部分数据,在传输过程中出现了丢包等情况,导致后面已经被对方收到的数据得不到响应。
例如图中的1001-2000的数据包如果在传输过程中丢包了,此时虽然2001-5000的数据都被对方收到了,此时对方发来的确认序号也只能是1001,当发送端补发了1001-2000的数据包后,对方发来的确认序号就会变为5001,此时发送缓冲区当中1001-5000的数据也会立马被归置到滑动窗口的左侧。
小结:
- ACK丢失:如果是窗口中间有ACK没有收到,它后续的ACK都收到了,那么这个丢失的ACK不影响报文的确认序号,即不需要补发,因为接收端返回的确认序号表明“x需要之前的报文我都收到了!”
- 数据丢失:如果是窗口中间的报文在网络中丢失而超时,它后续的报文都收到了ACK,那么滑动窗口的左指针只能移动到丢失报文的左边界!因为即使它后面的报文发送成功,对方也回应ACK成功,但是对方回应的报文中的确认序号一定还是丢失报文的第一个数据的下标(例如,1001-2000丢了,那么后续收到的ACK的确认序号一定是1001)
这两中情况都表明,滑动窗口是稳定的,不会因为ACK丢失或数据丢失而遗漏任何报文。关键点就是对方报文内的确认序号。
快重传
如果连续收到的三个报文内确认序号一样,那么立即重传确认序号的数据。但是快重传有条件限制“连续收到的三个报文”,所以如果丢失的是最后两个报文,之后就不通信了,那么只能依靠超时重传来解决。
动态的思考报文重传,如果1001-2000丢了,4001-5000也丢了但是他们之后的报文都收到了ACK,那么这些报文都的确认序号一定是丢失报文中最小的序号,即1001,既然后面仍然在通信,那么发送端一定会收到三个同样确认序号的报文,此时就会触发快重传机制,如果后续不再通信了,那么最后一波因为数据丢失而需要补发的报文就会触发超时重传来补发报文。
快重传 VS 超时重传
- 快重传是能够快速进行数据的重发,当发送端连续收到三次相同的应答时就会触发快重传,而不像超时重传一样需要通过设置重传定时器,在固定的时间后才会进行重传。
- 虽然快重传能够快速判定数据包丢失,但快重传并不能完全取待超时重传,因为有时数据包丢失后可能并没有收到对方三次重复的应答,此时快重传机制就触发不了,而只能进行超时重传。
- 因此快重传虽然是一个效率上的提升,但超时重传却是所有重传机制的保底策略,也是必不可少的。
滑动窗口会向左移动吗?向右?大小会变化吗?怎么变化?会为0吗?
- 不会,因为左边区域的数据都已经发送并收到了ACK。
- 整体只会向右移动
- 会根据接受到的TCP报文的16为窗口大小动态调整右指针
- 会变为0,表示对方TCP接收缓冲区已经满了,无法再接受TCP报文
总结:
- 窗口左指针 start 根据接受到的TCP报文的确认序号来设置
- 窗口右指针 end = 确认序号 + min(16位窗口大小,剩余数据大小,拥塞窗口)(对方缓冲区剩余空间大小)
- 只要双方在通信,缓冲区有空余,那么双方可以即使的通过TCP报头的16为窗口大小获取对方缓冲区剩余大小。只有当缓冲区空间满了,窗口大小为0时,一旦缓冲区空间发生变化,那么就会主动的发送报文告知对方窗口已经变化
拥塞控制
为什么会有拥塞控制?
两个主机在进行TCP通信的过程中,出现个别数据包丢包的情况是很正常的,此时可以通过快重传或超时重发对数据包进行补发。但如果双方在通信时出现了大量丢包,此时就不能认为是正常现象了。
TCP不仅考虑了通信双端主机的问题,同时也考虑了网络的问题。
- 流量控制:考虑的是对端接收缓冲区的接收能力,进而控制发送方发送数据的速度,避免对端接收缓冲区溢出。
- 滑动窗口:考虑的是发送端不用等待ACK一次所能发送的数据最大量,进而提高发送端发送数据的效率。
- 拥塞窗口:考虑的是双方通信时网络的问题,如果发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
双方网络通信时出现少量的丢包TCP是允许的,但一旦出现大量的丢包,此时量变引起质变,这件事情的性质就变了,此时TCP就不再推测是双方接收和发送数据的问题,而判断是双方通信信道网络出现了拥塞问题。
如何解决网络拥塞问题?
网络出现大面积瘫痪时,通信双方作为网络当中两台小小的主机,看似并不能为此做些什么,但“雪崩的时候没有一片雪花是无辜的”,网络出现问题一定是网络中大部分主机共同作用的结果。
- 如果网络中的主机在同一时间节点都大量向网络当中塞数据,此时位于网络中某些关键节点的路由器下就可能排了很长的报文,最终导致报文无法在超时时间内到达对端主机,此时也就导致了丢包问题。
- 当网络出现拥塞问题时,通信双方虽然不能提出特别有效的解决方案,但双方主机可以做到不加重网络的负担。
- 双方通信时如果出现大量丢包,不应该立即将这些报文进行重传,而应该少发数据甚至不发数据,等待网络状况恢复后双方再慢慢恢复数据的传输速率。
需要注意的是,网络拥塞时影响的不只是一台主机,而几乎是该网络当中的所有主机,此时所有使用TCP传输控制协议的主机都会执行拥塞避免算法。
因此拥塞控制看似只是谈论的一台主机上的通信策略,实际这个策略是所有主机在网络崩溃后都会遵守的策略。一旦出现网络拥塞,该网络当中的所有主机都会受到影响,此时所有主机都要执行拥塞避免,这样才能有效缓解网络拥塞问题。通过这样的方式就能保证雪崩不会发生,或雪崩发生后可以尽快恢复。
拥塞控制
虽然滑动窗口能够高效可靠的发送大量的数据,但如果在刚开始阶段就发送大量的数据,就可能会引发某些问题。因为网络上有很多的计算机,有可能当前的网络状态就已经比较拥塞了,因此在不清楚当前网络状态的情况下,贸然发送大量的数据,就可能会引起网络拥塞问题。
因此TCP引入了慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
- TCP除了有窗口大小和滑动窗口的概念以外,还有一个窗口叫做拥塞窗口。拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
- 刚开始发送数据的时候拥塞窗口大小定义以为1,每收到一个ACK应答拥塞窗口的值就加一。
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小。
每收到一个ACK应答拥塞窗口的值就加一,此时拥塞窗口就是以指数级别进行增长的,如果先不考虑对方接收数据的能力,那么滑动窗口的大家就只取决于拥塞窗口的大小,此时拥塞窗口的大小变化情况如下:
拥塞窗口 | 滑动窗口 |
---|---|
2^0 | 1 |
2^1 | 2 |
2^2 | 4 |
2^3 | 8 |
... | ... |
但指数级增长是非常快的,因此“慢启动”实际只是初始时比较慢,但越往后增长的越快。如果拥塞窗口的值一直以指数的方式进行增长,此时就可能在短时间内再次导致网络出现拥塞。
- 为了避免短时间内再次导致网络拥塞,因此不能一直让拥塞窗口按指数级的方式进行增长。
- 此时就引入了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长。
- 当TCP刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。
- 在每次超时重发的时候,慢启动阈值会变成当前拥塞窗口的一半,同时拥塞窗口的值被重新置为1,如此循环下去。
如下图:
图示说明:
- 指数增长。刚开始进行TCP通信时拥塞窗口的值为1,并不断按指数的方式进行增长。
- 加法增大。慢启动的阈值初始时为对方窗口大小的最大值,图中慢启动阈值的初始值为16,因此当拥塞窗口的值增大到16时就不再按指数形式增长了,而变成了的线性增长。
- 乘法减小。拥塞窗口在线性增长的过程中,在增大到24时如果发生了网络拥塞,此时慢启动的阈值将变为当前拥塞窗口的一半,也就是12,并且拥塞窗口的值被重新设置为1,所以下一次拥塞窗口由指数增长变为线性增长时拥塞窗口的值应该是12。
主机在进行网络通信时,实际就是在不断进行指数增长、加法增大和乘法减小。
需要注意的是,不是所有的主机都是同时在进行指数增长、加法增大和乘法减小的。每台主机认为拥塞窗口的大小不一定是一样的,即便是同区域的两台主机在同一时刻认为拥塞窗口的大小也不一定是完全相同的。因此在同一时刻,可能一部分主机正在进行正常通信,而另一部分主机可能已经发生网络拥塞了。
为什么明明“慢启动”是指数增长的,却叫慢启动呢?
慢启动只是指初始时比较慢,但是后面是指数增长,非常快
如果要实现UDP可靠性,那么基本上就是在应用层做一遍TCP做过的事,套壳。
延迟应答
如果接收数据的主机收到数据后立即进行ACK应答,此时返回的窗口可能比较小。
- 假设对方接收端缓冲区剩余空间大小为1M,对方一次收到500K的数据后,如果立即进行ACK应答,此时返回的窗口就是500K。
- 但实际接收端处理数据的速度很快,10ms之内就将接收缓冲区中500K的数据消费掉了。
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再进行ACK应答,比如等待200ms再应答,那么这时返回的窗口大小就是1M。
需要注意的是,延迟应答的目的不是为了保证可靠性,而是留出一点时间让接收缓冲区中的数据尽可能被上层应用层消费掉,此时在进行ACK响应的时候报告的窗口大小就可以更大,从而增大网络吞吐量,进而提高数据的传输效率。
此外,不是所有的数据包都可以延迟应答。
- 数量限制:每N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次(这个时间不会导致误超时重传)。
延迟应答具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。
捎带应答
捎带应答其实是TCP通信时最常规的一种方式,就好比主机A给主机B发送了一条消息,当主机B收到这条消息后需要对其进行ACK应答,但如果主机B此时正好也要给主机A发生消息,此时这个ACK就可以搭顺风车,而不用单独发送一个ACK应答,此时主机B发送的这个报文既发送了数据,又完成了对收到数据的响应,这就叫做捎带应答。
捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不用再发送单纯的确认报文了。
此外,由于捎带应答的报文携带了有效数据,因此对方收到该报文后会对其进行响应,当收到这个响应报文时不仅能够确保发送的数据被对方可靠的收到了,同时也能确保捎带的ACK应答也被对方可靠的收到了。
面向字节流
创建一个TCP的socket,同时在内核中创建一个 发送缓冲区 和 一个 接收缓冲区
- 调用write函数就可以将数据写入发送缓冲区中,此时write函数就可以进行返回了,接下来发送缓冲区当中的数据就是由TCP自行进行发送的。
- 如果发送的字节数太长,TCP会将其拆分成多个数据包发出。如果发送的字节数太短,TCP可能会先将其留在发送缓冲区当中,等到合适的时机再进行发送。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,可以通过调用read函数来读取接收缓冲区当中的数据。
- 而调用read函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取。
- 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
- 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
- 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
但如果是UDP,UDP是面向数据报的,发送端发了几次,接收端就必须接收几次。UDP报头带有报文长度的字段,这就是证明。就特别像发邮件、发快递,发送端发了几个邮件、快递,你接收端就必须收相应数量的邮件或快递。
为什么TCP不需要在报头设置有效载荷的长度字段?
因为TCP是面向字节流的,TCP报头的序号确保了报文在到达接收端时是按序排列,是有序的,接收端需要做的仅仅就是根据报头的4位首部长度字段分离报头和有效载荷,将有效载荷尾插到接收缓冲区中即可,即使TCP将上层的例如HTTP的请求报文(40字节)按照20字节发一个TCP报文,接收端根本就不会考虑这个报文是否满足一个完整的上层报文,所以如果接收缓冲区只有HTTP报文的一半数据,那么应用层读取了这一半数据后,在反序列化时就会判断报文不完整,继续read!将从接收缓冲区新read来的数据追加到应用层用户自定义的缓冲区(例如我们的网络版本计算器定义的string类型的inbuffer),然后再进行下一次反序列化。
作为上帝视角我们知道应用层一次发送了几个报文(例如四个HTTP报文),但是作为内核的TCP来说,他根本不知道,他只知道发送缓冲区有160个字节(假定一个HTTP报文40字节),它是面向字节流的,不管是否是一个完整的应用层报文!
面向字节流就特别像自来水管内的水, 不管用户拿什么容器去接(用杯子、盆、水桶等等),如果不满足用户的需求,那么用户继续接水就行了。这就类比应用层一次read发现不是一个完整的应用层报文,那么他就会继续read,追加到应用层缓冲区,直到凑够一个完整的应用层报文可以供应用层反序列化解析。
粘报问题
应用层为了效率不会从接收缓冲区读一点数据处理完再读一点数据,而是一次性全读上来然后再处理,这样就会保持接收缓冲区空闲空间足够大,就可以让发送端滑动窗口变大,数据发送效率变高。
但是如果一次读上来很多数据,那么也就表明应用层缓冲区中会存在不完整的报文与完整报文的组合,那么在应用层如何对应用层缓冲区进行处理时一次处理一个报文,这就需要应用层自己订协议明确报文和报文之间的边界!分离报文,然后再反序列化报文成为结构化数据。
解决粘包问题可以采取以下几种方案:
要解决粘包问题,本质就是要明确报文和报文之间的边界。
- 定长报文,保证每次都按固定大小读取即可。
- 使用特殊字符,例如\n,\3等等不会出现在报文中的字符(透明传输),在包和包之间使用明确的分隔符
- 使用自描述字段(报文总长度)+ 定长报头,例如UDP报头描述自身报文长度
- 使用自描述字段 + 特殊字符,例如HTTP的报头+空行,网络版本计算器中定的 len \n content \n 报头协议采取的就归类为该方法。
UDP是否存在粘包问题?
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层的,有很明确的数据边界。
- 站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现“半个”的情况。
因此UDP是不存在粘包问题的,根本原因就是UDP报头当中的16位UDP长度记录的UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界。
TCP连接异常问题
进程终止
连接本身和文件直接相关,无论进程正常终止还是异常终止,文件的生命周期是随进程的,所以进程终止,资源全部释放,相当于自动调用了close函数关闭了对应的文件描述符,此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。也就是说,进程终止时会释放文件描述符,TCP底层仍然可以发送FIN,和进程正常退出没有区别。
连接建立时是由上层提出connect然后也是由操作系统自动完成三次握手。
机器重启
当客户端正常访问服务器时,如果将客户端主机重启,此时建立好的连接会怎么样?
电脑关机前会杀掉所有进程,平时关机前如果有一些进程没有关闭,电脑会提醒你该进程没有关闭,请确认是否要关闭该进程,所以机器重启等价于进程终止,操作系统自动完成四次挥手。关机慢可能就是操作系统在处理进程连接的四次挥手。
机器断电/网线断开
当客户端正常访问服务器时,如果将客户端突然掉线了,此时建立好的连接会怎么样?
当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的。
- 服务器会定期客户端客户端的存在状况,检查对方是否在线,如果连续多次都没有收到ACK应答,此时服务器就会关闭这条连接。
- 此外,客户端也可能会定期向服务器“报平安”,如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭。
其中服务器定期询问客户端的存在状态的做法,叫做基于保活定时器的一种心跳机制,是由TCP实现的。此外,应用层的某些协议,也有一些类似的检测机制,例如基于长连接的HTTP,也会定期检测对方的存在状态。
如果机器及时的重连网线,那么此时再向服务端发送数据,会有连接认知不一致问题,服务端会发送携带RST标志位的TCP报文,请求与客户端重新建立连接,然后服务端关闭之前那个异常连接。如果客户端重连网络后还不向服务端发送消息,服务器的保活机制最终也会将对应的连接关闭。
TCP小结
TCP协议这么复杂就是因为TCP既要保证可靠性,同时又尽可能的提高性能。
可靠性:
- 检验和。
- 序列号(有序、去重)。
- 确认应答(核心)。
- 超时重传。
- 连接管理。
- 流量控制。
- 拥塞控制。
提高性能:
- 滑动窗口。
- 快速重传。
- 延迟应答。
- 捎带应答。
三次握手阶段,双方建立连接、协商起始序号、协商双方的接收缓冲区大小。
需要注意的是,TCP的这些机制有些能够通过TCP报头体现出来的,但还有一些是通过代码逻辑体现出来的。
TCP定时器
此外,TCP当中还设置了各种定时器。
- 重传定时器:为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。
- 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔。
- 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔。
- TIME_WAIT定时器:双方在四次挥手后,主动断开连接的一方需要等待的时长。
理解传输控制协议
TCP的各种机制实际都没有谈及数据真正的发送,这些都叫做传输数据的策略。TCP协议是在网络数据传输当中做决策的,它提供的是理论支持,比如TCP要求当发出的报文在一段时间内收不到ACK应答就应该进行超时重传,而数据真正的发送实际是由底层的IP和MAC帧完成的。
TCP 做决策 而 IP + MAC 做执行,我们将它们统称为通信细节,它们最终的目的就是为了将数据传输到对端主机。而传输数据的目的是什么则是由应用层决定的。因此应用层决定的是通信的意义,而传输层及其往下的各层决定的是通信的方式。
常见的基于TCP的应用层协议如下:
- HTTP(超文本传输协议)。
- HTTPS(安全数据传输协议)。
- SSH(安全外壳协议)。
- Telnet(远程终端协议)。
- FTP(文件传输协议)。
- SMTP(电子邮件传输协议)。
当然,也包括你自己写TCP程序时自定义的应用层协议。