跳转至

🔴 TCP & UDP

约 14309 个字 2 行代码 6 张图片 预计阅读时间 72 分钟

什么是 TCP?

TCP 是 面向连接的、可靠的、基于字节流 的传输层通信协议。

  • 面向连接:一定是「一对一」才能连接,不能像 UDP 协议可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的
  • 可靠的:无论网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端
  • 字节流:用户消息通过 TCP 协议传输时,消息可能会被操作系统「分组」成多个的 TCP 报文,如果接收方的程序如果不知道「消息的边界」,是无法读出一个有效的用户消息的。并且 TCP 报文是「有序的」,当「前一个」TCP 报文没有收到的时候,即使它先收到了后面的 TCP 报文,那么也不能扔给应用层去处理,同时对「重复」的 TCP 报文会自动丢弃

为什么需要 TCP 协议?TCP 工作在哪一层?

IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。

如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。因为 TCP 是一个工作在 传输层可靠 数据传输的服务,它能确保接收端接收的网络包是 无损坏、无间隔、非冗余和按序的

什么是 TCP 连接?

用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。

建立一个 TCP 连接需要客户端与服务端达成上述三个信息的共识:

  • Socket:由 IP 地址和端口号组成
  • 序列号:用来解决乱序问题等
  • 窗口大小:用来做流量控制

如何唯一确定一个 TCP 连接?

TCP 四元组可以唯一的确定一个连接,四元组包括:

  • 源地址
  • 源端口
  • 目的地址
  • 目的端口

