本文带你详细了解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-20230317183835725

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

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

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

如果需要用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: 域名解析协议

2.6 udp的传输大小限制

在学习DNS协议的时候,发现DNS是512字节的数据采用UDP,大于512字节的数据就会采用TCP了。又看了几个相关的博客,发现有些用词不太严谨,直接说“UDP最大只支持512字节数据的传输”给我吓了一跳,还以为自己之前学习的内容有误,赶快来研究一下到底是什么情况。

https://taifua.com/udp-512bytes-limit.html

前文提到,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协议报头的一个基本结构图,我们需要了解整个结构,以及每一个部分的作用

image-20230317221627931

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

image-20230318125057097

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

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

比如一个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.3.6 第一个序号的产生

之前学习的时候都是用0来举例子,在计网的学习中又了解到TCP的第一个序号是随机生成的。下面是对这个随机生成的简单说明。

https://www.zhihu.com/question/397593729/answer/1248286390

假如一个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同时拥有发送和接收缓冲区。

image-20230318130053000

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

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

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

3.4.2 接收缓冲区满了咋办

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

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

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

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

QQ图片20220424132543

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

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

3.4.3 告知对方收缓大小

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

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

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

image-20230318131802239

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

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

3.4.4 缓冲区是否独立?

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

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

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

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

3.5 六个标记位

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

image-20230318154843384

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

3.5.1 8个标记位?

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

image-20230318155543377

  • 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连接。

image-20230318161138819

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
2
3
4
5
6
7
A     SYN->      B
A(SYN_SENT) B
A <-SYN+ACK B
A (SYN_RCVD)B
A ACK-> B
A(ESTABLISHED) B
A (ESTABLISHED)B 收到ACK
为什么接收方要发送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次挥手的过程,还需要知道每一次的状态变化!

img

3次握手和4次挥手对于应用层的C语言接口,都只有1个对应的函数。这些操作都是由西欧系统内核的tcp协议栈自主完成的。

3.5.4 PSH

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

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

image-20230318163912700

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-20230318164915979

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

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

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

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

image-20230318172209050

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

3.5.6 RST

RST为复位报文,即RESET

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

image-20230318182326946

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

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

  • 更多次握手?

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

image-20230318181619570

3.7 超时重传

3.7.1 超时重传的说明

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

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

image-20230318182832179

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

  • 服务器收到了消息,也发送了ACK,但是ACK在路上丢失了
  • 客户端没有收到ACK,于是进行超时重传
  • 服务器再次收到了消息,此时接收缓冲区里出现了两个一样的数据

但是,我们的报文是有序号的,tcp就可以直接根据序号去重,所以,tcp交给应用层的数据是去重+排序之后的数据!应用层并不需要担心数据重复或者顺序有误的情况。

如果同一个报文超时重传了好几次,还没有收到对方的应答,就会认为对方的服务挂掉了,此时本端会强制断连。此时客户端就可以发送一个带有RST标记位的报文,要求和对方重新建立连接。如果重连失败,则可以认为对方确实挂掉了,这便是我们在浏览器中有时会看到的 SERVICE_UNAVALIABLE 服务不可用;

3.7.2 超时重传时间RTO的选择

超时重传时间的选择是TCP最复杂的问题之一。

首先要知道一个概念:往返时间RTT,即一端发送一个报文+收到ACK报文的时间;

  • 如果超时重传时间RTO设置的小于往返时间RTT,则会产生不必要的重传,加大网络拥塞
  • 如果超时重传时间RTO设置的远大于往返时间RTT,则会导致网络中出现大段空闲,网络使用效率降低

由此可以得到一个暂时的结论:超时重传时间RTO的值应该设置为略大于RTT的值

image-20240101130801208

但是,这个问题的复杂在于,TCP下层的网络环境随时都有可能变化,往返时间RTT也有可能会有极具的波动,因此RTO也得跟着RTT实时发生变化。

比如下图所示,RTT1大于RTT0,此时RTO就不再适用于RTT1的情况,会造成不必要的重传。

image-20240101132011738

所以,并不能简单的使用某次测量得到的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

image-20240101132601814

由此可见,超时重传时间RTO的计算基于新的RTT样本。但是在已经出现超时重传的情况下,RTT的测量并不容易。如下图所示,左侧是发送的第一个报文丢失,进行超时重传后收到ACK的情况。但是A主机并没有办法确认这个ACK是对第一个丢失报文的确认,还是对超时重传后的报文的确认,RTT就有可能会有两个取值,如果取值错误,就容易出现问题。右侧情况也是同理

