1. 传输层
传输层负责可靠性传输,确保数据能够可靠地传送到目标地址。
1.1 再谈端口号
端口号的意义
简单来说,端口号是用来标识一台主机上进行网络通信的不同应用程序,实际本质上一个端口号就标识主机上唯一一个进程。

通信五元组
"源IP地址","源端口号","目的IP地址","目的端口号","协议号"

- 先提取出数据当中的目的IP地址和目的端口号,确定该数据是发送给当前服务进程的。
- 然后提取出数据当中的协议号,为该数据提供对应类型的服务。
- 最后提取出数据当中的源IP地址和源端口号,将其作为响应数据的目的IP地址和目的端口号,将响应结果发送给对应的客户端进程。
通过netstat命令可以查看到这样的五元组信息。

其中的Local Address表示的就是源IP地址和源端口号,Foreign Address表示的就是目的IP地址和目的端口号,而Proto表示的就是协议类型。
协议号 VS 端口号
-
协议号是存在于IP报头当中的,其长度是8位。协议号指明了数据报所携带的数据是使用的何种协议,以便让目的主机的IP层知道应该将该数据交付给传输层的哪个协议进行处理。
-
端口号是存在于UDP和TCP报头当中的,其长度是16位。端口号的作用是唯一标识一台主机上的某个进程。
-
协议号是作用于传输层和网络层之间的,而端口号是作用于应用层于传输层之间的。
1.2 端口号范围划分
端口号的长度是16位,因此端口号的范围是0 ~ 65535:
- 0 ~ 1023:知名端口号。比如HTTP,FTP,SSH等这些广为使用的应用层协议,它们的端口号都是固定的。
- 1024 ~ 65535:操作系统动态分配的端口号。客户端程序的端口号就是由操作系统从这个范围分配的。
1.3 认识知名端口号
常见的知名端口号
- ssh服务器,使用22端口。
- ftp服务器,使用21端口。
- telnet服务器,使用23端口。
- http服务器,使用80端口。
- https服务器,使用443端口。
查看知名端口号

1.4 两个重要问题
一个端口号是否可以被多个进程绑定?
一个端口号绝对不能被多个进程绑定,因为端口号的作用就是唯一标识一个进程,如果绑定一个已经被绑定的端口号,就会出现绑定失败的问题。
一个进程是否可以绑定多个端口号?
这个是OK的,只不过就是多个端口号标识同一个进程。
1.4 netstat命令
netstat命令
这个命令在网络中很常用,是用来查看网络状态的。
有以下常见选项:
- n:拒绝显示别名,能显示数字的全部转换成数字。
- l:仅列出处于LISTEN(监听)状态的服务。
- p:显示建立相关链接的程序名。
- t(tcp):仅显示tcp相关的选项。
- u(udp):仅显示udp相关的选项。
- a(all):显示所有的选项,默认不显示LISTEN相关。
查看TCP相关的网络信息时,一般选择使用nltp
组合选项。

查看UDP相关的网络信息时,一般选择使用nlup
组合选项。

如果想查看LISTEN状态以外的连接信息,可以去掉l
选项,此时就会将处于其他状态的连接信息显示出来。

2. UDP协议
2.1 UDP协议格式
UDP的位置
网络套接字编程时用到的各种接口,是位于应用层和传输层之间的一层系统调用接口,这些接口是系统提供的。
而socket接口往下的传输层实际就是由操作系统管理的,因此UDP是属于内核当中的,是操作系统本身协议栈自带的,UDP的工作由OS来操控
协议格式

-
16位源端口号:表示数据从哪里来。
-
16位目的端口号:表示数据要到哪里去。
-
16位UDP长度:表示整个数据报(UDP首部+UDP数据)的长度。
-
16位UDP检验和:如果UDP报文的检验和出错,就会直接将报文丢弃。
UDP如何将报头与有效载荷进行分离?
UDP采用的实际上是一种定长报头,UDP在读取报文时读取完前8个字节后剩下的就都是有效载荷了。
UDP如何决定将有效载荷交付给上层的哪一个协议?
UDP就是通过报头当中的目的端口号来找到对应的应用层进程的。
说明一下 : 内核中用哈希的方式维护了端口号与进程ID之间的映射关系,因此传输层可以通过端口号得到对应的进程ID,进而找到对应的应用层进程。
如何理解报头?
OS是用C语言写的,那么UDP协议一定也是C语言编写的,在内核中UDP报头实际上就是一个位段。

UDP数据封装:
-
当应用层将数据交给传输层后,在传输层就会创建一个UDP报头类型的变量,然后填充报头的每个字段,这样就得到了一个UDP报头。
-
OS开辟一块空间,将UDP报头和有效载荷拷贝在一起,形成了UDP报文。
UDP数据分用:
-
当传输层从下层获得一个报文时,直接读取其前8个字节,提取目的端口号。
-
通过目的端口号找到对应的上层应用层进程,然后将剩下的有效载荷向上交付给该应用层进程。
2.2 UDP协议的特点
- 无连接:知道对端的IP和端口号就直接进行数据传输,不需要建立连接。
- 不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
- 面向数据报:不能够灵活的控制读写数据的次数和数量。
注意: 报文在网络中进行路由转发时,并不是每一个报文选择的路由路径都是一样的,因此报文发送的顺序和接收的顺序可能是不同的。
2.3 面向数据报
应用层发多少,UDP就发多少,不拆分、不合并,这就是面向数据报。
比如用UDP传输100个字节的数据:
- 如果发送端调用一次sendto,发送100字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节;而不能循环调用10次recvfrom,每次接收10个字节。
2.4 UDP的缓冲区
-
UDP只有接收缓冲区,没有发送缓冲区,也不需要发送缓冲区,其数据是调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
-
接收缓冲区不能保证UDP报文的接收顺序与发送顺序一致,怎么发不是我们说了算,而是OS系统决定的,如果接收缓冲区被打满,那么后面再来的UDP报文就会被丢弃。
-
UDP的socket可以同时进行读写,因此UDP是全双工的。
为什么UDP要有接收缓冲区?
大家试想,如果没有接收缓冲区,那么对端主机发过来一个UDP报文,这边的主机一直不接收,那么下一条报文在过来的时候就会被直接丢弃,这样就相当于白传了,传输是需要消耗网络资源的,结果传过来了直接被丢弃,这本质上是对网络资源的一种浪费,所以UDP需要有一个接收缓冲区,数据来了先放在缓冲区里,这样即使接收方主机没有及时读取,也不会影响后面来的数据。
2.5 UDP使用注意事项
-
UDP协议报头当中的UDP最大长度是16位的,因此一个UDP报文的最大长度是64K(包含UDP报头的大小)。
-
如果需要传输的数据超过64K,就需要在应用层进行手动分包,多次发送,并在接收端进行手动拼装。
2.6 基于UDP的应用层协议
NFS:网络文件系统。
TFTP:简单文件传输协议。
DHCP :动态主机配置协议。
BOOTP:启动协议(用于无盘设备启动)。
DNS:域名解析协议。
3. TCP协议
TCP全称为"传输控制协议(Transmission Control Protocol)",TCP协议是当今互联网当中使用最为广泛的传输层协议,没有之一。
基于TCP的上层应用非常多,比如HTTP、HTTPS、FTP、SSH等,甚至MySQL底层使用的也是TCP,这么多上层应用都基于TCP,是因为TCP协议可以很大程度地保证数据传输的可靠性。
3.1 什么是可靠性
为什么网络中会存在不可靠?

