【Linux】udp | tcp | 协议详解
慕雪年华

本文带你详细了解tcp协议的相关知识

本文中部分截图为手写,字丑见谅

1.linux下常用网络命令

1
cat /etc/servcies # 系统常用服务和端口

我们自己写网络服务器进程时,绑定的端口不能和系统端口冲突。尽量绑定1024以上的端口,推荐绑定不常用的5位数端口。

绑定低于1024的端口,会出现权限不足的报错

1
2
3
$ ./tcpServer 100
DEBUG | 1679473830 | muxue | socket create success: 3
FATAL | 1679473830 | muxue | bind: Permission denied:3

1.1 netstat命令

1
2
3
4
5
6
7
netstat
netstat -l # 只列出listen状态服务
netstat -n # 将显示的信息用数字(id)代替
netstat -p # 显示端口和进程pid的关联
netstat -t # tcp
netstat -u # udp
netstat -a # 显示所有服务

1.2 pidof

获取某个进程名的进程pid

1
pidof 进程名

比如我想查看sshd的进程id

1
2
$ pidof sshd
20706 20703 10775 6067 6009 3339 3338 3272 3269 1340

2.udp协议

一下为udp报文格式的结构图

image

udp采用了定长报文,这也是udp 面向数据报

  • udp采用16位作为ip+端口的存放,源端口和目的端口用于数据的解包分用(系统需要知道当前的数据包应该丢给上层的哪一个端口)
  • 16位udp长度,表示整个数据报 udp首部+udp数据 的最大长度
  • 16位校验和用于校验报文是否出现错误。如果校验和出错,就会直接丢弃报文

由于udp的长度标志位只有16位,所以一个udp报文能传输的最大数据是64kb ( 2)

如果需要用udp传输大于64kb的数据,则需要在应用层进行拆分,在接收方的应用层进行合并。

2.1 理解报头

所谓报头,其实就是操作系统内核中的一个C语言的结构体。

1
2
3
4
5
6
7
8
9
//示例,不代表真实情况
//udp报头采用了位段
struct udp_hdr
{
unsigned int src_port:16;
unsigned int dst_port:16;
unsigned int udp_len:16;
unsigned int udp_check:16;
}

添加报头的本质,其实就是给数据的头部添加上一个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协议报头的一个基本结构图,我们需要了解整个结构,以及每一个部分的作用

image

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 按序到达

image

序号除了用于确认应答,还有多个功能

  • 保证数据的顺序收发问题

比如一个http的报头,原本的格式应该是下面这样

1
GET / HTTP/1.1

结果由于传输的过程中乱序了,变成了下面这样

1
HTTP/1.1 GET /

这种情况,是不能被应用层所正常解析的!数据全都乱了,原本写好的代码也没用了。

所以,为了避免数据在传输中乱序,tcp的序号就有了新的功能——保证数据的按序到达。

1
2
3
4
1.客户端发送了1-5号报文
2.服务端收到了1 3 4 2 5(乱序)
3.服务端在tcp的接收缓冲区中,将报文重排序为1-5
4.将重排序后的正确数据交付给应用层

但是,如果按顺序来接收数据,那就无法处理优先级问题。这部分将在后文6个标记位详解。

序号除了可以用于排序,还能用于去重,这部分也将在后文超时重传部分解析。

3.4 16位窗口大小

3.4.1 发送和接收缓冲区

tcp同时拥有发送和接收缓冲区。

image

我们在应用层调用的read/write函数,实际上只是将数据从接收缓冲区中拷贝出来/发送的数据拷贝到发送缓冲区

如果write包含将数据发送给对方的过程,那么这个函数的调用效率就太低了,影响应用层执行其他代码。

数据并没有被立即送入网络传输,而是由tcp协议自主决定发送数据的长度和发送的时间!这一切,都是由操作系统来决定的。这就是为什么tcp又称为传输控制协议

3.4.2 接收缓冲区满了咋办

既然有缓冲区,就肯定会存在缓冲区被写满的问题。

  • 发送缓冲区满,由操作系统告知应用层,不再往发送缓冲区中写入数据
  • 接受缓冲区满
    • 直接丢弃数据?
    • 告诉对方,不再给自己发信息?

在实际的tcp收发过程中,由于接收方缓冲区满而丢弃数据,是不可接受的。因为数据跨过了茫茫网络,都已经到你机器上了,结果因为你缓冲区满了给它丢掉了,这不是坑人吗?

