kcptun开发小记

kcptun自2017年在github首次commit以来,没有在任何社交媒体上做过PR(注意没有任何社交账号,包括twitter/facebook等,只在github通过issue交流)。作为一个开源的并且完全不收取任何费用的加速软件,觉得有必要写点这两年的一些技术开发心得,分享给有心人,首次在社交媒体发声,也顺面澄清一下很多媒体文章在理解上的局限性,以免给使用者和开发者造成误解。

kcptun是什么?

最开始kcptun是作为go语言版本的KCP协议(kcp-go)实现的一个简易测试工具(只有几百行代码用于调参),用于我曾经正在开发的游戏软件中,方便对游戏的不同地区网络进行参数调整测试。开源后随着使用者的增多而成为一个独立的工具,配合iperf工具,可以方便的观测游戏服务器的接入效果。

简而言之:kcptun是远程端口转发,一个本地TCP端口,对应一个远程TCP端口,转发消息流,ARQ协议KCP算法,辅以ReedSolomon算法的前向纠错FEC,数据分组通过可选的AES/DES/Salsa20等进行分组加密。使用场景多种多样;例如,你可以用来做地理上分割的,多个办公地点的内网之间的TCP流加速,也可以用来做跨机房的kafka抑或是数据库redolog的跨海同步,也可以用作在高损耗信道(可预测丢包率,并且均匀分布)上做QoS保证的通信;在长距环境下,尤其是跨海通信的效果尤其明显,但kcptun本身,不具有代理功能,未来也不会加入

帧结构

帧结构如图,每个外传的UDP包都具有上图的结构,这么设计的原因如下: nonce 的目的等同于加密的 iv ,如果没有 nonce ,消息就存在ECB的问题,毕竟诸如重传包,因为大概率分组数据内容相同,在仅使用ECB加密下,可以轻而易的按照加密后数据包相似度筛出来;始于 nonce 的CFB加密过程可以充分隐藏信息特征,这样就避免了对重传消息侦测与区分对待,也可以防止对游戏直接进行数据包外挂制作(当时)。

这里加密的好处,从根本来说,是用于保证协议层的安全,你可以理解为用于保护TCP 头部中的 \left\{SYN,RST,FIN,PSH,WIN,ACK,RTT \right\} 这类关键信息,如果不保护,被篡改影响很大,举个例子:在不加密的情况下,如果网络中存在一个对手恶意篡改KCP协议中的 window 大小为 0 ,传输马上会停止,或者将 RTT 值调整为一个很大的数,并执行丢包,速度即刻下降,当然应用层的影响就更大了。

注意在这里的 CRC32 的目的,是提供一个比较弱的身份真实性验证,相当于用一个只有client和server知道的key进行签名后通信,那么对加密后内容的篡改,decrypt后 CRC32 一定是错的,简单丢包即可,也就不可能对那些协议关键信息字段造成影响,保证协议层的安全性是传输可靠性的关键。在实际的应用中,可以改为用Diffie-Hellman Key Exchange算法进行ephemeral密钥交换。

这样处理后,数据包校验码错误被当作丢包,丢包和错误可以统一当作噪音处理,带宽可以按照香农定理估算给定信噪比下的信道容量上限,并在FEC层进行噪音补偿。

至于应用层的传输安全,建议采用TLS/SSL这类的完备体系,通常浏览器的应用层通信都是符合标准的。自己开发的时候,建议采用基于TLS的库,例如使用golang标准库提供的golang.org/pkg/crypto/tkcp-goUDPSession对象上进行TLS加密传输。

前向纠错在长距通信的意义

FEC是一个可选参数,并且默认开启,是否使用FEC的选择关键在于对延迟的容忍度。如果存在一个可预测丢包上限,并且丢包概率均匀分布在时域的场景,并且你需要为数据投递时间上限提供一个界限,那么只有开启FEC才能做到。

对于某些对实时性要求很高的长距通信,例如跨海视频流传输,如果一个数据包确认的来回时间为 300ms ,并产生一次丢包,则ARQ重传惩罚至少在 300ms ,多来两次就 1s ;ARQ解决了最终可达性问题(虽然延迟是指数倍增),但并不解决投递时间上限问题。极端一点的情况,可以考虑火星和地球的通信丢包惩罚。

