目录
- 一、再谈端口号
- 1.1 端口号
- 1.2 端口号的范围划分
- 1.3 常见知名端口号
- 1.4 netstat 命令
- 1.5 进程与端口号的关系
- 1.6 pidof 命令
- 二、UDP协议
- 2.1 UDP协议段格式
- 2.2 如何理解UDP报头和UDP报文
- 2.2.1 UDP报头
- 2.2.2 UDP报文和UDP报文的管理
- 2.2.3 UDP封装过程
- 2.3 UDP的特点
- 2.4 UDP的缓冲区
- 2.5 UDP使用注意事项
- 2.6 基于UDP的应用层协议
- 三、TCP协议
- 3.1 TCP协议段格式
- 3.1.1 16位紧急指针(标记位URG)
- 3.1.2 标记位PSH
- 3.1.3 标记位RST
- 3.2 流量控制(16位窗口大小)
- 3.3 确认应答机制(32位序号和32确认序号)
- 3.4 捎带应答(32位序号和32确认序号)
- 3.5 超时重传机制
- 3.6 三次握手和四次挥手
- 3.6.1 三次握手中服务器和客户端状态变化
- 3.6.2 三次握手(建立连接)
- 3.6.2.1 为什么要进行三次握手?
- 3.6.3 四次挥手(断开连接)
- 3.6.3.1 为什么要进行四次挥手
- 3.6.3.2 CLOSE_WAIT 和 TIME_WAIT
- 3.7 滑动窗口
- 3.7.1 滑动窗口在哪里?
- 3.7.2 如何理解滑动窗口?
- 3.7.3 滑动窗口的大小变化吗?
- 3.7.4 报文/响应报文丢失了怎么办?(快重传)
- 3.8 拥塞控制
- 3.9 面相字节流
- 3.10 粘包问题
- 3.11 TCP异常情况
- 3.12 半连接队列和全连接队列(listen 的第二个参数)
- 3.13 文件、Socket、系统和网络之间的关系
- 四、UDP与TCP的对比
- 4.1 UDP与TCP的特点对比
- 4.2 用UDP实现可靠传输
- 结尾
一、再谈端口号
1.1 端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序
在TCP/IP协议中,用 “源IP”,“源端口号”,“目的IP”,“目的端口号”,“协议号” 这样一个五元组来标识一个通信。
1.2 端口号的范围划分
- 0 - 1023:知名端口号,HTTP、FTP、SSH等这些广为使用的应用层协议,它们的端口号都是固定的
- 1024 - 65535:操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配的
1.3 常见知名端口号
- ssh服务器,使用22端口
- ftp服务器,使用21端口
- telnet服务器,使用23端口
- http服务器,使用80端口
- https服务器,使用443端口
执行下面的命令,可以看到知名端口号
cat /etc/services
所以在我们自己程序中需要使用端口号时,需要避开这些知名端口号
1.4 netstat 命令
语法:netstat [选项]
功能:netstat 是网络管理中常用的命令行工具
常见选项:
-
-n:以数字的形式显示IP地址和端口号
-
-a:显示所有端口
-
-l:仅仅显示监听状态的端口
-
-t:仅仅显示TPC连接
-
-u:仅仅显示UDP连接
-
-p:显示与每个连接关联的进程 ID(需要root权限)
1.5 进程与端口号的关系
- 一个进程是否可以bind多个端口号?不可以,端口号只能标识唯一的进程。
- 一个端口号是否可以被多个进程bind?可以。
1.6 pidof 命令
语法:pidof [进程名]
功能:通过进程名,查看进程id,在查看服务器的进程id时非常方便。
二、UDP协议
2.1 UDP协议段格式
- 16位源端口号:标识发送方端口号
- 16位目的端口号:标识接收方端口号
- 16位UDP长度:标识整个UDP报文的长度
- 16位UDP校验和:用于检测UDP数据报在传输过程中是否发生错误。
UDP协议由UDP报头和有效载荷(数据)构成,谈到协议就有下面两个问题需要解决:
- 如何解决报头与有效载荷分离的问题
UDP报头采用的是8字节固定报头长度,剩下的就是有效载荷 - 如何解决有效载荷向上交付的问题
UDP的报头中有一个字段叫做目的端口号,UDP就是通过目的端口号将有效载荷交付给上层进程的
对于UDP协议还有下面一个问题:UDP是面向数据报的,发送方发什么,接收方就收到什么,并且接收方不需要解决拆分报文,总需要有谁来解决这个问题,那它是如何知道报文是否被收齐的呢?
这个工作实际上是由接收方的操作系统来完成的,UDP报文中有一个字段叫做UDP长度,它记录的是整个UDP报文的长度,包括报头和有效载荷,去除报头部分的长度就是有效载荷的长度,若实际收到有效载荷的长度比它小,则说明没有收齐,就需要将该报文丢弃。
2.2 如何理解UDP报头和UDP报文
2.2.1 UDP报头
UDP报头实际上就是一个结构体:
struct udphdr
{uint32_t srcport:16;uint32_t dstport:16;uint32_t length:16;uint32_t checksum:16;
}
Linux操作系统内核就是这样表示的。
2.2.2 UDP报文和UDP报文的管理
在客户端和服务器中,一定存在同时收到很多UDP报文的情况,所以客户端和服务器需要对这些UDP报文进行管理,管理就需要提到先描述再组织了,先使用一个结构体描述UDP报文,再使用链表将所有的结构体管理起来。
对于UDP报文来说,它也是一个结构化字段(结构体):
struct sk_buff
{char* data; // 指向报文开头char* tail; // 指向报文结尾struct sk_buff* next; // 指向下一个结构体
}
Linux操作系统内核中对UDP报文的描述更加详细。
当发生方的应用层向传输层中的UDP交付数据后,此时UDP就需要对该数据进行封装,操作系统会为其创建一个sk_buff结构体和缓冲区,将数据和UDP报头依次保存好后,再将结构体连接到链表中,再将结构体向下交付给数据链路层继续进行封装。
当接收方在接收到报文后,会向上进行解包和分用,到传输层时,由于发送方和接收方使用的都是UDP协议,也就是同样的结构化字段,就可以根据UDP报文对应的结构体和UDP报头所对应的结构体,对UDP报文做出解释。
2.2.3 UDP封装过程
首先缓冲区中没有任何数据,所以data和tail最开始指向的同一个位置。
然后根据数据的大小,data指针向前移动数据大小的位置,将数据存放在data与tail指向的区间。
最后再添加报头,data指针向前移动报头大小的位置,将UDP报头存存放在data与tail指向的区间。
2.3 UDP的特点
UDP传输的过程类似于寄信。
- 无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接
- 不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息
- 面向数据报:不能够灵活的控制读写数据的次数和数量,将每一个独立的报文作为一个整体发送,保留报文边界
面向数据报:应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并
用UDP传输100个字节的数据:如果发送端调用一次sendto,发送100个字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节;而不能循环调用10次recvfrom,每次接收10个字节。
2.4 UDP的缓冲区
-
UDP没有真正意义上 显示的发送缓冲区。调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
-
UDP具有真正意义上显示的接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致,如果缓冲区满了,再到达的UDP数据就会被丢弃。
UDP的socket既能读,也能写,这个概念叫做 全双工。
2.5 UDP使用注意事项
我们注意到,UDP协议首部中有一个16位的最大长度。也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。
然而64K在当今的互联网环境下,是一个非常小的数字。所以当用户需要传输的数据超过64K时,用户需要在应用层手动进行分包,多次发送,并在接收端需要将这些数据进行手动拼装。
2.6 基于UDP的应用层协议
- NFS协议:网络文件系统
- TFTP协议:简单文件传输协议
- DHCP协议:动态主机配置协议
- BOOTP协议:启动协议(用于无盘设备启动)
- DNS协议:域名解析协议
三、TCP协议
3.1 TCP协议段格式
- 16位源端口号:标识发送方端口号
- 16位目的端口号:标识接收方端口号
- 32位序号:数据段第一个字节的序列号(用于排序和去重)
- 32位确认序号:用于检测UDP数据报在传输过程中是否发生错误
- 4位首部长度:TCP头部的长度(单位4字节)
- 6位标志位:控制TCP行为(URG、ACK等)
- URG:紧急指针是否有效
- ACK:确认号是否有效
- PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走
- RST:对方要求重新建立连接,我们把携带RST标识的称为复位报文段
- SYN:请求建立连接,我们把携带SYN标识的称为同步报文段
- FIN:通知对方,本端要断开连接
- 16位窗口大小:接收方当前可接收的字节数(用于流量控制)
- 16位校验和:头部和数据的校验和
- 16位紧急指针:紧急数据的偏移量(仅在URG标志位为1时有效)。
TCP协议由TCP报头和有效载荷(数据)构成,谈到协议就有下面两个问题需要解决:
- 如何解决报头与有效载荷分离的问题
TCP报头中,有20字节固定长度,其中包含一个字段4位首部长度,它的单位为4字节,4位比特位能表示[0,15],也就是首部长度最大为60字节,减去20字节的固定长度,剩下的就是选项,这样就完整的找到了报头,剩下的就是有效载荷了 - 如何解决有效载荷向上交付的问题
TCP报头中有一个字段叫做目的端口号,TCP就是通过目的端口号将有效载荷交付给上层进程的
3.1.1 16位紧急指针(标记位URG)
TCP会将接收到的报文保存在以队列形式组织起来的接收缓冲区中,如果说队列中已经存在了很多报文,此时有一个紧急任务需要处理,由于缓冲区的存在,这个任务也需要在缓冲区中排队,这显然是不合理的,紧急任务就是需要被尽快处理的,所以这个报文需要插队。
将报文中的URG标记位置为1,则说明该报文时紧急任务,报头中16位紧急指针表示的就是紧急数据在有效载荷的偏移量。仅仅数据的大小为1字节,也就是说TCP允许插队,但是不允许大量的插队。
那么什么是紧急任务呢?举个例子:假设用户正在想服务器中上传数据,但是此时用户发现自己想要上传的数据不是这个,所以用户需要终止上传行为,有了URG和紧急指针,即使服务器的接收缓冲区中有大量的报文,服务器已经可以优先处理这个紧急任务。
3.1.2 标记位PSH
在流量控制中会讲到报文中窗口大小表示的就是接收方当前可用的接收缓冲区的大小,假设TCP对应的上层执行任务非常消耗时间,导致其接收缓冲区被填满,此时接收方就需要通过窗口大小来告诉发送方自己的接收缓冲区已经不能接收数据了,此时发送到就需要等待接收方上层读取缓冲区的数据。
但是发送方一直这么等下去也不是办法,所以发送方会发送探测报文,以检查接收方是否已恢复接收能力,等待接收方报文中窗口大小,如果窗口大小不足时,发送方依旧会停止发送数据,发送方就会每过一段时间就重复这个过程,如果重复的次数多了,报文就会将PSH标记位置为1,告诉接收方尽快将接收缓冲区中的内容交付给上层。
还有一种方式就是发送方在等待的过程中,接收方上层读取了接收缓冲区内的数据,接收缓冲区就可以接收发送方的数据,接收方就会向发送方发送报文,报文中更新了自己的窗口大小。
无论是发送方发送的探测报文报文后,接收方回复报文,还是接收方直接发送报文,这两种方式都是告诉发送方,接收方已经能够接收数据,发送方可以继续发送数据了。
3.1.3 标记位RST
TCP是保证可靠性的,那么是否就能保证TCP的三次握手一定是成功的呢?
并不是,TCP保证可靠性并不是指TCP的三次握手一定是成功的,它指的是接收方收到了发送方发送的哪些数据,发送方是知道的,发送方发送的哪些数据是有问题的,发送方是知道的。
所以,TCP的三次握手是有可能失败的,当TCP第一次和第二次失败的时候,由于有响应的存在,客户端和服务器是报文是否被对方接收了的,但是第三次握手是没有响应的,所以客户端时无法保证服务器接收到了报文。
第三次握手时,客户端认为自己发送出报文,三次握手就完成了,而服务器需要接收到客户端的报文,服务器才认为三次握手完成。假设第三次握手的报文丢失了,客户端认为三次握手完成了,服务器认为三次握手没有完成。
客户端认为三次握手完成了,然后开始向服务器发送数据,然后服务器就感到疑惑,没有完成三次握手,客户端怎么就向我发送数据了,然后服务器就向客户端发送报文,报文中将RST标记位置为1,表示
3.2 流量控制(16位窗口大小)
TCP协议是拥有显示意义上的发送缓冲区和接收缓冲区的,当发送方应用层通过write/send函数向接收方发送数据时,由于write/send函数本质上就是拷贝函数,它会将应用层的数据拷贝到TCP的发送缓冲区中,通过一系列操作,将数据拷贝到接收方的接收缓冲区,最终接收方的应用层通过read/recv函数将数据读取上去。
在下图中,我就将这过程进行简单的演示,对于处在同一层协议栈的双方,发送方在同一层发出的数据,就是接收方收到的数据,并且下面的协议我们还没讲到,就先跳过一下。
这里假设发送方疯狂的向接收方发送数据,导致接收方来不及读取,那么接收方存储不下的报文应该怎么办?
丢弃吗?这里并不是报文的问题,并且报文传输也是消耗资源了的,所以丢弃报文显然是不合理的。
所以不能丢弃,那么接收方在自己接收缓冲区保存不下时,就需要告诉发送方不要再发送数据了。这就是我这里要将的流量控制,流量控制是谁做的?用户好像并没有管过,实际上流量控制是发送方的TCP做的,本质上就是操作系统做的。
发送方的操作系统如何进行流量控制呢?发送方一定需要知道接收方的当前可用的接收缓冲区的大小。
发送方怎么知道接收方当前可用的接收缓冲区的大小呢?一般来说发送方发送一个TCP报文,接收方就需要返回一个TCP报文,这就是确认应答机制(后面会讲),又TCP报头中有一个字段16位窗口大小,它就是记录接收方当前可用的接收缓冲区的大小。
有了流量控制,当接收方可用的接收缓冲区的大小不足时,发送方就会减少或停止发送数据,但是用户在一直发,这就会导致发送方的发送缓冲区被填满,最终导致进程阻塞,只有当操作系统将发送缓冲区中的数据发送出去时,进程才会被移除阻塞队列。
同样当接收方的接收缓冲区为空时,进程也会被阻塞,只有发送方有数据发送到接收缓冲区中,进程才会被移出阻塞队列。
但是用户并不需要关心这个过程,TCP(操作系统)会自己管理进程是否发送的问题。
这与我们在操作系统中文件读写相关知识很类似,当我们向文件中写入数据时,实际上是向的文件的内核基本缓冲区中,数据什么时候刷新到磁盘中,一次刷新多少,都不需要用户操心。
而这里同样如此,用户将数据发送出去,实际上就是将数据拷贝到发送缓冲区中,什么时候发,一次发多少,这都是TCP(操作系统)需要操心的事,用户就不需要管了。
3.3 确认应答机制(32位序号和32确认序号)
对于TCP而言,当客户端发送了一个报文以后,服务器就需要回应一个报文,当客户端接收到了服务器的报文就知道服务器收到了客户端的报文,但是服务器却不知道自己发送的报文客户端是否收到,所以客户端就又向服务器发送一个报文,当服务器收到了客户端发的报文以后,服务端就知道它发送的报文客户端收到了,此时客户端又不知道自己发送的报文服务器是否收到,一直重复这样的操作,最终最后发生报文的一方,无法知道对方是否接受到报文。所以,TCP暂时做不到100%可靠的网络通信,但是可以做到局部上的可靠通信,当一方收到了响应,就能100%保证历史上最近的一个报文是被对方收到了的。
下图是为了大家方便理解而画的图,这是TCP发送数据的一种方式,下图中客户端发送报文时,是串行发送的,但是这样有一个缺点就是慢。
在TCP中,TCP是允许同时发送多个TCP报文的。由于报文在网络传输中各种因素的影响,可能会导致服务器收到的报文顺序是乱序的,乱序是一直不可靠的一种,TCP报头中的32位序号就可以解决报文顺序乱序的问题,保证报文的按序到达(按序到达是对TCP的上层来说的)。
TCP会将数据中的每一个字节进行编号,并将数据中第一个序号保存在TCP报头中的32位序号中,当客户端同时发送多个报文时,经过网络的传输可能导致服务器接收报文的顺序是乱序的,但是由于有报头中序号的存在,TCP就可以通过序号的大小进行排序,从而使报文变得有序。
而报头的32位确认序号表示的是确认序号之前的所有报文都被对方全部接收了。以下图为例,服务器收到四个报文,就需要响应四个报文,其中四个报文中的确认序号为101、201、301和401,假设除了确认序号为401的报文,其他的报文全部丢失,只要客户端收到确认序号为401的报文,就可以确定客户端发送的四个报文已经被服务器收到了。这样做了以后,TCP协议就能够允许少量报文的丢失。
3.4 捎带应答(32位序号和32确认序号)
上面我们讲到了请求报文中的32序号能够让接收方接收到的报文按序到达,响应报文中的32位确认序号可以让发送方知道自己发送的哪些报文已经被接收方收到了。
这时候就有一个问题了,请求报文和响应报文,是两个分开的报文,为什么报文中需要存在序号和确认序号呢?为什么不可以将它们合并在一起呢?
这是因为上面我们讲到的只有服务器确认的情况,实际上服务器也会发送数据。例如,当客户端发送请求报文以后,服务器需要对客户端发送ACK,表示自己已经收到了报文,然后服务器也想给客户端发送数据,这样服务器就对客户端的一个报文,响应了两个报文,为什么不将这两个报文合并呢?将服务器想要发送数据的报文的ACK置为1,就可以将两个报文合并为一个报文了,这就是捎带应答。因为序号和确认序号可能会被同时使用,所以不能合并。
捎带应答就是通过将确认信息与待发送的数据结合在一起传输,从而减少通信开销和降低延迟。
3.5 超时重传机制
超时重传机制就是主机A向主机B发送数据,在特定的时间间隔中没有收到主机B的确认应答,主机A就会向主机B重新发送未被确认的数据。
主机A向主机B发送数据,但是可能由于网络中的各种原因,导致数据没有送达到主机B,主机A在一定时间内没有收到主机B的应答,就会将未被确认的数据进行重发。
由于主机A需要收到主机B的应答才能够确定,自己发送的数据被主机B收到了,所以除了上面的情况,还有主机B发送的确认应答由于网络的各种原因,导致主机A没有收到主机B的应答,主机A在一段时间内没有收到主机B的应答,就会将未被确认的数据进行重发。
在该情况下,主机B就会收到主机A相同的报文,主机B就会根据报文中的序号,对报文进行去重,保留新的还是旧的就需要看操作系统了。
在上面两种情况中,无论是主机A发送的请求报文丢失了,还是主机B发送的应答报文丢失了,最终结果都是主机A进行超时重传。
由于主机A需要得到主机B的确认应答,才能保证数据没主机B接收了,所以主机A发送数据后,不能立即从发送缓冲区移除,需要暂时保留一段时间,直到主机B发送确认应答后,才能够移除。
暂时保存在发送缓冲区的数据是在滑动窗口的区域中,收到应答后,通过滑动窗口的移动,移除指定的数据(滑动窗口在后面讲解)。
那么超时时间是多久呢?
- 如果超时时间太长会导致效率降低
- 如果超时时间太短会导致过于频繁的进行重传
并且由于网络环境是动态变化的,所以超时时间也需要是浮动的。
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中超时以500ms为一个单位进行控制,每次判定超时重发的超时
时间都是500ms的整数倍。
如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增。
累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
3.6 三次握手和四次挥手
主机可能在同一时间内存在不同状态的连接,所以操作系统就需要对连接进行管理,管理的方式就是先描述再组织,先通过连接的各种属性,将连接描述为一个结构体,再使用特定的数据结构将所有的结构体管理起来,当建立一个连接以后,操作系统就为这个连接创建一个结构体对象,在将对方放入到数据结构中,最终对连接的管理就转变为了对数据结构的管理。
3.6.1 三次握手中服务器和客户端状态变化
服务端状态转化:
- [CLOSED -> LISTEN],服务器端调用listen后进入LISTEN状态,等待客户端连接。
- [LISTEN -> SYN_RCVD] ,一旦监听到连接请求,就将该连接放入到内核的等待队列中,并向客户端发送SYN+ACK确认报文。
- [SYN_RCVD -> ESTABLISHED],服务端一旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了。
- [ESTABLISHED -> CLOSE_WAIT],当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入CLOSE_WAIT。
- [CLOSE_WAIT -> LAST_ACK],进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据),当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK状态,等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)。
- [LAST_ACK -> CLOSED],服务器收到了对FIN的ACK,彻底关闭连接。
客户端状态转化:
- [CLOSED -> SYN_SENT],客户端调用connect,发送同步报文段。
- [SYN_SENT -> ESTABLISHED],connect调用成功,则进入ESTABLISHED状态,开始读写数据。
- [ESTABLISHED -> FIN_WAIT_1],客户端主动调用close时,向服务器发送结束报文段,同时进入FIN_WAIT_1。
- [FIN_WAIT_1 -> FIN_WAIT_2],客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2,开始等待服务器的结束报文段。
- [FIN_WAIT_2 -> TIME_WAIT],客户端收到服务器发来的结束报文段,进入TIME_WAIT,并发出LAST_ACK。
- [TIME_WAIT -> CLOSED],客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入CLOSED状态。
3.6.2 三次握手(建立连接)
3.6.2.1 为什么要进行三次握手?
TCP为什么需要三次握手,为什么不是一次、两次和四次呢?
-
TCP为什么不是一次握手?
- 如果TCP一次握手就建立连接,只需要客户端向服务器发送SYN就能建立连接,客户端无法得知服务器是否收到SYN,若是客户端发送的报文丢失,就会造成双方建立不一致的问题。
- 攻击者可以伪造SYN,会导致SYN洪水的问题,并且服务器维护连接是需要成本的,会造成资源浪费。
-
TCP为什么不是两次握手?
- 两次握手不能避免旧的重复连接初始化造成混乱的问题。若客户端发送的SYN因网络延迟滞留,随后重发SYN并建立新连接,而滞留的SYN稍后到达服务器,服务器会误认为是新连接请求并响应SYN+ACK。此时客户端已忽略旧SYN,导致服务器资源被无效占用。
- 攻击者可以伪造SYN,会导致SYN洪水的问题,并且服务器维护连接是需要成本的,会造成资源浪费。
- 无法保证双方的通信能力,客户端向服务器发送SYN,证明客户端的发送能力,服务器收到SYN,并向客户端发送SYN+ACK,证明了服务器的接收能力和发送能力。所以两次握手不能证明客户端的接收能力。
- 两次握手不能保证双方初始序号同步
-
TCP为什么是三次握手?
- 三次握手可以避免旧的重复连接初始化造成混乱的问题。若客户端发送的SYN因网络延迟滞留,随后重发SYN并建立新连接,而滞留的SYN稍后到达服务器,服务器会误认为是新连接请求并响应SYN+ACK,并向。此时客户端已忽略旧SYN,导致服务器资源被无效占用。
- 三次握手以最小成本验证全双工,保证双方的通信能力。客户端向服务器发送SYN,证明客户端的发送能力,服务器收到SYN,并向客户端发送SYN+ACK,证明了服务器的接收能力和发送能力,客户端接收到SYN+ACK,并向客户端发送ACK,就能证明客户端的接收能力。
- 奇数次握手,服务器能够减少资源的浪费,最后一个报文时客户端发出的,客户端发出报文认为连接建立成功,服务器则是收到了报文才认为连接建立成功。建立连接是需要消耗资源的,客户端先认为连接建立成功,所以资源是需要客户端先消费的,当服务器收到报文后才会消费资源,假设报文在传输过程中丢失了,服务器就任务连接建立失败,就不会消耗资源。
- 三次握手可以保证双方序号同步
-
TCP为什么不是四次握手?
- 四次握手的过程:
- 客户端向服务器发送SYN
- 服务器收到SYN,向客户端发送ACK
- 服务器向客户端发送SYN
- 客户端收到SYN,向服务器发送ACK
- 服务器收到客户端的SYN,就必须向客户端发送ACK和SYN,所以服务器为了提高效率和节省资源,将ACK和SYN合并为一个报文。本质上三次握手就是四次握手+捎带应答。
- 四次握手的过程:
3.6.3 四次挥手(断开连接)
3.6.3.1 为什么要进行四次挥手
- 为什么要进行四次挥手
- 四次挥手的过程
- 由于TCP双方是平等的,所以断开连接需要双方同意
- 客户端向服务器发送FIN,表示客户端已经发送完数据,不会再向服务器发送数据了(此时客户端还可以接收数据)
- 服务器收到FIN,向客户端发送ACK,表示自己已经收到报文(此时服务器还可以向服务器发送数据)
- 服务器向客户端发送FIN,表示服务器已经发送完数据,不会再向客户端发送数据了(由于服务器收到FIN后,此时服务器还没将数据发送完毕,所以通常不将ACK与FIN进行合并)
- 客户端收到FIN,向服务器发送ACK,表示自己已经收到报文
- 四次挥手可以变为三次挥手吗?
- 可以的,但是现实中四次挥手更常见。四次挥手可以通过延迟应答机制,将ACK和FIN合并。首先服务器收到客户端的FIN,准备向客户端发送ACK,此时服务器会等待一段时间,若此时服务器需要向客户端发送FIN,就将ACK和FIN合并为一个报文发送给客户端,否则就先将ACK先发送给客户端。(延迟应答会在后面讲解)
- 可以的,但是现实中四次挥手更常见。四次挥手可以通过延迟应答机制,将ACK和FIN合并。首先服务器收到客户端的FIN,准备向客户端发送ACK,此时服务器会等待一段时间,若此时服务器需要向客户端发送FIN,就将ACK和FIN合并为一个报文发送给客户端,否则就先将ACK先发送给客户端。(延迟应答会在后面讲解)
- 四次挥手的过程
3.6.3.2 CLOSE_WAIT 和 TIME_WAIT
CLOSE_WAIT状态只有被动断开连接的一方才会拥有的状态,当被动断开连接一方收到FIN并返回ACK后,会将状态变为CLOSE_WAIT状态,直到它调用close函数,并向主动断开连接的一方发送FIN后,才会变为LAST_ACK状态。
服务器出现大量 CLOSE_WAIT 状态的连接原因有哪些?
- 服务器的代码存在问题,服务器在处理完数据以后,并没有调用关闭连接的函数(close()函数或shutdown()函数),导致服务器中存在大量CLOSE_WAIT状态的连接。
- 服务器在处理客户端发送的数据时,可能由于某些复杂的业务逻辑导致处理过程阻塞。在这种情况下,服务器无法及时处理完数据并发送FIN报文关闭连接,从而使连接长时间处于CLOSE_WAIT状态。
服务器中存在大量 CLOSE_WAIT 状态的连接,会导致服务器网络应用越来越卡。
TIME_WAIT是只有主动断开连接的一方才会拥有的状态。
进入TIME_WAIT状态的连接,此时它绑定的IP和端口号并未被彻底释放,通常此时重启服务器绑定同样的IP和端口号就会报错。
下图我关闭服务器后,立刻重启服务器并绑定相同IP和端口号,此时服务器并未启动,错误原因为IP和端口号已经被其他进程绑定了。
解决这个问题的方法就是使用setsockopt函数,设置套接字选项以允许地址重用。
TIME_WAIT的时间是2MSL,MSL是报文的最长存活时间。TIME_WAIT为两倍MSL,能够保证两个方向上未被接收到的报文和迟到的报文已经消失。
同时也保证了最后一个报文的可靠到达,假设最后一个ACK丢失了,服务器会重发FIN,此时客户端已经不在了,但是操作系统以及维护着TCP连接,依旧可以重复ACK。
TIME_WAIT存在的意义:
- 防止网络中历史报文对新连接的影响
- 报文三次握手时,初始的序号是随机的,所以并不是判断报文是新的还是旧的,如果是没有TIME_WAIT,旧报文会对新连接有影响。
- 报文的最长存活时间MSL,TIME_WAIT能够等待历史报文在网络中消散,就没有旧报文影响。
- TIME_WAIT能够保证最后一个报文的可靠到达,当最后一个报文丢失后
- 没有TIME_WAIT的情况,客户端(主动断开连接一方)发送ACK后就进入CLOSE状态,由于服务器(被动断开连接一方)没有收到ACK,就会重发FIN,由于客户端连接已经关闭,则会发送RST给服务器,这样服务器就会将连接强制关闭,强制关闭连接并不是很好。
- 有TIME_WAIT的情况,客户端(主动断开连接一方)发送ACK后就进入CLOSE状态,由于服务器(被动断开连接一方)没有收到ACK,就会重发FIN,由于客户端的连接并没有关闭,所以收到FIN以后会重置TIME_WAIT时间,并向服务器发送ACK,这样能够保证服务器正常关闭连接。
3.7 滑动窗口
滑动窗口就是TC并发多个数据暂时不需要ACK的解决方案,从而提高TCP的效率,滑动窗口还是流量控制的解决方案。
在确认应答部分我就讲到过,对于每一个报文,都要收到它对应的ACK,假设发送一个报文,就要等待收到ACK再发送下一个报文,这样TCP的效率就比较低了。
那么一次发送多个报文,就可以大大提高效率(多个报文的等待时间重叠了)。
3.7.1 滑动窗口在哪里?
滑动窗口就是发送缓冲区中的一个区域。
3.7.2 如何理解滑动窗口?
发送缓冲区我们可以理解为一个数组,滑动窗口则可以理解为两个指针,两个指针指向的区间就是滑动窗口,滑动窗口向右滑动就是指针向右移动。
需要注意的是,滑动窗口只能向右滑动,不能向左滑动。
这时候就有人问了,按照数组形式理解,滑动窗口越界了怎么办?
操作系统中对其有复杂的处理方式,大家可以将滑动窗口为环形结构即可。
- 滑动窗口的大小是由谁决定呢?
- 滑动窗口的大小是由接收方可用的接收缓冲区的大小决定的。
- 滑动窗口的大小还与拥塞窗口有关。
- 滑动窗口的大小就是接收方窗口大小与拥塞窗口中小的一个。
- 滑动窗口是如何更新的呢?
- 滑动窗口是根据确认序号和接收方窗口大小决定的
- win_start = 确认序号,win_end = win_start + win(窗口大小)
3.7.3 滑动窗口的大小变化吗?
滑动窗口的大小是可以变化的,滑动窗口的大小是根据对方可用的接收缓冲区大小来决定的,滑动窗口既可以变大,也可以变小,甚至可以为0。
当主机A将数据发送给主机B后,假设主机B的上层并未将接收缓冲区中读走,此时可以缓冲区就会变小,主机A的滑动窗口也会变小。主机A多次向发送数据,假设主机B上层一直没有读走数据,主机B的可以缓冲区就为0,主机A的滑动窗口也会变为0。
当主机A再次将数据发送给主机B后,假设主机B的上层一次将接收缓冲区中的数据全部读走,此时可以缓冲区就会变大,对应主机A的滑动窗口也会变大。
3.7.4 报文/响应报文丢失了怎么办?(快重传)
-
最左侧的报文/响应报文丢失了
- 当最左侧的报文丢失时,其他的报文被主机B接收到,主机B会对所有的报文进行响应,但由于主机B并未收到数据1001~2000,所以所有相应的报文的确认序号都为1001,当主机A收到3个及以上的响应报文的确认序号相同时,就会立即对该数据进行重发,这就是快重传。如果主机A没有收到3个及以上的响应报文的确认序号相同时,主机A则会等到超时时间,将对应数据进行超时重传。
- 当最左侧的报文对应的相应报文丢失了,但由于其他的响应报文并未丢失,确认序号的定义是确认序号之前的数据都收到了,所以并未有影响。
-
中间的报文丢失了
- 中间的报文丢失了,由于前面的报文已经被主机B接收到了,那说明滑动窗口就可以向右滑动,将已经被接收到的数据移除,则中间报文就变成了最左侧报文,中间报文丢失的问题就转变为了最左侧报文丢失的问题了。
- 中间报文对应的响应报文丢失,会转化为最左侧报文对应的响应报文丢失的问题。
-
最右侧报文/响应报文丢失了
- 最右侧报文丢失了,由于前面的报文已经被主机B接收到了,那说明滑动窗口就可以向右滑动,将已经被接收到的数据移除,则中间报文就变成了最左侧报文,最右侧报文丢失的问题就转变为了最左侧报文丢失的问题了。
- 最右侧报文对应的响应报文丢失,会被转化为最左侧报文对应的响应报文丢失的问题。
3.8 拥塞控制
上面讲述的机制都是与双方主机相关的,但是主机A向主机B发送数据需要通过网络,所以TCP还考虑了网络的状态,当网络中少量报文丢失,发送方就会将报文进行重传,但网络中大量的报文丢失了,发送方则会判断出是网络出现了问题 。
TCP引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。少量数据探路,有响应后,说明网络状态已经好了一些,就需要尽快的恢复网络通信。
此处引入一个概念为拥塞窗口
- 发送开始的时候,定义拥塞窗口大小为1
- 每次收到一个ACK应答,拥塞窗口加1
- 也就是先发送一个报文,收到应答后,下次就可以发送两个。发送两个报文,两个报文都收到了应答,则下次可以发送四个,以此类推
- 每次发送报文的时候,将拥塞窗口和接收端窗口大小做比较,取较小的值作为滑动窗口的大小
像上面这样的拥塞窗口增长速度,是指数级别的。“慢启动” 只是指初使时慢,但是增长速度非常快。
- 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍
- 此处引入一个叫做慢启动的阈值
- 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长
- 当TCP开始启动的时候,以慢启动的方式增加拥塞窗口,慢启动阈值等于窗口最大值
- 当拥塞窗口达到慢启动的阈值后,以线性方式增加拥塞窗口
- 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1
拥塞窗口的过程:
- 慢启动
- 发送方以指数方式逐渐增加拥塞窗口的大小。
- 拥塞避免
- 当拥塞窗口超过阈值后,发送方以线性方式缓慢增加拥塞窗口,避免网络过载。
- 快重传
- 当发送方收到3个重复的ACK时,立即重传丢失的数据包,而不必等待超时。
- cwnd = cwnd /2,ssthresh = cwnd
- 快恢复
- 在快重传后,发送方不进入慢启动阶段,而是进入拥塞避免阶段,调整拥塞窗口。主机还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像超时重传那么强烈。
- cwnd = ssthresh + 3
- 如果再收到重复的 ACK,那么 cwnd 增加 1
- 如果收到新的的 ACK,把 cwnd 设置为第一步中的 ssthresh 的值
- 超时重传
- 发送方的重传定时器超时,说明可能发生了严重的网络拥塞
- ssthresh = cwnd / 2
- cwnd = 1
3.9 面相字节流
创建一个TCP的socket,同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区
- 调用write时,数据会先写入发送缓冲区中
- 如果发送的字节数太长,会被拆分成多个TCP的数据包发出
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区
- 然后应用程序可以调用read从接收缓冲区拿数据
- 另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据,这个概念叫做 全双工
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
- 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次
如何理解面向字节流呢?
-
发送缓冲区
- 发送方实际发送的数据是 “滑动窗口” 内的字节,已发送但未确认的字节会保留在缓冲区中,当收到接收方的 ACK 就会通过将滑动窗口向右滑动,将已被接收的数据移除,看上去发送缓冲区中的数据就像是“流动的”。
-
接收缓冲区
- 发送方的发送的数据会按照顺序保存在接收缓冲区,而接收方的上层可以按顺序从接收缓冲区中读取数据,将数据读走后,接收缓冲区中的数据看起来就是“流动的”。
3.10 粘包问题
粘包问题中的 “包”,是指的应用层的数据包
- 在TCP的协议头中,没有如同UDP一样的 “报文长度” 这样的字段,但是有一个序号这样的字段
- 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中
- 站在应用层的角度,看到的只是一串连续的字节数据
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包
那么如何避免粘包问题呢?归根结底就是一句话,明确两个包之间的边界
- 对于定长的包,保证每次都按固定大小读取即可。例如上面的Request结构,是固定大小的,那么就从缓冲区从头开始按sizeof(Request)依次读取即可
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置
- 对于变长的包,还可以在包和包之间使用明确的分隔符
思考:对于UDP协议来说,是否也存在 “粘包问题” 呢?
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在。同时,UDP是一个一个把数据交付给应用层,就有很明确的数据边界
- 站在应用层的站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现"半个"的情况
3.11 TCP异常情况
- 进程终止
- 进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别
- 机器重启
- 机器重启时,操作系统会询问是否终止进程,所以和进程终止的情况相同
- 机器掉电(无法发送FIN)
- 发送方机器掉电
- 发送方所有未发送的数据和连接状态丢失,无法继续传输
- 接收方长时间未收到数据或 FIN,可能默认关闭连接
- 接收方机器掉电
- 发送方会启动重传定时器。若在定时器超时后仍未收到数据,发送方会持续重传确认报文,在多次重传无果后,发送方会认为连接出现问题,最终会选择关闭连接。
- 接收方所有数据和连接状态丢失,无法继续传输
- 发送方机器掉电
3.12 半连接队列和全连接队列(listen 的第二个参数)
- 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)
- 全连接队列(用来保存处于 ESTABLISHED 状态,但是应用层没有调用accept取走的请求)
半连接队列工作流程
- 客户端向服务器发送 SYN 包,请求建立连接。
- 服务器收到 SYN 包后,为这个连接创建一个新的条目,并将其放入半连接队列中。
- 服务器向客户端发送 SYN + ACK 包,等待客户端的 ACK 确认。
- 如果在一定时间内没有收到客户端的 ACK 确认,服务器会重发 SYN + ACK 包,达到一定重传次数后仍未收到 ACK,则会从半连接队列中移除该条目。
全连接队列工作流程
- 客户端收到服务器的 SYN + ACK 包后,向服务器发送 ACK 确认包。
- 服务器收到 ACK 确认包后,将连接信息从半连接队列移除,并加入到全连接队列中。
- 服务器进程调用 accept() 函数从全连接队列中取出一个连接进行处理。
- 如果全连接队列已满,新的连接将被暂时拒绝,客户端可能会收到连接超时的错误,服务器也可能会采取一些策略(如丢弃连接请求等)来处理这种情况。
全连接队列的容量 = listen 的第二个参数(backlog)+ 1。
3.13 文件、Socket、系统和网络之间的关系
进程在启动后,操作系统会为进程创建对应的PCB和文件描述符表,当进程创建套接字后,会返回一个文件描述符并保存在文件描述符表中。
数据通过网络后,最先到达网卡,网卡会向CPU对应的针脚发送中断信号,CPU中的寄存器会将其转化为中断号,操作系统可以通过中断号找到对应中断向量表中的方法,操作系统可以根据对应的方法,将网卡中的数据读取到传输层的接收缓冲区中,再通过文件的操作方法表中的方法将接收缓冲区中的数据读取到应用层。
每一个文件描述符指向一个 file 结构体, file 结构体中的一个字段指向 socket 结构体。socket 结构体中的一个字段指向 file 结构体,还有一个字段指向相关套接字函数。socket 结构体中有一个 struct sock* 字段,指向 sock 结构体(实际上指向的是 udp_sock 结构体或 tcp_sock 结构体)。
- tcp_sock 嵌套了 inet_connection_sock,inet_connection_sock 嵌套了 inet_sock,inet_sock 又嵌套了 sock。所以通过 tcp_sock 可以依次访问到其内部嵌套的各层结构体
- 同理,udp_sock 嵌套了 inet_sock,inet_sock 嵌套了 sock,通过 udp_sock 可以访问 sock 和 inet_sock
struct sock * 字段,既可以指向的是 udp_sock 结构体也可以指向 tcp_sock 结构体。将 struct sock * 进行强制类型转换,就可以分别访问 tcp_sock 和 udp_sock。所以Linux内核中,使用指针操作和结构体嵌套实现了多态,这样就使用一种方式就实现了TCP和UDP。
四、UDP与TCP的对比
4.1 UDP与TCP的特点对比
可靠传输/不可靠传输不是优缺点,而是协议的特点。
UDP特点:
- 面向数据报:将每一个独立的报文作为一个整体发送,保留报文边界
- 不建立连接:知道对方的IP和端口号就可以直接传输,不需要建立链接
- 不可靠传输:没有确认机制,没有重传机制,数据传输发生错误的时候,没有如何反馈
TCP特点:
- 面向字节流:将数据设置为连续的字节流,不保留报文边界
- 建立连接:在传输数据之前需要建立连接,连接过程我们称之为三次握手
- 可靠传输:通过确认应对、超时重传、拥塞控制、数据校验和、流量控制等机制保证了数据的可靠传输
4.2 用UDP实现可靠传输
参考TCP的可靠性机制,在应用层实现类似的逻辑
- 引入序号,保证数据按序到达
- 引入确认应对机制,确认数据是否对方收到
- 引入超时重传机制,无法保证对方收到数据,则将数据进行重传,尽可能的保证数据被对方收到
- 引入滑动窗口与流量控制,使用滑动窗口机制控制数据传输速率,避免接收方缓存溢出,导致数据丢失
- 引入拥塞控制,通过拥塞控制避免网络拥塞。
结尾
如果有什么建议和疑问,或是有什么错误,大家可以在评论区中提出。
希望大家以后也能和我一起进步!!🌹🌹
如果这篇文章对你有用的话,希望大家给一个三连支持一下!!🌹🌹