image-20240101133854845

因为这种超时重传的情况无法得到一个准确的新RTT样本,所以Karn提出一个算法,如果出现超时重传,则不更新RTTs和RTO。但这个算法并不完善,需要修正:报文段每重传一次,就将RTO稍微增大一些,典型的做法是将RTO取值为旧RTO值的2倍。

image-20240101134236720

3.7.3 超时重传时间例题

image-20240101134847585

image-20240101135244716

3.8 出现了很多CLOSE-WAIT状态的连接?

在上面提到过,当客户端向服务器发送FIN之后,服务器回复ACK,会进入CLOSE-WAIT状态。此时服务器还能给客户端发送消息,双方都还在维护连接的相关资源。

如果一个服务出现了很多个处于CLOSE-WAIT状态的连接,就必须要检查一下,应用层的代码里面是不是没有调用close(fd)函数来关闭对应的文件描述符。

一方的close(fd)就对应了两次挥手,两端都进行close(fd)即四次挥手。

对方明明都要和你分手了,你还挂着对方当备胎,还要找对方要钱,也太不像话了😂

3.8.1 活学活用🤣

230322下午,正准备通过之前写的tcp代码来验证tcp握手和挥手的各个状态的,没想到用命令一看,全是CLOSE-WAIT状态,填满了整个屏幕,这完全没办法写博客啊

image-20230322164218824

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

image-20230322164328076

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

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

image-20230322164435422

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

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

image-20230322165201221

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


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

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

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

image-20230325115349060

那就是这里面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客户端的响应,则认为客户端断连,服务器关闭连接。

image-20231221144600987

4.验证TCP状态

下面可以用代码来实地查看tcp在传输过程中的各种状态。之前写过一个简单的http服务器,现在为了方便,直接拿来使用。

采用如下bash命令来进行netstat的循环监测

1
while :; do netstat -ntp | grep 端口号;sleep 1; echo "########################"; done

4.1 TIME-WAIT

在浏览器访问,可以看到服务器返回的html页面

image-20230325120528729

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

image-20230325121400802

并按如下返回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-20230325121434464

这代表四次挥手的第一个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,返回ACK,进入TIME-WAIT状态,期间收到了S发送的data
  • C成功接收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请求是客户端发出的

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

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

image-20230325123306633

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

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

4.3 ESTABLISHED

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

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

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

image-20230325125153006

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

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

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

image-20230325125715102

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

image-20230325130041133

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

image-20230325130736659

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

image-20230325130555495

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状态的链接,所以系统认为这个端口上还有tcp链接在运行,不允许我们二次bind这个端口。这是操作系统在默认状态下的行为。

image-20230325130259120

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

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