虽然出现这种情况,我们可以让发送方重传报文,但这样效率太低!

image

所以,我们应该让收发双方知晓对方的缓冲区大小,从而避免这个问题!

这就是tcp报头中16位窗口大小的作用了!

3.4.3 告知对方收缓大小

如下图,在客户端和服务端互通有无的时候,假设服务端的接收缓冲区满了,应该告知客户端,让他别再给自己发消息了。

此时,服务端设置自己的16位窗口大小,以此告知客户端自己的缓冲区剩余容量。

如果对方发来的报文中,16位窗口大小所表示的缓冲区剩余容量已经不足了,发送方就不应该继续发送,而应该等待对方从缓冲区中取走数据。

image

这是已经开始通讯的情况,但如果是第一次通讯呢?如果客户端一来就发送了一个巨大的数据,直接塞满了服务端的缓冲区,那不是出事了?

这便是tcp在三次握手中要做的事情了,简单来说就是在通信开始前就互相告知自己缓冲区的大小。后文会讲解。

3.4.4 缓冲区是否独立?

  • 进程的tcp缓冲区是独立的吗?

每个进程都有自己的内核空间,内核空间里有tcp缓冲区,所以每个进程都有自己独立的tcp缓冲区

  • 线程的tcp缓冲区是独立的吗?

是的!虽然这些线程共享同一个内核TCP缓冲区,但是每个线程使用的缓冲区是独立的,互相之间不会产生冲突。每个线程对自己的缓冲区进行读写操作时,会使用内核提供的同步机制,如互斥锁、信号量等来确保线程之间的缓冲区不会互相干扰,从而实现数据的安全读写。

3.5 六个标记位

在4位首部长度右侧,有一块保留长度,和6个标记位。这六个标记位是所有设备都支持的标记位。

image

  • SYN: 连接标记位,用于建立连接(又称同步报文)
  • FIN: 表示请求关闭连接,又称为结束报文
  • ACK:响应报文,代表本次报文中包含对之前报文的确认应答
  • PSH:要求对方立马从tcp缓冲区中取走数据
  • URG:紧急指针标记位,用于紧急数据的传输
  • RST:要求重置连接(双方重新建立一次新的tcp连接)

3.5.1 8个标记位?

在部分书籍中,还会出现8个标记位与4位保留长度的说法(下图源自《图解tcp/ip第五版》)

image

  • 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个标记位即可

image

3.5.2 ACK

该标记位用于标识本条报文是对之前的报文的确认应答

ACK标记位的设置和其他标记位并不冲突,在捎带应答的时候,可以同时设置多个标记位

3.5.3 SYN/FIN

  • SYN:表示请求建立连接,并在建立连接时用于同步序列号,所以又称为同步报文
  • FIN:表示请求关闭连接,又称为结束报文。设置为1时,代表本方希望断开连接。此时双方要交换FIN(四次挥手)才能真正断开tcp连接。

image

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
2
$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60

3.5.4 PSH

PSH标记位的作用是:要求对方立马取走缓冲区中数据

如下图,S在接收缓冲区满了之后过了很久,还没有取走缓冲区中的数据,C实在忍不住了,给S发一个PSH标记位的报文,要求S立马取走这些数据!

image

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缓冲区的约束,所以又被称为带外数据

下图就举了一个紧急指针使用的场景:

image

TCP 在传输数据时是有顺序的,它有字节号,URG配合紧急指针,就可以找到紧急数据的字节号

紧急数据的字节号公式如下:

1
紧急数据字节号(urgSeq)=TCP报文序号(seq)+紧急指针(urgpoint)−1

比如图中的例子,如果 seq = 10,urgpoint = 5,那么字节序号 urgSeq = 10 + 5 -1 = 14

image

知道了字节号后,就可以计算紧急数据字位于所有传输数据中的第几个字节了。如果从第0个字节开始算起,那么紧急数据就是第urgSeq - ISN - 1个字节(ISN 表示初始序列号),减1表示不包括第一个SYN段,因为一个SYN段会消耗一个字节号。

3.5.6 RST

RST为复位报文,即RESET

如下图,如果A给B发送的ACK在传输路途上丢失了,咋办?

image

这时候,就会出现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丢失,要由客户端重新发起和服务器的连接。

注意,三次握手只是一定程度上避免攻击。我们依旧可以用很多宿主机“堆料”来和服务器硬碰硬,这是无可避免的情况。

  • 更多次握手?