现代计算机大都基于冯诺依曼体系架构,在这个体系架构中,硬件之间是相互独立的,想要彼此之间完成通信就需要用"线"将它们连接起来,其中连接内存和外设之间的"线"叫做IO总线,而连接内存和CPU之间的"线"叫做系统总线。
由于这几个硬件设备都是在一台机器上的,因此这里传输数据的"线"是很短的,传输数据时出现错误的概率也非常低。
但是如果是两个相距很远的设备之间进行通信,那么连接各个设备的"线"就会变得非常长,传输数据时出现错误的概率也会大大增高,此时要保证传输到对端的数据无误,就必须引入可靠性。
因此就需要引入TCP协议,它主要解决的就是数据传输可靠性的问题。
为什么会存在UDP协议?
TCP协议是一种可靠的传输协议,使用TCP协议能够在一定程度上保证数据传输时的可靠性,而UDP协议是一种不可靠的传输协议,那UDP协议这种不可靠的协议存在有什么意义呢?
实际上我们不能认为不可靠就一定是坏事,可靠不可靠只是它们的特点,而不能视为缺点,任何事都有两面性;
- TCP协议是可靠的协议,也就意味着TCP协议需要做更多的工作来保证传输数据的可靠,并且引起不可靠的因素越多,保证可靠的成本(时间+空间)就越高。
- UDP协议是不可靠的协议,也就意味着UDP协议不需要考虑数据传输时可能出现的问题,因此UDP无论是使用还是维护都足够简单。
- 需要注意的是,虽然TCP复杂,但TCP的效率不一定比UDP低,TCP当中不仅有保证可靠性的机制,还有保证传输效率的各种机制。
在网络通信的场景中,UDP和TCP都有可能被使用,这需要根据实际需求,所以没有绝对的谁好谁坏,只有谁适合哪个场景。
3.2 TCP协议格式

TCP报头当中各个字段的含义如下:
- 源/目的端口号:表示数据是从哪个进程来,到发送到对端主机上的哪个进程。
- 32位序号/32位确认序号:分别代表TCP报文当中每个字节数据的编号以及对对方的确认,是TCP保证可靠性的重要字段。
- 4位TCP报头长度:表示该TCP报头的长度,以4字节为单位。(TCP报头长度范围:20-60字节)
- 6位保留字段:TCP报头中暂时未使用的6个比特位。
- 16位窗口大小:保证TCP可靠性机制和效率提升机制的重要字段。
- 16位检验和:由发送端填充,采用CRC校验。接收端校验不通过,则认为接收到的数据有问题。(检验和包含TCP首部+TCP数据部分)
- 16位紧急指针:标识紧急数据在报文中的偏移量,需要配合标志字段当中的URG字段统一使用。
- 选项字段:TCP报头当中允许携带额外的选项字段,最多40字节。
TCP报头当中的6位标志位:
- URG:紧急指针是否有效。
- ACK:确认序号是否有效。
- PSH:提示接收端应用程序立刻将TCP接收缓冲区当中的数据读走。
- RST:表示要求对方重新建立连接。我们把携带RST标识的报文称为复位报文段。
- SYN:表示请求与对方建立连接。我们把携带SYN标识的报文称为同步报文段。
- FIN:通知对方,本端要关闭了。我们把携带FIN标识的报文称为结束报文段。
这里说明一下,其实TCP报头在内核中也是一个位段的结构,封装TCP报头时,OS会用该位段类型定义一个变量,然后将TCP报头的各个属性全部填充进去,最后再拷贝到数据的首部,就完成了TCP报头封装。
TCP如何将报头与有效载荷进行分离?
当TCP从底层获取到一个报文后,虽然TCP不知道报头的具体长度,但报文的前20个字节是TCP的基本报头,并且这20字节当中涵盖了4位的首部长度。
因此TCP是这样分离报头与有效载荷的:
-
当TCP获得一个报文后,首先要读取报文的前20个字节,并从中提取出4位的首部长度,此时便获得了TCP报头的大小。
-
如果报头大小大于20字节,那么说明选项字段还有额外字节(报头大小-20)。
-
读取完TCP的基本报头和选项字段后,剩下的就是有效载荷了。
TCP如何决定将有效载荷交付给上层的哪一个协议?
应用层的每一个网络进程都必须绑定一个端口号。
- 服务端进程必须显示绑定一个端口号。
- 客户端进程由系统动态绑定一个端口号。
而TCP的报头中涵盖了目的端口号,因此TCP可以提取出报头中的目的端口号,找到对应的应用层进程,进而将有效载荷交给对应的应用层进程进行处理。
3.2.1 序号与确认序号
什么是真正的可靠?
其实可靠性的本质就在于你发的消息对方有没有真正收到,那怎么判断对方是否收到呢,只要对方发回了应答,就说明上一条报文对方一定收到了。

图中实线就表示该报文一定会被对方收到,虚线则表示对方不一定能收到。
但TCP要保证的是双方通信的可靠性,虽然此时主机A能够保证自己上一次发送的数据被主机B可靠的收到了,但主机B也需要保证自己发送给主机A的响应数据被主机A可靠的收到了。因此主机A在收到了主机B的响应消息后,还需要对该响应数据进行响应,但此时又需要保证主机A发送的响应数据的可靠性...,这样就陷入了一个死循环。

