文森特·梵高《麦田群鸦》像素版
(本文基本逻辑:KCP 协议简介 → KCP 协议特性 → KCP 协议基本使用方式 → KCP 协议最佳实践)
1、协议简介
KCP 是一个开源的快速可靠协议,项目地址:https://github.com/skywind3000/kcp。KCP 能以比 TCP 浪费 10%-20% 带宽的代价,换取平均延迟降低 30%-40%,最大延迟降低 3 倍的传输速度。KCP 是一层纯算法实现,并不负责底层协议(如 UDP)的收发,需要使用者自己定义下层数据包的发送方式,并以 callback 的方式提供给 KCP。包括时钟也需要外部传递进来,内部不会有任何一次系统调用。
KCP 整个协议的实现只有 ikcp.h
和 ikcp.c
两个源文件,可以方便的集成到用户自己的协议栈中。比如你实现了一个 P2P,或者某个基于 UDP 的协议,而缺乏一套完善的 ARQ(自动重传请求,Automatic Repeat-reQuest)实现,那么简单的拷贝这两个文件到现有项目中,稍加改造适配,即可使用。
2、协议特性
TCP 是为流量设计的(每秒内可以传输的数据量),追求的是充分利用带宽。而 KCP 是为流速设计的(单个数据包从一端发送到另一端需要的时间),以 10%-20% 带宽浪费的代价换取了比 TCP 快 30%-40% 的传输速度。TCP 信道是一条流速很慢,但每秒流量很大的大运河,而 KCP 是水流湍急的小激流。KCP 有正常模式和快速模式两种,快速模式下通过多种策略达到提高流速的结果。
2.1、RTO 不翻倍
RTO(Retransmission TimeOut),即重传超时时间。
TCP 超时计算是 RTOx2
,这样连续丢三次包就变成 RTOx8
了,十分恐怖,而 KCP 启动快速模式后不 x2
,只是 x1.5
(实验证明 1.5 这个值相对比较好),提高了传输速度。
2.2、选择性重传
TCP 丢包时会对丢的那个包开始以后的数据全部重传,KCP 则是选择性重传,只重传真正丢失的数据包。
KCP 采用滑动窗口机制来提高发送速度。由于 UDP 是不可靠的传输方式,会存在丢包、包的乱序。而 KCP 是可靠的且保证数据有序的协议。为了保证包的顺序,接收方会维护一个接收窗口,接收窗口有一个起始序号 rcv_nxt(待接收消息序号)
以及尾序号 rcv_nxt rcv_wnd(接收窗口大小)
。如果接收窗口收到序号为 rcv_nxt
的分片(刚好是接收端待接收的消息序号),那么 rcv_nxt
就加 1,也就是滑动窗口右移,并把该数据放入接收队列供应用层取用。如果收到的数据在窗口范围内但不是 rcv_nxt
,那么就把数据保存起来,等收到 rcv_nxt
序号的分片时再一并放入接收队列供应用层取用。
当丢包发生的时候,假设第 n 个包丢失了,但是第 n 1、n 2 个包都已经传输成功了,此时只重传第 n 个包,而不重传成功传输的 n 1、n 2 号包,这就是选择重传。为了能够做到选择重传,接收方需要告诉发送方哪些包它收到了。比如在返回的 ACK 中包含 rcv_nxt
和 sn
,rcv_nxt
的含义是接收方已经成功按顺序接收了 rcv_nxt
序号之前的所有包,大于 rcv_nxt
的序号 sn
表示的是在接收窗口内的不连续的包。那么根据这两个参数就可以计算出哪些包没有收到了。发送方接收到接收方发过来的数据时,首先解析 rcv_nxt
,把所有小于 rcv_nxt
序号的包从发送缓存队列中移除。然后再解析 sn
(大于 rcv_nxt
),遍历发送缓存队列,找到所有序号小于 sn
的包,根据我们设置的快速重传的阈值,对每个分片维护一个快速重传的计数,每收到一个 ACK 解析 sn
后找到了一个分片,就把该分片的快速重传的计数加 1,如果该计数达到了快速重传阈值,那么就认为该分片已经丢失,可以触发快速重传,该阈值在 KCP 中可以设置。
2.3、快速重传
发送端发送了 1,2,3,4,5
几个包,然后收到远端的 ACK:1,3,4,5
,当收到 ACK 3
时,KCP 知道 2
被跳过 1 次,收到 ACK 4
时,知道 2
被跳过了 2 次,当次数大于等于设置的 resend 的值的时候,此时可以认为 2
号丢失,不用等待超时,直接重传 2
号包,大大改善了丢包时的传输速度。这就是 KCP 的快速重传机制。
2.4、ACK UNA
ARQ(自动重传请求,Automatic Repeat-reQuest)模型响应有两种:UNA(表示此编号之前的所有包已收到,如 TCP)和 ACK(表示此编号包已收到)。只用 UNA 会导致丢包后只能全部重传,只用 ACK 则丢包后维护成本太高(某个中间包丢掉后,可能需要维护比较多的后续包的缓存),以往协议都是二选其一,而 KCP 协议中,除了单独的 ACK 包外,其他所有包都有 UNA 信息。
2.5、非退让流控
KCP 正常模式同 TCP 一样使用公平退让法则,即发送窗口大小由:发送端发送缓存大小
、接收端剩余接收缓存大小
、丢包退让
、慢启动
这四要素决定。但对于传送即时性要求很高的小数据时,可选择通过配置跳过后两步,仅用前两项来控制发送频率。以牺牲部分公平性和带宽利用率的代价,换取了流畅传输的收益。
3、基本使用
KCP 的 input/output 方法用来对接下层的 UDP 收发模块。而 ikcp_send
、ikcp_recv
提供给上层逻辑调用实现协议的收发。
KCP 的数据格式如下图所示:
KCP 的发送和接收单元是 segment,即应用层的数据可能会拆分成多个 segment 发送。
conv
:会话编号。cmd
:segment 类型,IKCP_CMD_ACK、IKCP_CMD_PUSH、IKCP_CMD_WASK、IKCP_CMD_WINS。frg
:是否最后一个 segment。0:数据包的最后一个 segment;1:数据包的中间切片 segment。sn
:下一个待发 segment 的序号。una
:待接收消息序号,表示这之前的所有 segment 都收到了。len
:segment 数据长度,不包含头。
3.1、发送端
在发送端应用层通过 kcp_send
发送数据,KCP 会把用户数据拆分 KCP 数据包,通过 kcp_output
再以 UDP 包的方式发送出去。
1)创建 KCP 对象:
代码语言:javascript复制// 初始化 kcp 对象,conv 为一个表示会话编号的整数。
// 和 tcp 的 conv 一样,通信双方需保证 conv 相同,相互的数据包才能够被认可,user 是一个给回调函数的指针。
ikcpcb *kcp = ikcp_create(conv, user);
2)设置回调函数:
代码语言:javascript复制// KCP 下层协议的输出函数,KCP 需要发送数据时会调用它。
// buf/len 表示缓存和长度。
// user 指针为 kcp 对象创建时传入的值,用于区别多个 KCP 对象。
int udp_output(const char *buf, int len, ikcpcb *kcp, void *user) {
....
}
// 设置回调函数。ikcp_setoutput(kcp, udp_output);
3)发送数据:
代码语言:javascript复制int ikcp_send(ikcpcb *kcp, const char *buffer, int len);
代码语言:javascript复制4)循环调用 update:
代码语言:javascript复制// 以一定频率调用 ikcp_update 来更新 kcp 状态,并且传入当前时钟(单位毫秒)。
// 如 10ms 调用一次,或用 ikcp_check 确定下次调用 update 的时间不必每次调用。
ikcp_update(kcp, millisec);
3.2、接收端
在接收端,UDP 收到的包,不断通过 kcp_input
喂给 KCP,KCP 会对这部分数据(KCP 协议数据)进行解包,重新封装成应用层用户数据,应用层通过 kcp_recv
获取。
1)创建 KCP 对象:
代码语言:javascript复制// 初始化 kcp 对象,conv 为一个表示会话编号的整数。
// 和 tcp 的 conv 一样,通信双方需保证 conv 相同,相互的数据包才能够被认可,user 是一个给回调函数的指针。
ikcpcb *kcp = ikcp_create(conv, user)
2)读取一个下层协议的数据包:
代码语言:javascript复制// 收到一个下层协议的数据包(比如 UDP 包)时需要调用:
ikcp_input(kcp, received_udp_packet, received_udp_size);
3)将 KCP 数据还原成发送端发送的 buffer 数据给应用层:
代码语言:javascript复制int ikcp_recv(ikcpcb *kcp, char *buffer, int len);
代码语言:javascript复制3.3、协议配置
协议默认模式是一个标准的 ARQ,需要通过配置打开各项加速开关:
1)工作模式:
代码语言:javascript复制int ikcp_nodelay(ikcpcb *kcp, int nodelay, int interval, int resend, int nc)
代码语言:javascript复制nodelay:是否启用 nodelay 模式。0 不启用,1 启用。interval:协议内部工作的 interval,单位毫秒,比如 10ms 或者 20ms。resend:快速重传模式,默认 0 关闭,可以设置 2(2 次 ACK 跨越将会直接重传)。nc:是否关闭流控,默认是 0 代表不关闭,1 代表关闭。
按模式设置:
- 普通模式:
ikcp_nodelay(kcp, 0, 40, 0, 0);
。 - 极速模式:
ikcp_nodelay(kcp, 1, 10, 2, 1);
。
2)最大窗口:
代码语言:javascript复制int ikcp_wndsize(ikcpcb *kcp, int sndwnd, int rcvwnd);
代码语言:javascript复制该调用将会设置协议的最大发送窗口和最大接收窗口大小,默认为 32。这个可以理解为 TCP 的 SND_BUF 和 RCV_BUF,只不过单位不一样 SND/RCV_BUF 单位是字节,这个单位是包。
3)最大传输单元:
纯算法协议并不负责探测 MTU,默认 MTU 是 1400 字节,可以使用 ikcp_setmtu
来设置该值。该值将会影响数据包归并及分片时候的最大传输单元。
4)最小 RTO:
不管是 TCP 还是 KCP 计算 RTO 时都有最小 RTO 的限制,即便计算出来 RTO 为 40ms,由于默认的 RTO 是 100ms,协议只有在 100ms 后才能检测到丢包,快速模式下为 30ms,可以手动更改该值:
代码语言:javascript复制kcp->rx_minrto = 10;
代码语言:javascript复制4、最佳实践
4.1、内存分配器
默认 KCP 协议使用 malloc/free
进行内存分配释放,如果应用层接管了内存分配,可以用 ikcp_allocator
来设置新的内存分配器,注意要在一开始设置:
ikcp_allocator(my_new_malloc, my_new_free);
代码语言:javascript复制4.2、注意前向纠错
为了进一步提高传输速度,下层协议也许会使用前向纠错技术。前向纠错会根据冗余信息解出原始数据包。这里就需要注意,相同的原始数据包不要两次 input 到 KCP,否则将会导致 KCP 以为对方重发了,这样会产生更多的 ACK 占用额外带宽。
比如下层协议使用最简单的冗余包:单个数据包除了自己外,还会重复存储一次上一个数据包,以及上上一个数据包的内容:
代码语言:javascript复制Fn = (Pn, Pn-1, Pn-2)
P0 = (0, X, X)
P1 = (1, 0, X)
P2 = (2, 1, 0)
P3 = (3, 2, 1)
这样几个包发送出去,接收方对于单个原始包都可能被解出 3 次来(后面两个包仍然会重复该包内容),那么这里需要记录一下,一个下层数据包只会 input 给 KCP 一次,避免过多重复 ACK 带来的浪费。
4.3、管理大规模连接
如果需要同时管理大规模的 KCP 连接(比如大于 3000 个),比如你正在实现一套类 epoll 的机制,那么为了避免每秒钟对每个连接大量调用 ikcp_update
,我们可以使用 ikcp_check
来大大减少 ikcp_update
调用的次数。ikcp_check
返回值会告诉你需要在什么时间点再次调用 ikcp_update
(如果中途没有 ikcp_send
、ikcp_input
的话,否则中途调用了 ikcp_send
、ikcp_input
的话,需要在下一次 interval 时调用 ikcp_update
)。
标准顺序是每次调用了 ikcp_update
后,使用 ikcp_check
决定下次什么时间点再次调用 ikcp_update
,而如果中途发生了 ikcp_send
、ikcp_input
的话,在下一轮 interval 立马调用 ikcp_update
和 ikcp_check
。使用该方法,原来在处理 2000 个 KCP 连接且每个连接每 10ms 调用一次 update,改为 check 机制后,CPU 从 60% 降低到 15%。
4.4、避免缓存积累延迟
不管使用 TCP 还是 KCP,你都不可能超越信道限制的发送数据。TCP 的发送窗口 SNDBUF 决定了最多可以同时发送多少数据,KCP 的也一样。
当前发送且没有得到 ACK/UNA 确认的数据,都会滞留在发送缓存中,一旦滞留数据超过了发送窗口大小限制,则该连接的 TCP send 调用将会被阻塞,或者返回 EAGAIN/EWOULDBLOCK,这时候说明当前 TCP 信道可用带宽已经赶不上你的发送速度了。
代码语言:javascript复制可用带宽 = min(本地可用发送窗口大小,远端可用接收窗口大小) * (1 - 丢包率) / RTT
当你持续调用 ikcp_send
,首先会填满 KCP 的 snd_buf
,如果 snd_buf
的大小超过发送窗口 snd_wnd
限制,则会停止向 snd_buf
里追加数据包,只会放在 snd_queue
里面滞留着,等待 snd_buf
有新位置了(因为收到远端 ACK/UNA 而将历史包从 snd_buf
中移除),才会从 snd_queue
转移到 snd_buf
,等待发送。
TCP 发送窗口满了不能发送了,会阻塞住或者 EAGAIN/EWOULDBLOCK;KCP 发送窗口满了,ikcp_send
并不会给你返回 -1,而是让数据滞留在 snd_queue
里等待有能力时再发送。
因此,千万不要以为 ikcp_send
可以无节制的调用,为什么 KCP 在发送窗口满的时候不返回错误呢?这个问题当年设计时权衡过,如果返回希望发送时返回错误的 EAGAIN/EWOULDBLOCK 你势必外层还需要建立一个缓存,等到下次再测试是否可以 send。那么还不如 KCP 直接把这一层缓存做了,让上层更简单些,而且具体要如何处理 EAGAIN,可以让上层通过检测 ikcp_waitsnd
函数来判断还有多少包没有发出去,灵活抉择是否向 snd_queue
缓存追加数据包还是其他。
4.4.1、重设窗口大小
要解决上面的问题首先对你的使用带宽有一个预计,并根据上面的公式重新设置发送窗口和接收窗口大小,你写后端,想追求 TCP 的性能,也会需要重新设置 TCP 的 sndbuf
、rcvbuf
的大小,KCP 默认发送窗口和接收窗口大小都比较小而已(默认 32 个包),你可以朝着 64、128、256、512、1024 等档次往上调,kcptun
默认发送窗口 1024,用来传高清视频已经足够,游戏的话,32-256 应该满足。
不设置的话,如果默认 snd_wnd
太小,网络不是那么顺畅,你越来越多的数据会滞留在 snd_queue
里得不到发送,你的延迟会越来越大。
设定了 snd_wnd
,远端的 rcv_wnd
也需要相应扩大,并且不小于发送端的 snd_wnd
大小,否则设置没意义。
4.4.2、缓存控制策略
其次对于成熟的后端业务,不管用 TCP 还是 KCP,你都需要实现相关缓存控制策略:
1)传送文件
你用 TCP 传文件的话,当网络没能力了,你的 send 调用要不就是阻塞掉,要不就是 EAGAIN,然后需要通过 epoll 检查 EPOLL_OUT
事件来决定下次什么时候可以继续发送。
KCP 也一样,如果 ikcp_waitsnd
超过阈值,比如 2 倍 snd_wnd
,那么停止调用 ikcp_send
、ikcp_waitsnd
的值降下来,当然期间要保持 ikcp_update
调用。
2)实时视频直播
视频点播和传文件一样,而视频直播,一旦 ikcp_waitsnd
超过阈值了,除了不再往 KCP 里发送新的数据包,你的视频应该进入一个「丢帧」状态,直到 ikcp_waitsnd
降低到阈值的 1/2,这样你的视频才不会有积累延迟。
这和使用 TCP 推流时碰到 EAGAIN 期间,要主动丢帧的逻辑时一样的。
同时,如果你能做的更好点,waitsnd
超过阈值了,代表一段时间内网络传输能力下降了,此时你应该动态降低视频质量,减少码率,等网络恢复了你再恢复。
3)游戏控制数据
大部分逻辑严密的 TCP 游戏服务器,都是使用无阻塞的 TCP 连接配套个 epoll 之类的东西,当后端业务向用户发送数据时会追加到用户空间的一块发送缓存,比如 ring buffer 之类,当 epoll 到 EPOLL_OUT
事件时(其实也就是 TCP 发送缓存有空余了,不会 EAGAIN/EWOULDBLOCK 的时候),再把 ring buffer 里面暂存的数据使用 send 传递给系统的 SNDBUF,直到再次 EAGAIN。
那么 TCP Server 的后端业务持续向客户端发送数据,而客户端又迟迟没能力接收怎么办呢?此时 epoll 会长期不返回 EPOLL_OUT
事件,数据会堆积再该用户的 ring buffer 之中,如果堆积越来越多,ring buffer 会自增长的话就会把 Server 的内存给耗尽。因此成熟的 TCP 游戏服务器的做法是:当客户端应用层发送缓存(非 TCP 的 sndbuf
)中待发送数据超过一定阈值,就断开 TCP 链接,因为该用户没有接收能力了,无法持续接收游戏数据。
使用 KCP 发送游戏数据也一样,当 ikcp_waitsnd
返回值超过一定限度时,你应该断开远端链接,因为他们没有能力接收了。
但是需要注意的是,KCP 的默认窗口都是 32,比 TCP 的默认窗口低很多,实际使用时应提前调大窗口,但是为了公平性也不要无止尽放大(不要超过 1024)。
4)小结
缓存积累这个问题,不管是 TCP 还是 KCP 你都要处理,因为 TCP 默认窗口比较大,因此可能很多人并没有处理的意识。
当你碰到缓存延迟时:
- 1、检查
snd_wnd
、rcv_wnd
的值是否满足你的要求,根据上面的公式换算,每秒钟要发多少包,当前snd_wnd
是否满足条件。 - 2、确认是否打开了
ikcp_nodelay
,让各项加速特性得以运转,并确认 nc 参数是否设置,以关闭默认的类 TCP 保守流控方式。 - 3、确认
ikcp_update
调用频率是否满足要求(比如 10ms 一次)。
如果你还想更激进:
- 1、确认
minrto
是否设置,比如设置成 10ms,nodelay
只是设置成 30ms,更激进可以设置成 10ms 或者 5ms。 - 2、确认
interval
是否设置,可以更激进的设置成 5ms,让内部始终循环更快。 - 3、每次发送完数据包后,手动调用
ikcp_flush
。 - 4、降低 MTU 到 470,同样数据虽然会发更多的包,但是小包在路由层优先级更高。
如果你还想更快,可以在 KCP 下层增加前向纠错协议。
4.5、协议栈分层组装
不要试图将任何加密或者 FEC 相关代码实现到 KCP 里面,请实现成不同协议单元并组装成你的协议栈。
1)协议单元
一个纯算法的 KCP 对象,组成了一个干净独立的协议单元:
KCP 的 input/output 方法用来对接下层的 UDP 收发模块。而 ikcp_send
、ikcp_recv
提供给上层逻辑调用实现协议的收发。
2)协议组装
不同的协议单元模块可以串联起来,比如:
假设你设计了一套 FEC 协议,那么可以把 KCP 的 input/output 和 FEC 协议的 send/recv 串联起来,使 KCP 的 output 被调用时,把 KCP 希望发送的数据调用 FEC 的 send 方法传递给 FEC 模块,而从 FEC 模块 recv 到的数据再反向 input 给 KCP。
而原来直接和 KCP 接触的 UDP 传输层,就放到了 FEC 层下面,与 FEC 打交道,这样就完成了协议组装。
3)协议栈
你可能需要实现 UDP 会话管理、KCP、加密等若干功能,那么最好的做法就是把他们实现成协议单元,然后串联起来成为协议栈,这样每一层可以 单独开发调试,需要时再进行串联,这是网络库成熟的写法。
为了方便数据在协议栈中高性能的传递,你可以选择实现类似 Linux skbuf 的数据结构来管理各个数据包:
这样的数据结构方便在包的头部不断添加或者剥离数据,当数据包由最高层协议产生不断往下传递的过程是一个不断追加包头的过程,而数据接收回来,从最底层进入一直往上的过程是一个不断剥离包头的过程,使用 skbuf 数据结构利于避免追加/剥离包头时的频繁内存拷贝。
4.6、支持收发可靠和非可靠数据
有的产品可能除了需要可靠数据,还需要发送非可靠数据,那么 KCP 如何支持这种需求呢?很简单,你自己实现:
代码语言:javascript复制connection.send(channel, pkt, size);
代码语言:javascript复制channel == 0 使 KCP 发送可靠包,channel == 1 使用 UDP 发送非可靠包。
因为传输是你自己实现的,你可以在发送 UDP 包的头部加一个字节,来代表这个 channel
,收到远程来的 UDP 以后,也可以判断 channel == 0
的话,把剩下的数据给 ikcp_input
,否则剩下的数据为远程非可靠包。
这样你得到了一个新的发送函数,用 channel
来区别你想发送可靠数据还是非可靠数据。再统一封装一个 connection.recv
函数,先去 ikcp_recv
那里尝试收包,收不到的话,看刚才有没有收到 channel == 1
的裸 UDP 包,有的话返回给上层用户。
如果你的服务端是混用 TCP/UDP 的话,你还可以针对一些比较大的,延迟不敏感的数据设计一个 channel == 2
使用 TCP 发送数据。
5、和现有 TCP 服务器整合
KCP 可以用在 TCP 的基建上,在登陆时由服务端返回 UDP 端口和密钥,客户端通过 TCP 收到以后,向服务端的 UDP 端口每隔一秒重复发送包含握手信息,直到服务端返回成功或者失败。服务端通过 UDP 传上来的密钥得知该客户端 sockaddr
对应的 TCP 连接,这样就建立 TCP 连接到 UDP 连接的映射关系。为了保持连接和 NAT 出口映射,客户端一般需要每 60 秒就发送一个 UDP 心跳,服务端收到后回复客户端,再在这个 UDP 连接的基础上增加调用 KCP 的逻辑,实现快速可靠传输,这样一套 TCP/UDP 两用的传输系统就建立了。
可以参考下述例子:
- 1、客户端连接 TCP,服务端为该 TCP 连接分配一个整数 id 作为标识。
- 2、登录后服务端给客户端发送 UDP 握手信息,包括:自己的 UDP 端口、用户的 TCP 标识 id、32 位随机数 key。
- 3、客户端给服务端 UDP 地址发送握手信息,把刚才服务端发过来的
(id, key)
发送给服务端。 - 4、服务端确认 UDP 握手,并且记录该用户 UDP 远端地址。
- 5、以后客户端和服务端 UDP 通信,每个包都包含
(id, key)
。 - 6、服务端用客户端发上来的
(id, key)
,确认用户身份,并对比远端地址确认是否是合法用户。
注意,为了保持 NAT 映射关系,UDP 需要每隔 60 秒就像服务器 ping 一次。同时为了防止出口地址改变(NAT 映射改变,或者移动设备切换基站),可以使用重连或者 UDP 重绑定(但是在移动网络环境,出口改变,TCP 也就断了,所以简单重连也没问题)。
客户端和原有的 TCP 连接对象稍微封装一下,就得到一个类似 connection.send(channel, data, size)
的接口,channel == 0
时,使用原有 TCP 发送,channel == 1
时使用 KCP 发送,channel == 2
时使用裸的 UDP 发送。三个 channel 同时工作,适配不同情况。服务端接口也类似。
国内的网络情况比较特殊,会存在有些网络 UDP 连接不上的情况,因此都是先连接 TCP,然后尝试 UDP,UDP 不通的情况下,退回 TCP 也能正常使用,一旦 TCP 断开,则认为 UDP 也断开了。
本文参考
1)KCP 简介
https://github.com/skywind3000/kcp/wiki
2)KCP 协议快在哪 https://wetest.qq.com/lab/view/391.html
(通过上文的介绍,我们了解了 KCP 协议的特性和应用建议,在直播中选择 KCP 来降低延时是一个不错的选择。我们将在后面继续探讨其他常见的音视频协议,敬请期待)