TCP协议

2022/1/27

# 拥塞控制

为了防止网络的拥塞现象,TCP 提出了一系列的拥塞控制机制。

TCP 的拥塞控制主要原理依赖于一个拥塞窗口(cwnd)来控制,TCP 还有一个对端通告的接收窗口(rwnd)用于流量控制。窗口值的大小就代表能够发送出去的但还没有收到 ACK 的最大数据报文段,显然窗口越大那么数据发送的速度也就越快,但是也有越可能使得网络出现拥塞,如果窗口值为 1,那么就简化为一个停等协议,每发送一个数据,都要等到对方的确认才能发送第二个数据包,显然数据传输效率低下。TCP 的拥塞控制算法就是要在这两者之间权衡,选取最好的 cwnd 值,从而使得网络吞吐量最大化且不产生拥塞。

由于需要考虑拥塞控制和流量控制两个方面的内容,因此 TCP 的真正的发送窗口=min(rwnd, cwnd)。但是 rwnd 是由对端确定的,网络环境对其没有影响,所以在考虑拥塞的时候我们一般不考虑 rwnd 的值,我们暂时只讨论如何确定 cwnd 值的大小。

# 慢启动(Slow Start)

最初的 TCP 在连接建立成功后会向网络中发送大量的数据包,这样很容易导致网络中路由器缓存空间耗尽,从而发生拥塞。

因此新建立的连接不能够一开始就大量发送数据包,而只能根据网络情况逐步增加每次发送的数据量,以避免上述现象的发生。

具体来说,当新建连接时,cwnd 初始化为 1 个最大报文段(MSS)大小,发送端开始按照拥塞窗口大小发送数据,每当有一个报文段被确认,cwnd 就增加 1 个 MSS 大小。这样 cwnd 的值就随着网络往返时间(Round Trip Time,RTT)呈指数级增长,事实上,慢启动的速度一点也不慢,只是它的起点比较低一点而已。

# 拥塞避免(Congestion Avoidance)

从慢启动可以看到,cwnd 可以很快的增长上来,从而最大程度利用网络带宽资源,但是 cwnd 不能一直这样无限增长下去,一定需要某个限制。

TCP 使用了一个叫慢启动门限(ssthresh)的变量,当 cwnd 超过该值后,慢启动过程结束,进入拥塞避免阶段。

对于大多数 TCP 实现来说,ssthresh 的值是 65536(同样以字节计算)。拥塞避免的主要思想是加法增大,也就是 cwnd 的值不再指数级往上升,开始加法增加。此时当窗口中所有的报文段都被确认时,cwnd 的大小加 1,cwnd 的值就随着 RTT 开始线性增加,这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。

TCP 是如何确定网络进入了拥塞状态的--TCP 认为网络拥塞的主要依据是它重传了一个报文段。发生重传时:

  1. 把 ssthresh 降低为 cwnd 值的一半
  2. 把 cwnd 重新设置为 1
  3. 重新进入慢启动过程。

从整体上来讲,TCP 拥塞控制窗口变化的原则是 AIMD 原则,即加法增大、乘法减小。可以看出 TCP 的该原则可以较好地保证流之间的公平性,因为一旦出现丢包,那么立即减半退避,可以给其他新建的流留有足够的空间,从而保证整个的公平性。

# 快速重传(Fast Retransmit)

收到 3 个相同的 ACK 也会进行重传。TCP 在收到乱序到达包时就会立即发送 ACK,TCP 利用 3 个相同的 ACK 来判定数据包的丢失,此时进行快速重传,快速重传做的事情有:

  1. 把 ssthresh 设置为 cwnd 的一半
  2. 把 cwnd 再设置为 ssthresh 的值(具体实现有些为 ssthresh+3)
  3. 重新进入拥塞避免阶段。

# 快速恢复(Fast Recovery)

“快速恢复”算法是在上述的“快速重传”算法后添加的,当收到 3 个重复 ACK 时,TCP 最后进入的不是拥塞避免阶段,而是快速恢复阶段。快速重传和快速恢复算法一般同时使用。

快速恢复的思想是“数据包守恒”原则,即同一个时刻在网络中的数据包数量是恒定的,只有当“老”数据包离开了网络后,才能向网络中发送一个“新”的数据包,如果发送方收到一个重复的 ACK,那么根据 TCP 的 ACK 机制就表明有一个数据包离开了网络,于是 cwnd 加 1。如果能够严格按照该原则那么网络中很少会发生拥塞,事实上拥塞控制的目的也就在修正违反该原则的地方。

主要步骤是:

  1. 当收到 3 个重复 ACK 时,把 ssthresh 设置为 cwnd 的一半,把 cwnd 设置为 ssthresh 的值加 3,然后重传丢失的报文段,加 3 的原因是因为收到 3 个重复的 ACK,表明有 3 个“老”的数据包离开了网络。
  2. 再收到重复的 ACK 时,拥塞窗口增加 1。
  3. 当收到新的数据包的 ACK 时,把 cwnd 设置为第一步中的 ssthresh 的值。原因是因为该 ACK 确认了新的数据,说明从重复 ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态。