1
2
3
// 1.1 允许端口被复用(即便还有TIME-WAIT的链接)
int optval = 1;
setsockopt(_listenSock, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

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

image-20230325134257205

除了这种形式的端口复用,其实还有一个SO_REUSEPORT,它允许多个进程绑定同一个的端口;在nginx中就是采用这样的方式,让多个worker线程进行链接的竞争的。

4.5 accpet不影响tcp

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

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

image-20230326161637486

这便证实了我们的结论: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-20230326162356252

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

image-20230326162348693

4.6.3 为什么?

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

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

image-20230326163628670

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

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

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

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

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

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

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

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

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

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

image-20230326165304255

餐馆也需要面临这个问题

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

服务器也是如此

  • 第二个参数设置的低了,排队的空位太少,超过该参数的链接直接被os拒绝,错过了本来可以提供服务的用户
  • 第二个参数设置的高了,用户过来排队,等了好久都没等到,于是就报错连接超时了。
  • 而且,打开一个网页的响应速度,也会直接影响用户对你的服务的满意度。每次点开你的网页都需要加载个十几秒的,久而久之,用的人只会越来越少!
  • 设置的太高了,维护的连接也会占用系统资源,服务进程可用资源变少了!
  • 与其增长队列,还不如增加服务进程的服务能力(扩大店面)

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

4.6.5 listen和accept

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

image-20230326165957731

5.滑动窗口(可靠传输)

tcp中引入了滑动窗口的操作来实现可靠传输

5.1 概念

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

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

5.2 滑动窗口示意图

滑动窗口可以用下图来形象的理解,对图中的文字就不复述了(如果图片404了请及时告知我,谢谢)

image-20230326204959886

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

5.3 总结和习题

image.png

image.png

image.png

6.流量控制

6.1 流量控制基本概念

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

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

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

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

image-20230326205114217

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发送出去,此时发送窗口已满,不能继续发送!

image.png

过了一会,A发送还没有收到对201到300这个报文的确认,重传计时器超时,触发对201和300的超时重传。由于发送窗口依旧是满的,所以A只能进行重传,不能发送新的数据。

B收到重传的报文后,对501之前的报文进行累计确认,并更新B自己的接受窗口大小为100字节,对A主机再次进行流控。

image.png

此时A就能更新自己的滑动窗口到501字节之后,同时将发送窗口缩短到100字节。A只能发送501到600的数据,就需要等待B的确认。

假设B收到501到600后,又对A进行了第三次流量控制,将rwnd设置为0。此时A可以将滑动窗口移动到601的位置,601之前的数据可以被删除。但由于B的接收窗口为0,A需要更新自己的发送窗口为0,此时A不能发送任何数据。

image.png

6.3 持续计时器

上面例子最后,A的发送窗口被设置为了0。此时就需要一个持续计时器的介入。

假设B过了一段时间再次发送了一个窗口更新报文,将rwnd设置为300,但是该报文丢包了。这时候A和B就会进入一个很尬尴的局面,即B在等待A发送数据,A在等待B更新rwnd,双方谁都没有办法继续发送数据。即出现死锁

image.png

持续计数器会在一端的发送窗口被置为0的时候介入:A过了一段时间后,如果还没有收到窗口更新报文,则会发送一个零窗口探测报文(携带1字节数据)。B收到这个零窗口探测报文后,需要发送一个响应,并携带新的rwnd值。

这个步骤会被一直重复,直到A收到一个rwnd不为0的报文后,将自己的发送窗口大小更新,开始继续发送数据。

image.png

如果这个零窗口探测报文也丢失了会咋样?不用担心,接收方需要对零窗口探测报文进行ACK,发送方一直没有收到ACK,会触发超时重传机制。

6.4 发送窗口例题

image.png

7.拥塞控制

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

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

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

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

7.1 如何确认网络出问题?

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

  • 答案是否定的。

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

  • 实际上,只有你完全听不到对方声音了,又确认双方的手机都没有问题,才能认为是通信出了问题。

网络也是一样,只有出现大面积丢包,才能认为是网络出了问题。当网络中的某一资源的要求超过了该资源能提供的部分,网络性能就会变坏,这种情况就叫做拥塞

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

一个进程这么干,那多几个进程加入,网络直接雪上加霜。卡死了,谁都别想用!因此,如果出现了拥塞还不对连接进行控制,整个网络的吞吐量会随着输入载荷的扩大而不断下降。

image.png

7.1.1 TCP提供的拥塞控制策略

为了解决网络拥塞问题,TCP提供了四个步骤:慢开始、拥塞避免、快重传、快恢复。

下面将依次介绍这四个步骤的原理,并在如下假定条件下进行

  • 数据单方向传送,另外一方只发送确认
  • 接收方总是有足够大的缓存空间,发送方的窗口大小只由网络拥塞程度来决定
  • 以最大报文段MSS的个数为讨论问题的单位,而不是以字节为单位。

为了实现这些机制,发送方需要维护一个cwnd拥塞窗口字段,用于约束自己的发送。该值取决于网络的拥塞程度,并会动态变化。

  • 拥塞窗口维护原则:只要网络没有出现拥塞,拥塞窗口就会增大。网络出现拥塞,拥塞窗口减少
  • 判断出现网络拥塞的依据:没有按时收到应当到达的确认报文

发送方会将拥塞窗口cwnd作为发送窗口swnd,即swnd=cwnd。

image-20230724092314419

7.2 慢启动和拥塞避免

TCP添加了慢启动机制。说白了就是:刚开始发送的少,逐渐增多!

为了实现慢启动,TCP还需要维护一个慢开始门限ssthresh状态变量:

  • 当cwnd < ssthresh时,采用慢开始算法;
  • 当cwnd > ssthresh时,停止使用慢开始算法,而改用拥塞避免算法;
  • 当cwnd = ssthresh时,既可以使用慢开始,又可以使用拥塞避免算法。

image-20230724092314418

整个过程如下:

  • 拥塞窗口从一个段的大小开始(约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
2
实际传输的数据大小 = min(拥塞窗口,对方接收窗口大小)
Swnd = Min[Rwnd, Cwnd]

这便是慢启动和拥塞控制的机制,上面贴的图能形象的展示数据慢慢增长到指数级增长的过程。

image.png

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快重传的步骤如下:

  1. 发送方发送一个数据包,并启动定时器。
  2. 接收方收到数据包后,发送对应的ACK回去。
  3. 如果发送方收到三个连续的相同ACK(即收到三个对同一个数据包的确认应答),就立即重传该数据包,而不等待定时器超时。

需要注意的是,快重传只适用于可靠传输的TCP连接,而不适用于无连接的UDP协议。TCP快重传是TCP协议中的一种优化措施,它能够在一定程度上改善网络性能,减少不必要的传输延迟,提高数据传输的效率。

因为收到三个连续相同ACK才会触发立即重传,这里的问题又叫3ACK问题

使用快重传策略,可以避免某些报文出现超时重传,从而导致发送方错误重置cwnd和ssthresh,让网络的吞吐率增加。

image.png

3ACK之后,一般的做法下需要将cwnd和ssthresh都设置为当前cwnd的一半。

7.4 快恢复

快恢复算法也是基于3ACK问题的,当发送方收到三个重复的ACK,这代表接收方其实收到了后续的三个报文,只不过中间有一个丢包了而已。因此,网络中需要发送的报文其实是少了三个,可以将拥塞窗口增大(ssthresh+3)

image.png

7.5 总结和习题

image.png

image.png

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
2
3
4
A -> B 吃了吗
B -> A 吃了
B -> A 你呢?
A -> B 我还没有

如果采用下面这种方式,就节省了一次收发的时间,提高了通信效率

1
2
3
A -> B 吃了吗
B -> A 吃了,你呢?
A -> B 我还没有

TCP的捎带应答就是如此,在回答对方的消息(ACK)的同时,携带上自己要发送给对方的信息。

要知道,所谓的ACK报文,只是需要将报头中的ACK标记位置为1即可,并不影响这个报文其他部分的功能!

image-20230401095727239

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的扩展选项中实现。

image-20240101140053632

但是,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
2
3
4
5
假设应用层定义的协议一个报文是100KB大小;
C -> 100KB S
C -> 100KB S
S 一次性读取了150KB的数据;
此时,它读取了1.5个应用层的报文,并不完整!

这种情况,被称为粘包问题;解决粘包问题最好的办法,就是要明确报文和报文之间的边界,其通过应用层的协议来明确,必须要在应用层来处理;

  • 定长报文,要求双方都必须读写特定长度的报文,多的拆包,少的补空
  • 变长报文,可以采用在报文开头写明本条报文长度的方式,告知对方应读取多少字节
  • 变长报文,还可以采用数据分隔符的方式作为边界(需要保证正文中不能出现分隔符,比如http中的\r\n

对于UDP而言,并不存在粘包问题。因为UDP采用的是定长报文(面向数据报),应用层只会读取到一个完整的报文。如果UDP的一个报文不完整,代表数据在传输过程中出现了丢失。

但是上面的描述都不完整,其实TCP有粘包问题最直接的证明就是:TCP的报头中,并没有报文总长度字段,只有一个4位首部长度字段用于标识TCP首部的长度。但是在UDP的报头中,有一个16位总长度,且UDP的首部长度固定为8字节,接收方只需要读取固定的前8字节,就能得到整个UDP报文的总长度,并进一步进行读取。

13.UDP如何实现可靠传输?

有的时候,会遇到这个问题。解决这个问题的思路就是:借鉴!

tcp就是可靠的传输机制,我们只需要在tcp里面选择一部分tcp协议的特性,加到udp中,就能在一定程度上实现udp的可靠传输

  • 报文中添加序号,保证按序到达
  • 引入确认应答,确认对方收到了信息
  • 引入按序到达,保证数据包发送的顺序正确
  • 引入超时重传,如果一段时间对方没有应答,则重发

这些操作就需要程序猿在应用层去实现了。

The end

UDP/TCP协议的基本知识如上,后续还会继续补充