网络

KCP-GO的重传机制以及带宽利用率的提升

1. KCP

按KCP的README,这协议并不是设计来跑大流量的:

TCP是为流量设计的(每秒内可以传输多少KB的数据),讲究的是充分利用带宽。而 KCP是为流速设计的(单个数据包从一端发送到一端需要多少时间),以10%-20%带宽浪费的代价换取了比 TCP快30%-40%的传输速度。

从它的技术特性,你也可以得知这协议相对TCP的重点改进是在重传上,而不是拥塞控制。那它为什么快(某些情况下)呢?一方面,它重传机制的改进,丢包后可以更早重传,对方更早收到补发的包,更早能把数据递交给应用层,缓冲区的释放更快,发送方也能更早继续发送后续的数据;另一方面,它技术特性里面有个“非退让流控”,名字听起来略微高端大气上档次,其实通俗点来讲就是没有拥塞控制。简单来说,就是拥塞窗口大小不会因丢包率发生变化,也不需要经历TCP的慢启动阶段,KCP会尽可能地帮你把自己的发送窗口或者对方的接收窗口塞满。还有就是,大概很多人用的都不是纯skywind3000实现的kcp,而是xtaci的kcptun,这实现默认启用了FEC,每十个包发送三个冗余,补偿了丢包。

2. QUIC

应该有不少人听过QUIC,那KCP跟QUIC相比,哪个好呢?其实QUIC的比纯KCP丰富得多完整得多,还真的不太好对比,这里就拿QUIC跟xtaci的KCP实现进行对比吧。这里你可以暂时简单地把QUIC看作xtaci的KCP+BBR,所以“非退让流控”变成了“BBR拥塞控制”。有拥塞控制的好处是窗口可以自动调整,例如像HTTP这种公开的网络服务,服务器预先是不了解客户端的带宽、网络拥塞状况是如何的,因此需要一个拥塞控制算法,经历慢启动,根据丢包动态地调整窗口。如果说你很了解两个host之间的网络状况,或者说想保证两点间的传输质量,这种情况拥塞控制就有点多余了,“非退让流控”倒是能让你得到更好的体验。所以哪个更好,还得看场景。

QUIC的特性有一项是“无队头阻塞的多路复用(Multiplexing without head-of-line blocking)”,xtaci的kcp也有实现多路复用,但并不能避免队头阻塞,注意这里所说的队头阻塞并不是kcptun开发小记所提到缓冲区耗尽导致的那种阻塞,而是因KCP无法感知上层的多路复用所导致的队头阻塞。例如A向B发送了编号为0-9的十个数据包,但是其中1丢了,即使2-9中的数据跟1是毫不相关的,2-9携带的数据也因为1的丢失而无法及时递交给应用层。这也是为什么QUIC要使用UDP,因为TCP的机制就决定了一定会发生队头阻塞,HTTP/2在TCP上实现了多路复用,解决了HTTP/1.1的队头阻塞问题,但解决不了TCP的,所以这也是为什么会有HTTP/3,而且还要用QUIC。目前KCP多路复用的实现仅类似于HTTP/1.1到HTTP/2。

此外还有连接迁移(Connection Migration),xtaci实现的kcp是不支持此特性的,也就是说,如果客户端的IP和端口发生了变化,那么先前构建的KCP连接就失效了。

3. KCP-GO的INPUT/OUTPUT

3.1. OUTPUT流程

应用层发出数据调用的是WriteBuffers(),WriteBuffers()把一大份数据拆分成一份份mss大小的;KCP.Send()把mss大小的数据存入队列;KCP.flush()主要负责构建数据包,还有重传的判断;UDPSession.output()负责加密、CRC32校验以及FEC;到uncork()和tx(),就是调用操作系统的API发出UDP数据包了。

其中的update()会周期性调用,周期通过参数interval指定。

3.2. INPUT流程

monitor()和UDPSession.readLoop()都是通过系统调用接收UDP包,区别是monitor()用于server模式,readLoop()用于client模式。Listener.packetInput()与UDPSession.packetInput()负责解密、CRC32校验。UDPSession.kcpInput()负责FEC。KCP.Input()对数据包的包头进行解析,根据其中的CMD,做出对应的处理。

4. 重传

重传机制的实现在KCP.flush()中,分别有fast retransmit、early retransmit以及RTO retransmit。