# 快速恢复算法改进版

可以看出 Reno 的快速重传算法是针对一个包的重传情况的,然而在实际中,一个重传超时可能导致许多的数据包的重传,因此当多个数据包从一个数据窗口中丢失时并且触发快速重传和快速恢复算法时,问题就产生了。

因此 NewReno 出现了,它在 Reno 快速恢复的基础上稍加了修改,可以恢复一个窗口内多个包丢失的情况。具体来讲就是:Reno 在收到一个新的数据的 ACK 时就退出了快速恢复状态了,而 NewReno 需要收到该窗口内所有数据包的确认后才会退出快速恢复状态,从而进一步提高吞吐量。

# 选择性应答(Selective Acknowledgement, SACK)

SACK 就是改变 TCP 的确认机制,最初的 TCP 只确认当前已连续收到的数据,SACK 则把乱序等信息会全部告诉对方,从而减少数据发送方重传的盲目性。

比如说序号 1,2,3,5,7 的数据收到了,那么普通的 ACK 只会确认序列号 4,而 SACK 会把当前的 5,7 已经收到的信息在 SACK 选项里面告知对端,从而提高性能,当使用 SACK 的时候,NewReno 算法可以不使用,因为 SACK 本身携带的信息就可以使得发送方有足够的信息来知道需要重传哪些包,而不需要重传哪些包。

# TCP 和 UDP

# TCP 和 UDP 可以同时绑定相同的端口吗?

TCP 和 UDP 可以同时绑定相同的端口吗?

可以的。

TCP 和 UDP 传输协议,在内核中是由两个完全独立的软件模块实现的。

当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。

因此, TCP/UDP 各自的端口号也相互独立,互不影响。

# 多个 TCP 服务进程可以同时绑定同一个端口吗?

如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。

如果两个 TCP 服务进程绑定的端口都相同,而 IP 地址不同,那么执行 bind() 不会出错。

# 如何解决服务端重启时,报错“Address already in use”的问题?

当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。

当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。

要解决这个问题,我们可以对 socket 设置 SO_REUSEADDR 属性。

这样即使存在一个和绑定 IP+PORT 一样的 TIME_WAIT 状态的连接,依然可以正常绑定成功,因此可以正常重启成功。

# 客户端的端口可以重复使用吗?

在客户端执行 connect 函数的时候,只要客户端连接的服务器不是同一个,内核允许端口重复使用。

TCP 连接是由四元组(源 IP 地址,源端口,目的 IP 地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。

所以,如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。

# 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗?

要看客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。

如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。即使在这种状态下,还是可以与其他服务器建立连接的,只要客户端连接的服务器不是同一个,那么端口是重复使用的。

# 如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题?

打开 net.ipv4.tcp_tw_reuse 这个内核参数。

因为开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态。

如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。

# 服务端挂掉时后续会怎样?还会进行四次挥手吗?

  1. 如果「服务端挂掉」指的是「服务端进程崩溃」,那么服务端进程在发生崩溃时,内核会发送 FIN 报文,与客户端进行四次挥手。
  2. 如果「服务端挂掉」指的是「服务端主机宕机」,那么是不会发生四次挥手的,具体发生什么要看客户端会不会继续发送数据:
    1. 如果客户端会发送数据,由于服务端已经不存在,客户端的数据报文会超时重传,当重传总间隔时长达到一定的阈值(内核会根据 tcp_retries2 设置的值计算出一个阈值)后,会断开 TCP 连接;
    2. 如果客户端一直不会发送数据,再看客户端有没有开启 TCP Keepalive 机制:
      1. 如果开启了,客户端在一段时间没有进行数据交互时,会触发 TCP Keepalive 机制,探测对方是否存在,如果探测到对方已经消亡,则会断开自身的 TCP 连接;
      2. 如果没有开启,客户端的 TCP 连接会一直存在,并且一直保持在 ESTABLISHED 状态。

# UDP 一定比 TCP 快吗?

显然不一定。举例:

传输特大数据包时,在传输层 TCP 内部会根据 MSS 的大小进行分段,这时候数据进入到 IP 层时每个包的大小都不会超过 MTU,因此 IP 层一般不会再次进行分片,这时如果发生丢包,只需要重传每个 MSS 分段就够了。但是对于 UDP,其本身并不会分段,如果数据过大,到了 IP 层就会进行分片,此时发生丢包的话,再次重传就会重传整个大数据包,这时使用 UDP 就比 TCP 慢。比如:早期基于 UDP 的 NFS 协议就有这个问题。

上次更新: 5/31/2024