热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

网络编程中网络传输KCP协议原理解析

KCP协

前言

一、KCP概述

对于游戏开发,尤其是MOBA(多人在线竞技)游戏,延迟是需要控制的。但是对于传统的TCP(网络友好,很棒),并不利于包的实时性传输,因为他的超时重传和拥塞控制都是网络友好,对于我们包的实时性,没有优势。所以一般都是需要基于UDP去实现一套自己的网络协议,保证包的实时,以及可靠。其实就是牺牲TCP的友好,牺牲带宽,以空间换时间。基于UDP,网上有一些优秀的协议,比如KCP。

KCP是一种网络传输协议(ARQ,自动重传请求),可以视它为TCP的代替品,但是它运行于用户空间,它不管底层的发送与接收,只是个纯算法实现可靠传输,它的特点是牺牲带宽来降低延迟。因为TCP协议的大公无私,经常牺牲自己速度来减少网络拥塞,它是从大局上考虑的。而KCP是自私的,它只顾自己的传输效率,从不管整个网络的拥塞情况。举个例子,TCP检测到丢包的时候,首先想到的是网络拥塞了,要放慢自己的速度别让网络更糟,而KCP想到的赶紧重传别耽误事。

kcp可以理解为可靠的udp协议。udp是面向无连接的协议,由于其实时性较好,通常用于游戏或音视频通话中,同时由于其不需要提前建立连接,能节省设备资源,也广泛应用于嵌入式设备中。另外,对于大量的数据传输如下载文件等场景,以及DNS中客户端请求域名对应的地址这个环节中,使用的是udp。注意,DNS另一部分,域名服务器节点之间的数据同步用的是tcp。

为了提高udp可靠性,在udp协议上封装一层可靠性传输机制(类似tcp的ACK机制、重传机制、序号机制、重排机制、窗口机制),就做到了兼具tcp的安全性(流量控制和拥塞控制等)和udp的实时性,并且具备一定的灵活性(超时重传、ack等),其中一个代表是kcp协议。

TCP的特点是可靠传输(累积确认、超时重传、选择确认)、流量控制(滑动窗口)、拥塞控制(慢开始、拥塞避免、快重传、快恢复)、面向连接。KCP对这些参数基本都可配,也没用建立/关闭连接的过程。

二、kcp协议头部

conv:连接号。UDP是无连接的,conv用于表示来自于哪个客户端,是对连接的一种替代。
cmd:命令字。如,IKCP_CMD_ACK确认命令,IKCP_CMD_WASK接收窗口大小询问命令,IKCP_CMD_WINS接收窗口大小告知命令。
frg:分片,用户数据可能会被分成多个KCP包,发送出去。
wnd:接收窗口大小,发送方的发送窗口不能超过接收方给出的数值。
ts:时间戳。
sn:序列号。
una:下一个可接收的序列号。其实就是确认号,类似tcp的ack。
len:数据长度。
data:用户数据。

三、KCP流程

注意收发数据时,数据要先放到缓存重排然后再进行收发。

1.kcp数据接收

接收数据时,kcp会先把数据放到rcv_buf中进行缓存,注意rcv_buf中的数据是已经按sn排好序的,但是不一定完整连续,因为传输过程中可能有丢失,rcv_queue是从rcv_buf中拷贝出的连续完整的数据分片。用户调用ikcp_recv()时是从rcv_que中读取数据。还要注意一点,ikcp_recv中的len一定要是整个数据的长度,要一次性读完,这和recvfrom的size是一样的。

与接收过程类似,只不过顺序相反,用户调用ikcp_send()只是将数据放到了snd_buf中,不过会自动进行数据分片,使得数据大小不超过mtu。然后,kcp会通过流量控制和拥塞控制将数据放到snd_buf中再进行发送。

注意区分一下,不断调用ikcp_send(),数据会累积在snd_queue中,如果对方没有接收,对方的数据则会累积在对方的rcv_buf中。
说了这么多,其实kcp底层调用的,还是recvfrom()和sendto(),这一点不要忘记。