KCP-GO通过KCP结构体中的名为“snd_buf”的数组对待发以及已发出还未被ACK的segment进行跟踪,以实现重传。这个数组的类型是名为segment的结构体,segment中的ts、rto、resendts以及fastack与重传有关。

Segment跟数据包有什么关系呢?每个segment都拥有一个完整的KCP header,header中有这个segment所携带的payload的长度,因此一个数据包中,可以有多个segment,只要不超过MTU。ACK就是一个包中携带多个segment的例子:

4.1. RTO Retransmit

当一个push segment(带数据)需要发出时,会记录当前的RTO,并且通过resendts记录超时的时间:

这里你可以看到,这个resendts就很准地定在了一个RTO,而且是当前的RTO,其实这里会导致一些问题,文后再提。

遍历snd_buf中的segment时,就通过这个resendts来判断是否需要重传:

上面对segment.rto的计算,是重传的退让,其中对kcp.nodelay的判断,对应KCP特性中的“RTO翻倍vs不翻倍”,除以2那个是1.5倍。

KCP-GO的RTO并没有应用指数退让,每次重传RTO的增量只是一个(或者0.5个)RTO,这是跟skywind3000/kcp不一样的地方。

4.2. Fast Retransmit

即KCP特性中的快速重传,通过判断segnemt->fastack的值是否大于设定被跳过的次数,决定是否需要进行fast retransmit。

发出segment时,会在segment的ts中记录当前的时间:

对方响应ack时,会把对应包的ts也带上,这个一方面用于计算RTT,另一方面,收到不是对应这个segment的ack时,可用于判断这个ack,是否应该在这个segment在应该到达对方的时间点后响应的,只要ack中的ts大于等于这个segment的ts,就可以肯定这个ack应该是在这个segment到达后(理论上应该到达)响应的,因为既然同时以及后来发segment都被ack了,那这个segment也应该到了,这时就可以给这个segment ack被跳过次数的计数器+1:

上面的_itimediff(seg.ts, ts) <= 0,就是对比这个ack的ts,是否大于等于segment中的ts,是的话,被跳过的次数+1。

flush()中遍历snd_buf时,就通过比较fastack是否大于设定的值,以决定是否要应用fast retransmit:

4.3. Early Retransmit

这个是KCP中没有提到的,先来看看应用这个机制的判定条件:

这里又出现了fast retransmit的fastack,条件是大于0,也就是只要被跳过;那newSegsCount又是哪来的呢?:

snd_queue是在UDPSession.WriteBuffers()中对数据拆分后,调用KCP.Send()存入的,这里这个for循环的任务是给snd_queue中的每份segment分配sn,也就是编号,然后存入到snd_buf中;cwnd是拥塞窗口,cwnd = min(发送方的发送窗口, 接收方的接收窗口)。

newSegsCount要为0的话,要么就是snd_queue为空,要么就是刚进循环,就被“_itimediff(kcp.snd_nxt, kcp.snd_una+cwnd) >= 0”这个判定条件给break了。

kcp.snd_nxt是要下一个要发出的segment的编号,kcp.snd_una是发送方滑动窗口的左边沿,也就是已发出或者将要发出的数据包中,还未被确认的且编号最小的那一个(注意这里的滑动窗口是不受选择性重传的ACK影响的,窗口仅会在对方响应的una发生变化后滑动,见KCP.parse_una())。kcp.snd_una+cwnd就是滑动窗口的右边沿。所以,这个条件,判断的就是要发出的segment的编号,是否已经超过了滑动窗口的右边沿;换句话说,这个比较为true的条件就是:发出的segment已经把窗口塞满了。

所以如果snd_queue为空,或者窗口已满的情况下,就有可能触发重传,重传那些有被ack跳过的segment。那这个重传机制的意义在哪呢?

窗口的滑动是仅在对方响应的una更新后发生,una的值代表的是,una前的编号的segment,都已经收到了。理想情况下,窗口应该是持续向右滑动的,这时窗口停止滑动,导致segment把窗口塞满,而且还有segment被ack跳过,那完全可以判定为丢包了,因为丢包会阻止una的更新。

4.4. 关于重传的一些备注

KCP的特性中有选择性重传,上面没有专门提到这个机制,其实上面的三个重传机制的实现都是选择性重传的,因为他们都是对单个segment进行跟踪。

KCP的最小RTO定在100ms,所以如果两个host之间的RTT小于100ms,生效的重传机制基本不会是RTO那个。