由于三次握手已经满足了我们的要求,更多次握手依旧有被攻击的可能,还降低了效率,完全没必要!

image

3.7 超时重传

为了保证可靠性,如果一个报文长时间未收到对方的ACK回应,则需要进行超时重传

linux下每一次尝试的时间间隔为500ms,若500ms内尚未收到对方的ACK,则重发报文,再等待1000ms……以此类推。

image

超时重传还可能遇到下面的情况:

  • 服务器收到了消息,也发送了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状态,填满了整个屏幕,这完全没办法写博客啊

image

而且这些状态清一色来自python3.10的程序,看到它的时候,我已经基本猜到了是啥进程引发的了——我的两个valorant机器人。查了查pid,坐实了这一点

image

我将数据写入到一个文件里面,统计了一下,一共1200多个CLOSE-WAIT

1
2
netstat -ntp > log # 将统计结果写入文件log
netstat -antp | grep CLOSE_WAIT # 只统计CLOSE_WAIT状态的链接

image

这些状态值的远程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%在线

image

前面提到过,系统是需要消耗资源来维护tcp链接的。如下图,机器人占用了将近400mb的内存,其中肯定有一部分就是被这些没有关闭的tcp链接所占用的

image

大量CLOSE-WAIT,只可能是一个原因:uptimebot的请求已经结束并发送了FIN,而我的api代码作为服务端,并没有在收到FIN后,对链接进行close,于是链接一直处于CLOSE-WAIT半关闭状态。只有程序关闭(机器人下线)才会被操作系统清空。


后来又研究了一下,经过他人点醒,才发现上面的结论都是错的

https://segmentfault.com/q/1010000043572705/a-1020000043573118

其实在netstat里面很明显的一点,表示这一切和uptimebot以及我写的api没有任何关系

image

那就是这里面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页面

image

后台可以看到,服务器接收到了请求的报头

image

并按如下返回response

1
2
3
4
5
6
7
8
DEBUG | 1679717397 | muxue | [sockfd: 4] filePath: web/index.html
######### response header ##########
HTTP/1.0 200 OK
Content-Type: text/html
Content-Length: 362
Set-Cookie: This is my cookie test

######### response end ##########

使用netstat命令查看,当前多出了一个处于time wait状态的连接

image

这代表四次挥手的第一个FIN是由服务器发出的,这一点在代码中也能体现,服务器accpet到连接后,会交由孙子进程来执行handlerHttpRequest(conet)服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 提供服务(孙子进程)
pid_t id = fork();
if(id == 0)
{
close(_listenSock);//因为子进程不需要监听,所以关闭掉监听socket
//又创建一个子进程,大于0代表是父进程,即创建完子进程后父进程直接退出
if(fork()>0){
exit(0);
}
// 父进程推出后,子进程被操作系统接管

// 孙子进程执行
handlerHttpRequest(conet);
exit(0);// 服务结束后,退出,子进程会进入僵尸状态等待父进程回收
}
// 爷爷进程
close(conet); //这个close并不会影响孙子进程内部的,因为有写时拷贝
pid_t ret = waitpid(id, nullptr, 0); //此时就可以直接用阻塞式等待了
assert(ret > 0);//ret如果不大于0,则代表等待发生了错误

这个服务函数并不是while(1)的死循环,内部也没有进行socket的close操作,而是发送完毕客户端请求的文件后,直接退出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void handlerHttpRequest(int sock)
{
cout << "########### header-start ##########" << endl;//打印一个分隔线
char buffer[10240];
ssize_t s = read(sock, buffer, sizeof(buffer));
if(s > 0){
cout << buffer << endl;
cout << "########### header-end ##########" << endl;
}
fflush(stdout);
string path = getPath(buffer);
// 假设用户请求的是 /a/b 路径
// 那么服务端处理的时候,就需要添加根目录位置和默认的文件名
// <root>/a/b/index.html
// 在本次用例中,根目录为 ./web文件夹,所以完整的文件路径应该是
// ./web/a/b/index.html

string resources = ROOT_PATH; // 根目录路径
resources += path; // 文件路径
logging(DEBUG,"[sockfd: %d] filePath: %s",sock,resources.c_str()); // 打印用作debug

string html = readFile(resources);// 打开文件

// 开始响应
string response = "HTTP/1.0 200 OK\r\n";
//如果readFile返回的是404,代表文件路径不存在
if(strcmp(html.c_str(),"404")==0)
{
response = "HTTP/1.0 404 NOT FOUND\r\n";
}
// 追加后续字段
response += "Content-Type: text/html\r\n";
response += ("Content-Length: " + to_string(html.size()) + "\r\n");
response += "Set-Cookie: This is my cookie test\r\n";
response += "\r\n";
cout << "######### response header ##########\n" << response << "######### response end ##########\n";
fflush(stdout);
response += html;

// 发送给用户
send(sock, response.c_str(), response.size(), 0);
}