FASTACK的本质是什么

在时间上早发出去的包,如果出现了在时间上一个更晚发送的包已经收到来自对方确认回执的情况,而当前包并没受到确认时,则判定此包丢失并执行重传。在具体实现上:判断fastack是否成立逻辑为,如果同时发的,同一毫秒发的,那么如果序号大的都收到ACK了,即判定为需要fastack;如果不是在同一毫秒发的,发送时间更晚的都收到ACK了,即判定为fastack。所以在实际环境中,fastack也是降低平均投递延迟的一个重要补充。原有KCP协议实现此处存在错误,最近已完成修正

窗口策略与经济模型

关于窗口的大小设定,很多不明就里的人,完全把此当成一个道德标准来抨击,而不具备科学态度。问:不缩窗是不是不道德?我现在统一回答:在不缩窗的同时,协议也不扩窗占带宽,具有利他主义。

这本质是一个博弈:

首先在国际线路上,每天的丢包率变化,幅度可以在30%到1%,一个小时之内,14% loss rate可以瞬间变化到3% loss rate。如果传输策略不刚性一点,是没有办法做到平稳传输的,长距通信经过那么多节点,一个节点拥塞/丢包就会影响ARQ算法,所以长距离通信中基于RTO的丢包反馈的算法是不具有伸缩性的。(这也是BBR算法改动的地方)

其次,云服务商对带宽使用的计费,会逼迫用户选择一个满足自己日常使用的合理大小的窗口,这是经济手段上的平衡,如果需要使用更多的带宽,以更稳定的速率传输更多的数据,经济上也自然会付出更多,这是合理的。

另外从目前基于反馈测量模式的ARQ算法而言:但凡有一个灵活的针对丢包率的传输策略存在;那一定存在一个可以让其传输策略失效的丢包模式,传输平稳性和带宽灵活性不可兼得。(注:也许用户态的深度学习算法可以更智能的传输,但对手也可以用更智能的丢包算法)。

RTO的计算

标准的RTO算法是一个加权移动平均(Weighted Moving Average),加上瞬时高频信息的一个估算RTO, 股民朋友应该一眼看懂,这不就是MACD指标么,rttvar快速线和srtt慢速线。理解很简单,但实现起来可以影响快速线的东西太多了,快速线直接影响RTO瞬时波动,比如API之间的缓存,CPU处理不及时造成的延迟,都会对rttvar快速线产生极大波动。

如果是由于缓存过大而排队过长,造成的处理时间延迟有个专有名词叫做bufferbloat。其他影响因素有,进程调度延迟,goroutine调度延迟,同一物理机的虚拟机之间使用共享资源竞争而造成的延迟,经过的路由节点的处理延迟,驱动延迟;实际RTO计算存在大量变量,且大多数无法在本地获得并参与计算;

你能做的就是尽量让数据包携带的时间,尽可能接近真实离开网卡的时间。对于接收分组过程,内部尽量不要产生堆积行为,来一个包,处理一个,当成一个机械的流处理系统,尽量不做缓存,这样反馈给对方的RTT值会相对准确;对于发送分组过程,在每个分组发送的那一刻,准确获得进程当前系统时钟(monotonic clock)并填入包头部时间戳,这样就尽可能的规避了由于goroutine调度,程序对数据包的预处理而引入的延迟。

传输收敛性分析

数据包收敛性是三个部分的叠加,我们表示为 r_{total}=\left\{ r_{arq}, r_{FEC},  r_{fastack}\right\} ,先看ARQ 的收敛性,RTO的重传输次数符合等比级数:

r_{arq}=\left(1+\frac{1}{1.5} +  \frac{1}{1.5*1.5}  +  \frac{1}{1.5*1.5*1.5} + ...\right)\cdot \frac{1}{RTO}

可以简单求得ARQ的极限传输次数为: r_{arq}=\lim_{0 \rightarrow \infty}{r_{arq}(t)} = \frac{1}{1-\frac{1}{1.5}}  \cdot \frac{1}{RTO} ,RTO是一个取值范围为 (0, \infty] 之间的值,此函数收敛为 \frac{3}{RTO}

