KCP 在 UDP 上做可靠传输:RTO 不翻倍、选择性重传与快速重传
声明: KCP 是一个开源项目,作者为 林伟 (skywind3000)。本文是阅读该项目源码和文档后整理的学习笔记,用于理解可靠传输协议的工程实现方式。本文作者不是该项目的开发者,未参与该项目的任何代码贡献。 文中所有工程细节均来自对开源代码的分析,不代表本文作者的设计决策。
项目仓库:github.com/skywind3000/kcp(UDP 之上的快速可靠 ARQ 协议)
TCP 在公网丢包时延迟表现不理想。超时重传让 RTO 翻倍,丢包后触发全部重传,延迟 ACK 又抬高 RTT 估算。这些机制为最大化吞吐量设计,代价是丢包时延迟显著上升。
游戏、实时视频、VPN 隧道对延迟敏感,能容忍少量丢包和带宽冗余,但不能接受几百毫秒的卡顿。KCP 为这类场景设计,是一套快速可靠 ARQ 协议:比 TCP 多消耗 10% 至 20% 带宽,换取平均延迟下降 30% 至 40%、最大延迟下降约三倍。
协议只有 ikcp.h 与 ikcp.c 两个文件,不含任何系统调用。底层收发、时钟源、定时调度全部由使用者通过回调提供。
协议分层:KCP 只做 ARQ 算法层
KCP 是一套纯 ARQ 算法层,不含完整网络库的收发与调度职责,连时钟也由外部传入。
创建 KCP 对象后,使用者提供三样东西:
- 输出回调:KCP 组装好协议包,通过
output回调交给使用者发送。底层是 UDP、串口还是其他通道,KCP 不关心。 - 时钟:
ikcp_update(kcp, current)需要外部传入当前毫秒时间戳。内部所有超时判断都基于这个时间。 - 定时调度:使用者周期调用
ikcp_update,或用ikcp_check获取下次调度的精确时间点。KCP 内部不启动任何定时器。
完整帧输入:ikcp_input 每次调用必须传入一个完整的 KCP 段:24 字节头部加上 len 字段指定的数据体,缺一不可。源码中 size < len 直接返回 -2,内部没有对不完整段的缓冲。KCP 设计时假设底层是 UDP 这类数据报传输,一个 send 对应一个完整段;如果底层是串口、TCP 隧道等流式通道,调用方必须自己做帧定界,收齐一个完整段再交给 ikcp_input,否则会被当作协议错误。
收到数据调 ikcp_input 喂入完整段,发送调 ikcp_send,接收调 ikcp_recv。五个 API 构成完整交互面。
这种拆开的设计让 KCP 可嵌入任意协议栈:下层加 FEC 纠删码,上层加流加密,握手层做非对称密钥交换。每个协议单元独立可替换。
段格式与四条内部队列
KCP 只有一种包格式,数据和控制消息共用同一个 24 字节头部:
- conv:会话编号,两端必须一致,用于区分不同连接
- cmd:命令类型,PUSH(81) 数据推送、ACK(82) 确认、WASK(83) 窗口探测、WINS(84) 窗口通知
- frg:分片编号,从
count-1递减到 0,接收端据此合并分片 - wnd:接收窗口剩余大小,用于流量控制
- ts:时间戳,用于 RTT 计算
- sn:包序号
- una:未确认序号,此编号之前的包全部已收到
- len:后续数据体字节数
注意 una 字段:KCP 的每个包(包括数据包)都携带 UNA 信息。TCP 只在 ACK 包中确认,KCP 让每个发出的包同时承担 ACK 职责。即使反向没有独立 ACK 包,确认信息也能顺带到达。
KCP 内部维护四条队列,收发两条路径各自独立:
ikcp_send 把数据写入 snd_queue,ikcp_flush 将其搬到 snd_buf,分配序号与时间戳,交给 output 发送。snd_buf 中已发送的包等待对方确认,确认后才移除。
接收端 ikcp_input 把收到的包插入 rcv_buf(按序号排序的接收缓冲),再按序搬到 rcv_queue。rcv_queue 中的数据连续可读,ikcp_recv 从这里取走。
RTO 不翻倍与选择性重传
TCP 在超时重传时把 RTO 翻倍(RTO × 2),连续丢三次包变成 RTO × 8。丢包越多,等待时间指数增长。
KCP 在快速模式下,RTO 每次只乘 1.5。RTO 增长曲线被压低,丢包后恢复更快。配合 rx_minrto 可设到 30ms(TCP 默认最小 RTO 为 200ms),进一步压低等待下限。
重传策略上,TCP 采用累积确认:收到 ACK=N 只表示 N 之前的包全收到。如果包 2 丢失、包 3/4/5 已到达,TCP 仍只确认到包 1,发送方必须从包 2 开始全部重传(Go-Back-N)。
KCP 同时使用 UNA 和独立 ACK。收到包 3 时,除了 UNA 告知"包 1 之前全收到",还会为包 3 单独生成 ACK。发送方收到 ACK 1、3、4、5 后,知道包 2 被跳过,只重传包 2。
两种确认的组合让 KCP 不必在"全部重传"与"每条都独立 ACK 开销过大"之间二选一。
快速重传:不等超时的 fastack 计数
选择性重传避免了全部重传,但仍要等到超时。KCP 的快速重传机制在超时前即可触发重传。
每个已发送的段(IKCPSEG)内部维护一个 fastack 计数器。当收到的 ACK 跳过了某个包,包 2 的 fastack 随之累加:收到 ACK 3 时加 1,收到 ACK 4 时再加 1。fastack 达到阈值(fastresend 参数,通常设为 2)时,不等超时,立即重传。
源码中 ikcp_parse_fastack 负责更新计数器,ikcp_flush 遍历 snd_buf 时检查 segment->fastack >= resent,满足条件就标记 needsend = 1。
快速重传配合 RTO × 1.5 和低 rx_minrto,形成 KCP 降低延迟的三层机制:丢包时先靠快速重传抢时间,抢不到再靠压低后的超时兜底。
可关闭的拥塞控制与可插拔 CC 接口
KCP 内置的流控算法与 TCP 类似:慢启动阶段 cwnd 指数增长,拥塞避免阶段线性增长,丢包时 ssthresh 减半、cwnd 回退。
但 KCP 允许跳过拥塞控制。调用 ikcp_nodelay(kcp, 1, 10, 2, 1) 时,最后一个参数 nc=1 关闭流控。此时的发送窗口仅由 snd_wnd 和远端 rmt_wnd 决定,不再受丢包退让和慢启动约束。代价是牺牲公平性,网络上其他 TCP 流可能在竞争中吃亏。
KCP V2 引入了可插拔的拥塞控制接口 IKCPOPS:
struct IKCPOPS {
const char *name;
int (*init)(ikcpcb *kcp);
void (*release)(ikcpcb *kcp);
void (*on_ack)(ikcpcb *kcp, IUINT32 acked_segs, IUINT32 acked_bytes, IUINT32 prior_in_flight);
void (*on_fast_retransmit)(ikcpcb *kcp, IUINT32 fast_retrans, IUINT32 inflight, IUINT32 prior_cwnd);
void (*on_timeout)(ikcpcb *kcp, IUINT32 prior_cwnd);
// ...
};通过 ikcp_setcc 可以替换整套流控算法,不需要修改协议本身。内置算法与自定义算法可在同一份 KCP 代码上切换。
适用边界与上层职责
KCP 已被多个用户规模上亿的项目采用:米哈游《原神》用 KCP 降低游戏消息传输耗时,SpatialOS 用它加速大型多人游戏服务端,网易 CC 和 BOBO 用它加速视频推流,kcptun 用它做 TCP over UDP 隧道加速。
KCP 适合的场景特征:延迟敏感、能容忍适度带宽增加、数据包较小且频繁发送。不适合的场景:需要严格公平共享带宽、传输大文件追求最高吞吐量。
工程上要明确两层职责边界:KCP 默认不加密,加密由上层处理;KCP 不负责握手和连接管理,会话建立和 conv 分配由上层协议完成。
源码只有两个文件,阅读入口清晰:
ikcp.h:数据结构定义(IKCPSEG、IKCPCB、IKCPOPS)和全部 API 声明ikcp.c:ikcp_input解析输入包并驱动 ACK/重传逻辑,ikcp_flush组装输出包并执行发送,ikcp_update按间隔触发 flush
本文核对的源码基线:KCP commit b1a7a21