源地址和目的地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。源端口和目的端口的字段(16 位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。

有一个 IP 的服务端监听了一个端口,它的 TCP 的最大连接数是多少?

服务端通常固定在某个本地端口上监听,等待客户端的连接请求。因此,客户端 IP 和端口是可变的。

对 IPv4,客户端的 IP 数最多为 2^32,客户端的端口数最多为 2^16,也就是服务端单机最大 TCP 连接数,约为 2^48。

当然,服务端最大并发 TCP 连接数远不能达到理论上限,会受以下因素影响:

  • 文件描述符限制:每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 Too many open files
  • 内存限制:每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM

TCP 的头部结构

TCP的头部结构

  • 源端口:16 位,标识报文的返回地址
  • 目的端口:16 位,指明接收方计算机上的应用程序接口
  • 序列号:32 位,在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就累加一次该数据字节数的大小。用来解决网络包乱序问题
  • 确认号:32 位,指下一次期望收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题
  • 数据偏移/首部长度:4 位,TCP 首部可能含有可选项内容,所以 TCP 报头的长度是不确定的,报头不包含任何任选字段则长度为 20 字节,4 位首部长度字段所能表示最大长度为 60 字节。首部长度也叫数据偏移,因为首部长度实际上指示了数据区在报文段中的起始偏移值
  • 保留:6 位,为将来定义新的用途保留,现在一般置 0
  • 校验和:16 位,由发送端填充,接收端对 TCP 报文段执行 CRC 算法以检验 TCP 报文段在传输过程中是否损坏,这个校验不仅包括 TCP 头部,也包括数据部分。这是 TCP 实现可靠传输的一个重要保障
  • 窗口:16 位,是 TCP 流量控制的一个手段。通过窗口告诉对方本端的 TCP 接收缓冲区还能容纳多少字节的数据,这样对方可以控制发送数据的速度,从而达到流量控制。窗口大小为16 bit 字段,因而窗口大小最大为 65535
  • 紧急指针:16 位,只有当 URG 标志置 1 时紧急指针才有效。紧急指针是一个正的偏移量,和顺序号字段中的值相加表示紧急数据最后一个字节的序号。使用紧急指针是发送端向另一端发送紧急数据的一种方式
  • 选项和填充:TCP 头部的最后一个选项字段是可变长的可选信息。这部分最多包含 40 字节,因为 TCP 头部最长是 60 字节。 最常见的可选字段是最长报文大小 MSS,每个连接方通常都在通信的第一个报文段中指明这个选项,它表示本端所能接受的最大报文段的长度
  • 数据部分:TCP 报文段中的数据部分是可选的。在连接建立或者终止时,双方交换的报文段仅有 TCP 首部;如果一方没有数据要发送,也会使用没有任何数据的首部来确认收到的数据;在处理超时的许多情况中,也会发送不带任何数据的报文段

还包括控制位:

  • URG:紧急指针标志,为 1 时表示紧急指针有效,该报文应该优先传送,为 0 则忽略紧急指针
  • ACK:确认序号标志,为 1 时表示确认号有效,为 0 表示报文中不含确认信息。携带 ACK 标识的 TCP 报文段被称为确认报文段
  • RST:重置连接标志,用于重置由于主机崩溃或其他原因而出现错误的连接,或者用于拒绝非法的报文段和拒绝连接请求。称携带 RST 标志的 TCP 报文段为复位报文段
  • SYN:表示请求建立一个连接。称携带 SYN 标志的 TCP 报文段为同步报文段
  • FIN:finish 标志,用于释放连接,为 1 时表示发送方已经没有数据发送了,即关闭本方数据流。称携带 FIN 标志的 TCP 报文段为结束报文段
  • PSH:push 标志,为 1 表示是带有 push 标志的数据,指示接收方在接收到该报文段以后,应优先将这个报文段交给应用程序,而不是在缓冲区排队

TCP 如何保证可靠传输

  • 校验和:目的是为了 验证 TCP 首部和数据在发送过程中没有任何改动,一旦发现校验和有差错,直接丢弃 TCP 段并重新发送
  • 序列号/确认号:序列号用于 解决包乱序问题,确认号用于 解决包丢失问题
  • 连接管理:三次握手、四次挥手
  • 流量控制:基于滑动窗口机制实现。TCP 的报文信息中有一个 16 位字段来标识滑动窗口,窗口大小就是接收方剩余缓冲区大小,在回复 ACK 时,接收方将自己剩余缓冲区大小填入。发送方根据窗口大小来调整自己的发送速度,如果缓冲区大小为 0,那么发送方会停止发送数据。并且发送方定期会发送探测报文,来获取缓冲区大小
  • 拥塞控制:拥塞控制与流量控制相比,更加关注网络线路中的拥塞程度,即网络的包转发还要受到当前网络端到端路由的拥塞情况

    • 慢启动算法:一开始不发送大量数据,而是应该先发一小部分探测数据,然后由小到大逐渐增大发送窗口。通常在刚刚开始发送报文段时,先把拥塞窗口 cwnd 设置为1,每次接收到报文之后将窗口大小翻倍。如果指数增长到避免拥塞算法的门限 ssthresh,则改用避免拥塞算法
    • 拥塞避免算法:每当收到一个 ACK 时,cwnd 增加 1,变为线性增长。一但发现丢包和超时重传,就进入拥塞处理状态
    • 拥塞发生

      • 超时重传:ssthresh 设为 cwnd/2;cwnd 重置为 1;回到慢启动过程
      • 快速重传:当发送方在超时之前收到来自接收方的 3 个冗余 ACK,cwnd = cwnd/2,也就是设置为原来的一半;ssthresh = cwnd;进入快速恢复算法
        • 快速恢复:拥塞窗口 cwnd = ssthresh + 3;因为 3 个冗余 ACK 已经收到,所以窗口应该增加 3。重传丢失的数据包 如果再收到重复的 ACK,那么 cwnd 增加 1;如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态

TCP 重传机制

超时重传

在发送数据时设定一个定时器,当超过指定的时间后没有收到对方的 ACK 确认应答报文,就会重发该数据。TCP 会在以下两种情况发生超时重传:

  • 数据包丢失
  • 确认应答丢失

超时重传时间是以 RTO (Retransmission Timeout 超时重传时间)表示。超时重传时间 RTO 的值应该略大于报文往返 RTT 的值,因为:

  • 当超时时间 RTO 较大:重发过慢,没有效率,性能差
  • 当超时时间 RTO 较小:导致可能没有丢包而重发。于是重发速度快导致增加网络拥塞、更多的超时、更多的超时也导致更多的重发

Linux 计算 RTO 需要采样 加权平均 RTT 时间 以及 RTT 波动范围,计算过程表示为:

Note

SRTT = R1

DevRTT = R1 / 2

RTO = μ * SRTT + θ * DevRTT

SRTT = SRTT + α * (RTT - SRTT)

DevRTT = (1 - β) * DevRTT + β *(|RTT - SRTT|)

RTO = μ * SRTT + θ * DevRTT

其中 SRTT 是计算平滑的 RTT,DevRTR 是计算平滑的 RTT 与 最新 RTT 的差距。在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4

快速重传

快速重传的工作方式是当收到三个相同的 ACK 报文时会在定时器过期之前重传丢失的报文段。

SACK

SACK( Selective Acknowledgment)意为 选择性确认。它 可以将已收到的数据的信息发送给「发送方」。这样发送方就可以知道哪些数据收到了,哪些数据没收到。知道了这些信息就可以 只重传丢失的数据。如果要使用 SACK,必须通信双方都支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。

D-SACK

Duplicate SACK 又称 D-SACK,其主要 使用 SACK 来告诉「发送方」有哪些数据被重复接收

D-SACK 的主要作用有:

  1. 让「发送方」知道是发出去的包丢失还是接收方回应的 ACK 包丢失
  2. 可以分析「发送方」的数据包是否有网络延迟
  3. 可以分析网络中「发送方」的数据包是否被复制

在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。

TCP 滑动窗口

引入窗口概念的原因

TCP 每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了,再发送下一个。这种方式的缺点是效率比较低,数据包的往返时间越长,通信的效率就越低。

为解决这个问题,TCP 引入了 窗口 这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。

有了窗口,就可以指定窗口大小,窗口大小就是指 无需等待确认应答,而可以继续发送数据的最大值

窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。

窗口大小由哪一方决定?

TCP 头里有一个字段叫 Window,也就是窗口大小。这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。所以,通常窗口的大小是由接收方的窗口大小来决定的。

发送方的滑动窗口

发送方缓存的数据根据处理的情况分成四个部分:

  • 1 是已发送并收到 ACK 确认的数据
  • 2 是已发送但未收到 ACK 确认的数据
  • 3 是未发送但总大小在接收方处理范围内(接收方还有空间)
  • 4 是未发送但总大小超过接收方处理范围(接收方没有空间)

TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节:

  • SND.WND:表示发送窗口的大小(大小是由接收方指定的)
  • SND.UNASend Unacknoleged):是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节
  • SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节