5. KCP-GO的四个参数

用KCPTUN的可能不了解这四个参数,因为这四个参数是隐藏参数,一般来说用户传的是normal、fast、fast2和fast3。

四个参数分别是no delay,interval,resend和no congestion。

no delay是RTO的退让,在KCP-GO中,未启用增加一个RTO,启用后增加0.5个;interval是调用UDPSession.update()的周期,单位是ms;resend是fast retransmit的参数,即segment被ack跳过多少次后,应该视为丢包并重传;no congestion即是否禁用拥塞控制,对应KCP特性中的“非退让流控”,千万别设为0,不然你不如考虑TCP+BBR吧。

四种模式,有何区别呢:

最大的区别大概就是interval了,这个参数是调用UDPSession.update()的周期,update()调用的是KCP.flush()。

KCP.flush()负责响应ack,从snd_queue取出segment分配sn并发出segment,跟踪丢失的segment,并重传。默认情况下,调用wirte()向对方发送数据并不会等到下一个update()调用才发出,前面所提供的流程图,write()是有指向flush()的。ACK则默认会延迟到每个update()的调用才发出,除非你启用了ack nodelay(一般来说不建议启用)。所以interval影响的是跟踪丢包以及响应ack的周期。

对于发送方来说,如果持续有数据发送,这个interval的影响不是很大。不过如果接收方是重收轻发的,那这个interval对发送方的丢包检测影响就很大。因为发送方对RTT的计算、以及丢包重传机制的应用都是通过ack实现的,interval越小,ack响应越快,那发送方就越早能发现丢包,越早重传。

6. 带宽利用率的提升

文章开头提到这协议不是设计来跑大流量的,实践过程中发现,跑大流量,的确是有点问题。

多跑流量这个有很多讨论,其实这个是有办法避免。多跑流量的原因,你能看到的可能有窗口设置不合理、内核socket缓冲区不够大,客户端处理不过来、还有FEC。其实这几个原因都不是关键。

先来看看这个多跑流量的问题有多严重。

6.1. 多跑了多少流量?

A和B之间是142ms RTT,千兆带宽,先用iperf3测测A以百兆速率向B发送数据的UDP丢包率:

网络质量非常优秀,没有丢包,再来看看KCP-GO,从A发送1GB数据到B,重传了多少,关闭nodelay,关闭FEC,关闭fast retransmit:

OutSegs:1161866,RetransSegs:113270,LostSegs:113270,发出了116万个segment,重传了11万+。

那到底是不是真的丢了那么多个segment呢?看看B的统计数据:

RepeatSegs:113270,跟A统计的重传数一致,也就是说,这些重传的,一个都没丢,全都是重复发送,有近十分之一的流量是多跑的。如果跑的带宽更大,还启用了fast retransmit的话,这个repeat数将会更高。

如果让KCP-GO输出RTO、重传的记录以及ack的时间,你会发现很多类似这样的记录:

850928这个segment在46306ms重传了,但是在15ms后,也就是46321ms收到了ack。收到这个850928的ack时,计算出的RTO也从193ms上升到了220ms。如果你还开了fast retransmit,最严重的情况,就是fast retransmit一次,然后RTO也retransmit一次。

前面介绍重传,有提到segment resendts的计算是当前的时间+当前计算出的RTO,会导致一些问题。影响RTT的因素有很多,除了当前的网络状况,还有双方的CPU负载、调度等问题,这些因素每一刻都在发生变化,特别是KCP属于用户空间的程序,CPU负载和调度等问题对这个影响更大。而RTT越高,在路上的ack就越多,例如A和B之间是142ms的RTT,参数设定的interval是10ms,那从一个segment的发出,到对应这个segment的ack,这期间RTO能更新14次以上!此外,我们永远也无法预先知道这个segment的发出,到收到响应,需要多少时间。

一方面,可以改进计算RTT的算法,KCP的RTT算法并没有什么问题,不过可能因为KCP是用户空间的,太容易受影响,变化太突然,采用的算法不能很好地应付这种情况。另一方面,我们可以不要用固定的resendts去与当前的时间比较,而是用segment发出的时间+当前最新的RTO与当前时间进行比较决定是否需要重传。此外,还可以给RTO应用一个退让;在Linux Kernel中,第一次重传的时间其实不是定在当前时间+一个RTO,而是对初始RTO应用了一个系数为2的退让,例如计算出的RTO是200ms,那第一次重传的时间是当前时间+2*RTO,也就是400ms后,而不是200ms:

当然,也不是所有情况都应用这种初始退让,上面的代码中,还有个thin stream的判断,这个功能默认是没启用的,大概就是,如果发出去还没被ack的包少于4个,就在一个RTO后立刻重传。

对算法进行改进这个可能有点挑战;对resendts的改进有效果,但不算很大;实测还是对KCP的RTO应用一个退让简单,而且效果也是非常好,当然也不需要像TCP那样两倍这么夸张,我这里选了1.125倍,重传量为1756,对比原先的11万,少了99.9%:

上面的参数–irtobackoff参数原版的kcptun是没有的,只是我在自己fork的版本加进去的。–irtobackoff 3,意思就是RTO = RTO + (RTO >> 3),也就是1.125倍的RTO。如果参数改成2,也就是1.25倍的RTO,这个repeat还可以到0。具体取多少,还得测,跑的带宽更大、CPU负载更高的话,可能得应用1.5倍的RTO。

对RTO应用这个系数,会不会对传输速度产生负面影响呢?

其实一般不会有的,因为还有更准确的fast retransmit机制,它是通过segment中的ts(发送时间)与收到的ack所携带的ts进行比较判断的,而不是RTO,因此即使没到RTO,fast retransmit会在一个RTT后就立刻帮你重传,不对RTO应用这个系数的话,丢包后还很可能会fast retransmit一次,之后RTO再retransmit一次。

那交互式程序呢?例如SSH,敲键盘的速度,一般来说对方并到不了让对方在一个RTO内响应多个ack的程度,那这时岂不是要依赖RTO?前面有提到过thin stream,因此可以在KCP中引入这个机制,待发或者已发出还未被ack的segment少于某个值,就不应用这个系数,直接在一个RTO后重传。上面的参数–irtobthresh 128,就是对这个值的指定,意思就是segment少于等于128个,就在一个RTO后立刻重传。

7. 一些其它问题

7.1. 跑不满千兆

不知道有多少人拿KCP来跑那么大带宽的?之所以选用KCP,是因为一方面可以免去TCP三次握手的消耗,另一方面可以跳过慢启动,直接蹦到千兆。要知道RTT越高,这两个机制的时间成本就越高。

首先要解决前面提到的带宽利用率问题,不然跑的带宽越大,带宽利用率越低。其次,CPU要够好。目前网络设备对TCP的优化还是很足的,但是UDP比较少,例如TCP有segment和checksum offload,但是对于KCP来说,这些offload都不能用,因为NIC不认识KCP,所以只能依赖CPU处理。还有就是,KCPTUN默认给io.CopyBuffer()提供的buffer只有4KB,要跑满千兆,增大这个buffer很关键,例如调整到64K。

除此之外,别忘了调整net.core.rmem_max,至少要能容纳一个BDP的量。KCPTUN的README还提到要调整wmem的,个人在这里建议不要做调整,实测会有问题,具体调整wmem是否合适,各位自行测试。

7.2. RTT越高,速度越慢

前面提到带宽利用率是其中一个问题,此外还有segment堆积的问题,千兆带宽,snd_buf中可以堆积超过一万个segment,flush()去处理这些segment也有消耗,所以RTT越高,速度越慢,但这个影响不及带宽利用率的问题大。实测千兆,会有五十兆左右的性能损耗。

7.3. 用了后速度变慢/丢包率非常高

一般来说慢一点肯定会有的,但是如果差异太大就肯定有问题。

首先是解决带宽利用率的问题,其次,如7.1提到的,别忘了调整net.core.rmem_max。另外,窗口的设定也要合理。

如果发现丢包率非常高,而且不是在中间网络设备丢的,可能是你调大了wmem,建议将wmem_default和wmem_max都调回212992,不会影响上行性能。如果丢包还是很严重,尝试调大队列的packet limit,具体方法,请参考另一篇文章:UDP SndbufErrors & ENOBUFS

7.4. FEC

这个适用于pps比较高的情况,其实对交互式应用来说,意义不算很大,例如你设置的FEC是10:3,你SSH敲键盘一个RTO内一般也敲不出十个字母,这种场景,回退到多倍发包比较合适。

8. 小修改过的KCP-GO

KCP-GO:https://github.com/yzslab/kcp-go

KCPTUN:https://github.com/yzslab/kcptun