函数退出了之后,文件描述符就交由了操作系统。一个没有进程使用的文件描述符,会被操作系统直接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
2
$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60

一般情况下,设置为MSL*2是最好的,这样能保证双方数据的递达,和最后ACK的递达

4.2 CLOSE-WAIT

如果我们在handlerHttpRequest(conet);向客户端发送了html文件后,休眠几秒钟,是否就能看到其他状态呢?

1
2
3
4
// 发送给用户
send(sock, response.c_str(), response.size(), 0);
// 休眠几秒钟作为测试
sleep(20);

如下,情况又不同了。这次出现的是CLOSE-WAIT状态,代表第一个FIN请求是客户端发出的

image

这是因为当前的进程没有进行长链接的维护,如果想维护长连接,则服务器应该给客户也返回一个Connection: keep-alive

如下图,可以看到客户端发来的http-header里面,是有该字段的。而服务器并没有返回相同的字段,客户端就认为服务器不支持长链接,从而主动发出了FIN

image

进一步看tcp的状态,当前是有两个父进程为1(采用了孙子进程的写法,父进程退出后会被操作系统接管)的进程在进行休眠,它们同属于295942这个tcp服务器主进程的进程组(PGID相同)

当这两个进程结束休眠的时候,CLOSE-WAIT状态的连接立马消失了。因为操作系统接管了文件描述符后,进行了close,服务端也发出了fin,四次挥手成功,连接终止。

image

4.3 ESTABLISHED

如果我们给response加上长链接的报头,是否可以看到ESTABLISHED状态呢?

1
response += "Connection: keep-alive\r\n";

可以看到,确实出现了这个状态,这代表双方成功维护起了长链接(虽然当前情况下,这个长链接并没有起到应有的作用)

image

进一步轮换,将处理函数改为while(1)的死循环调用,我们应该可以通过一个socket实现多个报文的发送

1
2
3
4
5
// 孙子进程执行
logging(DEBUG, "new child process");//打印一个新进程的提示信息,方便观察结果
while(1){
handlerHttpRequest(conet);
}

可以看到,只出现了一个子进程,对客户端进行服务

image

查看日志,能看到,成功实现了长链接通信

image

如果不采用while(1)死循环进行服务,则客户端的每一次请求,都需要一个新的子进程来服务

image

即便response中带有长链接标识,也会因为fd被操作系统回收而进入TIME-WAIT状态

image

4.4 端口不能被bind

之前在tcp服务器的学习中,出现了如果立马把tcp服务器关了后开,同一个端口无法被bind的情况

1
2
3
4
$ ./tcpServer 50000 > log
FATAL | 1679720482 | muxue | bind: Address already in use:3
$ ./tcpServer 50000 > log
FATAL | 1679720491 | muxue | bind: Address already in use:3

经过对tcp协议的学习,现在能知道为何这个端口不能被bind了。使用netstat -ntp命令查看,能看到这个端口上还有处于TIME-WAIT状态的链接,所以系统不允许我们bind这个端口。这是操作系统在默认状态下的行为。

image

前面提到过,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

image

只需要在bind函数之前添加上如下代码,就能实现端口复用。

1
2
3
// 1.1 允许端口被复用
int optval = 1;
setsockopt(_listenSock, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

如下图,即便50000端口存在time-wait的链接,我们依旧可以正常bind这个端口!

image

4.5 accpet不影响tcp

linux给我们提供的接口accpt,并不参与3次握手的阶段

将http服务的accpet给去掉,来观察这一情况。如下图,服务器直接是一个啥事不干的死循环,不对新来的连接进行accept,此时浏览器访问该服务,依旧会出现两个处于ESTABLISHED的连接

image

这便证实了我们的结论:accpet不参与tcp3次握手的过程

4.6 listen的第二个参数

4.6.1 概念

之前学习tcp服务器写法的时候,粗略提到了listen函数第二个参数的作用。

1
2
3
4
5
6
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);