可用窗口大小的计算:可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)

接收方的滑动窗口

接收窗口相对简单一些,根据处理的情况划分成三个部分:

  • 1 + 2 是已成功接收并确认的数据(等待应用进程读取)
  • 3 是未收到数据但可以接收的数据
  • 4 未收到数据并不可以接收的数据

使用两个指针进行划分:

  • RCV.WND:表示接收窗口的大小,它会通告给发送方
  • RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节

接收窗口和发送窗口的大小是相等的吗?

并不是完全相等,接收窗口的大小是**约等于**发送窗口的大小的。因为滑动窗口并不是一成不变的,当接收方的应用进程读取数据的速度非常快的话,接收窗口可以很快的就空缺出来。新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。这个传输过程存在时延,所以接收窗口和发送窗口是约等于的关系。

TCP 的三次握手

TCP 的三次握手

  • 第一次握手:SYN = 1; seq = x; 进入 SYN_SENT 状态
  • 第二次握手:SYN = 1; ACK = 1; seq = y; ack = x + 1; 进入 SYN_RCVD 状态
  • 第三次握手:ACK = 1; seq = x + 1; ack = y + 1; 进入 ESTABLISHED 状态

TCP 为什么要三次握手?

只有三次握手才能证明服务端和客户端的 收发能力都是正常 的。

  • 第一次握手:服务端可以知道客户端发消息的能力是正常的,自己接收消息的能力是正常的
  • 第二次握手:客户端可以知道自己发送接收消息的能力和服务端发送接收消息的能力是正常的
  • 第三次握手:服务端可以知道自己发送消息的能力是正常的,客户端接收消息的能力是正常的

由此经过三次握手之后双方就可以都知道自己的发送和接收消息的能力是正常的。

如果 TCP 的三次握手丢失会发生什么?

  • 第一次丢失:客户端发送的 SYN 报文会收不到服务端的响应,从而会 触发超时重传,重传的 SYN 报文序列号和之前相同,重传最大重传次数由内核参数控制,一般是 5。如果超过最大次数客户端仍 没有收到回复就会断开连接
  • 第二次丢失:服务端在收到客户端的报文之后会回复 SYN + ACK 报文。如果第二次握手丢失了客户端会认为自己丢包了, 触发超时重传,重新发送 SYN 报文,服务端因为收不到确认的 ACK 自身也会重传
  • 第三次丢失:客户端收到服务端的 SYN-ACK 报文后会给服务端回一个 ACK 报文,此时客户端状态进入到 ESTABLISH 状态。如果发生了丢包,服务端收不到 ACK 会触发超时重传机制,重传 SYN-ACK 报文,直到收到确认 ACK 或者达到最大重传次数

TCP 为什么不是两次握手?

最好再加上 如果 TCP 的三次握手丢失会发生什么?

  • 避免历史连接(首要原因):如果使用的是两次握手建立连接,可能客户端发送的第一个请求连接并且没有丢失,只是因为在网络中滞留的时间太长了,由于 TCP 的客户端迟迟没有收到确认报文,以为服务器没有收到,此时重新向服务器发送这条报文,此后客户端和服务器经过两次握手完成连接,传输数据,然后关闭连接。之前滞留的那一次请求连接,因为网络通畅了,到达了服务器,这个报文本该是失效的,但是,两次握手的机制将会让客户端和服务器再次建立连接,这将导致不必要的错误和资源的浪费
  • 同步双方初始序列号:为了实现可靠数据传输,TCP 协议的通信双方,都必须维护一个序列号,以标识发送出去的数据包中,哪些是已经被对方收到的。 三次握手的过程即是通信双方相互告知序列号起始值,并确认对方已经收到了序列号起始值的必经步骤。如果只是两次握手,至多只有连接发起方的起始序列号能被确认,另一方选择的序列号则得不到确认

TCP 为什么不是四次握手?

尽管四次握手其实也能够可靠的同步双方的初始化序号,但由于 第二次和第三次可以合并,优化结果为三次握手。三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。

为什么每次建立 TCP 连接时,初始化的序列号都要求不一样?

  • 为了防止历史报文被下一个相同四元组的连接接收(主要方面):如果每次建立连接客户端和服务端的初始化序列号都是一样的,容易出现历史报文被下一个相同四元组的连接接收的问题
  • 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收

初始序列号 ISN 是如何随机产生的?

起始 ISN 是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。

  • M 是一个计时器,这个计时器每隔 4 微秒加 1
  • F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择

随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。

TCP 的四次挥手