因为只有当一端收到对方的响应消息后,才能保证自己上一次发送的数据被对端可靠的收到了,但双方通信时总会有最新的一条消息,因此无法百分之百保证可靠性。
所以我们不对响应作响应,否则就会陷入死循环中,我们这里所说的可靠性是指历史消息的可靠性,总会有最新的消息无法保证其可靠性。
实际没有必要保证所有消息的可靠性,我们只要保证双方通信时发送的每一个核心数据都有对应的响应就可以了。

这种策略在TCP当中就叫做确认应答机制。
需要注意的是,确认应答机制不是保证双方通信的全部消息的可靠性,而是只要一方收到了另一方的应答消息,就说明它上一次发送的数据被另一方可靠的收到了。
32位序号
在进行网络通信时,允许一方向另一方连续发送多个报文数据,只要保证发送的每个报文都有对应的响应消息就行了,此时也就能保证这些报文被对方收到了。
TCP报头中的32位序号的作用之一实际就是用来保证报文的有序性的。如果没有序号,那么接收顺序和发送顺序就会有偏差,这本质上是不可靠的一种表现,所以32序号的存在本质上也是为了保证可靠性。
TCP将发送出去的每个字节数据都进行了编号,这个编号叫做序列号。
- 比如现在发送端要发送3000字节的数据,如果发送端每次发送1000字节,那么就需要用三个TCP报文来发送这3000字节的数据。
- 此时这三个TCP报文当中的32位序号填的就是发送数据中首个字节的序列号,因此分别填的是1、1001和2001。

接收端接收到三个TCP报文后,根据报头中的32位序号对报文进行排序,排好后放入TCP接收缓冲区中,此时接收端这里报文的顺序就和发送端发送报文的顺序是一样的了。
32位确认序号
TCP报头当中的32位确认序号是告诉对端,我当前已经收到了哪些数据,你的数据下一次应该从哪里开始发。
确认序号=序号+1,表示此序号之前的报文已经全部收到。

这里需要注意一点,响应数据与其他数据一样,也是一个完整的TCP报文,尽管该报文可能不携带有效载荷,但至少是一个TCP报头。我们在网络通信中发的都是TCP报文,就算没有数据也至少有一个报头。
报文丢失怎么办?

大家来看上面这张图,当主机A将这三段报文都发给主机B的过程中,序号1000-2001的报文丢失了,那么主机B 应该怎么样给主机A应答呢?
实际上问题就在于主机B给主机A发回去的应答报文中确认序号是多少,其实这里显而易见,我们一定要根据确认序号的定义来思考这个问题,确认序号表示的是该序号之前的报文都收到了,那么这里主机B的确认序号一定是1001,表示1001之前的报文都收到了,告诉主机A下次向我发送数据时应该从序列号为1001的字节数据开始进行发送。
这样主机A就知道了1001报文丢失了,下一次就从1001开始发。
为什么要用两套序号机制?
如果通信双方只是一端发送数据,另一端接收数据,那么只用一套序号就可以了。但是TCP是全双工的,通信双方可能同时收发消息,所以我们需要有两套序号机制。
32位序号表示自己发送给对方的数据,32位确认序号表示对对方发送的历史报文的确认,告诉对方下次应该从哪里开始发。
总结来说,双方都需要有确认应答机制,32位序号可以保证数据按序到达,并且可以去重;32位确认序号可以通知对端下一次报文发送的起始序号。
3.2.2 窗口大小
TCP的接收缓冲区和发送缓冲区
TCP本身是具有接收缓冲区和发送缓冲区的:
-
接收缓冲区用来暂时保存接收到的数据。
-
发送缓冲区用来暂时保存还未发送的数据。
-
这两个缓冲区都是在TCP传输层内部实现的。

这里我们需要澄清一个概念,TCP发送缓冲区当中的数据由上层应用应用层进行写入,我们之前调用是write/send这样的接口,本质上是将数据拷贝到TCP发送缓冲区;同理,我们调用的read/recv接口也是将数据从TCP接收缓冲区拷贝到应用层。
就好比调用read和write进行文件读写时,并不是直接从磁盘读取数据,也不是直接将数据写入到磁盘上,而对文件缓冲区进行的读写操作。

当数据写入到TCP的发送缓冲区后,对应的write/send函数就可以返回了,至于发送缓冲区当中的数据具体什么时候发,怎么发等问题实际都是由TCP决定的,所以我们称TCP为传输控制协议,用户只负责拷贝数据,具体怎么发,TCP说了算,本质上就算OS说了算。
TCP的发送缓冲区和接收缓冲区存在的意义
发送缓冲区和接收缓冲区的作用:
-
在网络发送的过程中,难免会有一些错误或者丢包的情况,有了发送缓冲区,是有利于进行数据的重传,如果没有发送缓冲区,那一旦出现丢包,丢失是数据就找不到了,那最终重传什么都不知道;发送缓冲区来暂时保存发送出去的数据,当对方确认收到数据后,发送缓冲区中的数据才会被覆盖掉。
-
接收端处理数据的速度是有上限的,如果发送端一次性发送了大量报文,接收端来不及接收就会将报文进行丢弃,那这样本质上是在浪费网络资源,所以TCP需要有接收缓冲区,这样可以将发送来是数据缓存起来,给上层处理数据留出更多时间,此外,TCP的数据重排也是在接收缓冲区当中进行的。
经典的生产者消费者模型:
-
对于TCP发送缓冲区来说,上层应用层不断将数据拷贝下来,下层网络层不断从发送缓冲区当中拿出数据准备进一步封装,此时上层应用层就扮演了"生产者"的角色,而下层网络层就扮演"消费者"的角色,TCP发送缓冲区就扮演"交易场所"。
-
对于TCP接收缓冲区来说,上层应用不断从接收缓冲区当中拿出数据进行处理,下层网络层不断往接收缓冲区当中放入数据。此时上层应用扮演的就是"消费者"的角色,下层网络层扮演的就是"生产者"的角色,而接收缓冲区对应的就是"交易场所"。
-
生产者消费者模型将上层应用与底层通信细节进行了解耦,此外,生产者消费者模型的引入同时也支持了并发和忙闲不均。
窗口大小
16位窗口大小当中填的是自身接收缓冲区中剩余空间的大小,也就是当前主机接收数据的能力。
接收端在做应答时,会填充16位窗口大小,这样发送端就知道了对端主机的接收能力。
如果窗口大小越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度。
如果窗口大小越小,说明接收端接收数据的能力越弱,此时发送端可以减缓发送数据的速度。
如果窗口大小的值为0,说明接收端接收缓冲区已经被打满了,此时发送端就不应该再发送数据了。
理解现象:
-
在编写TCP套接字时,我们调用read/recv函数从套接字当中读取数据时,可能会因为套接字当中没有数据而被阻塞住,本质是因为TCP的接收缓冲区当中没有数据了,我们实际是阻塞在接收缓冲区当中了。
-
而我们调用write/send函数往套接字中写入数据时,可能会因为套接字已经写满而被阻塞住,本质是因为TCP的发送缓冲区已经被写满了,我们实际是阻塞在发送缓冲区当中了。
3.2.3 六个标志位
为什么会存在标志位?
-
TCP报文有很多种类,除了正常通信发的普通报文以外,还有我们建立连接和断开连接时发送的报文等。
-
不同种类的报文对应的是不同的处理逻辑,我们需要区分报文的种类,那么TCP中的六个标志位就是帮助我们区分报文种类的,这六个标志位都只占用一个比特位,为0表示假,为1表示真。
SYN
-
报文当中的SYN被设置为1,表明该报文是一个连接建立的请求报文。
-
只有在连接建立阶段,SYN才被设置,正常通信时SYN不会被设置。
ACK
-
报文中ACK被置为1,表示这个报文是应答报文。
-
一般除了第一个请求报文没有设置ACK以外,其余报文基本都会设置ACK。
FIN
-
报文当中的FIN被设置为1,表明该报文是一个连接断开的请求报文。
-
只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置。
URG
一般情况下,我们发的数据都是有顺序的,对端收数据也是按顺序收的,但有时候发送端可能发送了一些"紧急数据",这些数据需要让对方上层提取进行读取,此时应该怎么办呢?

