RUDP 是 Reliable UDP 的简称,指可靠 UDP 协议。它通过一些额外的操作,为 UDP 协议提供了可靠性。本篇会来尝试比较一下 RUDP 和 TCP 的区别。
引子
UDP 和 TCP 是传输层最常用的两个协议。UDP 是个不可靠的协议,它不保证数据交付,而 TCP 是可靠的,它可以保证接收方接收的字节流与发送方发送的字节流完全相同。
那么就有了一个问题,既然已经有 TCP 提供可靠服务了,直接用不可以吗?为什么还要魔改 UDP 让它来提供可靠服务呢?
要回答这个问题,首先要了解一下 TCP 的弱点是什么。
TCP 的弱点
TCP 之所以可以保证可靠,是因为它使用了包括数据校验和,为报文段增加序号,增加接收方反馈,超时重传,快速重传,在双端增加缓冲区等机制。
当网络畅通无阻时,这些机制都可以非常有效的运行,也就是说如果网络环境良好时,没必要使用 RUDP 了,直接使用 TCP 就是最好的选择。
但是实际中的网络环境千差万别,尤其是现在移动互联网盛行,用户可能是在地铁,电梯等信号不太强的地方使用 4G 等方式上网的。这时候如果继续使用 TCP 的话,在一些对网络延迟要求比较高的场景中体验可能就会很差。
来看一些 TCP 的各种机制存在的问题。
三次握手
众所周知,TCP 在建立连接的时候需要三次握手才行,三次握手一共需要 1.5 RTT 才能完成。如果是在 HTTPS 上的话,则三次握手以后还要再次进行 TLS 握手,又需要 1.5 RTT 才行。
虽然对于长连接的服务来说,握手的消耗完全可以接受,但是对于短链接服务来说,这就是一个比较可观的优化点了。
以 Web 开发已经大量使用的 QUIC 协议来说,它可以合并连接握手和加密握手,最终在 1.5 RTT 内实现连接建立,甚至可以通过对服务器的缓存,实现 0 RTT 建立连接。
四次挥手
因为 TCP 全双工的特点,且报文段都需要 ACK 来确认。所以 TCP 的断开连接比握手更麻烦一点,要 A 先发送断开报文,B 确认断开报文,B 再发送断开报文,A 确认断开报文,才能完成整条连接的关闭。
因为报文又涉及到重传之类的问题,所以还需要设计出一个 TIME_WAIT 状态,等待 2 MSL 来确认对方收到了自己对它的断开报文的确认。
QUIC 对这个过程也进行了不小的简化,比如把关闭改为了单工的,一方发起关闭即代表了整条连接的关闭,同时也简化了连接的状态机,不再需要 TIME_WAIT 状态。
RTO 倍率
TCP 会为拥有最小还未被确认序号(SendBase)的报文段设置一个重传定时器,如果超时了,会将它重传,并且会再次为它设置定时器。如果收到了确认报文,则会取消当前的定时器,为下一个待确认报文创建新的定时器。
重传超时的时间被称为 RTO(Retransmission Timeout),它在根据 RTT(Round Trip Time)通过一定的算法计算得来的。在 TCP 的实现中,第一次设置的定时器超时时间为 RTO,如果触发超时以后,下一次为本报文段设置的定时器超时时间会变为 2RTO,如果再次触发,则下一次会是 4RTO,每次会比上一次翻倍。
之所以这样实现,是因为 TCP 考虑到丢包很可能是因为发送方和接收方之间的一台或多台路由器上有太多数据包来不及处理,如果在短时间内继续重传,不仅依然收不到,而且可能会导致拥堵问题更加严重。
在弱网情况下,这个每次翻倍的机制可能就会拖累用户的使用体验。并且弱网下的丢包大部分情况下都是因为硬件的信号问题,而不是线路上路由器拥堵。所以一般 RUDP 的实现会把这个倍率调低,通过更加频繁的重发,尝试将报文成功发送出去。
但是需要注意的是,如果确实是因为线路拥堵的原因导致的丢包,那么调低倍率 RTO 倍率并不会带来任何好处,不仅多浪费了流量,而且还让线路更加拥堵了。
差错恢复
网络数据流中处理差错恢复的方法主要有:回退 N 步(Go Back N,GBK)和选择重传(Selective Repeat,SR)这两种。TCP 的实现是把这两种混合在一起的。
这里先不展开讨论这几种差错恢复的全部区别,只把重点放在数据重传上。
GBN 的接收端不设置缓存,失序报文全部丢掉,返回最后确认的 ACK。如果某个序号为 n 的报文段在发送过程中丢失了,它会把包括 n 在内的所有 n 以后的已发送的报文全部重发一次。
SR 中每个报文段相对独立,如果一个报文段在发送中丢失了,那么它自己的定时器超时以后只会重传它自己即可。
TCP 一般的实现中会在接收端缓存失序到达的报文段,并且在收到失序报文时会给发送端回复一个冗余 ACK,三次冗余 ACK 以后会触发 TCP 的快速重传机制,让发送端立即重发缺失的报文段。接收端在收到缺失的报文段以后,会在缓存中查找后续的报文段,根据缓存中的情况,直接返回给发送端最新的 ACK 即可。
RUDP 的实现一般不太一样,以 KCP 为例,它采用了 SR 的策略,并且接收端不会传冗余 ACK,而是由发送端根据接收到的后续的 ACK 来判断某个报文是否丢失了,从而发起快速重传。
拥塞控制
如果说前面的都是小问题的话,那么 TCP 的拥塞控制可能就是 RUDP 大行其道的最大原因了。
拥塞控制是 TCP 的一套试探线路负载上限的策略,包括了慢启动,拥塞避免和快速恢复三种可以互相转化了状态。
慢启动,是指 TCP 会以 1 个 MSS 为拥塞窗口,逐步向上增加,每次收到一个新的 ACK 时就增加一个 MSS,看起来好像是线性增加,但是其实是指数增长的,因为如果网络没问题的话,下一次在一个 RTT 后会收到 2 个新的 ACK,会直接增加 2 个 MSS,同理下一次会增加 4 个 MSS。虽然增加的速度已经挺快了,但是一开始的速度会比较慢。
如果在 TCP 中发生了超时丢包,都会将拥塞窗口重置为 1 个 MSS,并且 ssthresh(Slow Start Threshold,慢启动阈值)会被设为当前拥塞窗口的二分之一。在拥塞窗口超过了 ssthresh 以后,会进入到拥塞避免状态,由指数增长变为线性增长,每个 RTT 只能增加一个 MSS。同时如果发生的是由三个冗余 ACK 触发的丢包,那么 TCP 会将拥塞窗口变为原来的二分之一。
可以看到在 TCP 中只要发生了丢包,都会导致速率大幅下降,然后再慢慢升上来。
同时 TCP 的拥塞控制秉承了公平性的原则,两条链接如果都要大量使用带宽的话,那么最终它们分到的带宽都接近带宽总量的二分之一。
TCP 的拥塞策略是 RUDP 大量修改的地方,一般会改的激进很多,也不会完全遵循公平原则,可以通过一些参数配置,尽量多的抢掉带宽。不单是 RUDP,谷歌搞得 bbr 已经被合并到了 Linux 新版内核里,也是为了修改拥塞控制。
难修改
为什么 TCP 有了这些问题,也有了明确的改进方向,但是并没有进行大刀阔斧的改革呢?
主要是因为 TCP 是运行在内核态的协议,整个实现都在内核中。这就导致了它难开发,也难修改,因为不能把修改的部分简单的集成到客户端中。就算自己魔改了服务器上的 TCP 实现,但是用户的客户端 TCP 协议还是自带的实现,无法与魔改的实现兼容。即使是内核的实现也更新了,也还有大量运行着旧版内核的设备无法使用新特性。
RUDP 基本上都是在用户态实现的,调试修改都很容易,也可以很简单的集成到客户端程序和服务器程序中,完全不需要改动内核,方便使用和更新。
RUDP 的问题
运营商 QOS
国内的一些地方的一些运营商,包括御三家,经常会对 UDP 数据进行限制,尤其是出国流量,轻则丢包,重则断连,可能完全无法使用。
这样的情况导致 RUDP 很难作为单一协议使用,基本上还需要一份 TCP 的实现进行保底。
为了解决这种情况,有一些特殊的办法。比如可以通过一些手段将 UDP 数据伪装成 TCP 数据。有一个开源项目 udp2raw 就是针对功能这个实现的。或者通过发送冗余数据这种简单粗暴的方法,使用更多流量来对抗丢包,可以尝试使用 UDPspeeder。
能耗
由于 RUDP 都是运行在用户空间的,每一次的数据收发都需要进行上下文切换,而运行在内核空间的 TCP 的上下文切换会少很多。
为了应对运营商的劣化,可能需要做的伪装也更加剧了 RUDP 的性能消耗。
此外因为 TCP 已经作为主流协议使用几十年了,几乎任何需要使用网络的设备都会对 TCP 做各种软件硬件的优化,而这都是 RUDP 们不具备的,缺少优化也是现阶段 RUDP 能耗比 TCP 高的原因,可能需要 QUIC 这样强势的 RUDP 快速普及才会有好转。