TCP 的四次挥手

  • 第一次挥手:客户端发送释放报文,并停止发送数据,将首部的 FIN 标识位置为 1,序列号 seq = u 发送给服务器,值等于前面已经传送过来的数据的最后一个字节的序号加 1,此时客户端进入 FIN_WAIT_1 状态。即便 FIN 报文不携带数据,也要消耗一个序列号
  • 第二次挥手:服务器在收到释放报文后,发送确认报文,ACK 标识位置为 1,ack 值为客户端发送的序列号 u + 1,并带上自己的序列号 v,然后服务器进入 CLOSE_WAIT 关闭等待状态。这时服务器 TCP 通知高级应用进程:客户端向服务器的连接释放了,进入半关闭状态。但是服务器如果向客户端发送数据,客户端仍然可以接收,这个状态要持续一段时间,也就是 CLOSE_WAIT 关闭等待持续的时间。客户端收到服务器的确认请求后,进入 FIN_WAIT_2 状态,等待服务器发送释放报文
  • 第三次挥手:服务器数据处理完毕后向客户端发送释放连接报文,FIN 标识位置为 1,ack 的值为客户端的序列号 u + 1。由于在半关闭状态,服务器很可能又发送一些数据,假定此时序列号为 w,服务器进入 LAST_ACK 状态,等待客户端确认
  • 第四次挥手:客户端在收到服务器的释放连接报文后,会发送确认报文,ACK 标识位置为 1,ack 值为服务器发送的序列号 w + 1,自己的序列号是 u + 1,然后客户端就进入 TIME_WAIT 状态。此时 TCP 连接还没有释放,必须经过 两个 MSL 时间 (一个 MSL 指的是报文段最长寿命),当客户端撤销 TCB,才进入 CLOSED 状态。服务器只要收到客户端发送的确认请求,立即进入 CLOSED 状态。同时会撤销 TCB,TCP 连接至此结束

TCP 为什么要四次挥手?

关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。

服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。

如何在 Linux 系统中查看 TCP 状态

Bash
netstat -napt

在 FIN_WAIT_2 状态下,是如何处理收到的乱序到 FIN 报文,然后 TCP 连接又是什么时候才进入到 TIME_WAIT 状态?

FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文会加入到乱序队列,并不会进入到 TIME_WAIT 状态。等再次收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,才会进入 TIME_WAIT 状态

如果 TCP 的四次挥手丢失会发生什么?

  • 第一次丢失:客户端发送的报文 FIN 报文收不到服务端的 ACK 响应,会触发超时重传,重传 FIN 报文,重发次数由内核参数控制
  • 第二次丢失:服务端回复的 ACK 报文发生丢失,客户端会触发超时重传,重传 FIN 报文,直到收到服务端的 ACK 或者达到最大的重传次数。超过最大重传次数还没收到 ACK 会等待一段时间,再断开连接
  • 第三次丢失:服务端收到客户端的 FIN 报文后内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态。服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。收不到 ACK 的话会重发 FIN 报文直到最大次数为止
  • 第四次丢失:最后一次的 ACK 发生了丢失,服务端没有收到 ACK 报文前是处于 LAST_ACK 状态。超时之后服务端会重传 FIN 报文,客户端此时是在 TIME_WAIT 状态,开启时长为 2MSL 的定时器,如果途中再次收到第三次挥手(FIN 报文)后,会重置定时器,当等待 2MSL 时长后,客户端会断开连接

TCP 为什么不是三次挥手?

当被动关闭方在 TCP 挥手过程中,没有数据要发送并且开启了延迟应答,第二和第三次挥手就会合并传输,这样就出现了三次挥手。

TCP 的状态转换图,供随时查阅

TCP状态转换图

TCP 的延迟应答和累计应答?

  • 延迟应答:TCP 在接收到对端的报文后并不会立即发送 ACK,而是等待一段时间发送 ACK,以便将 ACK 和要发送的数据一块发送。延迟时间不能无限延长,否则对方端会认为丢包超时而造成超时重传。Linux 采用动态调节算法来确定等待的时间
  • 累计应答:为了保证顺序性,每一个包都有一个 ID(序号),在建立连接的时候,双方会商定起始的 ID 是多少,然后按照 ID 一个个发送。为了保证不丢包,对应发送的包都要进行应答,但不是一个个应答,而是会应答 某个之前的 ID,该模式称为 累计应答

TCP 的 MSL

MSL 是任何报文在网络中被丢弃前的最长存活时间,这个时间是有限的,因为 TCP 是以 IP 数据报的形式在网络中传输,IP 有限制其生存的时间 TTL,RFC793 指出 MSL 为 2 分钟,现实中常用 30 秒1 分钟

已经建立了连接,客户端突然出现故障了会怎样?

TCP 存在保活计时器,如果客户端故障,服务器不会一直等待。通常计时器设置为两小时,在每次收到客户端发来的报文都会重置计时器,超时之后服务端就会向客户端发送探测报文,每隔 75s 发送一次,如果连续 10 个探测报文都没有收到回复,服务器会认为客户端发生故障,中断此次连接

已经建立了连接,但是服务端的进程崩溃会发生什么?

TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后内核需要回收该进程的所有 TCP 连接资源。内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与。所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。