此时就需要用到URG标志位,以及TCP报头当中的16位紧急指针。
-
当URG指针被置为1时,就需要关注16位紧急指针,它代表紧急数据在报文中的偏移量。
-
因为紧急指针只有一个,它只能标识数据段中的一个位置,因此紧急数据只能发送一个字节。
recv函数的第四个参数flags有一个叫做MSG_OOB的选项可供设置,OOB被称为带外数据,就是一些比较重要的数据,如果上层想要读取紧急数据,可以添加这个选项。

与之对应的send函数的第四个参数flags也提供了一个叫做MSG_OOB的选项,上层如果想发送紧急数据,就可以使用send函数进行写入,并设置MSG_OOB选项。

PSH
当PSH被置为1,表示提醒对端TCP缓冲区尽快将数据交付上层。
一般来说,我们调用read/recv函数从缓冲区中读数据时,如果缓冲区中有数据,read/recv函数就可以正常读取并返回实际读到的字节数,但是如果缓冲区中没有数据了,read/recv函数就会在接收缓冲区阻塞,直到缓冲区有数据再读。
但实际上这种说法是不准确的,接收缓冲区和发送缓冲区都有一个水位线的概念。

这个水位线存在意义就在于,保证数据读取的效率,正常情况下,当数据量超过水位线的时候,我们才可以进行读取,如果有一点数据就去读,这样会降低读取效率,因为读取操作是有时间消耗的(用户态与内核态的切换)。
而PSH标志位如果被置为1,就是要告诉对端OS,尽快将接收缓冲区中数据交付到上层,不管数据到没到水位线,
RST
-
报文当中的RST被设置为1,表示需要让对方重新建立连接。
-
当双方连接还没有建立好的时候,发送方就直接将数据发送过去了,此时接收方响应时的应答报文中RST标志位就会被置为1,表示需要重新建立连接。
-
通信中途发现之前建立好的连接出现了异常也会要求重新建立连接。
3.3 确认应答机制(ACK)
TCP保证可靠性的机制之一就是确认应答机制。
确认应答机制也算是保证可靠性最重要的一个机制了,通过32位序号和32位确认序号实现,这里需要再次强调的是,确认应答机制不能保证100%可靠性,它是保证历史数据的可靠性。

如何理解TCP将每个字节的数据都进行了编号?
TCP是面向字节流的,我们可以将TCP的发送缓冲区和接收缓冲区都想象成一个大的字符数组。

-
将缓冲区视为字符数组后,上层下来的数据天然就有了编号,实际上就是数组下标,只不过这个下标从1开始。
-
通信的本质就是拷贝,将自己发送缓冲区的数据拷贝到对端是接收缓冲区中。
-
发送方在发送数据时,TCP报头中填的序号其实就是首个字节数据在发送缓冲区中对应的下标。
-
接收方在响应的时候,应答报文中填的确认序号其实就是接收缓冲区中最后一个字节数据下一个位置的下标。
-
当发送方收到接收方的响应后,就可以从下标为确认序号的位置继续进行发送了。
3.4 超时重传机制
超时重传机制也是TCP协议中一个保证可靠性的重要机制,属于一个兜底机制,如果发送方发送数据后一段特定时间内没有收到应答,那么此时就会触发超时重传机制,发送方将重新发送报文。
丢包的两种情况
第一种情况,发送方的报文直接丢失了,此时发送端在一定时间内收不到报文,就会进行超时重传。

第二种丢包情况,是接收端进行响应时发送的应答报文丢失了,此时发送端也会因为收不到对应的响应报文,而进行超时重传。

这种情况下,发送方将报文重新发一次,也不用担心报文重复的问题,因为报文都有序号,可以根据报头当中的32位序号来判断曾经是否收到过这个报文,从而达到报文去重的目的。
这里还需要特别强调一下,发送方发送完数据时,实际上就是网卡将发送缓冲区的数据拷贝了一份然后进行发送,这时OS不会立即将发送缓冲区中的这个数据清除或者覆盖,而会让其保留在发送缓冲区当中,以备超时重传时可以找到原数据,当发送方收到应答报文后,OS才会将发送过的数据从缓冲区中清除。
超时重传的等待时间
超时重传的时间不能设置的太长也不能设置的太短。
-
时间太长,会导致丢包后对端长时间收不到数据,影响重传的效率。
-
时间太短,会导致发送大量重复报文,可能应答报文还在传输,发送方就进行重传了,这样就会浪费网络资源。
-
超时重传设置的等待时间一定是上下浮动的,不是固定的。
-
TCP会动态计算这个等待时间,超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍,以指数的形式递增。
-
当累计到一定的重传次数后,TCP就会认为是网络或对端主机出现了异常,进而强制关闭连接。
3.5 连接管理机制
TCP是面向连接的
我们在进行TCP通信之前需要先建立连接,就是因为TCP的各种可靠性保证都是基于连接的,要保证传输数据的可靠性的前提就是先建立好连接。
操作系统对连接的管理
当一个服务器运行起来后,客户端是很多个,所以一台机器上可能会存在大量的连接,此时操作系统就不得不对这些连接进行管理。
-
那么提到OS对资源的管理,还是"先描述,再组织",OS内部会有一个描述连接的结构体(struct Link),该结构体当中包含了连接的各种属性字段,所有定义出来的连接结构体最终都会以某种数据结构组织起来,此时操作系统对连接的管理就变成了对该数据结构的增删查改。
-
建立连接时,OS会用上面提到的结构体定义出一个变量,再填充好其中的属性字段,最后再将这个变量插入到管理连接的数据结构中进行管理。
-
断开连接时,实际就是将某个连接从管理连接的数据结构当中删除,释放该连接曾经占用的各种资源。
-
因此连接的管理也是有成本的,这个成本就是管理连接结构体的时间成本,以及存储连接结构体的空间成本。
3.5.1 三次握手
三次握手的过程
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手。

