【网络】UDP和TCP协议详解
本文带你详细了解tcp协议的相关知识
本文中部分截图为手写,字丑见谅
1.linux下常用网络命令
1 | cat /etc/servcies # 系统常用服务和端口 |
我们自己写网络服务器进程时,绑定的端口不能和系统端口冲突。尽量绑定1024以上的端口,推荐绑定不常用的5位数端口。
绑定低于1024的端口,会出现权限不足的报错
1 | $ ./tcpServer 100 |
1.1 netstat命令
1 | netstat |
1.2 pidof
获取某个进程名的进程pid
1 | pidof 进程名 |
比如我想查看sshd
的进程id
1 | $ pidof sshd |
2.UDP协议
一下为udp报文格式的结构图
udp采用了定长报文,这也是udp 面向数据报
的特性
- udp采用16位作为ip+端口的存放,源端口和目的端口用于数据的解包分用(系统需要知道当前的数据包应该丢给上层的哪一个端口)
- 16位udp长度,表示整个数据报
udp首部+udp数据
的最大长度 - 16位校验和用于校验报文是否出现错误。如果校验和出错,就会直接丢弃报文
由于udp的长度标志位只有16位,所以一个udp报文理论上能传输的最大数据是64kb
(216);
如果需要用udp传输大于64kb
的数据,则需要在应用层进行拆分,在接收方的应用层进行合并。
2.1 理解报头
所谓报头,其实就是操作系统内核中的一个C语言的结构体。
1 | //示例,不代表真实情况 |
添加报头的本质,其实就是给数据的头部添加上一个struct udp_hdr
结构体;
而解包的时候,也是将指针移动固定长度(8个字节)的空间,将指针强转为struct udp_hdr
,即获取到了当前报文的udp报头
2.2 udp的特点
udp传输的过程类似于飞鸽传书
- 无连接:知道对方的
ip:端口
就能直接传输数据,不需要建立连接 - 面向数据报:定长报文,不能灵活控制报文的读取次数和数量
- 一次必须要读取完毕一个完整的udp报文
- 假设报文100字节,不能通过10次每次读10字节来获取报文。必须一次读完100字节
- 不可靠:没有确认机制和重传机制,如果因为各种原因,鸽子在路上出事了,那传输的信息也直接丢失了。udp也不会给应用层返回错误信息。
2.3 udp缓冲区
udp支持全双工
,udp的socket即可写也可读
udp没有发送缓冲区,应用层调用sendto
会直接将数据交给OS内核(其实就是拷贝),内核再交由网络模组进行后续传输。
由于udp采用了定长报头,其报头较为简单,OS只需要添加上报头即可发送。这个过程很快,所以缓冲区的作用不大。
udp有接收缓冲区,这个接收缓冲区只是一味地接收,并不能保证报文的顺序
因为不保证顺序,所以有可能乱序,也是udp不可靠的体现
若缓冲区满,新到达的udp数据就会被丢弃。
2.4 丢包
一个数据包丢包可能有多种情况
- 数据包内容出错(比特位翻转等)
- 数据包延迟到达(延迟过久视为丢包)
- 数据包在路上被阻塞(到不了)
- 数据包在路上由于网络波动而丢失(网络突然抽风了,报文直接不见了)
udp的报文也是如此,但udp不可靠并不是一个贬义词,应该是一个中性词。
- udp不可靠是他的特点,由于udp简单,其不需要进行连接,报头添加的效率快,由此性能消耗小于tcp。
- 带来的缺点就是udp不可靠
在直播场景中,udp的使用很多。同一场直播观看的人数会很多,如果每一个用户都维持一个tcp连接,服务器的负载就太大了。用udp就能直接向该用户广播数据,负载小。
2.5 基于udp的应用层协议
- NFS: 网络文件系统
- TFTP: 简单文件传输协议
- DHCP: 动态主机配置协议
- BOOTP: 启动协议(用于无盘设备启动)
- DNS: 域名解析协议
2.6 udp的传输大小限制
在学习DNS协议的时候,发现DNS是512字节的数据采用UDP,大于512字节的数据就会采用TCP了。又看了几个相关的博客,发现有些用词不太严谨,直接说“UDP最大只支持512字节数据的传输”给我吓了一跳,还以为自己之前学习的内容有误,赶快来研究一下到底是什么情况。
前文提到,UDP报文中的16位长度字段,限制了udp报文的长度为64KB
,对应有效载荷的大小是 64KB - 20IP首部 - 8UDP首部 = 65507 字节
(这里减去IP首部长度是因为IP报文的最大长度限制也是64KB)。
如果计算 MTU 1500 字节的限制,那么UDP的有效载荷就变成了1500-20-8=1472
字节了,超过1472字节的数据都需要在网络层进行分片才能传输成功。这里的分片是由IP层来负责的,具体的参考IP分片的知识点。
但实际上,在最终的国际互联网中,由于IPv4协议的原因,很多链路的MTU并不是1500字节,而是576字节(IPv4 标准规定,每个主机必须能够重新组装576字节或更少的数据包)。
这就要求我们最好将UDP有效载荷的长度控制在 576-20-8=548
字节。但这还不是512字节,原博主在StackOverflow上看到的回答提到了另外一个知识点,即IP数据报的选项字段:
典型的 IPv4 头部是 20 字节,而 UDP 头部是 8 字节。然而,可以包括 IP 选项,该选项可以将 IP头部的大小增加到多达 60 字节(如图 1 所示)。
此外,有时中间节点需要将数据报封装在另一种协议(如IPsec(用于VPN等))中,以便将数据包路由到其目的地。
因此,如果不知道特定网络路径上的 MTU,最好为可能没有预料到的其他头部信息留出合理的余量。512字节的 UDP 有效载荷通常被认为可以做到这一点,尽管即使这样也没有为最大尺寸的IP报头留下足够的空间。
因为IP报文的选项字段最大能让IP的首部增长到60字节,如果考虑最大的IP首部,则udp的有效载荷是 576-60-8=508
,最终选用512字节,是给IP报文的选项留有一定空间余量的同时,给udp的有效载荷维持一个适中的值。
现在情况就明了了:UDP并非只能承载512字节的数据,最大有效载荷依旧是 64KB - 20IP首部 - 8UDP首部 = 65507 字节
,选用512字节是考虑互联网IPv4的底层架构和UDP本身无链接的特性给出的一个建议罢了,主要目的是避免路途上出现分片(因为分片增加了丢包的概率),让UDP的效率和稳定性提高。
3.TCP协议
本文往下都是tcp的内容了哦!
下图为tcp协议报头的一个基本结构图,我们需要了解整个结构,以及每一个部分的作用
3.1 源和目的端口号
这部分和udp相同,tcp也需要源端口和目的端口号,以用于找到报文要去的目的地。
3.2 4位首部长度
相比于udp的定长报头,tcp采用了不定长的方式。但tcp的报头有标准的20字节,所有报头都至少有20字节。
在这20字节中,有一个4位首部长度
,用于标识tcp报文的真实长度。
我们知道,4位二进制可以表示0~15
,这不比固定的20字节还少吗?难道说,这4位首部长度标识的是比20字节多余的内容?
并不是!这4位首部长度的标识是有单位的,每一位首部长度,实际上代表的是4字节,即tcp报头的最大长度为 15*4=60
字节。
1 | 由于标准长度也记入4位首部长度,所以4位首部长度的最小值为 0101 |
读取tcp报文的时候,只需要先读取20字节,再从这20字节中取出4位首部长度,获得报头的实际长度;再重新读取,即获得了完整的tcp报头。剩下的部分就是报文携带的数据了(有效载荷)
3.2.1 TCP报文完整长度
一个TCP报文段的最大长度为65495
字节.
TCP封装在IP内,IP报头有一个最大长度字段是16位,即65535
(2^16 - 1),IP头部最小20,TCP头部长度最小20,所以最大封装数据长度为 65535-20-20=65495
;
3.3 32位序号/确认序号
3.3.1 如何确认信息被对方收到?
tcp具有确认应答的机制
当我们和对方微信交流的时候,怎么样才能确认自己的信息被对方看到了?
- A发:吃饭了吗?
- B回应:吃了
在这个场景中,只有B给A发出回应,A才能确认自己的消息被B看到了。
tcp通信也是如此,只有给对方发送的报文收到了对方的应答,发送方才能确认自己的报文被对方收到了。
为此,tcp引入了32位 序号/确认序号
3.3.2 确认应答
用于确认自己和对方的通信,究竟收到了哪一个报文(序号)以及确认信息发出的顺序。
比如客户端会向服务器发 吃了吗?吃的什么?好吃吗?晚上想去干什么?
,如果没有对报头带上序号,服务器接收到的可能就会是下面这样 好吃吗?晚上想去干什么?吃的什么?吃了吗?
,看起来是不是十分怪异?
所以,为了保证tcp报文的顺序性,以及保证报文被送达到对方。tcp引入了以序号为基础的确认应答
机制:
- 客户端向服务器发送一个报头,并将序号设置为1
- 服务端收到信息后,回复客户端一个报头,将确认序号设置为2(为已经收到了的客户端消息的序号
+1
。确认序号为2,代表1号报文收到,期望收到2号报文) - 此时客户端就能确认服务器收到了自己刚刚发出的序号为1的消息
- 下次发送消息,客户端会从2号开始发送
以上是一次通信的过程,如果是多次通信呢?
- 客户端连续向服务器发送了n个消息,服务器应答:1、2、3、4……
- 服务器的每次应答会设置确认序号,代表n之前的报文(不包括n自己)被全部收到
- 比如假设客户端发送了
1-10
的报文,而第6个报文出现了丢失,那么服务端就应该设置确认序号
为6,代表6之前的报文都被正常收到。 - 此时客户端发现,明明自己已经都发到10了,服务端还在回应6。这就代表发送过程中,6号报文丢失了!于是客户端从6号报文开始,重发报文:6、7、8……
不管是服务端给客户端发信息,还是客户端给服务器发信息,收方都需要对信息进行回应。tcp通信中,通信双方地位是对等的!
重点:TCP确认序号应该设置为已经成功收到的报文的下一位序号
3.3.3 为什么有两组确认序号?
tcp是全双工的,通信一方在发送响应信息的同时,可能也会携带新的报文给对方。
- 客户端给服务器发了一条消息
吃了吗?
- 服务器在回复的同时,也带上了新的请求
吃了,你呢?
- 服务器的这种策略叫做:捎带应答
此时服务端就需要在填充客户端消息的确认序号的同时,填充自己所发消息的序号。这样才能保证tcp在双向交流中的可靠性!
所以在tcp报头中,序号和确认序号缺一不可!
3.3.4 没有完美的协议!
经过上面的过程,我们会发现,总有一条报文,在收到对方回应之前,是无法得知对方是否收到信息的。
这也说明:没有一定可靠的协议!
3.3.5 按序到达
序号除了用于确认应答,还有多个功能
- 保证数据的顺序收发问题
比如一个http的报头,原本的格式应该是下面这样
1 | GET / HTTP/1.1 |
结果由于传输的过程中乱序了,变成了下面这样
1 | HTTP/1.1 GET / |
这种情况,是不能被应用层所正常解析的!数据全都乱了,原本写好的代码也没用了。
所以,为了避免数据在传输中乱序
,tcp的序号就有了新的功能——保证数据的按序到达。
1 | 1.客户端发送了1-5号报文 |
但是,如果只按顺序来接收数据,那就无法处理优先级
问题。这部分将在后文6个标记位详解。
序号除了可以用于排序,还能用于去重
,这部分也将在后文超时重传部分解析。
3.3.6 第一个序号的产生
之前学习的时候都是用0来举例子,在计网的学习中又了解到TCP的第一个序号是随机生成的。下面是对这个随机生成的简单说明。
假如一个TCP连接的第一个报文序列号(ISN)是0,那么后续每个报文的序列号是固定的。但是为了防止黑客使用TCP Spoof方法攻击,这个ISN要求是随机的,避免被黑客猜到。
在Windows的不同版本,或者Linux的不同版本,这个随机的方法都不太一样。RFC1948里建议的随机算法是 ISN=M+F(localhost, localport,remotehost, remoteport)
,其中M是一个计时器,每4毫秒加1。F是一个Hash算法,比如MD5或者SHA256。
TCP协议要使用的序列号是后面报文实际携带的序列号和ISN的相对值。
3.4 16位窗口大小
3.4.1 发送和接收缓冲区
tcp同时拥有发送和接收缓冲区。
我们在应用层调用的read/write
函数,实际上只是将数据从接收缓冲区中拷贝出来/发送的数据拷贝到发送缓冲区
。
如果write包含将数据发送给对方的过程,那么这个函数的调用效率就太低了,影响应用层执行其他代码。
数据并没有被立即送入网络传输,而是由tcp协议自主决定发送数据的长度和发送的时间!这一切,都是由操作系统来决定的。这就是为什么tcp又称为传输控制协议
!
3.4.2 接收缓冲区满了咋办
既然有缓冲区,就肯定会存在缓冲区被写满的问题。
- 发送缓冲区满,由操作系统告知应用层,不再往发送缓冲区中写入数据
- 接受缓冲区满
- 直接丢弃数据?
- 告诉对方,不再给自己发信息?
在实际的tcp收发过程中,由于接收方缓冲区满而丢弃数据,是不可接受的。因为数据跨过了茫茫网络,都已经到你机器上了,结果因为你缓冲区满了给它丢掉了,这不是坑人吗?
虽然出现这种情况,我们可以让发送方重传报文,但这样效率太低!
所以,我们应该让收发双方知晓对方的缓冲区大小,从而避免这个问题!
这就是tcp报头中16位窗口大小
的作用了!
3.4.3 告知对方收缓大小
如下图,在客户端和服务端互通有无
的时候,假设服务端的接收缓冲区满了,应该告知客户端,让他别再给自己发消息了。
此时,服务端设置自己的16位窗口大小
,以此告知客户端自己的缓冲区剩余容量。
如果对方发来的报文中,16位窗口大小
所表示的缓冲区剩余容量已经不足了,发送方就不应该继续发送,而应该等待对方从缓冲区中取走数据。
这是已经开始通讯的情况,但如果是第一次通讯呢?如果客户端一来就发送了一个巨大的数据,直接塞满了服务端的缓冲区,那不是出事了?
这便是tcp在三次握手中要做的事情了,简单来说就是在通信开始前就互相告知自己缓冲区的大小。后文会讲解。
3.4.4 缓冲区是否独立?
- 进程的tcp缓冲区是独立的吗?
每个进程都有自己的内核空间,内核空间里有tcp缓冲区,所以每个进程都有自己独立的tcp缓冲区
- 线程的tcp缓冲区是独立的吗?
是的!虽然这些线程共享同一个内核TCP缓冲区,但是每个线程使用的缓冲区是独立的,互相之间不会产生冲突。每个线程对自己的缓冲区进行读写操作时,会使用内核提供的同步机制,如互斥锁、信号量等来确保线程之间的缓冲区不会互相干扰,从而实现数据的安全读写。
3.5 六个标记位
在4位首部长度右侧,有一块保留长度,和6个标记位。这六个标记位是所有设备都支持的标记位。
- SYN: 连接标记位,用于建立连接(又称同步报文)
- FIN: 表示请求关闭连接,又称为
结束报文
- ACK:响应报文,代表本次报文中包含对之前报文的确认应答
- PSH:要求对方立马从tcp缓冲区中取走数据
- URG:紧急指针标记位,用于紧急数据的传输
- RST:要求重置连接(双方重新建立一次新的tcp连接)
3.5.1 8个标记位?
在部分书籍中,还会出现8个标记位与4位保留长度的说法(下图源自《图解tcp/ip第五版》)
- CWR(Congestion Window Reduced):该标志位用于通知对方自己已经将拥塞窗口缩小。在TCP SYN握手时,发送方会将CWR标志位设置为1,表示它支持ECN(Explicit Congestion Notification)拥塞控制,并且接收到的TCP包的IP头部的ECN被设置为11。如果发送方收到了一个设置了ECE(ECN Echo)标志位的TCP数据包,则它将调整自己的拥塞窗口,就像它从丢失的数据包中快速恢复一样。然后,发送方会在下一个数据包中设置CWR标志位,向接收方表明它已对拥塞做出反应。发送方在每个RTT(Round Trip Time)间隔最多做出一次这种反应。
- ECE(ECN Echo):该标志位用于通知对方从对方到这边的网络有拥塞。在收到数据包的IP首部中ECN为1时,TCP首部中的ECE会被设置为1。接收方会在所有数据包中设置ECE标志位,以便通知发送方网络发生了拥塞。
而我百度到的文章提到,tcp给多出来的两个标记位新增了功能:
- 除了以上6个标志位,还有一个实验性的标志位NS(Nonce Sum),用于防止TCP发送者的数据包标记被意外或恶意改动。NS标志位仍然是一个实验标志,用于帮助防止发送者的数据包标记被意外或恶意更改。3 4
- TCP标志位中还有两个标志位后来加的一个功能:显式拥塞通知(ECN)。ECN允许拥塞控制的端对端通知而避免丢包。但是,ECN在某些老旧的路由器和操作系统(例如:Windows XP)上不受支持。在TCP连接上使用ECN也是可选的;当ECN被使用时,它必须在连接创建时通过SYN和SYN-ACK段中包含适当选项来协商。 2 3
诸如tcp的标记位到底是6个还是8个?
这种摸棱两可的问题,在考试中不会问道。
在学习中,我们只需要掌握所有设备都支持的6个标记位即可
3.5.2 ACK
该标记位用于标识本条报文是对之前的报文的确认应答
。设置了改标记位,那么接收方就应该去查看该报文中的确认应答序号。
ACK标记位的设置和其他标记位并不冲突,在捎带应答
的时候,可以同时设置包括ACK在内的多个标记位,而不影响当前报文的原本功能
3.5.3 SYN/FIN
- SYN:表示请求建立连接,并在建立连接时用于同步序列号,所以又称为
同步报文
; - FIN:表示请求关闭连接,又称为
结束报文
。设置为1时,代表本方希望断开连接。此时双方要交换FIN(四次挥手)才能真正断开tcp连接。
3.5.3.1 三次握手
在三次握手的时候,经历了如下过程
- 连接发方A向对方主机B发送SYN报文,请求建立连接(A进入
SYN-SENT
状态) - 主机B在收到报文后,回应
ACK+SYN
的报文,在确认应答的同时,请求建立连接(B进入SYN-RCVD
状态) - A收到这条报文后,发送确认应答ACK(A认为连接成功建立
ESTABLISHED
) - B收到A发送的ACK,三次握手完成(B认为连接成功建立
ESTABLISHED
)
下面这样写应该能更容易明白一些
1 | A SYN-> B |
为什么接收方要发送ACK+SYN
- 为什么接收方要发送ACK+SYN,而不是只发送ACK?
接收方发送带有ACK+SYN标志的数据包,是为了确认收到了发送方的SYN,并向发送方表明自己也同意建立连接。ACK字段用于确认收到了之前的数据包,而SYN字段用于表示接收方也希望建立连接。
若接收方只发送ACK而不带SYN标志,这可能会导致混淆。因为TCP中的ACK字段表示确认收到之前的数据包,而在初始握手阶段,双方都还没有进行数据传输,所以ACK字段无法明确指示是在确认SYN还是确认其他数据包。因此,发送方在收到只带ACK标志的数据包时,无法准确知道接收方是否同意建立连接。
通过在握手过程中同时发送ACK+SYN标志,接收方可以在一个数据包中明确表示两个信息:确认收到发送方的SYN,并表示自己也愿意建立连接。这样就能够确保双方在握手过程中能够准确理解对方的意图,并建立起可靠的连接。
3.5.3.2 四次挥手
在断开连接,四次挥手的时候,经历了如下过程
- A要断开连接,发送FIN(A进入
FIN WAIT 1
状态) - B收到了FIN,发送ACK(B进入
CLOSE-WAIT
半关闭状态) - A收到了ACK(A进入
FIN WAIT 2
状态) - 此时只是A要和B单方面分手,
A->B
的路被切断了,但是B->A
的还没有,B还能继续给A发数据 - B发完数据了,也和A分手了,B发送FIN(B进入
LAST ACK
状态) - A收到FIN,发送回应ACK(A进入
TIME WAIT
状态,将在一段时间后进入CLOSE
断连状态) - B收到了ACK(B进入
CLOSE
状态) - 连接关闭
我们不仅需要知道3次握手和4次挥手的过程,还需要知道每一次的状态变化!
3次握手和4次挥手对于应用层的C语言接口,都只有1个对应的函数。这些操作都是由西欧系统内核的tcp协议栈自主完成的。
3.5.4 PSH
PSH标记位的作用是:要求对方立马取走缓冲区中数据
如下图,S在接收缓冲区满了之后过了很久,还没有取走缓冲区中的数据,C实在忍不住了,给S发一个PSH
标记位的报文,要求S立马取走这些数据!
tcp在收到此报文后,将由操作系统告知应用层,取走缓冲区中的数据。
如果应用层不听操作系统的咋办?那就代表应用层写的有bug!人家给你发了那么多东西了你还不处理,有点过分了!
3.5.5 URG
URG是紧急指针标记位。
在3.3.5 按序到达部分提到过,如果只关注序号,则无法处理优先级问题。有一些数据对于应用层来说,优先级较高。如果tcp只会老老实实的按顺序把数据交付给应用层,那在高优先级的数据也搞不过操作系统对tcp的处理。
所以,为了能操作优先级,tcp提供了URG
标记位,设置了此标记位的报文具有较高优先级。
应用层有专门的接口可以优先读取带有URG
标记位的报文。
3.5.5.1 16位紧急指针
为了能标识这个紧急数据在报文中的位置,tcp还提供了16位紧急指针
;这个指针的指向便是紧急数据在tcp报文中的偏移量。紧急数据规定只有1个字节!
由于紧急指针的数据可以被提前读取,不受tcp缓冲区的约束,所以又被称为带外数据
下图就举了一个紧急指针使用的场景:
TCP 在传输数据时是有顺序的,它有字节号,URG配合紧急指针,就可以找到紧急数据的字节号。
紧急数据的字节号公式如下:
1 | 紧急数据字节号(urgSeq) = TCP报文序号(seq) + 紧急指针(urgpoint) −1 |
比如图中的例子,如果 seq = 10,urgpoint = 5
,那么字节序号 urgSeq = 10 + 5 -1 = 14
知道了字节号后,就可以计算紧急数据字位于所有传输数据中的第几个字节了。如果从第0个字节开始算起,那么紧急数据就是第urgSeq - ISN - 1
个字节(ISN 表示初始序列号),减1表示不包括第一个SYN段,因为一个SYN段会消耗一个字节号。
3.5.6 RST
RST为复位报文,即RESET
。
如下图,如果A给B发送的ACK在传输路途上丢失了,咋办?
这时候,就会出现A认为连接已经建立,而B由于没收到A的ACK而处于SYN-RCVD
状态。
- 此时A开始给B发送数据,B一看,不是说好了要建立连接才能发送数据的吗,你这是在干嘛?
- 于是B告知A,发送RST标志位的报文,要求和A重新建立连接(重新进行三次握手)
- 重新建立连接成功后,AB再正常发送信息。
以上只是RST使用的情况之一。我们使用浏览器访问一些网页时,F5刷新
就可以理解为浏览器向服务器发送了一个带有RST标记位的报文。
3.6 为什么是3次握手?
为什么握手的次数是3次,不是1次、2次、4次、5次?
在讨论这个问题之前,我们要知道:连接建立是有消耗的!需要维护其缓存区、连接描述符(linux下为文件描述符)等等数据。基于这点认识,我们接着往下看
- 如果是一次握手?
一次握手,即A给B发送一个SYN,双方就认为连接建立了。
那么我们直接拿个机器,写个死循环,一直给对方发送SYN,自己直接丢弃文件描述符(不做维护)
由于服务器并不知道你(发送方)直接丢弃了文件描述符,其还是要为此次连接维护相关数据,这样会导致服务器的资源(为了维护tcp链接)在短时间内被大量消耗,最后直接dead了
这种攻击叫做SYN洪水
- 如果是二次握手?
A给B发送一个SYN,B给A发送一个ACK,即认为连接建立。
这和一次握手其实是相似的,服务器发送完毕ACK之后,就认为连接已经建立,需要维护相关资源。而我们依旧可以直接丢弃,不进行任何维护,最后还是服务器的资源被消耗完了
- 三次握手
三次握手了之后,双方都必须维护连接的相关资源;这样,哪怕你攻击我的服务器,你也得付出同等的资源消耗。最后就是比谁资源更多呗!
相比于前两种情况,三次握手能在验证全双工的同时,一定程度上避免攻击。
三次握手还将最后一次ACK丢失的成本嫁接给了客户端(连接发起方)如果最后一次ACK丢失,要由客户端重新发起和服务器的连接。
注意,三次握手只是一定程度上避免攻击。我们依旧可以用很多宿主机“堆料”来和服务器硬碰硬,这是无可避免的情况。
- 更多次握手?
由于三次握手已经满足了我们的要求,更多次握手依旧有被攻击的可能,还降低了效率,完全没必要!
3.7 超时重传
3.7.1 超时重传的说明
为了保证可靠性,如果一个报文长时间未收到对方的ACK回应,则需要进行超时重传
。
linux下每一次尝试的时间间隔为500ms,若500ms内尚未收到对方的ACK,则重发报文,再等待1000ms……以此类推。
超时重传还可能遇到下面的情况:
- 服务器收到了消息,也发送了ACK,但是ACK在路上丢失了
- 客户端没有收到ACK,于是进行超时重传
- 服务器再次收到了消息,此时接收缓冲区里出现了两个一样的数据
但是,我们的报文是有序号的,tcp就可以直接根据序号去重,所以,tcp交给应用层的数据是去重+排序
之后的数据!应用层并不需要担心数据重复或者顺序有误的情况。
如果同一个报文超时重传了好几次,还没有收到对方的应答,就会认为对方的服务挂掉了,此时本端会强制断连。此时客户端就可以发送一个带有RST标记位的报文,要求和对方重新建立连接。如果重连失败,则可以认为对方确实挂掉了,这便是我们在浏览器中有时会看到的 SERVICE_UNAVALIABLE
服务不可用;
3.7.2 超时重传时间RTO的选择
超时重传时间的选择是TCP最复杂的问题之一。
首先要知道一个概念:往返时间RTT,即一端发送一个报文+收到ACK报文的时间;
- 如果超时重传时间RTO设置的小于往返时间RTT,则会产生不必要的重传,加大网络拥塞
- 如果超时重传时间RTO设置的远大于往返时间RTT,则会导致网络中出现大段空闲,网络使用效率降低
由此可以得到一个暂时的结论:超时重传时间RTO的值应该设置为略大于RTT的值
但是,这个问题的复杂在于,TCP下层的网络环境随时都有可能变化,往返时间RTT也有可能会有极具的波动,因此RTO也得跟着RTT实时发生变化。
比如下图所示,RTT1大于RTT0,此时RTO就不再适用于RTT1的情况,会造成不必要的重传。
所以,并不能简单的使用某次测量得到的RTT样本来计算超时重传时间RTO。
而需要使用一个加权公式来计算平均往返时间RTTs,又称为平滑往返时间。
$$
RTT_{si} = RTT_{i}
$$
$$
新 RTT_{s} = (1-α) * 旧 RTT_{s} + α*新RTT样本
$$
在上述公式中,0<=α<1
- 如果α很接近于0,则新RTT样本对RTTs影响不大
- 如果α很接近于1,则新RTT样本对RTTs的影响较大
已成为建议标准的RFC6298推荐的α值为1/8,即0.125
;
用这种方式得到的加权平均往返时间RTTs就比测量出的RTT值更加平滑。超时重传时间RTO应该略大于加权平均往返时间RTTs;
RFC6298建议使用下式计算出超时重传时间RTO
$$
RTO = RTT_S +4*RTT_D
$$
RTT偏差的加权平均RTTd的计算公式如下
$$
RTT_{DI} = RTT_I /2 \
$$
$$
新 RTT_D = (1-β) * 旧 RTT_d + β * |RTT_S - 新RTT样本| \
$$
$$
0<=β<1
$$
已成为建议标准的RFC6298推荐的β值为1/4,即0.25
;
由此可见,超时重传时间RTO的计算基于新的RTT样本。但是在已经出现超时重传的情况下,RTT的测量并不容易。如下图所示,左侧是发送的第一个报文丢失,进行超时重传后收到ACK的情况。但是A主机并没有办法确认这个ACK是对第一个丢失报文的确认,还是对超时重传后的报文的确认,RTT就有可能会有两个取值,如果取值错误,就容易出现问题。右侧情况也是同理
因为这种超时重传的情况无法得到一个准确的新RTT样本,所以Karn提出一个算法,如果出现超时重传,则不更新RTTs和RTO。但这个算法并不完善,需要修正:报文段每重传一次,就将RTO稍微增大一些,典型的做法是将RTO取值为旧RTO值的2倍。
3.7.3 超时重传时间例题
3.8 出现了很多CLOSE-WAIT状态的连接?
在上面提到过,当客户端向服务器发送FIN之后,服务器回复ACK,会进入CLOSE-WAIT
状态。此时服务器还能给客户端发送消息,双方都还在维护连接的相关资源。
如果一个服务出现了很多个处于CLOSE-WAIT
状态的连接,就必须要检查一下,应用层的代码里面是不是没有调用close(fd)
函数来关闭对应的文件描述符。
一方的
close(fd)
就对应了两次挥手,两端都进行close(fd)
即四次挥手。
对方明明都要和你分手了,你还挂着对方当备胎,还要找对方要钱,也太不像话了😂
3.8.1 活学活用🤣
230322下午,正准备通过之前写的tcp代码来验证tcp握手和挥手的各个状态的,没想到用命令一看,全是CLOSE-WAIT状态,填满了整个屏幕,这完全没办法写博客啊
而且这些状态清一色来自python3.10
的程序,看到它的时候,我已经基本猜到了是啥进程引发的了——我的两个valorant机器人。查了查pid,坐实了这一点
我将数据写入到一个文件里面,统计了一下,一共1200多个CLOSE-WAIT
1 | netstat -ntp > log # 将统计结果写入文件log |
这些状态值的远程ip来源虽然有多个,但一个ip出现了多次,于是我就使用 itdog 看了一下其中几个ip的来源,是Anycast/cloudflare.com
,也就是很出名的cloudflare-cdn。
在我的kook-valorant-bot里面,有一项业务是方便开发者使用的valorant登录和商店查询的api(使用aiohttp库编写)
为了统计其上线状态,我使用了uptimerobot
定时请求,每5分钟获取一次api的在线情况
1 | https://stats.uptimerobot.com/Wl4KwU6Bzz |
嗯,运行状态倒是蛮好的,100%在线
前面提到过,系统是需要消耗资源来维护tcp链接的。如下图,机器人占用了将近400mb的内存,其中肯定有一部分就是被这些没有关闭的tcp链接所占用的
大量CLOSE-WAIT
,只可能是一个原因:uptimebot
的请求已经结束并发送了FIN,而我的api代码作为服务端,并没有在收到FIN后,对链接进行close
,于是链接一直处于CLOSE-WAIT
半关闭状态。只有程序关闭(机器人下线)才会被操作系统清空。
后来又研究了一下,经过他人点醒,才发现上面的结论都是错的!!
https://segmentfault.com/q/1010000043572705/a-1020000043573118
其实在netstat
里面很明显的一点,表示这一切和uptimebot以及我写的api没有任何关系
那就是这里面Local Address
的端口,每一个都是不一样的。如果是我写的api导致的,那么他们的端口都应该是api绑定的端口,且固定才对!
后来就找到了一个2014年的issue,大概情况就是,python的requests库会维护一个连接池(类似于线程池)。这些处于close-wait
状态的连接,都是requests库维护的。并不是说他们是忘记关闭的链接。
https://github.com/psf/requests/issues/1973
好嘛,原来是自己学艺不精,闹了个大笑话。当时找处理aiohttp的web状态的资料找了老半天都没找到……原来一开始方向就错了😶🌫️
3.9 异常情况
- 进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别
- 机器重启/关机:和进程终止的情况相同,操作系统会在重启/关机前释放TCP文件描述符。
- 机器断电/网络断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行
reset
。即使接收端没有写入操作,TCP内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
除了TCP本身,应用层的某些协议,也会有这样的重连检测机制。
3.10 校验和
这部分并不是重点。
校验和能保证TCP双方发送的数据不会出现数据对不上的问题
校验和是一种简单的数据验证机制,它通过对数据进行数学运算得到一个校验值,并将该校验值与原始数据一起传输。接收方收到数据后,会再次计算数据的校验和,并将结果与传输过来的校验值进行比较。如果两个校验和相匹配,那么说明数据在传输过程中没有发生错误或损坏。但如果校验和不匹配,接收方会认为数据发生了错误或损坏,于是会请求发送方重新传输数据。
TCP在数据包的头部添加了一个16位的校验和字段,该字段包含了数据段的校验和值。在发送端,TCP将数据段中的所有字节(包括TCP头部和数据部分)加起来,然后取其反码作为校验和。接收端也采用同样的方式计算校验和,并与接收到的校验和进行比较,以验证数据的完整性。
举个简单的栗子,我们可以把发送的数据体当作一个字符串,对其进行MD5计算,再将计算后的MD5同数据体一同发送给对方。这时候接受方收到信息后,同样进行相同算法的MD5计算,得出来的MD5和对方发送过来的MD5进行对比,如果相同,代表数据没有问题。不相同代表数据发送错误,该数据包被丢弃。
如果发送的MD5在传输过程中出现了损坏,那么最终计算出来的MD5字符串依旧是不相同的,也会当作无效报文被丢弃;
实际TCP使用的并不是MD5,上面的这个栗子只是为了方便理解。
校验和不仅用于TCP协议,在许多其他协议中也有类似的机制,用于确保数据传输的可靠性。
通过使用校验和,网络协议可以帮助确保数据在传输过程中不会因为噪声、干扰或其他错误而变得损坏。然而,需要注意的是,校验和并不能提供绝对的数据完整性保证,因为一些复杂的错误模式可能会导致校验和匹配,但数据仍然损坏。
为了进一步增强数据的可靠性,TCP协议还使用了序列号(按序到达)、确认应答(确认收到)和超时重传(避免丢包)等机制。
3.11 TCP保活计数器
为了避免TCP单端发生故障,TCP内会有一个连接保活计数器。
当保活计时器的周期内,没收到TCP发来的数据,那么保活计数器结束后,TCP服务器就会发送探测报文段,判断客户端是否还存在。如果连续发送10个探测报文还没有收到TCP客户端的响应,则认为客户端断连,服务器关闭连接。
4.验证TCP状态
下面可以用代码来实地查看tcp在传输过程中的各种状态。之前写过一个简单的http服务器,现在为了方便,直接拿来使用。
采用如下bash命令来进行netstat的循环监测
1 | while :; do netstat -ntp | grep 端口号;sleep 1; echo "########################"; done |
4.1 TIME-WAIT
在浏览器访问,可以看到服务器返回的html页面
后台可以看到,服务器接收到了请求的报头
并按如下返回response
1 | DEBUG | 1679717397 | muxue | [sockfd: 4] filePath: web/index.html |
使用netstat
命令查看,当前多出了一个处于time wait
状态的连接
这代表四次挥手的第一个FIN是由服务器发出的,这一点在代码中也能体现,服务器accpet到连接后,会交由孙子进程来执行handlerHttpRequest(conet)
服务
1 | // 提供服务(孙子进程) |
这个服务函数并不是while(1)
的死循环,内部也没有进行socket的close操作,而是发送完毕客户端请求的文件后,直接退出了
1 | void handlerHttpRequest(int sock) |
函数退出了之后,文件描述符就交由了操作系统。一个没有进程使用的文件描述符,会被操作系统直接close关掉。相当于操作系统帮我们发出了FIN,就出现了TIME WAIT
状态。
4.1.1 为啥要有这个状态?
知道了4次挥手的过程后,我们就能知道,TIME-WAIT
是4次挥手的发起方才有的状态。
既然对方已经给我发了FIN,这不就代表对方也想和我分手吗?那我为啥还留着好友不删,非要等等呢?
这是因为,我们发出的最后一次ACK是否被对方收到,是未知的!
- A给B发送最后一次ACK,B没有收到
- A不TIME-WAIT直接退出,A已经断开连接了,但是B还在维护这个连接
- 如果有TIME-WAIT状态,B没有收到ACK,会对FIN进行超时重传
- A再次收到FIN,代表上一次ACK丢了,那就再次发送ACK
- 如果A在
TIME-WAIT
状态什么信息都没有收到,那就代表自己的ACK被B收到了,便可以放心断连
此时,TIME-WAIT
状态保证了最后一次ACK的正常递达
还有第二种情况:
- C给S发送FIN,准备断连
- S给C发送data,发送完毕后,立马发送FIN
- data和FIN都在路由传输的过程,可能会出现FIN比data早到的情况
- C收到FIN,返回ACK,进入
TIME-WAIT
状态,期间收到了S发送的data - C成功接收data,继续等待到计时器结束,释放链接
此时,TIME-WAIT状态保证了二者之间的消息能都被收到
4.1.2 等多久?
这里引入一个新概念:一个报文在双方之间传输花费的时间,被称为这个消息的 MSL(maximun segment lifetime 最大生存时间)
TIME-WAIT等待的时间需要适中,不同的操作系统,默认等待的时间都是不同的。CentOS下,这个时间是60s
1 | $ cat /proc/sys/net/ipv4/tcp_fin_timeout |
一般情况下,设置为MSL*2
是最好的,这样能保证双方数据的递达,和最后ACK的递达
4.2 CLOSE-WAIT
如果我们在handlerHttpRequest(conet);
向客户端发送了html文件后,服务端休眠几秒钟;客户端打开网页后关闭网页,是否就能看到其他状态呢?
1 | // 发送给用户 |
如下,情况又不同了。这次出现的是CLOSE-WAIT
状态,代表第一个FIN请求是客户端发出的
这是因为当前的进程没有进行长链接
的维护,如果想维护长连接,则服务器应该给客户也返回一个http协议报头 Connection: keep-alive
如下图,可以看到客户端发来的http-header
里面,是有该字段的。而服务器并没有返回相同的字段,客户端就认为服务器不支持长链接,从而主动发出了FIN
进一步看tcp的状态,当前是有两个父进程为1(采用了孙子进程的写法,父进程退出后会被操作系统接管)的进程在进行休眠,它们同属于295942
这个tcp服务器主进程的进程组(PGID
相同)
当这两个进程结束休眠的时候,CLOSE-WAIT
状态的连接立马消失了。因为操作系统接管了文件描述符后,进行了close,服务端也发出了fin,四次挥手成功,连接终止。
4.3 ESTABLISHED
如果我们给response加上长链接的报头,是否可以看到ESTABLISHED
状态呢?
1 | response += "Connection: keep-alive\r\n"; |
可以看到,确实出现了这个状态,这代表双方成功维护起了长链接(虽然当前情况下,这个长链接并没有起到应有的作用)
进一步轮换,将处理函数改为while(1)
的死循环调用,我们应该可以通过一个socket实现多个报文的发送
1 | // 孙子进程执行 |
可以看到,只出现了一个子进程,对客户端进行服务
查看日志,能看到,成功实现了长链接通信
如果不采用while(1)
死循环进行服务,则客户端的每一次请求,都需要一个新的子进程来服务
即便response中带有长链接标识,也会因为fd被操作系统回收而进入TIME-WAIT
状态
4.4 端口不能被bind
之前在tcp服务器的学习中,出现了如果立马把tcp服务器关了后开,同一个端口无法被bind的情况
1 | $ ./tcpServer 50000 > log |
经过对tcp协议的学习,现在能知道为何这个端口不能被bind了。使用netstat -ntp
命令查看,能看到这个端口上还有处于TIME-WAIT
状态的链接,所以系统认为这个端口上还有tcp链接在运行,不允许我们二次bind这个端口。这是操作系统在默认状态下的行为。
前面提到过,centos默认的TIME-WAIT
等待时间是60s。只要等待60s,操作系统释放了这个端口上的冗余链接,就能被bind了!
但是,这样会有很大的问题!请接着往下看
4.4.1 问题
假设我现在的服务器进程是直接bind 80
端口对外进行服务的,这样他人就能直接通过我服务器的ip,以http协议与我的服务进程进行通信。
以http网页服务为例,经过了很久很久的运行时间
- 服务器进程出了恶性bug,导致进程退出了
- 服务器压力过大,操作系统为了释放资源,直接把服务进程给kill了
这时候,由于第一个FIN是由服务端发出的,服务器会进入TIME-WAIT
状态。
假设服务进程崩溃的时候,有数个用户正在访问你的网页。对于他们而言,崩溃的表现就是,刷新网页,直接白屏,显示不出来后续的页面了。
此时就需要运维老哥赶快ssh连上服务器,重启服务进程
为了关照运维老哥的头发,让出错的服务进程快速重启,一般情况下,我们会给这个服务进程增加一个监视进程
- 监视进程是个死循环,其要做的功能很单一,所以负载并不大
- 监视进程实时查看,每几秒就看一眼服务进程的状态
- 服务进程挂掉了,监视进程在下一轮监视时会立马发现,通过 exec系列函数 直接重启服务进程
这时候,TIME-WAIT
的问题就出现了:服务进程想绑定的是80端口,也只能绑定80端口(不然客户端无法知道服务器端口改变,也依旧无法访问服务)但是80端口还有没有清理的tcp链接,操作不给你bind啊!
如果等操作系统60s后清除链接再bind,那也太晚了🙅♀️
大型服务进程启动时要干的活很多,所以启动会较慢。等待系统释放TIME-WAIT
的链接后再bind,相当于多给服务进程启动增加了60s
- 对于一些客户量级巨大的服务,时间就是生命呀!
- 用户的耐心都不咋地,拿我自己举栗子吧!当我去访问一些网站时,如果
5s
之内网页没有加载出来,我就准备x掉那个网页了
所以,为了避免由于TIME-WAIT/CLOSE-WAIT
未释放而无法bind端口的问题,操作系统提供了端口复用的接口。让进程可以忽略冗余连接,直接bind这个端口!
4.4.2 端口复用
端口复用,复用的是有TIME-WAIT/CLOSE-WAIT
这种冗余链接的端口,而不是处于服务状态的端口哈!一个端口只能对应一个服务,老规矩可不能破坏了。
1 | int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); |
默认情况下,端口有冗余链接,无法bind
只需要在bind函数之前添加上如下代码,就能实现端口复用。
1 | // 1.1 允许端口被复用(即便还有TIME-WAIT的链接) |
如下图,即便50000端口存在time-wait
的链接,我们依旧可以正常bind这个端口!
除了这种形式的端口复用,其实还有一个SO_REUSEPORT
,它允许多个进程绑定同一个的端口;在nginx中就是采用这样的方式,让多个worker线程进行链接的竞争的。
4.5 accpet不影响tcp
linux给我们提供的接口accpt,并不参与3次握手的阶段
将http服务的accpet给去掉,来观察这一情况。如下图,服务器直接是一个啥事不干的死循环,不对新来的连接进行accept
,此时浏览器访问该服务,依旧会出现两个处于ESTABLISHED
的连接
这便证实了我们的结论:accpet不参与tcp3次握手的过程
4.6 listen的第二个参数
4.6.1 概念
之前学习tcp服务器写法的时候,粗略提到了listen函数第二个参数的作用。
1 |
|
这里的阻塞等待连接是什么意思?还是用前面用到的http服务,以实际情况来看看
- 什么情况下,一个连接会被阻塞?
这一点就涉及到服务器的承受能力了。假设服务器现在很忙,压根没时间去accept
一个新的连接,那这个连接就一直存在操作系统的tcp连接中,而没有进程对它服务。这种状态,就可以被称为连接的阻塞等待
4.6.2 看看具体情况
假设我将listen的第二个参数设置为了2,服务器是个啥事不干的死循环
1 | if (listen(_listenSock, 2) < 0) |
在浏览器内直接开5个窗口请求这个连接,加上我的手机,一共是6个请求
但后台可以看到,再继续增加浏览器请求的数量,依旧都只有两个连接是处于ESTABLISHED
状态,和listen的第二个参数正好相同!这两个连接因为没有被服务进程accept
,它们就是处于阻塞等待状态的!
4.6.3 为什么?
为什么操作系统要给一个进程维护阻塞等待的连接呢?既然这个进程不进行新连接的accept,操作系统为何不直接把这个连接丢弃呢?
拿生活中非常场景的餐厅排队
举例子吧。大家应该都见过一个餐馆在中晚餐高峰期时,门口有人在排队等位吧?特别是河海底捞,每次想去都得提前预定,不然排队的时间吃门口的小零食都要吃饱了。
那么,餐馆为什么要提供排队等位呢?为何服务员不直接告诉新来的客人,馆子里没空位了,请另寻他处呢?
- 原因很简单:为了上桌率。
一个餐馆的上桌率越高,就代表其生意越好。如果餐馆内部没桌了,但是外头有人排队,这样就能让有客人离开(空出桌子后)立马有新的客人上桌。
- 对于我们的服务进程也是一样!
假设这个服务进程有10个线程对外进行服务,此时来了第11位需要服务的客人。服务的10个线程(桌子)都被坐满了,没人能给11号客人服务。那这时候,操作系统就告诉11位客人:“你在这里稍作等待,我去给你买个橘子取个排队单号”,这时候11号客人就在操作系统为服务进程提供的等候位置上坐了下来,等待服务进程腾出空位来给他服务(链接阻塞等待)
这时候,有一个用户断开了连接,空出来了一个进程,那么服务进程(餐馆)内的服务员就跑出来,和11号客户说,他可以上桌了(accept)这时候,服务进程就开始给11号客户提供服务了。
这样一来,只要服务进程有空闲,就能立马有新的进程入座,让服务进程不至于摸鱼。提高了服务器资源的利用效率。
我买了一个服务器,我肯定是希望它在不崩溃的前提下为越多客户服务越好,资源最大化嘛!
4.6.4 该参数应该设置成多少?
既然我们已经知道了这个参数的作用,那么应该把它设置为多少呢?
餐馆也需要面临这个问题
- 如果自己设置的排队等位太少,那么可能会有想排队的客户没有位置坐。
- 如果设置的太多,那新来的客户压根不打算排队了,因为他们知道,轮到自己的时候,已经饿扁了。
服务器也是如此
- 第二个参数设置的低了,排队的空位太少,超过该参数的链接直接被os拒绝,错过了本来可以提供服务的用户
- 第二个参数设置的高了,用户过来排队,等了好久都没等到,于是就报错
连接超时
了。 - 而且,打开一个网页的响应速度,也会直接影响用户对你的服务的满意度。每次点开你的网页都需要加载个十几秒的,久而久之,用的人只会越来越少!
- 设置的太高了,维护的连接也会占用系统资源,服务进程可用资源变少了!
- 与其增长队列,还不如增加服务进程的服务能力(扩大店面)
所以,我们应该根据自己服务的面向用户数量级,设置一个合适的等位数量!这个应该根据具体情况来看的!
4.6.5 listen和accept
如下图,我让服务进程只对一个链接进行accept,相当于餐厅里面只有两张桌子。此时新来的链接就会处于等待状态,数量正好是listen
的第二个参数(但是我的第二个参数是2,我也不知道为啥会是3个🤣)
5.滑动窗口(可靠传输)
tcp中引入了滑动窗口的操作来实现可靠传输
5.1 概念
在实际通信中,如果真的只是让双方一发一答,那效率也太低了。所以,一般都是直接一次性发送多条消息,对方也是对多条报文进行ACK的,而且只需要ACK一次(这点在前面序号部分已经讲过原理了)
- 一次性可以发送多条报文,但前提是对方有能力收那么多
- 窗口大小:一次性可以发送的数据数量(无需等待前面已发报文的ACK,就可以发送这么多)
- 窗口大小是由对方的接收能力决定的
- tcp报头中,16位窗口大小就是滑动窗口的大小
- S给C所发报头中的窗口大小,既代表S接收缓冲区的大小,又代表C可以一次发送的数据大小
- S接收缓冲区的大小变化,也会导致S给C所发报文中,窗口大小的变化
- 窗口越大,代表双方通信的吞吐率就越大
- 发送的数据会保留在发送缓冲区中,发送缓冲区以如下区域构成
- 已发,收到了ACK的报文(可删)
- 已发,未收到ACK的报文
- 未发,准备发送的报文
5.2 滑动窗口示意图
滑动窗口可以用下图来形象的理解,对图中的文字就不复述了(如果图片404了请及时告知我,谢谢)
咳咳,本人字丑,用pad写就更丑了,请谅解
5.3 总结和习题
6.流量控制
6.1 流量控制基本概念
所谓流量控制,就是发送方根据对方的接收能力来选择发送数据的多少,让发送方发送速率别太快。
如果B的接收缓冲区满了,会通过报文中的窗口大小告知A,A不再继续发送数据。
此时,A会在过一会后,向B发送一个窗口探测
报文,该报文没有有效载荷,所以不会过多占用接收缓冲区;
B在收到该报文后,会回应报文,告知A自己的窗口大小,被称为窗口更新
6.2 流量控制举例
下图是一个更加详细的过程,A和B建立TCP连接后,开始发送数据。假设起始的接收窗口为400字节,前两次通信正常, 但是A发送201~300的数据时,出现了丢包。
B端收到1到200的数据后,发送了ACK=201的报文,并在该报文中设置rwnd=300,即告诉A,B的接收窗口只有300字节,要求A更新发送窗口的值。
A收到这个报文后,能得到两个信息
- 201以前的报文(1到200)都被B成功收到了,但201到300的报文还没有收到
- 需要将自己的发送窗口调整为300字节。
随后,A先根据自己的发送策略,将301到400和401到500发送出去,此时发送窗口已满,不能继续发送!
过了一会,A发送还没有收到对201到300这个报文的确认,重传计时器超时,触发对201和300的超时重传。由于发送窗口依旧是满的,所以A只能进行重传,不能发送新的数据。
B收到重传的报文后,对501之前的报文进行累计确认,并更新B自己的接受窗口大小为100字节,对A主机再次进行流控。
此时A就能更新自己的滑动窗口到501字节之后,同时将发送窗口缩短到100字节。A只能发送501到600的数据,就需要等待B的确认。
假设B收到501到600后,又对A进行了第三次流量控制,将rwnd设置为0。此时A可以将滑动窗口移动到601的位置,601之前的数据可以被删除。但由于B的接收窗口为0,A需要更新自己的发送窗口为0,此时A不能发送任何数据。
6.3 持续计时器
上面例子最后,A的发送窗口被设置为了0。此时就需要一个持续计时器的介入。
假设B过了一段时间再次发送了一个窗口更新报文,将rwnd设置为300,但是该报文丢包了。这时候A和B就会进入一个很尬尴的局面,即B在等待A发送数据,A在等待B更新rwnd,双方谁都没有办法继续发送数据。即出现死锁。
持续计数器会在一端的发送窗口被置为0的时候介入:A过了一段时间后,如果还没有收到窗口更新报文,则会发送一个零窗口探测报文(携带1字节数据)。B收到这个零窗口探测报文后,需要发送一个响应,并携带新的rwnd值。
这个步骤会被一直重复,直到A收到一个rwnd不为0的报文后,将自己的发送窗口大小更新,开始继续发送数据。
如果这个零窗口探测报文也丢失了会咋样?不用担心,接收方需要对零窗口探测报文进行ACK,发送方一直没有收到ACK,会触发超时重传机制。
6.4 发送窗口例题
7.拥塞控制
前面提到的tcp处理措施,都是为了保证通信双方的主机不会出什么错误,导致数据的丢失。
但是一直么有提到一点:网络出错了咋办?
你和对方打电话,结果电线都断了,那还咋电话呢?
为了避免通信给网络造成太大的负担,tcp除了考虑对方的接受能力以外,还需要考虑网络的承载能力。
7.1 如何确认网络出问题?
如果双方通信的时候,出现了丢包,我们真的能确认网络出现问题了吗?
- 答案是否定的。
你和朋友之间打电话,突然对方的声音卡了一下,你就能下结论,是的电话线断了吗?
- 实际上,只有你完全听不到对方声音了,又确认双方的手机都没有问题,才能认为是通信出了问题。
网络也是一样,只有出现大面积丢包
,才能认为是网络出了问题。当网络中的某一资源的要求超过了该资源能提供的部分,网络性能就会变坏,这种情况就叫做拥塞。
我们知道,tcp基于字节流,一次性可以发送大量的信息。要是一个进程的tcp连接一建立,就开始往网络里面塞一大堆的信息,把网络给整堵塞了,那好吗?
一个进程这么干,那多几个进程加入,网络直接雪上加霜。卡死了,谁都别想用!因此,如果出现了拥塞还不对连接进行控制,整个网络的吞吐量会随着输入载荷的扩大而不断下降。
7.1.1 TCP提供的拥塞控制策略
为了解决网络拥塞问题,TCP提供了四个步骤:慢开始、拥塞避免、快重传、快恢复。
下面将依次介绍这四个步骤的原理,并在如下假定条件下进行
- 数据单方向传送,另外一方只发送确认
- 接收方总是有足够大的缓存空间,发送方的窗口大小只由网络拥塞程度来决定
- 以最大报文段MSS的个数为讨论问题的单位,而不是以字节为单位。
为了实现这些机制,发送方需要维护一个cwnd拥塞窗口字段,用于约束自己的发送。该值取决于网络的拥塞程度,并会动态变化。
- 拥塞窗口维护原则:只要网络没有出现拥塞,拥塞窗口就会增大。网络出现拥塞,拥塞窗口减少
- 判断出现网络拥塞的依据:没有按时收到应当到达的确认报文
发送方会将拥塞窗口cwnd作为发送窗口swnd,即swnd=cwnd。
7.2 慢启动和拥塞避免
TCP添加了慢启动机制。说白了就是:刚开始发送的少,逐渐增多!
为了实现慢启动,TCP还需要维护一个慢开始门限ssthresh状态变量:
- 当cwnd < ssthresh时,采用慢开始算法;
- 当cwnd > ssthresh时,停止使用慢开始算法,而改用拥塞避免算法;
- 当cwnd = ssthresh时,既可以使用慢开始,又可以使用拥塞避免算法。
整个过程如下:
- 拥塞窗口从一个段的大小开始(约1KB),可以理解为从1开始
- 拥塞窗口有一个阈值
ssthresh
,默认为对方的窗口大小,这在3次挥手的时候已经确定了; - 收到一次ACK,且
拥塞窗口<阈值ssthresh
,直接将现有拥塞窗口大小加倍【指数增长】- 也可以理解为,一个ACK(注意是一个ACK,并非一个ACK报文)就加1;
- 比如第一次发送了1000个字节的消息,那么收到对方的ACK为1001后,直接将拥塞窗口大小加倍,此时为2000,下一次发送就发2000字节的消息;
- 拥塞窗口加倍的时候不能大于ssthresh,如果本次加倍会大于ssthresh,则只能增加到ssthresh并使用拥塞避免;
- 收到ACK,
拥塞窗口>=阈值ssthresh
,开始使用拥塞避免算法,窗口值+1【线性增长】
如果出现了发送超时(重传计时器超时),发送方可能会认为网络拥塞,开始进行控制:
- 阈值
ssthresh
设置为拥塞窗口/2
; - 拥塞窗口cwnd置为1(从头开始,避免大面积的重传);
- 拥塞窗口cwnd始终小于接收端的接收窗口;
- 拥塞窗口重新开始慢开始,直到
拥塞窗口>=阈值ssthresh
,开始使用拥塞避免算法,重复上述步骤。
这便是慢启动和拥塞控制的机制,上面贴的图能形象的展示数据慢慢增长到指数级增长的过程。
1 | 实际传输的数据大小 = min(拥塞窗口,对方接收窗口大小) |
7.3 快重传(3ACK)
TCP快重传(Fast Retransmit)是TCP协议中的一种拥塞控制机制,用于加快数据包的重传,以减少数据传输的延迟和提高网络性能。
在TCP协议中,当发送方发送数据包到接收方后,会启动一个定时器,等待接收方发送确认应答(ACK)回来。如果发送方在定时器超时前收到了相应的ACK,说明数据包已经成功接收,发送方会将定时器停止,并继续发送下一个数据包。
这个默认的定时器策略,是用于TCP的
超时重传
的。
然而,如果发送方在定时器超时前没有收到ACK,通常会认为数据包丢失了,因此会重新发送该数据包。但有时候,接收方实际上已经收到了数据包,只是ACK因为某种原因没有及时发送回来,这可能是由于网络延迟、拥塞等造成的。
而TCP快重传的思想是,当发送方收到连续的相同的ACK时,不等待定时器超时,而是立即重传对应的数据包。
- 要求接收方不要等自己发送数据的时候才进行捎带确认,而是立马发送确认
- 即便收到了失序的报文段,也要立即发出对已收到的报文段的重复确认。比如收到了1、2、3、6、5,收到后续的报文时必须发送对3的重复确认(即ACK=4,期望收到4号报文)
这是因为连续收到相同的ACK,表明接收方已经收到了一系列的数据包,并且正在等待接收下一个数据包。通过立即重传,可以避免等待定时器超时的延迟,从而更快地恢复数据传输。
具体来说,TCP快重传的步骤如下:
- 发送方发送一个数据包,并启动定时器。
- 接收方收到数据包后,发送对应的ACK回去。
- 如果发送方收到三个连续的相同ACK(即收到三个对同一个数据包的确认应答),就立即重传该数据包,而不等待定时器超时。
因为收到三个连续相同ACK才会触发立即重传,这里的问题又叫3ACK问题。
使用快重传策略,可以避免某些报文出现超时重传,从而导致发送方错误重置cwnd和ssthresh,让网络的吞吐率增加。
3ACK之后,一般的做法下需要将cwnd和ssthresh都设置为当前cwnd的一半。
7.4 快恢复
快恢复算法也是基于3ACK问题的,当发送方收到三个重复的ACK,这代表接收方其实收到了后续的三个报文,只不过中间有一个丢包了而已。因此,网络中需要发送的报文其实是少了三个,可以将拥塞窗口增大(ssthresh+3)
7.5 总结和习题
8.延迟应答
8.1 问题
如何在保证不拥塞网络的前提下,传输更多数据?
要知道,网络环境复杂多变,一次性发送更多数据,是优于多次发送少量数据的
记住:窗口越大,网络吞吐量越大,传输效率就越高!
这时候,如何提高网络传输效率的问题,就被转换成了:上层如何尽快取走缓冲区中的数据的问题。
8.2 概念
所谓延迟应答,即收到消息后,等一会再给对方应答;
此时等待的是应用层取走接收缓冲区中的数据。这样在回应ACK的时候,缓冲区的容量更富裕,ACK中携带的接收窗口大小也就更大,下次对方就能发送更多数据,提高了tcp通信的效率!
需要注意的是,窗口大小的增加,是与网络拥塞无关的。二者是tcp在传输中都要考虑的两个独立的问题。
我们只要多等一等,就能给出时间让应用层取走一些数据,再给对方ACK,就能让自己的滑动窗口大小更大!
比如下面这个栗子:
- 假设接收端缓冲区为1MB,一次收到了500KB的数据;如果立刻应答,此时返回的窗口大小就是500KB;
- 但实际上,接收端处理的很快,在30ms后,应用层就取走了收到的这500KB的数据
- 这时候,接收端在等待发送端给自己发来的500KB数据的时候,缓冲区就已经恢复成1MB了
- 但发送端并不能知晓这件事,它依旧是按500KB给接收端发送的信息。这时候,接收端的缓冲区就出现了闲置
- 如果我们让接受端晚一点(比如晚200ms)再给发送端进行应答,此时应用层就已经取走缓冲区中的数据了,缓冲区大小恢复为1MB,接收端发送的应答中滑动窗口的大小也增加为1MB,发送端可以一次发送更多数据,缓冲区利用率提高!
注意,接收端缓冲区提高,能让发送端一次发送数据量的上限提高。实际发送多少数据,还是由双方通信的内容决定的。
8.3 策略
延迟应答的时间需要注意,不能太长(否则发送端会因为超时没有收到ACK而触发重传);也不能太短,否则应用层还没来得及取走数据
一般延迟应答有如下两个策略
- 隔N个包应答一次
- 隔一定时间应答一次(要在延迟的同时,避免对方进行超时重传)
延迟应答的策略根据不同平台而不同,一般N取2,间隔时间取200ms
;
9.捎带应答
在tcp报文的ACK应答的时候,如果采用一问一答的方式,会导致双方通信效率较低
1 | A -> B 吃了吗 |
如果采用下面这种方式,就节省了一次收发的时间,提高了通信效率
1 | A -> B 吃了吗 |
TCP的捎带应答就是如此,在回答对方的消息(ACK)的同时,携带上自己要发送给对方的信息。
要知道,所谓的ACK报文,只是需要将报头中的ACK标记位置为1即可,并不影响这个报文其他部分的功能!
10.选择确认
前面讲述拥塞控制以及TCP可靠传输的时候,都提到TCP接收方只能对收到的按序报文数据中的最高序号给出确认,如果还有多余的未按序抵达的报文,则无法实现确认,并且会在超时重传的时候一并重传
- 接受方收到了0,1,2,3,4,7,6;
- 接收方只能确认0到4,即ACK=5;
- 发送方收到该ACK,会认为6和7也丢了,会从5开始重新发送5,6,7;
- 但是实际上接收方已经收到了6和7,发送方相当于多重传了一次6和7;
未了避免这种情况,TCP可以使用选择确认,来让发送方只重传缺少的报文,而不出现多余的重传问题。
因为默认的TCP报头中并没有提供选择确认字段相关的取值,所以RCF2018规定,如果需要使用选择确认功能,要在TCP的扩展选项中实现。
但是,SACK相关文档并没有明确指出发送方应该如何响应SACK,因此大部分TCP的实现还是采用了重传所有未被确认的数据块。
11.面向字节流
调用write
时,数据会先写入发送缓冲区中,并不是立马发送给对方
- 如果一次性发送的字节数太长,会被拆分成多个TCP的数据包发出
- 如果发送的字节数太短,可能会在缓冲区中等待,到一定数据量后,再发出
这两个操作都是TCP自主帮我们完成的
- 接收数据时,数据从网卡(数据一定是先被网卡收到的),被驱动程序读取后,流入操作系统的接收缓冲区
- 应用层调用 read 从接收缓冲区中拿到数据
因为同时拥有接收和发送缓冲区(全双工),所以TCP程序的读写不需要完全同步
- 写100个字节,可以调用 write 写入100个字节,也可以调用100次 write 写入1个字节
- 读100个字节,也可以多次读取,或一次性读取完毕
- 二者都不需要考虑对方的写入是如何写入的!
因为数据在缓冲区中都是没有严格分界的字节数据,TCP不关心这些数据的组成,所以TCP是面向字节流的。我们只需要读取之后,依照应用层的协议进行数据的处理即可。
这也是为什么使用TCP时,应用层的协议需要规定一个协议中数据的边界(比如报文中数据字节的长度,或者用特殊字符来做边界,参考HTTP协议)
12.粘包问题
前面提到了,tcp在读写数据的时候,并没有严格的读取字节的要求,那么就可能会遇到下面的问题(C代表发送端,S代表接收端)。
1 | 假设应用层定义的协议一个报文是100KB大小; |
这种情况,被称为粘包问题;解决粘包问题最好的办法,就是要明确报文和报文之间的边界
,其通过应用层的协议来明确,必须要在应用层来处理;
- 定长报文,要求双方都必须读写特定长度的报文,多的拆包,少的补空
- 变长报文,可以采用在报文开头写明本条报文长度的方式,告知对方应读取多少字节
- 变长报文,还可以采用数据分隔符的方式作为边界(需要保证正文中不能出现分隔符,比如http中的
\r\n
)
对于UDP而言,并不存在粘包问题。因为UDP采用的是定长报文(面向数据报),应用层只会读取到一个完整的报文。如果UDP的一个报文不完整,代表数据在传输过程中出现了丢失。
但是上面的描述都不完整,其实TCP有粘包问题最直接的证明就是:TCP的报头中,并没有报文总长度字段,只有一个4位首部长度字段用于标识TCP首部的长度。但是在UDP的报头中,有一个16位总长度,且UDP的首部长度固定为8字节,接收方只需要读取固定的前8字节,就能得到整个UDP报文的总长度,并进一步进行读取。
13.UDP如何实现可靠传输?
有的时候,会遇到这个问题。解决这个问题的思路就是:借鉴!
tcp就是可靠的传输机制,我们只需要在tcp里面选择一部分tcp协议的特性,加到udp中,就能在一定程度上实现udp的可靠传输
- 报文中添加序号,保证按序到达
- 引入确认应答,确认对方收到了信息
- 引入按序到达,保证数据包发送的顺序正确
- 引入超时重传,如果一段时间对方没有应答,则重发
- …
这些操作就需要程序猿在应用层去实现了。
14.SYN洪水攻击如何抵御?
syn攻击实质是只发送syn报文,占满服务端的半连接队列,导致正常请求的syn被丢弃。
1,增大tcp_max_backlog,somaxconn和listen参数backlog,达到增大半连接队列的作业
2,开启tcp_syncookies服务,半连接队列慢也不会丢弃syn包,会采取cookie验证
3,减少syn_ack重传次数,调节tcp_synack_retries。同时
The end
UDP/TCP协议的基本知识如上,后续还会继续补充