什么时候用长连接,短连接?

长连接多用于 操作频繁,点对点的通讯,而且连接数不能太多 的情况。每个 TCP 连接都需要三步握手,这需要时间。如果每个操作都是先连接,再操作的话那么处理速度会降低很多。所以每个操作完后都不断开,下次处理时直接发送数据包就可以,不用建立 TCP 连接。例如: 数据库的连接用长连接

WEB 网站的 HTTP 服务一般都用短连接,因为长连接对于服务端来说会耗费一定的资源,而 WEB 网站成千上万客户端的连接用短连接会更省一些资源,如果用长连接,而且同时有成千上万的用户,如果每个用户都占用一个连接的话,所以并发量大,用短连接可以快速释放资源。

TCP 的半连接队列和全连接队列?

  • 半连接队列:也称 SYN 队列,服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端发 SYN + ACK
  • 全连接队列:也称 accept 队列,服务端收到第三次握手的 ACK 后, 内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到全连接队列,等待进程调用 accept 函数时把连接取出来

什么是 SYN 攻击?如何避免?

概念

SYN 攻击 是指利用合理的服务请求来占用过多的服务资源从而使合法用户无法得到服务的响应。如果向某个服务器端口发送大量的 SYN 报文,接收到客户端发来的 SYN 报文之后服务端就需要为每个请求分配一个进程控制块 TCB 并返回一个 SYN-ACK 报文,立即 转为 SYN_RECV 半开连接状态。收不到对端 ACK 回复的服务端还会重传 SYN-ACK 报文,系统会为此耗尽资源

避免方法

  • Cache:系统在收到一个 SYN 报文时,在一个专用 HASH 表中保存这种半连接信息,直到收到正确的回应 ACK 报文再分配 TCB。这个开销远小于 TCB 的开销
  • Cookie:利用算法,通过对方的 IP、端口、己方 IP、端口的固定信息,以及对方无法知道而己方比较固定的一些信息,如 MSS(最大报文段大小)、时间等,在收到对方的 ACK 报文后,重新计算一遍,看其是否与对方回应报文中的 (Sequence Number - 1) 相同,从而决定是否分配 TCB 资源
  • Proxy 防火墙:设立中间层防火墙,防火墙在确认了连接的有效性后,才向内部的服务器发起 SYN 请求,所有的无效连接均无法到达内部的服务器。而防火墙采用的验证连接有效性的方法则可以是 CookieCache 等其他技术
  • 减少 SYN+ACK 重传次数:减少 SYN-ACK 的重传次数,以加快处于 SYN_RECV 状态的 TCP 连接断开
  • 无效连接监视释放:不停监视系统的半开连接和不活动连接,当达到一定阈值时拆除这些连接,从而释放系统资源。这种方法对于所有的连接一视同仁,正常连接请求也会被这种方式误释放掉
  • 增大半连接队列:修改 TCP 的内核参数,增大半连接队列以及全连接队列大小
  • 调大 netdev_max_backlog:当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包,可以调大队列大小

TIME_WAIT 作用,过多如何解决?

作用

  • 实现全双工的可靠释放连接:假设发起主动关闭的一方最后发送的 ACK 在网络中丢失,由于 TCP 的重传机制,被动关闭的一方会重新发送 FIN 报文,在 FIN 在被主动关闭方接收之前,主动关闭方都需要维护这条连接状态,包括对应的 IP 地址和端口号。如果发送方不维护 TIME_WAIT 状态,那么当 FIN 到达主动关闭方的时候,主动关闭方会发送 RST 包来响应,被动关闭方就会认为有错误发生
  • 为使旧的数据包在网络因过期而消失:如果不存在 TIME_WAIT 状态,当前的一个 TCP 四元组因为某些原因关闭之后,假设有一个新的相同的四元组建立了 TCP 连接,因为 TCP 连接是由四元组唯一标识的,所以没法区分新旧连接。旧的已经关闭的 TCP 连接发送的数据到达接受方之后,会被当作正常数据而向上传输,从而导致数据错乱。有了 TIME_WAIT 状态之后,可以使旧 TCP 产生的数据包全部在网络中消亡

危害

  • 占用服务器系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等
  • 占用客户端端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过 net.ipv4.ip_local_port_rang 参数指定范围

注意:服务器一般只监听一个端口,端口资源不会占用过多

避免方法

  • 修改短连接为长连接
  • 扩大可使用端口号的范围
  • 客户端机器打开 tcp_tw_reusetcp_timestamps 选项:tcp_tw_reuse 调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。复用连接之后需要更新 timestamps 参数,当旧的 TCP 数据包到达时,根据时间戳判断是旧连接的数据可以舍弃
  • 客户端机器打开 tcp_tw_recycletcp_timestamps 选项:当开启之后内核会快速回收 TIME_WAIT 状态的连接,时间是一个 RTO,远小于两个 MSL 。在启用该配置,当连接进入 TIME_WAIT 状态后,内核里会记录包括该连接对应五元组的一些统计数据,包括从该对方 IP 所接收到的最近的一次数据包时间。当有新的数据包到达,只要时间晚于内核记录的这个时间,数据包都会被统统的丢掉
  • 缩小 net.ipv4.tcp_max_tw_buckets:当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置
  • 程序中使用 SO_LINGER:调用 close 后,会立刻发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭

