本文带你详细了解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
( 22 )
如果需要用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: 域名解析协议
本文往下都是tcp的内容了哦!
3.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.3 32位序号/确认序号
3.3.1 如何确认信息被对方收到?
tcp具有确认应答的机制
当我们和对方微信交流的时候,怎么样才能确认自己的信息被对方看到了?
- A发 吃饭了吗?
- B回应 吃了
在这个场景中,只有B给A发出回应,A才能确认自己的消息被B看到了。
tcp通信也是如此,只有给对方发送的报文收到了对方的应答,发送方才能确认自己的报文被对方收到了。
为此,tcp引入了32位 序号/确认序号
3.3.2 确认应答
用于确认自己和对方的通信,究竟收到了哪一个报文(序号)以及确认信息发出的顺序。
比如客户端会向服务器发 吃了吗?吃的什么?好吃吗?晚上想去干什么?
,如果没有对报头带上序号,服务器接收到的可能就会是下面这样 好吃吗?晚上想去干什么?吃的什么?吃了吗?
,看起来是不是十分怪异?
所以,为了保证tcp报文的顺序性,以及保证报文被送达到对方。tcp引入了以序号为基础的确认应答
机制
- 客户端向服务器发送一个报头,并将序号设置为1
- 服务端收到信息后,回复客户端一个报头,将确认序号设置为2(为客户端所发消息的序号
+1
) - 此时客户端就能确认服务器收到了自己刚刚发出的序号为1的消息
- 下次发送消息,客户端会从2号开始发送
以上是一次通信的过程,如果是多次通信呢?
- 客户端连续向服务器发送了n个消息,服务器应答:1、2、3、4……
- 服务器的每次应答会设置确认序号,代表n之前的报文被全部收到
- 比如假设客户端发送了
1-10
的报文,而第6个报文出现了丢失,那么服务端就应该设置确认序号
为6,代表6之前的报文都被正常收到。 - 此时客户端发现,明明自己已经都发到10了,服务端还在回应6。这就代表发送过程中,6号报文丢失了!于是客户端从6号报文开始,重发报文:6、7、8……
不管是服务端给客户端发信息,还是客户端给服务器发信息,收方都需要对信息进行回应。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.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标记位的设置和其他标记位并不冲突,在捎带应答
的时候,可以同时设置多个标记位
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
)
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次挥手对于应用层而言,都只有1个对应的函数。这些操作都是由tcp自主完成的。
在centos下,可以使用如下命令,查看到TIME WAIT
状态默认等待的时间
1 | $ cat /proc/sys/net/ipv4/tcp_fin_timeout |
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,自己直接丢弃文件描述符(不做维护)
由于服务器并不知道你直接丢弃了文件描述符,其还是要为此次连接维护相关数据,这样会导致服务器的资源在短时间内被大量消耗,最后直接dead了
这种攻击叫做SYN洪水
- 如果是二次握手?
A给B发送一个SYN,B给A发送一个ACK,即认为连接建立。
这和一次握手其实是相似的,服务器发送完毕ACK之后,就认为连接已经建立,需要维护相关资源。而我们依旧可以直接丢弃,不进行任何维护,最后还是服务器的资源被消耗完了
- 三次握手
双方都必须维护连接的相关资源,这样,哪怕你攻击我的服务器,你也得付出同等的资源消耗。最后就是比谁资源更多呗!
相比于前两种情况,三次握手能在验证全双工的同时,一定程度上避免攻击。
三次握手还将最后一次ACK丢失的成本嫁接给了客户端(连接发起方)如果最后一次ACK丢失,要由客户端重新发起和服务器的连接。
注意,三次握手只是一定程度上避免攻击。我们依旧可以用很多宿主机“堆料”来和服务器硬碰硬,这是无可避免的情况。
- 更多次握手?
由于三次握手已经满足了我们的要求,更多次握手依旧有被攻击的可能,还降低了效率,完全没必要!
3.7 超时重传
为了保证可靠性,如果一个报文长时间未收到对方的ACK回应,则需要进行超时重传
。
linux下每一次尝试的时间间隔为500ms,若500ms内尚未收到对方的ACK,则重发报文,再等待1000ms……以此类推。
超时重传还可能遇到下面的情况:
- 服务器收到了消息,也发送了ACK,但是ACK在路上丢失了
- 客户端没有收到ACK,于是进行超时重传
- 服务器再次收到了消息,此时接收缓冲区里出现了两个一样的数据
但是,我们的报文是有序号的,tcp就可以直接根据序号去重,所以,tcp交给应用层的数据是去重+排序
之后的数据!
如果同一个报文超时重传了好几次,还没有收到对方的应答,就会认为对方的服务挂掉了,此时本端会强制断连。
此时客户端就可以发送一个带有RST标记位的报文,要求和对方重新建立连接。
3.8 出现了很多CLOSE-WAIT状态的连接?
在上面提到过,当客户端向服务器发送FIN之后,服务器回复ACK,会进入CLOSE-WAIT
状态。此时服务器还能给客户端发送消息,双方都还在维护连接的相关资源。
如果一个服务出现了很多个处于CLOSE-WAIT
状态的连接,就必须要检查一下,应用层的代码里面是不是没有调用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状态的资料找了老半天都没找到……原来一开始方向就错了😶🌫️
4.验证状态
下面可以用代码来实地查看tcp在传输过程中的各种状态。之前写过一个简单的http服务器,现在为了方便,直接拿来使用。
采用如下命令进行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,进入TIME-WAIT状态,期间收到了S发送的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请求是客户端发出的
这是因为当前的进程没有进行长链接
的维护,如果想维护长连接,则服务器应该给客户也返回一个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
状态的链接,所以系统不允许我们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 允许端口被复用 |
如下图,即便50000端口存在time-wait
的链接,我们依旧可以正常bind这个端口!
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 看图
滑动窗口可以用下图来形象的理解,对图冲的文字就不复述了
本人字丑,用pad写就更丑了,请谅解
6.流量控制
所谓流量控制,就是发送方根据对方的接收能力来选择发送数据的多少。
如果B的接收缓冲区满了,会通过报文中的窗口大小告知A,A不再继续发送数据。
此时,A会在过一会后,向B发送一个窗口探测
报文,该报文没有有效载荷,所以不会过多占用接收缓冲区;
B在收到该报文后,会回应报文,告知A自己的窗口大小,被称为窗口更新
7.拥塞控制
前面提到的tcp处理措施,都是为了保证通信双方的主机不会出什么错误,导致数据的丢失。
但是一直么有提到一点,网络出错了咋办?
你和对方打电话,结果电线都断了,那还咋电话呢?
为了避免通信给网络造成太大的负担,tcp除了考虑对方的接受能力以外,还需要考虑网络的承载能力
7.1 如何确认网络出问题?
如果双方通信的时候,出现了丢包,我们真的能确认网络出现问题了吗?
- 答案是否定的。
你和朋友之间打电话,突然对方的声音卡了一下,你就能下结论,是的电话线断了吗?
- 实际上,只有你完全听不到对方声音了,才能认为是通信出了问题。
网络也是一样,只有出现大面积丢包
,才能认为是网络出了问题。
我们知道,tcp基于字节流,一次性可以发送大量的信息,要是一个进程的tcp连接一建立,就开始往网络里面塞一大堆的信息,把网络给整堵塞了,那好吗?
一个进程这么干,那多几个进程加入,网络直接雪上加霜。
7.2 慢启动
所以,为了避免这种情况,tcp添加了慢启动机制。
说白了就是:刚开始发送的少,逐渐增多
整个过程如下:
- 拥塞窗口从一个段的大小开始(约1kb)
- 拥塞窗口有一个阈值
ssthresh
,默认为对方的窗口大小,这在3次挥手的时候已经确定了 - 收到一次ACK,且
拥塞窗口<阈值
,直接将现有拥塞窗口大小加倍【指数增长】- 也可以理解为,一个ACK就加1
- 比如第一次发送了1000个消息,那么收到对方的ACK后,直接将拥塞窗口大小加倍,为2000,下一次发送就发2000的消息
- 收到ACK,
拥塞窗口>=阈值
,窗口值+1【线性增长】 - 超时,阈值
ssthresh
设置为拥塞窗口/2
,拥塞窗口置为1(从头开始,避免大面积的重传) - 拥塞窗口始终小于接收器窗口
1 | 实际传输的数据大小=min(拥塞窗口,对方窗口大小) |
这便是慢启动的机制,上面贴的图能形象的展示这一点
8.延迟应答
收到消息后,等一会再给对方应答
此时等待的是应用层取走接收缓冲区中的数据,这样回应ACK的时候,缓冲区的容量更富裕,ACK中携带的窗口大小也就更大,下次对方就能发送更多数据,提高了tcp通信的效率!
需要注意的是,窗口大小的增加,是与网络拥塞无关的,二者是tcp在传输中都要考虑的两个问题
在保证不拥塞网络的前提下,传输更多数据
要知道,网络环境复杂多变,一次性发送更多数据,是优于多次发送少量数据的
一般延迟应答有如下两个策略
- 隔N个包应答一次
- 隔一定时间应答一次(避免对方进行超时重传)
这两个策略都是可行的
- 本文标题:【Linux】udp | tcp | 协议详解
- 创建时间:2023-03-17 15:30:55
- 本文链接:posts/4287572457/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!