3.kcp确认机制
snd_buf中存放的是已发送但未收到对方ack的数据包和未发送但可发送的数据包,如果收到了ack,相应的数据就会从snd_buf中删除。如上图中,8包已收到ack,从snd_buf删除,9、10包已发送但未收到ack。这里提一下,kcp的确认机制有两种,一种是una,与tcp的ack类似,代表una之前的包全部收到;还有一种是单独ack,只对某一个单独的包进行确认。kcp优先检测una。

4.kcp重传机制
kcp的重传机制体现了其较于tcp而言,灵活的地方。一是超时重传时间,可以自己设置,tcp是超时一次就将RTO×2,kcp可以×1.5,缩短了重传等待时间;二是建立了快速重传机制,如果一个数据包之后的指定个数据包已收到ack,那么即便未到该包的rto,也立即重传。取3包未收到ack,而4包和5包已经收到了ack,如果指定的是跳过2个包就认为包丢失,那么此时3包就立即重传,无论是否经过了3包的RTO。此外,kcp还可以选择是否设置延迟ack,超时只重传丢失包,采用非退让流控等,这些都使kcp的传输效率得以提高。

四、KCP实现原理

KCP只是简单的算法实现,并没有涉及到任何的底层调用。我们只需要在UDP系统调用的时候,注册KCP回调函数,即可使用。所以可以将它理解为一个应用层协议。

 对比TCP:
TCP的RTO翻倍。这个概念是很恐怖的。KCP为1.5倍。
选择性重传,只会传输丢失的数据包。
快速重传,不会等到超时。默认若干次重新传输。
TCP会延时发送ACK。KCP可设置。
非退让流控。发送窗口可以只取决于发送缓存大小和接收端剩余接收缓存大小。
KCP为了实现选择性重传(ARQ),会维护一个接收窗口(滑动窗口)。如果收到有序数据会将其放到接收队列,以待应用层消费。如果存在包丢失,会判断。超过设置的次数,会让其选择重传对应的包。

其实就是通过一个rcv_nxt(接收窗口当前偏移)来判断当前需要接受的数据包。如果收到的包在窗口范围,但是不是rcv_nxt。先保存,等包连续之后才会将连续的数据包放入到接受队列供应用层消费。

同样网络不好的情况,KCP也会实现拥塞控制,限制发送端的包。