出现大量 TIME_WAIT 状态的原因有哪些?

TIME_WAIT 状态是 主动关闭连接方才会出现 的状态,所以如果服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接。

服务器主动断开 TCP 连接的场景有:

  • HTTP 没有使用长连接:虽然 RFC 文档没有规定短连接的情况下由哪一方关闭连接,但大多数场景下都是由服务器负责关闭连接。当客户端禁用了 HTTP Keep-Alive 时 HTTP 请求的 header 就会有 Connection:close 信息,这时服务端在发完 HTTP 响应后,就会主动关闭连接。TCP Keep-Alive 的初衷是 为客户端后续的请求重用连接,如果我们 在某次 HTTP 请求 - 响应模型中请求的 header 定义了 connection:close 信息,那不再重用这个连接的时机就只有在服务端,所以在 HTTP 请求 - 响应这个周期的「末端」关闭连接是合理的
  • HTTP 长连接超时:为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,如果客户端在完后一个 HTTP 请求后 60 秒内都没有再发起新的请求,定时器的时间一到 nginx 就会触发回调函数来关闭该连接,此时服务端上就会出现 TIME_WAIT 状态的连接
  • HTTP 长连接的请求数量达到上限:对于一些 QPS 比较高的场景,如果 keepalive_requests 参数值较低,nginx 就会频繁地关闭连接,此时服务端上会出现大量的 TIME_WAIT 状态

出现大量 CLOSE_WAIT 状态的原因有哪些?

当服务端出现大量 CLOSE_WAIT 状态的连接时说明服务端的程序没有调用 close 函数关闭连接。先分析一个普通的 TCP 服务端的流程:

  1. 创建服务端 socket,bind 绑定端口、listen 监听端口
  2. 将服务端 socket 注册到 epoll
  3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
  4. 将已连接的 socket 注册到 epoll
  5. epoll_wait 等待事件发生
  6. 对方连接关闭时,我方调用 close

可能导致服务端没有调用 close 函数的原因如下:

  • 第一个原因:第 2 步没有做,没有将服务端 socket 注册到 epoll。有新连接到来时服务端无法感知事件,也就无法获取到已连接的 socket,服务端自然没机会对 socket 调用 close 函数。这种原因发生的概率比较小,属于明显的代码逻辑 bug,在前期 read view 阶段就能发现
  • 第二个原因:第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。发生这种情况可能是因为服务端在执行 accpet 函数之前代码卡在某一个逻辑或者提前抛出了异常
  • 第三个原因:第 4 步没有做,通过 accpet 获取已连接的 socket 后没有将其注册到 epoll,导致后续收到 FIN 报文时服务端无法感知事件,服务端没机会调用 close 函数。发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常

tcp_tw_reuse 为什么默认是关闭的?

  • 历史 RST 报文可能会终止后面相同四元组的连接,因为 PAWS 检查到即使 RST 是过期的,也不会丢弃
  • 如果第四次挥手的 ACK 报文丢失了,有可能被动关闭连接的一方不能被正常的关闭

PAWS 的保护机制

  • 序列号回绕:​当序列号在高数据传输速率下回绕时,旧的数据包可能被误认为新的。PAWS 通过时间戳机制,确保只有时间上更新的数据包被接受
  • 连接重启后的旧数据包:​在连接关闭后,若同一对端口重新建立连接,之前的旧数据包可能会被误认为新的。PAWS 通过时间戳机制,确保只有在当前连接中接收到的最新数据包被接受

潜在问题:

  • NAT(网络地址转换)设备的时间戳问题:​某些 NAT 设备可能会修改 TCP 数据包中的时间戳,导致 PAWS 机制错误地丢弃合法的数据包
  • 时间戳同步问题:​如果连接双方的系统时间不同步,可能导致 PAWS 机制误判数据包的有效性

TIME_WAIT 状态为什么需要经过 2MSL

因为客户端最后一个发送的 ACK 有可能丢失。假如服务器没有收到客户端发送的最后一个 ACK,就会重新发送 FIN 报文,为了确保服务器收到了 FIN 报文,客户端在 TIME_WAIT 状态需要经过 2MSL,在这个期间客户端收到重发的 FIN 报文就会重新发送 ACK 并且重设计时器。MSL 指一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间。第一个 MSL 是保证最后一次挥手客户端响应服务端的 ACK 到达了服务端。第二个 MSL 是保证服务端没有重发新的报文给客户端,没有超时重传。

如果客户端直接关闭,然后向服务器建立新连接,而新连接和老连接的端口是一样的,假设老连接还有一些数据,因为网络或者其他原因,一直滞留没有发送成功,新连接建立后,就直接发送到新连接里面去了,造成数据的紊乱。因此,需要等到 2MSL,让滞留在网络中的报文失效,再去建立新的连接。

2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。

CLOSE_WAIT 状态过多如何解决?