我们这里以客户端服务器为例说明三次握手的过程,客户端要与服务端进行通信,首先要与服务器建立连接,此时双方TCP在底层会进行三次握手。
-
第一次握手,客户端向服务器发送一个TCP报头,其中SYN标志位被置为1,表示要与服务器建立连接。
-
第二次握手,服务器收到客户端发来的请求连接的报头后,紧接着向客户端发起连接建立请求并对客户端发来的连接请求进行响应,此时应答报头中的SYN和ACK标志位都会被置为1。
这里要注意一点,在前两次握手的过程中,发送的报文中是不能携带有效载荷的,因为前两次握手过程中连接还没有建立好,此时如果报文中如果携带了有效载荷,对方是无法收到的。
- 第三次握手:客户端收到服务器发来的报文后,得知服务器收到了自己发送的连接建立请求,并请求和自己建立连接,最后客户端再向服务器发来的报文进行响应。
为什么是三次握手?
首先我们需要明确一个问题,就是建立连接可能会失败,在三次握手的过程中,前两次握手有应答,可以保证可靠性,但是第三次握手没有应答,就有可能出现丢包,那么对端没有收到回应,就无法正常建立连接。

虽然客户端发起第三次握手后就完成了三次握手,但服务器却没有收到客户端发来的第三次握手,此时服务器端就不会建立对应的连接。所以建立连接时不管采用几次握手,最后一次握手的可靠性都是不能保证的。
既然连接的建立都不是百分之百成功的,因此建立连接时具体采用几次握手的依据,实际是看几次握手时的优点更多。
三次握手是验证双方通信信道的最小次数:
-
TCP是全双工的,建立连接的核心就在于确认双方通信信道是否连通,本质就是验证全双工。
-
从客户端的角度来看,收到第二次握手时,说明自己的第一次握手服务器已经收到了,这就说明客户端可以发服务器可以收;同时也证明了服务器可以发,客户端可以收,此时就证明自己和服务器都是能发能收的。
-
从服务器角度来看,当它收到客户端发来第一次握手时,证明客户端能发以及自己能收;当服务器收到第三次握手时,说明自己发出的第二次握手客户端收到了,这就证明自己能发客户端能收,此时就证明自己和服务器都是能发能收的。
-
上面的叙述说明了三次握手就是最小次数就可以验证双方通信信道是否正常,本质就是以最短的方式验证了双方的全双工,既然三次已经能验证了就没有必要再进行更多次的握手了。
三次握手能够保证连接建立时的异常连接挂在客户端:
-
如果客户端最后发出的第三次握手丢包了,此时在服务器端就不会建立对应的连接,而在客户端就需要短暂的维护一个异常的连接。
-
而维护连接是需要时间成本和空间成本的,因此三次握手还有一个好处就是能够保证连接建立异常时,这个异常连接是挂在客户端的,而不会影响到服务器。
总结来说,两个建立连接时采用三次握手的理由:
1. 三次握手是验证双方通信信道的最小次数,能够让能建立的连接尽快建立起来。
2. 三次握手可以让异常连接挂在客户端,实现了风险转移。
3. 三次握手可以以最小的成本100%确定双方的通信意愿。
三次握手时的状态变化

三次握手时的状态变化如下:
-
最开始时客户端和服务器都处于CLOSED状态。
-
服务器为了能够接收客户端的连接请求,需要将CLOSED状态变位LISTEN状态。
-
客户端发起第一次握手,状态变为SYN_SENT状态。
-
处于LISTEN状态的服务器收到连接请求后,将该连接放入内核的等待队列中,并向客户端发起第二次握手,此时服务器状态变为SYN_RCVD。
-
当客户端收到服务器发来的第二次握手后,紧接着向服务器发送最后一次握手,此时客户端的连接已经建立,状态变为ESTABLISHED。
-
而服务器收到客户端发来的最后一次握手后,连接也建立成功,此时服务器的状态也变成ESTABLISHED。
这就是三次握手的全过程,三次握手完成后,双方就可以进行正常通信了。
套接字和三次握手之间的关系
-
在客户端发起建立连接请求之前,服务器首先要进入LISTEN状态,此时服务器就需要调用listen函数。
-
当服务器进入LISTEN状态后,客户端就可以发起三次握手了,此时客户端调用connect函数。
-
这里需要特别注意,connect函数并不参与三次握手的过程,它只是发起三次握手,当connect函数返回时,底层要么三次握手成功了,要么就失败了。
-
如果客户端服务器完成了三次握手,那么服务器端会建立一个连接,这个连接会被放在内核的等待队列中,此时服务器端就需要调用accept函数将该连接获取上来。
-
当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了。
3.5.2 四次挥手
四次挥手的过程
由于双方维护连接都是需要成本的,因此当双方TCP通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手。