//backlog参数限制了能被阻塞等待连接的数量
//如果超过这个数量,则会返回一个ECONNREFUSED错误。亦或者如果协议支持重传,多余的请求会被忽略,后续可以重传

这里的阻塞等待连接是什么意思?还是用前面用到的http服务,以实际情况来看看

  • 什么情况下,一个连接会被阻塞?

这一点就涉及到服务器的承受能力了。假设服务器现在很忙,压根没时间去accept一个新的连接,那这个连接就一直存在操作系统的tcp连接中,而没有进程对它服务。这种状态,就可以被称为连接的阻塞等待

4.6.2 看看具体情况

假设我将listen的第二个参数设置为了2,服务器是个啥事不干的死循环

1
2
3
4
5
if (listen(_listenSock, 2) < 0)
{
logging(FATAL, "listen: %s", strerror(errno));
exit(LISTEN_ERR);
}

在浏览器内直接开5个窗口请求这个连接,加上我的手机,一共是6个请求

image

但后台可以看到,再继续增加浏览器请求的数量,依旧都只有两个连接是处于ESTABLISHED状态,和listen的第二个参数正好相同!这两个连接因为没有被服务进程accept,它们就是处于阻塞等待状态的!

image

4.6.3 为什么?

为什么操作系统要给一个进程维护阻塞等待的连接呢?既然这个进程不进行新连接的accept,操作系统为何不直接把这个连接丢弃呢?

拿生活中非常场景的餐厅排队举例子吧。大家应该都见过一个餐馆在中晚餐高峰期时,门口有人在排队等位吧?特别是海底捞,每次想去都得提前预定,不然排队的时间吃门口的小零食都要吃饱了。

image

那么,餐馆为什么要提供排队等位呢?为何服务员不直接告诉新来的客人,馆子里没空位了,请另寻他处呢?

  • 原因很简单:为了上桌率。

一个餐馆的上桌率越高,就代表其生意越好。如果餐馆内部没桌了,但是外头有人排队,这样就能让有客人离开(空出桌子后)立马有新的客人上桌。

  • 对于我们的服务进程也是一样!

假设这个服务进程有10个线程对外进行服务,此时来了第11位需要服务的客人。服务的10个线程(桌子)都被坐满了,没人能给11号客人服务。那这时候,操作系统就告诉11位客人:“你在这里稍作等待,我去给你买个橘子取个排队单号”,这时候11号客人就在操作系统为服务进程提供的等候位置上坐了下来,等待服务进程腾出空位来给他服务(链接阻塞等待)

这时候,有一个用户断开了连接,空出来了一个进程,那么服务进程(餐馆)内的服务员就跑出来,和11号客户说,他可以上桌了(accept)这时候,服务进程就开始给11号客户提供服务了。

这样一来,只要服务进程有空闲,就能立马有新的进程入座,让服务进程不至于摸鱼。提高了服务器资源的利用效率。

我买了一个服务器,我肯定是希望它在不崩溃的前提下为越多客户服务越好,资源最大化嘛!

4.6.4 该参数应该设置成多少?

既然我们已经知道了这个参数的作用,那么应该把它设置为多少呢?

image

餐馆也需要面临这个问题

  • 如果自己设置的排队等位太少,那么可能会有想排队的客户没有位置坐。
  • 如果设置的太多,那新来的客户压根不打算排队了,因为他们知道,轮到自己的时候,已经饿扁了

服务器也是如此

  • 第二个参数设置的低了,排队的空位太少,超过该参数的链接直接被os拒绝,错过了本来可以提供服务的用户
  • 第二个参数设置的高了,用户过来排队,等了好久都没等到,于是就报错连接超时
  • 设置的太高了,维护的连接也会占用系统资源,服务进程可用资源变少了!
  • 与其增长队列,还不如增加服务进程的服务能力(扩大店面)

所以,我们应该根据自己服务的面向用户数量级,设置一个合适的等位数量!这个应该根据具体情况来看的!

4.6.5 listen和accept

如下图,我让服务进程只对一个链接进行accept,相当于餐厅里面只有两张桌子。此时新来的链接就会处于等待状态,数量正好是listen的第二个参数(但是我的第二个参数是2,我也不知道为啥会是3个🤣)

image

5.滑动窗口

tcp中引入了滑动窗口的操作

5.1 概念