如果一直保持在 CLOSE_WAIT 状态,原因是在对方关闭连接之后服务器程序自己没有进一步发出 ACK 信号。

CLOSE_WAIT 的解决办法是:查代码。因为问题出在服务器程序

TCP 和 UDP 的区别?

  • 连接:TCP 是面向连接的传输层协议,传输数据前先要建立连接;UDP 是不需要连接,即刻传输数据
  • 服务对象:TCP 是一对一的两点服务,即一条连接只有两个端点;UDP 支持一对一、一对多、多对多的交互通信
  • 可靠性:TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达;UDP 是尽最大努力交付,不保证可靠交付数据
  • 拥塞控制、流量控制:TCP 有拥塞控制和流量控制机制,保证数据传输的安全性;UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率
  • 首部开销:TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的;UDP 首部只有 8 个字节,并且是固定不变的
  • 传输方式:TCP 是流式传输,没有边界,但保证顺序和可靠;UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序
  • 分片不同:TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片;UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,接着再传给传输层

应用场景

  • TCP 用于 FTP 文件传输、HTTP / HTTPs
  • UDP 用于包总量较少的通信,如 DNS、SNMP 等、视频、音频等多媒体通信、广播通信

UDP 的头部结构

UDP的头部结构

  • 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程
  • 长度:该字段保存了 UDP 首部的长度跟数据的长度之和
  • 检验和:校验和是为了提供可靠的 UDP 首部和数据而设计,防止收到在网络传输中受损的 UDP 包

为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段?

原因是 TCP 有**可变长**的「选项」字段,而 UDP 头部长度则是**不会变化**的,无需多一个字段去记录 UDP 的首部长度。

为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段?

TCP 的数据长度可以通过 IP 总长度、IP 首部长度、TCP 首部长度计算得出。而 UDP 的「包长度」字段可能是为了:

  • 为了网络设备硬件设计和处理方便,首部长度需要是 4 字节的整数倍
  • 如今的 UDP 协议是基于 IP 协议发展的,而当年可能并非如此,依赖的可能是别的不提供自身报文长度或首部长度的网络层协议

TCP 和 UDP 可以使用同一个端口吗?

可以

在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。

传输层的两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。

粘包和拆包问题的解决办法?

概念

  • TCP 的特点之一就是面向字节流的,也就是说传输时候数据像“水流一样”,是没有边界的,因此 拆包这个功能本身就不在 TCP 来完成
  • 所谓的粘包拆包就是 TCP 流的特性导致的,而且根本不能说是问题,拆包本身就应该在应用层来完成

解决办法

  • 遇到这个面试题,作者个人认为是面试官基础不扎实才会问出来,就直接怼它

TCP 的 keepalive 和 HTTP 的 keepalive 的区别?

  • HTTP 的 Keep-Alive 是由应用层(用户态)实现的,称为 HTTP 长连接; TCP 的 Keepalive,是由 TCP 层(内核态)实现的,称为 TCP 保活机制
  • HTTP Keep-Alive 是指使用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,好处是避免了连接建立和释放的开销,只要任意一端没有明确提出断开连接,就保持 TCP 连接状态; TCP Keepalive 是指建立 TCP 连接的两端一直没有数据交互达到触发 TCP 保活机制的条件,内核里的 TCP 协议栈就会发送探测报文,如果对端程序正常工作,收到探测报文之后就会回复响应,同时保活时间重置,如果对端主机崩溃没有响应或者网络原因报文不可达,连续几次探测报文之后 TCP 会报告该 TCP 连接已经死亡
  • web 服务软件一般都会提供 keepalive_timeout 参数来指定 HTTP 长连接的超时时间:例如设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完成一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接

IP 层会分片,为什么 TCP 层还需要 MSS 呢?

  • MTU:一个网络包的最大长度,以太网中一般为 1500 字节
  • MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度

如果交给 IP 来进行分片,一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。当某一个 IP 分片丢失后,接收方的 IP 层就无法组装成一个完整的 TCP 报文(头部 + 数据),也就无法将数据报文送到 TCP 层,所以接收方不会响应 ACK 给发送方,因为发送方迟迟收不到 ACK 确认报文,所以会触发超时重传,就会重发整个 TCP 报文(头部 + 数据)。

针对 TCP 应该如何 Socket 编程?

  • 服务端和客户端初始化 socket,得到文件描述符
  • 服务端调用 bind,将 socket 绑定在指定的 IP 地址和端口
  • 服务端调用 listen,进行监听
  • 服务端调用 accept,等待客户端连接
  • 客户端调用 connect,向服务端的地址和端口发起连接请求
  • 服务端 accept 返回用于传输的 socket 的文件描述符
  • 客户端调用 write 写入数据;服务端调用 read 读取数据
  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭

服务端没有 listen,客户端发起连接建立,会发生什么?

如果服务端只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。