这里还是以客户端服务器为例,来说明四次挥手的过程;
-
第一次挥手:客户端向服务器发送的报文当中的FIN位被设置为1,表示请求与服务器断开连接。
-
服务器收到客户端发来的断开连接请求后对其进行响应。
-
第三次挥手:服务器收到客户端断开连接的请求,且已经没有数据需要发送给客户端的时候,服务器就会向客户端发起断开连接请求。
-
第四次挥手:客户端收到服务器发来的断开连接请求后对其进行响应。
这里注意,四次挥手完成后,双方连接才算真正断开。
为什么是四次挥手?
-
由于TCP是全双工的,建立连接的时候我们需要建立双方的连接,那么在断开连接的时候也是一样,我们需要断开客户端到服务器的通信信道,也需要断开服务器到客户端的通信信道,每两次挥手断开一个方向的通信信道,所以断开连接时至少需要四次挥手(以最小次数断开双方通信信道)。
-
四次挥手当中的第二次和第三次挥手不能合并在一起,因为在服务器在响应第二次挥手后,可能还有数据要发给客户端,所以不会立马发起第三次挥手,只有当数据全部发完后,才会发起第三次挥手。
四次挥手时的状态变化

四次挥手时的状态变化如下:
-
在挥手前客户端和服务器都处于连接建立后的ESTABLISHED状态。
-
客户端向服务器主动发起断开连接的请求,此时客户端的状态变为FIN_WAIT_1。
-
服务器收到客户端发来的连接断开请求后对其进行响应,此时服务器的状态变为CLOSE_WAIT。
-
客户端收到响应,状态变为FIN_WAIT_2。
-
当服务器没有数据需要发送给客户端的时,服务器会向客户端发起断开连接请求,等待最后一个ACK到来,此时服务器的状态变为LASE_ACK。
-
客户端收到服务器发来的第三次挥手后,会向服务器发送最后一个响应报文,此时客户端进入TIME_WAIT状态。
-
服务器收到最后一次ACK,彻底关闭连接,进入CLOSED状态。
-
而客户端不会立刻进入CLOSED状态,需要等待2MSL(最大报文生存时间),才可以进入CLOSED状态。
这就是四次挥手的全部过程,四次挥手完成后,双方就算断开连接了。
套接字和四次挥手之间的关系
-
客户端发起断开连接请求,对应就是客户端主动调用close函数。
-
服务器发起断开连接请求,对应就是服务器主动调用close函数。
-
一个close对应的就是两次挥手,双方都要调用close,因此就是四次挥手。
理解CLOSE_WAIT状态
-
双方在进行四次挥手时,如果只有客户端调用close函数,服务器不调用close函数,那么服务器就会进入CLOSE_WAIT状态,客户端进入FIN_WAIT_2状态。
-
只有四次挥手完成后,双方连接才算完全断开,如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少。
-
不及时关闭文件描述符,会造成泄漏,本质上是一种内存泄漏问题。

如果当服务器收到客户端的FIN请求后,服务器端不调用close函数关闭对应的文件描述符,那么服务器就不会给客户端发送FIN请求,相当于只完成了四次挥手当中的前两次挥手,此时客户端和服务器的连接状态分别会变为FIN_WAIT_2和CLOSE_WAIT。
我们可以编写一个程序来看一下这个CLOSE_WAIT状态,主线程就可以通过调用accept函数从底层获取建立好的连接了。获取到连接后主线程创建新线程为该连接提供服务。
cpp
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
const int port = 8081;
const int num = 5;
void *Routine(void *arg)
{
pthread_detach(pthread_self());
int fd = *(int *)arg;
delete (int *)arg;
while (1)
{
std::cout << "socket " << fd << " is serving the client" << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
// 创建监听套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
std::cerr << "socket error" << std::endl;
return 1;
}
// 绑定
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_port = htons(port);
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
std::cerr << "bind error" << std::endl;
return 2;
}
// 监听
if (listen(listen_sock, num) < 0)
{
std::cerr << "listen error" << std::endl;
return 3;
}
// 启动服务器
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
while(true)
{
int sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error" << std::endl;
continue;
}
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;
}
代码编写完毕后运行服务器,并用telnet工具连接我们的服务器,此时通过以下监控脚本就可以看到两条状态为ESTABLISHED的连接。

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

理解TIME_WAIT状态

客户端在发起第四次挥手后,如果立即进入CLOSED状态,一旦第四次挥手报文丢包,服务端会进行超时重传,但是客户端已经断开连接,无法再进行响应了,为了避免这种情况,因此客户端在四次挥手后没有立即进入CLOSED状态,而是进入到了TIME_WAIT状态进行等待,此时要是第四次挥手的报文丢包了,客户端也能收到服务器重发的报文然后进行响应。
TIME_WAIT状态存在的必要性:
-
当第四次挥手的报文丢包后,服务器端会出发超市重传,客户端在一段时间内仍然可以接收到服务器重发的FIN报文并对其进行响应。
-
客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方。因此客户端四次挥手后进入TIME_WAIT状态,还可以保证双方通信信道上的数据在网络中尽可能的消散。
TCP并不能完全保证建立连接和断开连接的可靠性,TCP保证的是建立连接之后,以及断开连接之前双方通信数据的可靠性。
下面我们继续利用代码,来验证一下TIME_WAIT状态,还是用上面的代码,我们只需要在telnet退出后直接关闭服务器,就可以看到后两次挥手客户端进入TIME_WAIT状态,因为我们直到,文件描述符的生命周期是随进程的,当进程退出时,会自动关闭它打开过的文件描述符,所以这就相当于服务器调用了close函数,完成了后两次挥手。


解决TIME_WAIT状态引起bind失败的方法
之前我们在编写网络套接字代码时,我们关闭TCP服务器后,想要立即启动发现启动不了,终端会显示绑定失败,这其实就是因为服务器进入了TIME_WAIT状态,原本的端口号还在被占用,所以我们当时只能换一个端口号才能重新启动服务器,但在实际中当服务器崩溃后最重要实际是让服务器立马重新启动,如果让服务器等2MSL时间,那会造成额外损失。
如果想要让服务器崩溃后在TIME_WAIT期间也能立马重新启动,需要让服务器在调用socket函数创建套接字后,继续调用setsockopt函数设置端口复用,这也是编写服务器代码时的推荐做法。
setsockopt函数
cpp
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,该选项设置为非零值表示开启端口复用。
此时当服务器崩溃后我们就可以立马重新启动服务器,而不用等待TIME_WAIT结束。
cpp
int opt = 1;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));


连接是由TCP管理的
上面图中我们可以看到,即使双方进程都退出了,服务器端依然还存在一个TIME_WAIT状态的连接,这就说明连接管理和进程管理是互相独立的,连接不一定会随进程的退出而关闭。
TIME_WAIT的等待时长是多少?
TCP协议规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(Maximum Segment Lifetime,报文最大生存时间)的时间才能进入CLOSED状态。
MSL在RFC1122中规定为两分钟,但是各个操作系统的实现不同,我们可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout命令来查看MSL的值。