本文结尾底部,领取最新最全C++音视频学习提升资料,内容包括(C/C++Linux 服务器开发,FFmpeg webRTC rtmp hls rtsp ffplay srs↓↓↓↓↓↓文章底部

五、KCP源码分析

首先在分析之前我们应该去github看一下使用方法。其实很简单,初始化kcp对象,然后实现回调函数,其实就是实现自己底层UDP系统调用。每次我们通过KCP发包的时候,他都会调用这个回调。

UDP收到包之后调用ikcp_input函数,即可。我们最终只需要通过ikcp_send和ikcp_recv收发数据。

在看代码前,先看看kcp数据包的结构,Segement。

struct IKCPSEG { struct IQUEUEHEAD node; IUINT32 conv; //会话编号,两方一致才会通信 IUINT32 cmd; //指令类型,四种下面会说 IUINT32 frg; //分片编号 倒数第几个seg。主要就是用来合并一块被分段的数据。 IUINT32 wnd; //自己可用窗口大小 IUINT32 ts; IUINT32 sn; //编号 确认编号或者报文编号 IUINT32 una; //代表编号前面的所有报都收到了的标志 IUINT32 len; IUINT32 resendts; //重传的时间戳。超过当前时间重发这个包 IUINT32 rto; //超时重传时间,根据网络去定 IUINT32 fastack; //快速重传机制,记录被跳过的次数,超过次数进行快速重传 IUINT32 xmit; //重传次数 char data[1]; //数据内容 };

Kcp就是通过数据包的这些字段,实现稳定通信,针对不同的点可以去做优化。从上面的字段,也可以看出kcp借助UNA和ACK实现了选择性重传。

1.首先来看包发送的逻辑,我们会调用 ikcp_send方法
这个方法,首先会判断kcp流。并尝试将包追加到前一段,如果可能的话。否则进行分片传输。

if (len <= (int)kcp->mss) count = 1; else count = (len + kcp->mss - 1) / kcp->mss; if (count >= (int)IKCP_WND_RCV) return -2; if (count == 0) count = 1; // fragment for (i = 0; i (int)kcp->mss ? (int)kcp->mss : len; seg = ikcp_segment_new(kcp, size); assert(seg); if (seg == NULL) { return -2; } if (buffer && len > 0) { memcpy(seg->data, buffer, size); } seg->len = size; seg->frg = (kcp->stream == 0)? (count - i - 1) : 0; iqueue_init(&seg->node); iqueue_add_tail(&seg->node, &kcp->snd_queue); kcp->nsnd_que++; if (buffer) { buffer += size; } len -= size; } return 0;

上面的代码逻辑中count其实就是包的分片数。然后循环,创建segment,segment的数据结构主要就是保存了分片的数据包信息。比如eg->frg保存当前分片的编号。完事之后调用iqueue_add_tail方法将segment传入到发送队列。这些方法通过宏定义实现。其实就是链表操作。队列是一个双向链表。逻辑很简单。

那么这一步之时将数据分片放入到队列。具体发送逻辑在哪实现呢,继续往下看。

我们看一下回调的逻辑,其实就是ikcp_output方法,这个方法会在ikcp_flush 中调用。也就是ikcp_output做的是最终的数据发送。那是如何驱动的呢?我先来看看这个方法。

1、这个方法首先发送ack。遍历所有ack。调用ikcp_output 方法发送。

count = kcp->ackcount; for (i = 0; i (int)kcp->mtu) { ikcp_output(kcp, buffer, size); ptr = buffer; } ikcp_ack_get(kcp, i, &seg.sn, &seg.ts); ptr = ikcp_encode_seg(ptr, &seg); } kcp->ackcount = 0;

2、判断当前是否需要进行窗口探测,因为如果窗口为0,是不能发数据,所以必须进行窗口探测才行。探测结束之后,如果需要,设置探测窗口时间。发送探测窗口的请求或者窗口恢复的请求。主要就是请求对端窗口大小,以及告知远端窗口大小。完事之后将结果放入seg中。

if (kcp->rmt_wnd == 0) { if (kcp->probe_wait == 0) { kcp->probe_wait = IKCP_PROBE_INIT; kcp->ts_probe = kcp->current + kcp->probe_wait; } else { if (_itimediff(kcp->current, kcp->ts_probe) >= 0) { if (kcp->probe_wait probe_wait = IKCP_PROBE_INIT; kcp->probe_wait += kcp->probe_wait / 2; if (kcp->probe_wait > IKCP_PROBE_LIMIT) kcp->probe_wait = IKCP_PROBE_LIMIT; kcp->ts_probe = kcp->current + kcp->probe_wait; kcp->probe |= IKCP_ASK_SEND; } } } else { kcp->ts_probe = 0; kcp->probe_wait = 0; }

3、计算本次发送可用的窗口大小,由多个因素决定,KCP有选择性配置。可以选择不结合流控窗口。

4、将发送队列中的消息放到发送缓冲区,其实就是发送窗口。也就是说所有发送后的数据都会在这个缓存区。发送数据之前,还需要设置对应的重传次数和间隔。这个逻辑就比较简单了,其实就从发送窗口队列拿出一个seg。然后设置对应的参数。并且更新缓冲队列。以及缓冲队列的大小。如果设置nodelay,重传时间有*2 变成1.5。

while (_itimediff(kcp->snd_nxt, kcp->snd_una + cwnd) <0) { IKCPSEG *newseg; if (iqueue_is_empty(&kcp->snd_queue)) break; newseg = iqueue_entry(kcp->snd_queue.next, IKCPSEG, node); iqueue_del(&newseg->node); iqueue_add_tail(&newseg->node, &kcp->snd_buf); kcp->nsnd_que--; kcp->nsnd_buf++; newseg->cOnv= kcp->conv; newseg->cmd = IKCP_CMD_PUSH; newseg->wnd = seg.wnd; newseg->ts = current; newseg->sn = kcp->snd_nxt++; newseg->una = kcp->rcv_nxt; newseg->resendts = current; newseg->rto = kcp->rx_rto; newseg->fastack = 0; newseg->xmit = 0; }

5、遍历发送窗口队列。判断是否有需要发送的数据(包括重新传输的)。其实就是拿到对应的segment,然后根据信息进行逻辑判断是否需要重新传输。或者需要发送。判断结束之后进行重新传输。

逻辑也很简单:

如果包是第一次传输,直接发。
如果到了包的重传时间,再次传输,并且记录丢失标志。
如果被跳过的次数超过了fastack,重新传输。
其实lost和change是用来更新窗口大小的字段。并且两个更新算法不一样。

if (segment->xmit == 0) { needsend = 1; segment->xmit++; segment->rto = kcp->rx_rto; segment->resendts = current + segment->rto + rtomin; } else if (_itimediff(current, segment->resendts) >= 0) { needsend = 1; segment->xmit++; kcp->xmit++; if (kcp->nodelay == 0) { segment->rto += kcp->rx_rto; } else { segment->rto += kcp->rx_rto / 2; } segment->resendts = current + segment->rto; //记录包丢失 lost = 1; } else if (segment->fastack >= resent) { if ((int)segment->xmit <= kcp->fastlimit || kcp->fastlimit <= 0) { needsend = 1; segment->xmit++; segment->fastack = 0; segment->resendts = current + segment->rto; //用来标示发生了快速重传 change++; } }

基本上所有的快速重传和超时重传的逻辑都在这个方法中。如果出现超时重传(丢包),就会进入慢启动,拥塞窗口减半,滑动窗口变为1。如果发生了快速重传,也会更新拥塞窗口。具体算法可看代码。

2.看完这个flush方法,我们基本了解发送数据的逻辑。然后就看他在哪调用的
其实就是在ikcp_update方法中就行调用,这个方法需要应用层反复调用,一般可以为10ms和100ms,时间将决定数据发送的实时性。也就是说他会定时刷新判断发送窗口队列的数据或者需要重传的数据,并通过底层UDP进行数据发送。这个方法没有什么逻辑。

void ikcp_update(ikcpcb *kcp, IUINT32 current) { IINT32 slap; kcp->current = current; if (kcp->updated == 0) { kcp->updated = 1; kcp->ts_flush = kcp->current; } slap = _itimediff(kcp->current, kcp->ts_flush); if (slap >= 10000 || slap <-10000) { kcp->ts_flush = kcp->current; slap = 0; } if (slap >= 0) { kcp->ts_flush += kcp->interval; if (_itimediff(kcp->current, kcp->ts_flush) >= 0) { kcp->ts_flush = kcp->current + kcp->interval; } ikcp_flush(kcp); } }

3.我们再来看一下底层接受数据的方法 ikcp_input
这个方法是在底层UDP接收到网络数据之后调用。其实就是解析对应的数据。在KCP中,主要有四种报文格式,ACK报文、数据报文、探测window报文、响应窗口报文四种。

1、首先就是解析对应的头部数据,大概是24字节。然后根据字段去调用ikcp_parse_una和ikcp_shrink_buf方法。前者是解析una,确定已经发出去的数据包,有哪些对方接收到了。如果收到了直接重接受窗口移除。后者是更新kcp的send_una。send_una代表之前的包已经确定收到。

data = ikcp_decode8u(data, &cmd); data = ikcp_decode8u(data, &frg); data = ikcp_decode16u(data, &wnd); data = ikcp_decode32u(data, &ts); data = ikcp_decode32u(data, &sn); data = ikcp_decode32u(data, &una); data = ikcp_decode32u(data, &len); size -= IKCP_OVERHEAD; kcp->rmt_wnd = wnd; ikcp_parse_una(kcp, una); ikcp_shrink_buf(kcp);

2、如果是ACK指令,其实就是做了一些处理。ikcp_update_ack主要就是更新kcp的一些参数,包括rtt以及rto, 首先ikcp_parse_ack方法主要就是根据sn,去移除发送队列中对应的segment。然后就是更新maxack以及时间,并且记录日志

if (cmd == IKCP_CMD_ACK) { if (_itimediff(kcp->current, ts) >= 0) { ikcp_update_ack(kcp, _itimediff(kcp->current, ts)); } ikcp_parse_ack(kcp, sn); //根据snd队列去更新una ikcp_shrink_buf(kcp); if (flag == 0) { flag = 1; maxack = sn; latest_ts = ts; } else { if (_itimediff(sn, maxack) > 0) { #ifndef IKCP_FASTACK_CONSERVE //记录最大ACK maxack = sn; latest_ts = ts; #else if (_itimediff(ts, latest_ts) > 0) { maxack = sn; latest_ts = ts; } #endif } } //打印日志 }

3、如果收到的是数据包,这个逻辑其实很简单,就是检测数据,并将有效的数据放到接受队列,首先就是判断数据包是否有效,如果是,构造一个segment。将数据放入。,然后调用ikcp_parse_data方法。这个方法逻辑也比较简单,其实就是判断是否有效,如果已经被接收过的话,就丢弃,否则根据sn(编号)插入到接收队列。如果是询问窗口大小的包。这个其实就做个标记,因为每个kcp的header都有win大小。剩下的操作就是根据网络状况更新拥塞以及窗口大小了。

else if (cmd == IKCP_CMD_PUSH) { if (ikcp_canlog(kcp, IKCP_LOG_IN_DATA)) { ikcp_log(kcp, IKCP_LOG_IN_DATA, "input psh: sn=%lu ts=%lu", (unsigned long)sn, (unsigned long)ts); } if (_itimediff(sn, kcp->rcv_nxt + kcp->rcv_wnd) <0) { ikcp_ack_push(kcp, sn, ts); if (_itimediff(sn, kcp->rcv_nxt) >= 0) { seg = ikcp_segment_new(kcp, len); seg->cOnv= conv; seg->cmd = cmd; seg->frg = frg; seg->wnd = wnd; seg->ts = ts; seg->sn = sn; seg->una = una; seg->len = len; if (len > 0) { memcpy(seg->data, data, len); } ikcp_parse_data(kcp, seg); } } } 六、KCP快在哪里

没用使用任何系统调用接口。
无需建立/关闭连接(就KCP本身来说)。
很多影响速度的参数都可配。

七、KCP使用场景

丢包率高的网络环境下KCP的优点才会显示出来。如果不丢包,那么TCP和KCP的效率不会差别很大,可能就是少了连接建立/关闭而已。一般来讲,在公网上传输的都可以使用,特别是对实时性要求较高的程序,如LOL。

八、KCP有何缺点

学习成本
据说有些运营商对UDP有限制?

九、KCP总结

看了kcp的实现,其实发现和传输层的TCP差不多,只不过进行一下微调和可控。比如牺牲流控保证数据包的实时传输。或者加速重传等等。还有通过una和ack实现选择性重传。总的来说用于游戏帧同步或者数据实时传输领域还是有一定的优势。


推荐阅读
  • 一.avcodec_find_decoder获取解码器。在使用之前必须保证所用到的解码器已经注册,最简单的就是调用avcodec_register_all()函数,就像之前注册 ... [详细]
  • 本文介绍了Python高级网络编程及TCP/IP协议簇的OSI七层模型。首先简单介绍了七层模型的各层及其封装解封装过程。然后讨论了程序开发中涉及到的网络通信内容,主要包括TCP协议、UDP协议和IPV4协议。最后还介绍了socket编程、聊天socket实现、远程执行命令、上传文件、socketserver及其源码分析等相关内容。 ... [详细]
  • 在Docker中,将主机目录挂载到容器中作为volume使用时,常常会遇到文件权限问题。这是因为容器内外的UID不同所导致的。本文介绍了解决这个问题的方法,包括使用gosu和suexec工具以及在Dockerfile中配置volume的权限。通过这些方法,可以避免在使用Docker时出现无写权限的情况。 ... [详细]
  • Centos7.6安装Gitlab教程及注意事项
    本文介绍了在Centos7.6系统下安装Gitlab的详细教程,并提供了一些注意事项。教程包括查看系统版本、安装必要的软件包、配置防火墙等步骤。同时,还强调了使用阿里云服务器时的特殊配置需求,以及建议至少4GB的可用RAM来运行GitLab。 ... [详细]
  • 计算机存储系统的层次结构及其优势
    本文介绍了计算机存储系统的层次结构,包括高速缓存、主存储器和辅助存储器三个层次。通过分层存储数据可以提高程序的执行效率。计算机存储系统的层次结构将各种不同存储容量、存取速度和价格的存储器有机组合成整体,形成可寻址存储空间比主存储器空间大得多的存储整体。由于辅助存储器容量大、价格低,使得整体存储系统的平均价格降低。同时,高速缓存的存取速度可以和CPU的工作速度相匹配,进一步提高程序执行效率。 ... [详细]
  • 解决github访问慢的问题的方法集锦
    本文总结了国内用户在访问github网站时可能遇到的加载慢的问题,并提供了解决方法,其中包括修改hosts文件来加速访问。 ... [详细]
  • 本文讨论了如何使用GStreamer来删除H264格式视频文件中的中间部分,而不需要进行重编码。作者提出了使用gst_element_seek(...)函数来实现这个目标的思路,并提到遇到了一个解决不了的BUG。文章还列举了8个解决方案,希望能够得到更好的思路。 ... [详细]
  • java实现rstp格式转换使用ffmpeg实现linux命令第一步安装node.js和ffmpeg第二步搭建node.js启动websocket接收服务
    java实现rstp格式转换使用ffmpeg实现linux命令第一步安装node.js和ffmpeg第二步搭建node.js启动websocket接收服务第三步java实现 ... [详细]
  • POCOCLibraies属于功能广泛、轻量级别的开源框架库,它拥有媲美Boost库的功能以及较小的体积广泛应用在物联网平台、工业自动化等领域。POCOCLibrai ... [详细]
  • IP双栈环境下网络应用迁移
    IPv4向IPv6迁移有多种途径,在选择具体的迁移方式时,当前环境中运行的应用是否支持IPv6是重要的考量因素之一,同时在编写新的应用时,需要考虑新编写的应用不仅可以适应当前主流的IPv4环境, ... [详细]
  • python计算数据包校验和(python接口数据校验)
    本文目录一览:1、怎么用python算p值和t检验 ... [详细]
  • Java开发实战讲解!字节跳动三场技术面+HR面
    二、回顾整理阿里面试题基本就这样了,还有一些零星的问题想不起来了,答案也整理出来了。自我介绍JVM如何加载一个类的过程,双亲委派模型中有 ... [详细]
  • windows平台使用NSP拦截具体进程的域名解析过程(xFsRedir的代理功能之域名代理)
    byfanxiushu2022-10-17转载或引用请注明原始作者。xFsRedir软件其中之一的功能就是实现了全方位的网络代理,从主机代理,到本地代理 ... [详细]
  • MACAndroidStudioNDK环境环境下FFmpeg的编译
    MAC-AndroidStudio-NDK环境环境下FFmpeg的编译环境:macOS10.15.4AndroidStudio3.5.3ffmpeg3.3.6nd ... [详细]
  • ffmpeg【百度百科】
    FFmpeg是一个开源免费跨平台的视频和音频流方案,属于自由软件,采用LGPL或GPL许可证(依据你选择的组件)。它提供了录 ... [详细]
author-avatar
芋荌源
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有