原因:Linux 内核处理收到 TCP 报文的入口函数是 tcp_v4_rcv,在收到 TCP 报文后,会调用 __inet_lookup_skb 函数找到 TCP 报文所属 socket。这个函数首先查找连接建立状态的 socket,在没有命中的情况下(TCP 还没有确立连接,也就是现在这种情况)才会查找监听套接口。如果服务端没有调用 listen,就找不到监听该端口的 socket,最终找不到对应的 socket,内核就会调用 tcp_v4_send_reset 发送 RST 中止这个连接。

没有 accept,能建立 TCP 连接吗?

可以。建立连接的过程中根本不需要 accept() 参与,执行 accept() 只是为了从全连接队列里取出一条连接。

三次握手的流程:

  1. 服务端执行 listen 方法后,内核会为每一个处于 LISTEN 状态的 socket 分配两个队列,分别叫**半连接队列和全连接队列**
  2. 服务端收到**第一次握手**后,会将 sock 加入到半连接队列中,队列内的 sock 都处于 SYN_RECV 状态
  3. 在服务端收到**第三次握手**后,会将半连接队列的 sock 取出,放到全连接队列中。队列里的 sock 都处于 ESTABLISHED 状态。这里面的连接,就**等着服务端执行 accept() 后被取出了**

所以,即使服务端不执行 accept() 方法,三次握手照常进行,并顺利建立连接。在服务端执行 accept() 前,如果客户端发送消息给服务端,服务端是能够正常回复 ack 确认包的。

用了 TCP 协议,数据一定不会丢吗?

不一定。TCP 是一个可靠的传输协议,但并不意味着数据一定不会丢。整条链路下来,有不少地方可能会发生丢包:

建立连接时丢包

TCP 通过三次握手建立连接。如果服务端的半连接队列或全连接队列满了,新来的包就会被丢弃,导致连接建立失败。

流量控制丢包

应用层能发网络数据包的软件有那么多,如果所有数据不加控制一股脑冲入到网卡,网卡会吃不消。因此操作系统会使用**qdisc**(**Q**ueueing **Disc**iplines,排队规则)进行流量控制。当发送数据过快,流控队列长度不够大时,就容易出现**丢包**现象。

网卡丢包

在接收数据时,会将数据暂存到 RingBuffer 接收缓冲区中。如果这个**缓冲区过小**,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生**丢包**。

接收缓冲区丢包

当接收缓冲区满了,新来的数据包就会被丢弃。可以通过 netstat -i 命令查看网卡的丢包情况。

因此,虽然 TCP 有重传机制,但在某些情况下,数据包仍然可能会丢失。TCP 的可靠性保证是在网络层面尽可能保证数据不丢失,但不能保证在所有情况下都 100% 不丢失。

listen 时候参数 backlog 的意义?

listen() 函数是网络编程中用来使服务器端开始监听端口的系统调用,其定义为:

C
int listen(int socket, int backlog)

当一个应用使用 listen 系统调用让 socket 进入 LISTEN 状态时,它需要为该套接字指定一个 backlogbacklog 通常被描述为连接队列的限制。由于 TCP 使用 3 次握手,连接在到达 ESTABLISHED 状态之前经历中间状态 SYN RCVD,并且可以由 accept 系统调用返回到应用程序。这意味着 TCP/IP 堆栈有两个选择来为 LISTEN 状态的套接字实现 backlog 队列:

  1. 使用单个队列实现:其大小由 listen syscallbacklog 参数确定。 当收到 SYN 数据包时,它发送回 SYN/ACK 数据包,并将连接添加到队列。 当接收到相应的 ACK 时,连接将其状态改变为已建立。 这意味着队列可以包含两种不同状态的连接:SYN RCVDESTABLISHED。 只有处于后一状态的连接才能通过 accept syscall 返回给应用程序
  2. 使用两个队列实现:一个 SYN 队列(或半连接队列)和一个 accept 队列(或完整的连接队列)。 处于 SYN RCVD 显而易见,accept 系统调用只是简单地从完成队列中取出连接。 在这种情况下,listen syscallbacklog 参数表示完成队列的大小

历史上,BSD 派生系统实现的 TCP 使用第一种方法。该选择意味着当达到最大 backlog 时,系统将不再响应于 SYN 分组发送回 SYN/ACK 分组。通常,TCP 的实现将简单地丢弃 SYN 分组,使得客户端重试。

在 Linux 上是和上面不同的。如在 listen 系统调用的手册中所提到的:

Note

在 Linux 内核 2.2 之后,socket backlog 参数的行为改变了,现在它指等待 accept 的完全建立的套接字的队列长度而不是不完全连接请求的数量。不完全连接的长度可以使用 /proc/sys/net/ipv4/tcp_max_syn_backlog 设置。

这意味着当前 Linux 版本使用上面第二种说法,有两个队列:具有由系统范围设置指定的大小的 SYN 队列应用程序(也就是 backlog 参数)指定的 accept 队列

accept 发生在三次握手的哪一步?

服务端 accept 成功返回是在**三次握手成功之后**

客户端调用 close 后连接断开的流程?

  • 客户端调用 close 表明客户端没有数据需要发送了,此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态
  • 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被 放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态
  • 接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态
  • 客户端经过 2MSL 时间之后,也进入 CLOSE 状态

评论