这里我是ubuntu22.04系统,MSL为60s。
TIME_WAIT的等待时长设置为两个MSL的原因:
-
MSL是TCP报文最大生存时间,设置为2MSL,可以保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。
-
在理论上保证最后一个报文可靠到达的时间。
3.6 流量控制
TCP支持根据接收端的接收数据的能力来决定发送端发送数据的速度,这个机制叫做流量控制(Flow Control)。
-
接收端将自己可以接收的缓冲区大小放入TCP首部中的"窗口大小"字段,通过ACK通知发送端。
-
窗口大小越大,说明网络吞吐量越大,窗口大小会根据接收缓冲区剩余空间大小进行动态调整。
-
如果接收端缓冲区满了,就会将窗口值设置为0,这时发送方不再发送数据,但需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
当发送端得知接收端接收数据的能力为0时会停止发送数据,此时发送端会通过以下两种方式来得知何时可以继续发送数据。
-
等待对端通知。接收端上层将接收缓冲区中的数据读走,会给发送端发送一个TCP报文,告知发送端窗口大小,发送端就知道了接收缓冲区还有多少剩余空间,就可以继续发数据了。
-
主动询问。发送端每隔一段时间向接收端发送一个试探报文,这个报文不携带任何有效数据,目的就是得到对端的接收缓冲区还有多少空间。
16为数字最大表示65535,那TCP窗口最大就是65535吗?
理论上确实是这样的,但实际上TCP报头当中40字节的选项字段中包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位得到的,这就意味着这个窗口大小不止65535。
第一次向对方发送数据时如何得知对方的窗口大小?
这个问题其实很简单,双方在进行通信前,需要先建立连接,在三次握手的过程中双方就交换了彼此的窗口大小了。
3.7 滑动窗口
连续发送多个数据
双方在进行TCP通信时可以一次向对方发送多条数据,这样可以将等待多个响应的时间重叠起来,进而提高数据通信的效率。

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

而滑动窗口描述的就是,发送方不用等待ACK一次所能发送的数据最大量。

滑动窗口存在的最大意义就是可以提高发送数据的效率:
-
滑动窗口=min(对方窗口大小,拥塞窗口),因为发送数据时不仅要考虑对方的接收能力,还要考虑当前网络的状况,拥塞窗口的大小就可以反映网络的状况,后面会详细介绍拥塞窗口。
-
我们这里先不考虑拥塞窗口,并且假设对方的窗口大小一直固定为4000,此时发送方不用等待ACK一次所能发送的数据就是4000字节,因此滑动窗口的大小就是4000字节。(四个段)。
-
现在连续发送1001-2000、2001-3000、3001-4000、4001-5000这四个段的时候,不需要等待任何ACK,可以直接进行发送。
-
当收到对方确认序号为2001时,说明1001-2000的数据这个数据段已经被对方收到了,此时该数据段应该被纳入发送缓冲区当中的第一部分,上面我们假设了滑动窗口大小就是固定的4000,那么此时滑动窗口就需要向右移动,继续发送5001-6000的数据段,以此类推。
-
滑动窗口越大,则网络的吞吐率越高,同时也说明对方的接收能力很强。
当发送方发送出去的数据段陆陆续续收到对应的ACK时,就可以将收到ACK的数据段放到滑动窗口的左侧,并根据当前滑动窗口的大小来决定,是否需要将滑动窗口右侧的数据放到滑动窗口当中。
这里大家回想一下TCP重传机制,我们之前说发出去的报文,会在发送缓冲区中保存一段时间,不会被立即清除或者覆盖,当收到这个报文的应答后,OS才会将报文清除或者覆盖掉,那么问题来了,在没收到应答之前,这段报文保存在发送缓冲区的哪个部分呢?
其实它就保存在滑动窗口中,只有滑动窗口左侧的数据才是可以被覆盖或删除的,因为这部分数据才是发送并被对方可靠的收到了,所以滑动窗口除了限定不收到ACK而可以直接发送的数据之外,滑动窗口也可以支持TCP的重传机制。
滑动窗口一定会整体右移吗?

大家观察上图,在这种情况下,滑动窗口就没有进行右移,只是变窄了,其实这也很好理解,发送端发送了1001-2000的报文,假设对方窗口大小为4000,且对方上层一直不拿其接收缓冲区中的数据,此时ACK报文中确认序号是2001,表示刚才发的1000字节对方收到了,那么此时对方接收缓冲区就只剩3000字节的空间了,此时发送端就不能将滑动窗口右移,只能将滑动窗口变窄,所以滑动窗口是不一定右移的,因为对方接收能力可能不断在变化,从而滑动窗口也会随之不断变宽、变窄或者不变。
如何实现滑动窗口
前面说过,我们可以将TCP的缓冲区看成一个大的字符数组,而滑动窗口实际就可以看作是两个指针限定的一个范围,我们只需要用start指针作为左边界,end指针作为右边界,中间的部分就是滑动窗口。

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

-
大家来看上图,如果1001-2000的报文出现丢包,那么主机B的应答报文中确认序号就必须为1001,提醒发送端下一次从1001开始发,如果发送端连续三次收到确认序号为1001的报文时,就会触发"快重传",重新发送1001-2000的报文。
-
当接收端收到1001-2000的数据包后,就会直接发送确认序号为6001的响应报文,因为2001-6000的数据接收端其实在之前就已经收到了。
这种机制被称为"高速重发控制",也叫做"快重传"。
关于快重传,这里需要强调的是它需要在大量数据重传和个别数据重传做出平衡,比如上面的例子,发送端收到了确认序号为1001的响应报文时,理论上应该将1001-6000之间的报文全部重发,但是这样会造成大量的重复报文,所以发送端可以尝试先把1001-2000的数据包进行重发,然后根据重发后的得到的确认序号继续决定是否需要重发其它数据包。
快重传 VS 超时重传
简单来说,快重传是锦上添花的,可以进一步提高重传效率,但是它无法取代超时重传,因为如果发送端根本收不到应答,应答报文也丢包了,那这个时候肯定触发不了快重传,这时就只能靠超时重传了,相当于超时重传是一个兜底的机制,可以保证TCP的可靠性。
3.8 拥塞控制
为什么会有拥塞控制?
两个主机在通信过程中,可能会出现个别报文丢包的情况,这是很正常的,TCP的可靠性机制就是解决这个问题的;但是如果出现大量报文丢包的情况,就不是正常现象了,这时我们就需要怀疑是不是网络出现了问题。
TCP不仅考虑了通信双端主机的问题,同时也考虑了网络的问题。
- 流量控制:考虑的是对端接收缓冲区的接收能力,进而控制发送方发送数据的速度,避免对端接收缓冲区溢出。
- 滑动窗口:考虑的是发送端不用等待ACK一次所能发送的数据最大量,进而提高发送端发送数据的效率
- 拥塞窗口:考虑的是双方通信时网络的问题,如果发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
如何解决网络拥塞问题?
首先大家需要有一个认识,就是网络出现拥塞的问题,绝对不是一两台主机的问题,而是网络所有主机在同一时间段内向网络中发送了大量的报文,所以网络在短时间内就会出现拥堵,要想解决拥塞问题,本质就是要减轻网络的压力,此时所有使用TCP传输控制协议的主机都会执行拥塞避免算法,所有主机都少发数据甚至不发数据,等网络恢复后再进行发送。
拥塞控制
TCP引入了慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络状态,再决定按照多大的速度传输数据,如果一开始就发大量的数据,就会导致网络拥塞的概率大大增加,这样的话就会影响整体的通信效率。