FASTACK的传输按照均匀丢包比率 p_{loss} ,在每次收到确认后执行重传,那么传输量是与 p_{loss}r_{ARQ} 有关的一个多项式,即 r_{fastack}= p_{loss}\cdot r_{arq} ,因此 r_{fastack} 函数收敛为 r_{fastack}=  \frac{3\cdot p_{loss}}{RTO}

FEC是具有固定比例 \beta_{SNR} , \beta_{SNR}\subseteq \left[ 0, 255 \right] 的传输增幅,这个增幅通常根据 SNR 设置, 那么r_{FEC} = \beta_{SNR} \cdot (r_{arq} + r_{fastack})\beta_{SNR} 的实际取值范围等于 \frac{1}{1-p_{loss}} , p_{loss} 为丢包率,通常为低于 \frac{1}{2}

那么总体最大额外传输量为 r_{total} =\beta_{SNR} \cdot \frac{3\cdot(p_{loss} + 1)}{RTO}  =   \frac{3\cdot(p_{loss} + 1)}{RTO\cdot (1-p_{loss})} ,函数收敛。并不存在无限制发包的问题。在50%丢包率,且FEC参数设定符合丢包率的情况下,此函数在时间趋于无穷的的极限传输量为 \frac{9}{RTO},在 10%的丢包率下,函数极限为: \frac{3.7}{RTO}

SMUX的作用

SMUX是为了在同一条连路上,承载多路TCP链接,即承载多个 session := <srcaddr,srcport,dstaddr,dstport> 四元组。

在kcptun开发之初实验了yamux用于多路复用,但接收策略是完全不考虑内存用量的,这样在路由器上运行的时候很快就崩溃了,另外存在多处错误,其窗口的反馈策略使用了delta授权发送端产生数据,但如果网络有波动,必定存在突然delta累积的情况,使得传输曲线不够平稳,内存用量会突然增加。

于是重新开发smux,做了可选内存大小控制,并用TCP的窗口和确认来控制流速率。

链路复用中存在的一个根本困难是在解复用过程中存在的队头阻塞问题的一种。这个问题描述如下:

如果kcptun服务于多个用户,某个用户请求了大量的数据,返回后被smux读取并解复用,但这个用户在smux中取走数据的行为存在延迟(只发送不接收),那么由于内存有限,随着数据的继续堆积,必定会导致smux缓存耗尽而阻塞其他用户。

有限的内存,并行,充分的带宽利用,三者不可兼得。这是一个困境。

这是设计上的一个权衡,内存用量和完全并行度存在根本上的矛盾;缓解的措施是:

  1. 扩大内存用量:通过-smuxbuf将smux的解复用内存扩大,比如扩大到64 M 。(推荐)
  2. 限定独立流的最大内存占用:通过-streambuf设置大小,缓解耗尽全部smux解复用内存的可能性,注:需要-smuxver 2协议版本。(推荐)
  3. 扩大并行度并牺牲带宽利用率:在同一节点上开启-conn > 1,那么各个链路之间因为固定窗口问题,彼此存在竞争,需要调小sndbuf和rcvbuf的值。

最后

感谢 韦易笑 大神从零开始实现了一个用户态的ARQ协议,由此开启了一片用户态传输层的新天地,KCP协议目前应用在互联网的各个领域,包括但不限于网络延迟敏感的应用,例如:网络游戏,分布式一致性协议(consensus protocol),视频直播,文件同步。并且KCP生态包含各个主流语言的实现,如:C,C++,C#,rust, java, golang,非常方便,只需要少量的几行代码,便可以在现有的软件框架中接入KCP传输层。对原始协议的建议,可以在原始协议库进行学术探讨。

协议分析和设计是非常困难和复杂的一个领域,复杂是在于一个轻微的改动,就会引起连锁的影响,协议设计者需要非常谨慎的分析和观察传输效果才能得出结论,困难是在于工程上要做到类似于一个实时系统的效率满足各异的上层应用,否则对算法效果又是一种破坏。

编辑于 2019-10-03