在实际通信中,如果真的只是让双方一发一答,那效率也太低了。所以,一般都是直接一次性发送多条消息,对方也是对多条报文进行ACK的,而且只需要ACK一次(这点在前面序号部分已经讲过原理了)

  • 一次性可以发送多条报文,但前提是对方有能力收那么多
  • 窗口大小:一次性可以发送的数据数量(无需等待前面已发报文的ACK,就可以发送这么多)
    • 窗口大小是由对方的接收能力决定的
    • tcp报头中,16位窗口大小就是滑动窗口的大小
    • S给C所发报头中的窗口大小,既代表S接收缓冲区的大小,又代表C可以一次发送的数据大小
    • S接收缓冲区的大小变化,也会导致S给C所发报文中,窗口大小的变化
  • 窗口越大,代表双方通信的吞吐率就越大
  • 发送的数据会保留在发送缓冲区中,发送缓冲区以如下区域构成
    • 已发,收到了ACK的报文(可删)
    • 已发,未收到ACK的报文
    • 未发,准备发送的报文

5.2 看图

滑动窗口可以用下图来形象的理解,对图冲的文字就不复述了

image

本人字丑,用pad写就更丑了,请谅解

6.流量控制

所谓流量控制,就是发送方根据对方的接收能力来选择发送数据的多少。

如果B的接收缓冲区满了,会通过报文中的窗口大小告知A,A不再继续发送数据。

此时,A会在过一会后,向B发送一个窗口探测报文,该报文没有有效载荷,所以不会过多占用接收缓冲区;

B在收到该报文后,会回应报文,告知A自己的窗口大小,被称为窗口更新

image

7.拥塞控制

前面提到的tcp处理措施,都是为了保证通信双方的主机不会出什么错误,导致数据的丢失。

但是一直么有提到一点,网络出错了咋办?

你和对方打电话,结果电线都断了,那还咋电话呢?

为了避免通信给网络造成太大的负担,tcp除了考虑对方的接受能力以外,还需要考虑网络的承载能力

7.1 如何确认网络出问题?

如果双方通信的时候,出现了丢包,我们真的能确认网络出现问题了吗?

  • 答案是否定的。

你和朋友之间打电话,突然对方的声音卡了一下,你就能下结论,是的电话线断了吗?

  • 实际上,只有你完全听不到对方声音了,才能认为是通信出了问题。

网络也是一样,只有出现大面积丢包,才能认为是网络出了问题。

我们知道,tcp基于字节流,一次性可以发送大量的信息,要是一个进程的tcp连接一建立,就开始往网络里面塞一大堆的信息,把网络给整堵塞了,那好吗?

一个进程这么干,那多几个进程加入,网络直接雪上加霜。

7.2 慢启动

所以,为了避免这种情况,tcp添加了慢启动机制。

说白了就是:刚开始发送的少,逐渐增多

image

整个过程如下:

  • 拥塞窗口从一个段的大小开始(约1kb)
  • 拥塞窗口有一个阈值ssthresh,默认为对方的窗口大小,这在3次挥手的时候已经确定了
  • 收到一次ACK,且拥塞窗口<阈值,直接将现有拥塞窗口大小加倍【指数增长】
    • 也可以理解为,一个ACK就加1
    • 比如第一次发送了1000个消息,那么收到对方的ACK后,直接将拥塞窗口大小加倍,为2000,下一次发送就发2000的消息
  • 收到ACK,拥塞窗口>=阈值,窗口值+1【线性增长】
  • 超时,阈值ssthresh设置为拥塞窗口/2,拥塞窗口置为1(从头开始,避免大面积的重传)
  • 拥塞窗口始终小于接收器窗口
1
实际传输的数据大小=min(拥塞窗口,对方窗口大小)

这便是慢启动的机制,上面贴的图能形象的展示这一点

8.延迟应答

收到消息后,等一会再给对方应答

此时等待的是应用层取走接收缓冲区中的数据,这样回应ACK的时候,缓冲区的容量更富裕,ACK中携带的窗口大小也就更大,下次对方就能发送更多数据,提高了tcp通信的效率!

需要注意的是,窗口大小的增加,是与网络拥塞无关的,二者是tcp在传输中都要考虑的两个问题

在保证不拥塞网络的前提下,传输更多数据

要知道,网络环境复杂多变,一次性发送更多数据,是优于多次发送少量数据的

一般延迟应答有如下两个策略

  • 隔N个包应答一次
  • 隔一定时间应答一次(避免对方进行超时重传)

这两个策略都是可行的