-
拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞,所以可以说拥塞窗口的大小就反映了网络的状态。
-
刚开始发送数据的时候拥塞窗口大小定义以为1,每收到一个ACK应答拥塞窗口的值就加一。
-
每次发送数据包时,滑动窗口=min(对方窗口大小,拥塞窗口)。
每收到一个ACK应答拥塞窗口的值就加一,此时拥塞窗口就是以指数级别进行增长的,如果先不考虑对方接收数据的能力,那么滑动窗口的大小就只取决于拥塞窗口的大小,此时拥塞窗口的大小变化情况如下:

如果拥塞窗口的值一直以指数的方式进行增长,此时就可能在短时间内再次导致网络出现拥塞。

主机在进行网络通信时,实际就是在不断进行指数增长、加法增大和乘法减小。
慢启动的阈值初始时为对方窗口大小的最大值,如果到达一定值发生了网络拥塞,那么慢启动阈值将变为当前拥塞窗口的一半,并将拥塞窗口置为1,这样做的本质就是重新开始探测网络健康。
3.9 延迟应答
如果接收数据的主机收到数据后立即进行ACK应答,此时返回的窗口可能比较小。
这里大家要与确认应答进行区分,确认应答是保证可靠性的重要机制,而延迟应答是提高传输效率的。
原理也很简单,比如对方接收缓冲区还剩1000字节空间,这时发送端发送了500字节的数据,如果接收端收到后立即ACK,那么应答是窗口大小就是500字节;但是如果不着急响应,稍微等一段时间,等上层将数据拿走,这时接收缓冲区剩余空间就变大了,意味着可以给发送段响应一个更大的窗口,那么发送端就可以一次发更多的报文了,这样就提高了传输效率。

此外,不是所有的数据包都可以延迟应答。
- 数量限制:每个N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次(这个时间不会导致误超时重传)。
延迟应答具体的数量和最大延迟时间,依操作系统不同也有差异,一般N取2,最大延迟时间取200ms。
3.10 捎带应答
捎带应答是TCP常规的一个机制,当主机A向主机B发送报文时,主机B收到后需要进行ACK,但是这时主机B也想发报文给主机A,所以这个过程可以合并,主机B将自己要发的报文和确认应答报文一起发给主机A。

捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不用再发送单纯的确认报文了。
3.11 面向字节流
当创建一个TCP的socket时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区。
我们调用的write、read等函数本质都是在做拷贝工作,数据具体怎么发,是OS决定,因为TCP传输层属操作系统,所以我们上层在读数据的时候需要判断报文的完整性。
对于TCP来说,它不关心缓冲区是什么数据,都把它们当成一个个字节进行发送,TCP的任务就是将发送缓冲区中的所有字节可靠地传输到对端的接受缓冲区,至于上层怎么解释,那是用户来决定,这就是面向字节流。
3.12 粘包问题
什么是粘包?
-
这里的"包"指的是应用层数据包。
-
站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
-
站在应用层角度,我们看见的就是一串字节数据,无法区分一个完整的报文,在TCP的协议头中,没有如同UDP一样的"报文长度"这样的字段。
如何解决粘包问题
要解决粘包问题,本质就是要明确报文和报文之间的边界。
-
对于定长的包,保证每次都按固定大小读取即可。
-
对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如HTTP报头当中就包含Content-Length属性,表示正文的长度。
-
对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。
注意:UDP是不存在粘包问题的,根本原因就是UDP报头当中的16位UDP长度记录的UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界。
3.13 TCP异常情况
进程终止&&机器重启
当客户端正常访问服务器时,如果客户端进程突然崩溃了,此时建立好的连接会怎么样?
进程退出,该进程曾经打开的文件描述符将被自动关闭,相当于调用了close函数,此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。进程终止时会释放文件描述符,TCP底层仍然可以发送FIN,和进程正常退出没有区别。
重启和进程终止本质是一样的,重启就是系统杀死所有进程,和进程终止一样。
机器掉电/网线断开
当客户端正常访问服务器时,如果将客户端突然掉线了,此时建立好的连接会怎么样?
当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的。
- 服务器会定期客户端客户端的存在状况,检查对方是否在线,如果连续多次都没有收到ACK应答,此时服务器就会关闭这条连接。
- 此外,客户端也可能会定期向服务器"报平安",如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭。
3.14 TCP总结
TCP之所以比较复杂,就是因为它需要兼容可靠性和效率,这才有上面所述的各种机制。
可靠性:
- 检验和。
- 序列号。
- 确认应答。
- 超时重传。
- 连接管理。
- 流量控制。
- 拥塞控制。
提高性能:
- 滑动窗口。
- 快重传。
- 延迟应答。
- 捎带应答。
理解传输控制协议
我们上面所介绍的一系列策略与机制,其实都不涉及真正的数据发送,真正的数据发送是由IP网络层和数据链路层完成的,TCP只是提供理论支持。
TCP做决策和IP+MAC做执行,我们将它们统称为通信细节,它们最终的目的就是为了将数据传输到对端主机。
3.15 基于TCP的应用层协议
常见的基于TCP的应用层协议如下:
HTTP(超文本传输协议)。
HTTPS(安全数据传输协议)。
SSH(安全外壳协议)。
Telnet(远程终端协议)。
FTP(文件传输协议)。
SMTP(电子邮件传输协议)。