1. UDP 协议基础
1.1 UDP 是什么
UDP(User Datagram Protocol,用户数据报协议)是传输层最简单的协议。它只在 IP 之上加了端口号和校验和,几乎不做任何额外的事情。
UDP 的定位:
IP 层:负责把数据包从 A 送到 B(主机到主机)
UDP 层:加了端口号(进程到进程),其他什么都不管
TCP 层:加了可靠性、顺序性、流量控制、拥塞控制...(UDP 全都没有)
1.2 UDP 首部结构
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
┌───────────────────────────────────────────────────────────────────┐
│ Source Port (16 bit) │ Destination Port (16 bit)│
├───────────────────────────────────────────────────────────────────┤
│ Length (16 bit) │ Checksum (16 bit) │
├───────────────────────────────────────────────────────────────────┤
│ Data (变长) │
└───────────────────────────────────────────────────────────────────┘
只有 8 字节!对比 TCP 最小 20 字节。
| 字段 | 大小 | 说明 |
|---|---|---|
| Source Port | 16 bit | 源端口号(可选,填0表示不需要回复) |
| Destination Port | 16 bit | 目的端口号 |
| Length | 16 bit | UDP 数据报长度(首部+数据,最小8) |
| Checksum | 16 bit | 校验和(可选,但在 IPv6 中必须) |
UDP 校验和 vs TCP 校验和:
- 都使用伪首部(源IP、目的IP、协议号、长度)
- UDP 的校验和是可选的(IPv4),TCP 是必须的
- 校验失败直接丢弃,不重传
1.3 UDP 的特点
UDP 做了什么:
✓ 多路复用/分用(端口号区分不同进程)
✓ 数据完整性校验(可选的校验和)
✓ 消息边界保留(每次 sendto 对应一次 recvfrom)
UDP 没做什么:
✗ 不建立连接(无握手/挥手)
✗ 不保证可靠(丢了就丢了)
✗ 不保证顺序(先发的可能后到)
✗ 不做流量控制(发多快全凭本事)
✗ 不做拥塞控制(不管网络状况)
✗ 不做重传(丢了就丢了)
1.4 UDP 的消息边界
这是 UDP 与 TCP 最重要的行为差异之一:
TCP(字节流):
send("Hello") → send("World")
可能收到:["HelloWorld"] ← 粘包
可能收到:["Hel"] ["loWor"] ["ld"] ← 拆包
→ TCP 不保留消息边界
UDP(数据报):
sendto("Hello") → sendto("World")
recvfrom() → "Hello" ← 一定是完整的一条消息
recvfrom() → "World" ← 一定是完整的一条消息
→ UDP 保留消息边界,一次 sendto = 一次 recvfrom
这意味着:
- UDP 天然适合"消息型"通信(命令、状态同步、事件通知)
- TCP 适合"流型"通信(文件传输、视频流)
- 如果用 UDP 传大消息,需要自己处理分片和重组
1.5 UDP 的典型应用
| 应用 | 为什么用 UDP | 可靠性需求 |
|---|---|---|
| DNS | 请求/响应模式,一个包搞定 | 应用层重试(超时再查一次) |
| DHCP | 客户端还没有 IP,只能广播 | 应用层重试 |
| SNMP | 网络管理,简单查询 | 低(丢了就下次再查) |
| NTP | 时间同步,小包 | 低 |
| 视频直播 | 实时性 > 可靠性 | 低(丢了就丢了,不影响后续) |
| 语音通话 | 延迟敏感,丢几帧无所谓 | 低 |
| 游戏状态同步 | 实时性最重要 | 中(旧数据过时,新数据才重要) |
| TFTP | 简单文件传输 | 高(应用层自己实现 ACK) |
1.6 UDP 为什么"快"
TCP 发一个包的开销:
1. 三次握手建立连接(1.5 RTT)
2. 数据加上 20 字节首部
3. 等待 ACK(或 Delayed ACK 40~200ms)
4. 可能触发拥塞控制,降低速率
5. 四次挥手关闭连接(至少 1 RTT + TIME_WAIT)
UDP 发一个包的开销:
1. 直接 sendto(0 RTT)
2. 数据加上 8 字节首部
3. 发完就忘,不等 ACK
4. 不管网络状况,全速发
5. 不需要关闭连接
2. UDP 的局限性与可靠性需求
2.1 UDP 本身不保证什么
场景:用 UDP 发送 10 个包 [1][2][3][4][5][6][7][8][9][10]
可能的结果:
接收方收到:[1][3][5][7][8][10] ← 丢了 2,4,6,9
接收方收到:[10][8][7][5][3][1] ← 全到了但顺序反了
接收方收到:[1][1][3][3][5][5] ← 重复了
接收方收到:什么都没有 ← 全丢了
UDP 不关心这些,它只负责"尽力而为"地把数据报送到对端。
2.2 什么时候 UDP 本身就够了
场景 1:一问一答(DNS)
客户端发一个请求 → 等响应 → 超时?重发请求
不需要复杂机制,应用层超时重试就够了
场景 2:实时音视频
发送方以固定速率发送音频/视频帧
丢了?跳过这帧,播放下一帧
重传没意义------旧帧已经过时了
场景 3:游戏状态广播
服务器每 50ms 广播一次游戏状态
客户端收到旧的?丢弃,等新的
客户端没收到?用预测/插值补偿
场景 4:物联网传感器上报
传感器每 10 秒上报一次温度
丢了?下一次 10 秒后又来了
不需要重传
2.3 什么时候需要在 UDP 上实现可靠性
场景 1:弱网下的可靠传输
4G/卫星链路,丢包率 5~10%
TCP 的拥塞控制把丢包当拥塞,速率暴跌
→ 用 UDP + 自定义可靠层,可以区分"丢包"和"拥塞"
场景 2:低延迟可靠传输
游戏操作指令,必须可靠但不能有延迟
TCP 的 Nagle + Delayed ACK 额外增加 40~200ms
→ 用 UDP + 自定义可靠层,去掉所有延迟优化
场景 3:自定义拥塞控制
TCP 的拥塞控制算法在内核,应用层无法修改
→ 用 UDP + 自定义拥塞控制,完全按需定制
场景 4:多路复用(避免队头阻塞)
TCP 的字节流特性:一个包丢了,后面全等
→ 用 UDP + 多个独立的可靠流,互不阻塞
这就是 KCP、QUIC 等协议出现的原因------UDP 提供了"原始的传输能力",上层可以在上面构建任意复杂度的可靠性机制。
3. KCP 协议深度解析
3.1 KCP 的设计目标
KCP 的作者 skywind3000 在设计文档中明确写道:
KCP 是一个快速可靠协议,能以比 TCP 浪费 10%-20% 的带宽的代价,换取平均延迟降低 30%-40%,且最大延迟降低三倍的传输效果。
KCP 的核心目标:最小化延迟
TCP 的设计哲学:
"在网络不拥塞的前提下,最大化吞吐量"
→ 一切机制(Nagle、Delayed ACK、拥塞控制)都为吞吐量服务
→ 牺牲了延迟
KCP 的设计哲学:
"在可接受的带宽开销下,最小化传输延迟"
→ 关闭所有延迟优化
→ 快速重传,不等超时
→ 可选关闭拥塞控制
→ 牺牲了部分带宽
3.2 KCP 的整体架构
┌──────────────────────────────────────────────────┐
│ 应用层 │
│ ikcp_send() / ikcp_recv() │
├──────────────────────────────────────────────────┤
│ KCP 层(用户态) │
│ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ 可靠传输 │ │ 流量控制 │ │ 拥塞控制(可选) │ │
│ │ ARQ机制 │ │ 窗口管理 │ │ 慢启动/拥塞避免 │ │
│ └─────────┘ └──────────┘ └──────────────────┘ │
│ ikcp_input() / ikcp_flush() │
├──────────────────────────────────────────────────┤
│ output 回调(用户注册的发送函数) │
├──────────────────────────────────────────────────┤
│ UDP 层(内核) │
│ sendto() / recvfrom() │
├──────────────────────────────────────────────────┤
│ IP 层 │
└──────────────────────────────────────────────────┘
关键设计 :KCP 不做网络 IO,它只负责"计算应该发什么",实际的发送由用户注册的 output 回调完成。这种设计让 KCP 可以运行在任何传输之上(UDP、蓝牙、串口、共享内存...)。
3.3 KCP 报文格式详解
KCP 报文头:24 字节
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
┌─────────────────────────────────────────────────────────────────┐
│ Conversation ID (32 bit) │
├─────────────────────────────────────────────────────────────────┤
│ Command (8 bit) │ Fragment Count (8 bit) │
├─────────────────────────────────────────────────────────────────┤
│ Window (16 bit) │ Timestamp (32 bit) │
├─────────────────────────────────────────────────────────────────┤
│ Sequence Number (32 bit) │
├─────────────────────────────────────────────────────────────────┤
│ Unacknowledged Number (32 bit) │
├─────────────────────────────────────────────────────────────────┤
│ Data Length (32 bit) │
├─────────────────────────────────────────────────────────────────┤
│ Data (变长,0~MSS 字节) │
└─────────────────────────────────────────────────────────────────┘
各字段详解:
| 字段 | 大小 | 说明 | 对应 TCP 概念 |
|---|---|---|---|
| Conversation ID | 32 bit | 会话标识,类似连接ID | 无(TCP 用四元组) |
| Command | 8 bit | 命令类型 | Flags(SYN/ACK/FIN等) |
| Fragment Count | 8 bit | 上层消息被拆成几片 | 无(TCP 是字节流) |
| Window | 16 bit | 本方通告窗口 | Window Size |
| Timestamp | 32 bit | 发送时间戳 | Timestamps Option |
| Sequence Number | 32 bit | 包序列号 | Seq |
| Unacknowledged Number | 32 bit | 累积确认号 | Ack |
| Data Length | 32 bit | 数据长度 | 无(TCP 从长度推算) |
Command 类型:
IKCP_CMD_PUSH = 81 // 数据推送(携带数据)
IKCP_CMD_ACK = 82 // 确认应答
IKCP_CMD_WASK = 83 // 窗口探测请求("你还有空间吗?")
IKCP_CMD_WINS = 84 // 窗口告知响应("我还有 X 字节空间")
3.4 KCP 的 ARQ 机制
3.4.1 发送流程
应用层调用 ikcp_send(data, len)
│
▼
┌─────────────────────────────────────────────┐
│ 1. 分片:如果 data > MSS,拆成多个 fragment │
│ 例如 data=3000字节, MSS=1400 │
│ → frag0: 1400字节 (frg=1,表示还有后续) │
│ → frag1: 1400字节 (frg=1) │
│ → frag2: 200字节 (frg=0,表示最后一片) │
│ 注意:frg 是倒序的,最后一个片 frg=0 │
├─────────────────────────────────────────────┤
│ 2. 入队:每个 fragment 包装成 IKCPSEG │
│ → 挂到 snd_queue(发送队列) │
│ → 设置 sn(序列号),sn++ │
└─────────────────────────────────────────────┘
│
▼ 等待 ikcp_update() 驱动
┌─────────────────────────────────────────────┐
│ 3. 移动:snd_queue → snd_buf │
│ 条件:snd_nxt - snd_una < cwnd │
│ && snd_nxt - snd_una < rmt_wnd │
│ 即:在途包数量 < 发送窗口 && < 远端窗口 │
├─────────────────────────────────────────────┤
│ 4. 发送:ikcp_flush() 遍历 snd_buf │
│ → 填写报文头(sn, una, wnd, ts, ...) │
│ → 调用 output 回调 → 实际 UDP 发送 │
│ → 设置重传定时器(resendts = now + rto) │
└─────────────────────────────────────────────┘
3.4.2 接收流程
网络收到 UDP 数据 → 应用调用 ikcp_input(buf, len)
│
▼
┌─────────────────────────────────────────────┐
│ 1. 解析报文头:sn, una, wnd, cmd, frg, ... │
├─────────────────────────────────────────────┤
│ 2. 处理 ACK:una 表示"una 之前的包我都收到了"│
│ → 遍历 snd_buf,释放 sn < una 的段 │
│ → 更新 snd_una │
│ → 计算 RTT(当前时间 - 段的发送时间戳) │
│ → 更新 rto(SRTT + max(AckDelay, minRTO))│
├─────────────────────────────────────────────┤
│ 3. 处理数据(cmd == PUSH): │
│ if (sn == rcv_nxt) { │
│ // 按序到达,直接放入 rcv_queue │
│ rcv_nxt++ │
│ // 检查 rcv_buf 中是否有后续的乱序包 │
│ // 如果有,按序移到 rcv_queue │
│ } else { │
│ // 乱序到达,插入 rcv_buf(按sn排序) │
│ } │
├─────────────────────────────────────────────┤
│ 4. 更新远端窗口:rmt_wnd = 段的 wnd 字段 │
└─────────────────────────────────────────────┘
│
▼
应用层调用 ikcp_recv(buf, len)
→ 从 rcv_queue 取出数据
→ 如果一个消息有多个 fragment,组装后返回
3.4.3 ACK 处理机制
KCP 的 ACK 与 TCP 有重要区别:
TCP 的 ACK:
- 累积确认:Ack=N 表示 N 之前的所有数据都收到了
- 延迟确认:收到数据后等 40~200ms 才发 ACK
- ACK 不能携带数据(纯 ACK 包)
KCP 的 ACK:
- 也是累积确认:una=N 表示 N 之前的包都收到了
- 但 KCP 还在每个 PUSH 包的 una 字段中携带确认信息
- 不做延迟确认:收到数据立即回 ACK(或在下次 flush 时捎带)
- ACK 列表:KCP 维护 acklist,记录所有需要确认的 (sn, ts) 对
KCP 的 ACK 列表工作方式:
收到数据包 sn=5, ts=1000
→ acklist.push(5, 1000)
收到数据包 sn=6, ts=1010
→ acklist.push(6, 1010)
下次 flush 时:
→ 遍历 acklist,对每个 (sn, ts) 发送一个 ACK 包
→ 或者在下一个 PUSH 包中捎带(una 字段)
为什么不用延迟确认?
→ 延迟确认增加了延迟!
→ KCP 的核心目标是最小化延迟,所以不用延迟确认
3.5 KCP 的重传机制
3.5.1 RTO 计算
c
// KCP 的 RTT 估算(类似 TCP,但更激进)
// 每次收到 ACK,计算 RTT 样本
rtt_sample = current_time - segment.send_timestamp
// 更新 SRTT 和 RTTVAR(与 TCP 相同的指数平滑)
if (first_sample) {
srtt = rtt_sample
rttvar = rtt_sample / 2
} else {
rttvar = (3/4) * rttvar + (1/4) * abs(srtt - rtt_sample)
srtt = (7/8) * srtt + (1/8) * rtt_sample
}
// 计算 RTO(关键区别在这里)
rto = srtt + max(rttvar * 4, minRTO) // TCP 的公式
rto = srtt + max(rttvar, minRTO) // KCP 的公式,去掉了 ×4
// minRTO 的区别
TCP: minRTO = 200ms(Linux 默认)
KCP: minRTO = 可配置,nodelay 模式下 10ms
RTO 退避策略:
TCP 的 RTO 退避:
第1次超时:RTO = 200ms
第2次超时:RTO = 400ms (翻倍)
第3次超时:RTO = 800ms (翻倍)
第4次超时:RTO = 1600ms (翻倍)
→ 指数退避,非常保守
KCP 的 RTO 退避(nodelay=1):
第1次超时:RTO = 10ms
第2次超时:RTO = 15ms (×1.5)
第3次超时:RTO = 22ms (×1.5)
第4次超时:RTO = 33ms (×1.5)
→ 线性退避(×1.5),非常激进
KCP 的 RTO 退避(nodelay=0,普通模式):
第1次超时:RTO = 100ms
第2次超时:RTO = 150ms (×1.5)
第3次超时:RTO = 225ms (×1.5)
→ 同样是 ×1.5 退避,但起始值更大
3.5.2 快速重传
KCP 的快速重传原理:
发送方发送了 [sn=1] [sn=2] [sn=3] [sn=4] [sn=5]
其中 [sn=2] 丢失
接收方:
收到 sn=1 → rcv_nxt=2 → 回复 ACK(una=2)
收到 sn=3 → 乱序,插入 rcv_buf → 回复 ACK(una=2)
收到 sn=4 → 乱序,插入 rcv_buf → 回复 ACK(una=2)
收到 sn=5 → 乱序,插入 rcv_buf → 回复 ACK(una=2)
发送方:
收到 ACK(una=2) → snd_una 前进到 2
→ 在 snd_buf 中,sn=3,4,5 的 fastack 计数器递增
(因为 una=2 跳过了它们,说明它们"可能"丢了)
当 sn=3 的 fastack >= resend 阈值(如2)时:
→ 立即重传 sn=3 对应的包(不等 RTO 超时)
fastack 计数器的工作方式:
c
// 伪代码:收到 ACK 时更新 fastack
void update_fastack(IKCPCB *kcp, IUINT32 una, IUINT32 sn) {
// 遍历 snd_buf 中所有 sn >= una 的段
for (seg in snd_buf) {
if (seg->sn >= una && seg->sn < sn) {
// 这个段被"跳过"了(una 没有确认它,但后续的 ACK 跳过了它)
seg->fastack++
}
}
}
// flush 时检查是否需要快速重传
for (seg in snd_buf) {
if (seg->fastack >= kcp->resend) {
// 触发快速重传!
retransmit(seg)
seg->fastack = 0
}
}
与 TCP 快速重传的对比:
TCP:
收到 3 个重复 ACK → 重传
"重复 ACK" = ACK 号与上一个相同
需要至少 3 个包到达才能触发
KCP:
fastack >= resend(默认2)→ 重传
"fastack 递增" = 该包被后续的 ACK 跳过
只需要 2 个后续包到达就能触发
触发速度对比(假设 RTT=100ms):
TCP:3 个 dupACK × 100ms = 300ms 后触发快速重传
KCP:2 个跨越 ACK × 10ms(interval) = 20ms 后触发快速重传
3.5.3 重传流程
场景:发送 [1][2][3][4][5],[2] 丢失
时间线:
t=0ms 发送 [1][2][3][4][5](全部进入 snd_buf)
t=50ms 收到 ACK(una=2) → [1] 确认,[2][3][4][5] 在途
t=60ms 收到 ACK(una=2) → [3] 的 fastack=1
t=70ms 收到 ACK(una=2) → [3] 的 fastack=2 ≥ resend
→ 【快速重传】[2]
t=120ms 收到 ACK(una=6) → [2][3][4][5] 全部确认
对比 TCP(RTO=200ms):
t=0ms 发送 [1][2][3][4][5]
t=50ms 收到 ACK=2
t=100ms 收到 dupACK=2 (#1)
t=150ms 收到 dupACK=2 (#2)
t=200ms 收到 dupACK=2 (#3) → 【快速重传】[2]
t=250ms 收到 ACK=6
KCP 在 t=70ms 就重传了,TCP 在 t=200ms 才重传
KCP 快了 130ms
3.6 KCP 的窗口管理
KCP 的窗口结构:
发送端:
snd_una ──────────── snd_nxt ──────────── snd_una + cwnd
│ │ │
├── 已确认(可释放)──┤──── 在途(等待ACK)───┤──── 还能发 ────┤
│ │ │
└────────────────────┴──────────────────────┘
snd_queue:应用层写入但还没移到 snd_buf 的数据
snd_buf:已发送但未确认的数据(在途)
接收端:
rcv_nxt ──────────────────── rcv_nxt + rcv_wnd
│ │
├── 期望收到的下一个 ────────────┤──── 接收窗口 ────┤
rcv_queue:按序到达、等待应用读取的数据
rcv_buf:乱序到达、等待前序包的数据
窗口大小的影响:
窗口太小(如 32 个包):
┌──────────────────────┐
│ 发送窗口只有 32 个包 │
│ 发完 32 个就停了 │
│ 等 ACK 回来才能继续发 │
│ → 吞吐量受限 │
└──────────────────────┘
BDP = 带宽 × RTT
例如:10Mbps × 100ms = 125KB
125KB / 1400B = 89 个包
窗口 32 < 89 → 吞吐量只有 10Mbps × (32/89) = 3.6Mbps
窗口足够大(如 256 个包):
256 > 89 → 可以填满管道,吞吐量接近 10Mbps
建议:窗口 ≥ BDP / MSS
BDP = 带宽 × RTT
窗口 = BDP / MSS + 余量
3.7 KCP 的拥塞控制
KCP 实现了可选的拥塞控制,类似 TCP Reno:
KCP 拥塞控制状态机:
┌──────────┐ cwnd < ssthresh ┌──────────┐
│ 慢启动 │──────────────────────→│ 拥塞避免 │
│ cwnd++ │ 每 RTT 翻倍 │ cwnd++ │
│ 每ACK+1 │ │ 每RTT+1 │
└──────────┘ └──────────┘
↑ │
│ 丢包 │
│ ┌─────────────────────┐ │
└─────│ ssthresh=cwnd/2 │←──────┘
│ cwnd=ssthresh+3 │
│ (快速恢复) │
└─────────────────────┘
│
│ RTO 超时
▼
┌─────────────────────┐
│ ssthresh=cwnd/2 │
│ cwnd=1 │
│ (回到慢启动) │
└─────────────────────┘
完全关闭拥塞控制(nc=1):
关闭拥塞控制后:
cwnd = 128 * 1024(一个很大的值,实际上等于无限)
发送速率只受 rmt_wnd(远端窗口)限制
适用场景:
- 局域网(带宽充足,不会拥塞)
- 自己实现了拥塞控制(如基于延迟的算法)
- 弱网环境(丢包不是拥塞,是链路质量差)
风险:
- 如果真的拥塞了,会把网络搞瘫
- 公网使用必须谨慎
3.8 KCP 的流量控制
KCP 的发送条件检查(在 ikcp_flush 中):
while (snd_queue 不为空) {
// 条件1:在途包数量 < 拥塞窗口
if (snd_nxt - snd_una >= cwnd) break
// 条件2:在途包数量 < 远端窗口
if (snd_nxt - snd_una >= rmt_wnd) break
// 条件3:远端窗口 > 0
if (rmt_wnd == 0) break
// 全部满足,从 snd_queue 移到 snd_buf
move_segment(snd_queue → snd_buf)
snd_nxt++
}
远端窗口更新:
每次收到对方的包,都从包头的 wnd 字段读取对方的窗口大小
→ 更新 rmt_wnd
如果对方窗口为 0:
→ 发送 WASK(窗口探测请求)
→ 等待对方回复 WINS(窗口告知)
→ 对方应用层读取数据后,窗口恢复,回复 WINS
3.9 KCP 分片与重组
上层消息:3000 字节
MSS:1400 字节
分片过程(ikcp_send 内部):
片0:data[0:1400], frg = 2 (还有2个后续片)
片1:data[1400:2800], frg = 1 (还有1个后续片)
片2:data[2800:3000], frg = 0 (最后一片)
注意:frg 是倒序的!最后一片 frg=0
接收端重组:
收到 frg=2 → 缓存,等待后续
收到 frg=1 → 缓存,等待后续
收到 frg=0 → 发现 frg=0,组装完整消息
→ 按 frg 从大到小排列,拼接数据
→ 放入 rcv_queue,等应用读取
为什么用倒序 frg?
接收端收到 frg=0 时,就知道"这是一个完整消息的最后一片"
→ 可以立即开始重组
→ 如果用正序,需要先收到第一片才知道总片数
实际上 KCP 的 frg = fragment_count - 1 - current_index
例如 3 个片:frg = 2, 1, 0
4. KCP 的参数配置与模式
4.1 核心参数
c
// 设置 nodelay 模式
int ikcp_nodelay(ikcpcb *kcp, int nodelay, int interval, int resend, int nc);
// 参数说明:
// nodelay: 0=普通模式, 1=快速模式
// 影响 RTO 计算:
// 0: rto = srtt + max(rttvar * 2, 30)
// 1: rto = srtt + max(rttvar, 10)
// interval: KCP 内部刷新时钟间隔(毫秒)
// 建议值:10~100ms
// 越小:检测超时越及时,但 CPU 开销越大
// 越大:CPU 开销小,但重传检测延迟大
// resend: 快速重传触发阈值
// 0: 关闭快速重传
// 2: 2 次 ACK 跨越就重传(推荐)
// 3: 3 次 ACK 跨越才重传(类似 TCP)
// nc: 0=启用拥塞控制, 1=关闭拥塞控制
// 0: 有拥塞控制(类似 TCP Reno)
// 1: 无拥塞控制(只受远端窗口限制)
4.2 五种典型配置模式
模式一:类 TCP(带宽优先)
c
ikcp_nodelay(kcp, 0, 100, 0, 0);
ikcp_wndsize(kcp, 128, 128);
ikcp_setmtu(kcp, 1400);
特点:
- RTO 最小 100ms(接近 TCP)
- 关闭快速重传
- 保留拥塞控制
- 效果:吞吐量高,延迟接近 TCP
- 适用:文件传输、大块数据传输
模式二:普通低延迟
c
ikcp_nodelay(kcp, 1, 20, 2, 0);
ikcp_wndsize(kcp, 128, 128);
ikcp_setmtu(kcp, 1400);
特点:
- RTO 最小 20ms
- 快速重传阈值 2
- 保留拥塞控制
- 效果:延迟明显低于 TCP,带宽消耗适中
- 适用:互联网游戏、远程桌面
模式三:极速低延迟
c
ikcp_nodelay(kcp, 1, 10, 2, 1);
ikcp_wndsize(kcp, 128, 128);
ikcp_setmtu(kcp, 1400);
特点:
- RTO 最小 10ms
- 快速重传阈值 2
- 关闭拥塞控制
- 效果:延迟最低,带宽消耗最大
- 适用:局域网游戏、实时性要求极高的场景
- 风险:公网使用可能造成拥塞
模式四:弱网优化
c
ikcp_nodelay(kcp, 1, 30, 2, 0);
ikcp_wndsize(kcp, 256, 256);
ikcp_setmtu(kcp, 1200); // MTU 稍小,减少分片丢包概率
特点:
- RTO 最小 30ms(弱网下太小容易误判)
- 快速重传阈值 2
- 保留拥塞控制
- 大窗口(弱网下 BDP 大)
- 小 MTU(减少 IP 分片)
- 适用:4G/卫星/跨国弱网
模式五:音视频传输
c
ikcp_nodelay(kcp, 1, 10, 2, 1);
ikcp_wndsize(kcp, 64, 64);
ikcp_setmtu(kcp, 1400);
// 还需要配合:限制最大重传次数,超过就放弃
特点:
- 极速模式
- 小窗口(音视频数据量不大)
- 需要额外实现:最大重传次数限制
- 适用:直播、视频会议、语音通话
4.3 参数调优速查表
┌─────────────────┬──────────┬──────────┬────────┬────────┐
│ 场景 │ nodelay │ interval │ resend │ nc │
├─────────────────┼──────────┼──────────┼────────┼────────┤
│ 文件传输 │ 0 │ 50~100 │ 0 │ 0 │
│ 网页加载 │ 0 │ 30~50 │ 0 │ 0 │
│ 互联网游戏 │ 1 │ 10~20 │ 2 │ 0 │
│ 局域网游戏 │ 1 │ 5~10 │ 2 │ 1 │
│ 远程桌面 │ 1 │ 10 │ 2 │ 0 │
│ 弱网传输 │ 1 │ 20~30 │ 2 │ 0 │
│ 音视频 │ 1 │ 10 │ 2 │ 1 │
│ IoT 传感器 │ 0 │ 100 │ 0 │ 0 │
└─────────────────┴──────────┴──────────┴────────┴────────┘
5. KCP 使用详解
5.1 核心 API
c
// ===== 创建与销毁 =====
// 创建 KCP 对象
// conv: 会话ID,通信双方必须相同(类似 TCP 的四元组)
// user: 用户自定义指针,会传给 output 回调
ikcpcb* ikcp_create(IUINT32 conv, void *user);
// 销毁 KCP 对象
void ikcp_release(ikcpcb *kcp);
// ===== 配置 =====
// 设置输出回调(必须设置!KCP 不做网络IO)
// 函数原型:int output(const char *buf, int len, ikcpcb *kcp, void *user)
kcp->output = my_udp_output;
// 设置 nodelay 模式
int ikcp_nodelay(ikcpcb *kcp, int nodelay, int interval, int resend, int nc);
// 设置窗口大小(发送窗口, 接收窗口)
int ikcp_wndsize(ikcpcb *kcp, int sndwnd, int rcvwnd);
// 设置 MTU(最大传输单元,默认1400)
int ikcp_setmtu(ikcpcb *kcp, int mtu);
// ===== 数据收发 =====
// 发送数据(应用层 → KCP)
int ikcp_send(ikcpcb *kcp, const char *buffer, int len);
// 接收数据(KCP → 应用层)
int ikcp_recv(ikcpcb *kcp, char *buffer, int len);
// 从网络接收数据(UDP 收到的数据喂给 KCP)
int ikcp_input(ikcpcb *kcp, const char *data, long size);
// ===== 驱动 =====
// 定时更新(必须定期调用!驱动重传、拥塞控制等)
// current: 当前时间(毫秒)
void ikcp_update(ikcpcb *kcp, IUINT32 current);
// 获取下次更新时间(用于精确调度)
IUINT32 ikcp_check(const ikcpcb *kcp, IUINT32 current);
// ===== 状态查询 =====
// 获取等待发送的数据量
int ikcp_waitsnd(const ikcpcb *kcp);
// 获取接收缓冲区中的数据量
int ikcp_peeksize(const ikcpcb *kcp);
5.2 完整使用示例
c
#include "ikcp.h"
#include <sys/socket.h>
#include <unistd.h>
#include <string.h>
// ===== 输出回调 =====
// KCP 内部要发包时调用这个函数
int udp_output(const char *buf, int len, ikcpcb *kcp, void *user) {
int sock = *(int*)user;
struct sockaddr_in *addr = (struct sockaddr_in*)(kcp + 1); // 简化示例
return sendto(sock, buf, len, 0, (struct sockaddr*)addr, sizeof(*addr));
}
// ===== 客户端示例 =====
void client_example() {
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(8888),
.sin_addr.s_addr = inet_addr("127.0.0.1")
};
// 1. 创建 KCP
ikcpcb *kcp = ikcp_create(0x1, &sock); // conv=0x1
// 2. 设置 output 回调
kcp->output = udp_output;
// 3. 配置模式(快速模式)
ikcp_nodelay(kcp, 1, 10, 2, 1);
ikcp_wndsize(kcp, 128, 128);
ikcp_setmtu(kcp, 1400);
// 4. 事件循环
char buf[4096];
while (1) {
// 从 UDP 读取数据,喂给 KCP
int n = recvfrom(sock, buf, sizeof(buf), MSG_DONTWAIT, NULL, NULL);
if (n > 0) {
ikcp_input(kcp, buf, n);
}
// 更新 KCP(驱动重传等)
ikcp_update(kcp, get_current_ms());
// 从 KCP 读取已确认的数据
while ((n = ikcp_recv(kcp, buf, sizeof(buf))) > 0) {
printf("收到 %d 字节: %.*s\n", n, n, buf);
}
// 发送数据
if (has_data_to_send()) {
const char *msg = "Hello KCP!";
ikcp_send(kcp, msg, strlen(msg));
}
// 精确调度:获取下次需要 update 的时间
IUINT32 next = ikcp_check(kcp, get_current_ms());
IUINT32 now = get_current_ms();
if (next > now) {
usleep((next - now) * 1000); // 等待到下次 update 时间
}
}
ikcp_release(kcp);
close(sock);
}
5.3 数据流向图
发送方向:
应用层
│ ikcp_send(data)
▼
KCP 内部
├── 分片(如果 data > MSS)
├── 每个片段包装成 IKCPSEG
├── 放入 snd_queue
│
├── ikcp_update() 触发
│ ├── 从 snd_queue 移到 snd_buf(受窗口限制)
│ └── 检查超时/快速重传
│
└── ikcp_flush()
├── 遍历 snd_buf,填写报文头
├── 调用 output 回调
└── output → UDP sendto()
│
▼
UDP socket → 网络
接收方向:
网络 → UDP socket
│ recvfrom()
▼
应用层
│ ikcp_input(buf, len)
▼
KCP 内部
├── 解析报文头
├── 处理 ACK(更新 una, rto, cwnd)
├── 处理数据(cmd=PUSH)
│ ├── 按序(sn==rcv_nxt)→ 放入 rcv_queue
│ └── 乱序 → 放入 rcv_buf(等前序到达)
│
└── ikcp_recv() 被应用调用
├── 从 rcv_queue 取出数据
└── 如果有分片,重组后返回
│
▼
应用层处理数据
5.4 ikcp_update 的调度
c
// 方法1:固定间隔调用(简单但不精确)
while (running) {
ikcp_update(kcp, get_current_ms());
usleep(10 * 1000); // 10ms
}
// 方法2:精确调度(推荐)
while (running) {
// 先处理网络数据
int n = recvfrom(sock, buf, sizeof(buf), MSG_DONTWAIT, ...);
if (n > 0) ikcp_input(kcp, buf, n);
// 更新 KCP
ikcp_update(kcp, get_current_ms());
// 获取下次需要 update 的时间
IUINT32 next_update = ikcp_check(kcp, get_current_ms());
IUINT32 now = get_current_ms();
// 等待到下次 update 时间(或有网络数据到来)
if (next_update > now) {
// 用 select/poll/epoll 同时等待网络数据和定时器
struct pollfd pfd = { .fd = sock, .events = POLLIN };
poll(&pfd, 1, next_update - now); // 超时 = next_update - now
}
}
// 方法3:配合 epoll(高性能服务器)
// 将 KCP 的定时器注册到 epoll 的 timerfd
int timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
// 每次 update 后设置 timerfd 为 next_update 时间
// epoll_wait 同时监听 sock 和 timerfd
6. KCP 与 TCP 的深度对比
6.1 协议行为对比
场景:发送 100 个包,每个 1400 字节,其中第 50 个包丢失
RTT = 100ms
TCP(CUBIC,Linux 默认):
t=0 发送 [1-10](拥塞窗口初始 10)
t=50 收到 ACK 1-10,窗口增长
... 继续发送,窗口指数增长
t=490 发送 [50],丢包
t=540 收到 ACK 49,等待 [50] 的 ACK
t=590 收到 dupACK #1
t=640 收到 dupACK #2
t=690 收到 dupACK #3 → 快速重传 [50]
t=690 ssthresh = cwnd/2, cwnd = ssthresh + 3
t=740 收到 ACK 51-100,恢复传输
总延迟增加:约 200ms(从 540 到 690)
窗口变化:从 ~50 降到 ~25,缓慢恢复
KCP(快速模式,nodelay=1, interval=10, resend=2):
t=0 发送 [1-100](窗口足够大)
t=50 收到 ACK(una=1),[1] 确认
t=55 收到 ACK(una=1),[2] 的 fastack++
... 持续发送,持续收到 ACK
t=540 [50] 丢失
t=550 收到 ACK(una=50),[51] 的 fastack=1
t=560 收到 ACK(una=50),[51] 的 fastack=2 → 快速重传 [50]
t=610 收到 ACK(una=51),[50] 确认
总延迟增加:约 20ms(从 550 到 560)
窗口变化:不变化(nc=1 时)
对比:
TCP 增加延迟:200ms
KCP 增加延迟:20ms
KCP 比 TCP 快 10 倍
6.2 Nagle 与 Delayed ACK 的影响
场景:每 10ms 发送一个 100 字节的小包
TCP(默认开启 Nagle + Delayed ACK):
t=0ms send(100B) → 缓存,不发(Nagle:等 ACK 或攒够 MSS)
t=10ms send(100B) → 缓存,不发
t=20ms send(100B) → 缓存,不发
... 持续缓存
t=50ms 第一个包的 ACK 还没来(Delayed ACK 等 40ms)
t=60ms 收到 ACK → 立即发送缓存的数据(Nagle:有 ACK 了)
→ 小包被合并,延迟 60ms+
或者更糟糕的情况:
t=0ms send(100B) → 缓存
t=40ms 接收方 Delayed ACK 超时,回 ACK
t=50ms 收到 ACK → 发送缓存数据
→ 延迟 50ms
KCP(默认关闭合并):
t=0ms send(100B) → 立即发送
t=10ms send(100B) → 立即发送
t=20ms send(100B) → 立即发送
→ 延迟 ≈ 0ms
6.3 拥塞控制行为对比
场景:1% 随机丢包率的网络,不是拥塞
TCP(CUBIC):
检测到丢包 → 认为是拥塞 → cwnd 减半
恢复期间发送速率降低
持续丢包 → cwnd 持续在低水平
实际吞吐量:理论值的 30~50%
KCP(nc=1,关闭拥塞控制):
检测到丢包 → 只重传丢失的包 → cwnd 不变
发送速率不受丢包影响
实际吞吐量:接近理论值
代价:重传包占带宽的 1~2%
KCP(nc=0,保留拥塞控制):
行为类似 TCP Reno,但恢复更快
因为 RTO 更小,快速重传阈值更低
实际吞吐量:理论值的 50~70%(比 TCP 稍好)
6.4 资源消耗对比
内存消耗:
TCP:内核为每个连接维护 ~4KB 的 tcp_sock 结构
KCP:用户态,ikcpcb 结构 ~2KB + 发送/接收缓冲区
CPU 消耗:
TCP:内核实现,高度优化
KCP:用户态,需要定期调用 ikcp_update()
→ KCP 的 CPU 消耗略高,但通常可以忽略
系统调用:
TCP:send()/recv() 直接用内核协议栈
KCP:需要 sendto()/recvfrom() + ikcp_input()/ikcp_send()/ikcp_recv()
→ KCP 多了一层用户态处理,但延迟更低
7. KCP 的高级话题
7.1 与加密层配合
KCP 本身不加密,公网使用必须加加密层:
方案1:KCP + DTLS
┌──────────┐
│ 应用 │
├──────────┤
│ KCP │ ← 可靠传输
├──────────┤
│ DTLS │ ← 加密 + 完整性
├──────────┤
│ UDP │
└──────────┘
方案2:KCP + 自定义加密
┌──────────┐
│ 应用 │
├──────────┤
│ AES-GCM │ ← 自己加解密
├──────────┤
│ KCP │
├──────────┤
│ UDP │
└──────────┘
方案3:KCP over WireGuard/Noise
┌──────────┐
│ 应用 │
├──────────┤
│ KCP │
├──────────┤
│WireGuard │ ← 隧道加密
├──────────┤
│ UDP │
└──────────┘
7.2 多路复用
KCP 没有内置多路复用,需要自己实现:
c
// 方案1:多个 KCP 实例(简单但资源消耗大)
struct connection {
ikcpcb *kcp;
int id;
};
// 每个逻辑流一个 KCP 实例
// 优点:流之间完全独立,互不影响
// 缺点:每个实例都有自己的缓冲区和状态
// 方案2:单个 KCP 实例 + 应用层多路复用
// 在 KCP 数据中加一个 stream_id 字段
// 优点:只维护一个 KCP 实例
// 缺点:流之间有队头阻塞(KCP 层面)
// 推荐:对延迟敏感的场景用方案1
// 对资源敏感的场景用方案2
7.3 连接管理
KCP 没有连接建立/关闭的概念(没有握手/挥手),需要自己实现:
方案1:无连接管理
直接用 conv 区分会话
优点:最简单
缺点:无法检测对端是否在线
方案2:应用层心跳
定期发送心跳包(通过 KCP 发送)
超时无心跳 → 认为连接断开
优点:简单有效
缺点:心跳包也走 KCP 可靠传输,开销大
方案3:应用层握手
用 KCP 发送握手消息,确认双方在线
优点:连接状态明确
缺点:增加一次 RTT
方案4:UDP 直接发心跳(不走 KCP)
心跳包直接用 UDP 发送,不经过 KCP
优点:开销最小
缺点:心跳不可靠(丢了就丢了,靠频率弥补)
7.4 NAT 穿透
KCP 没有内置 NAT 穿透,但因为基于 UDP,穿透比 TCP 容易:
UDP NAT 穿透(UDP Hole Punching):
客户端 A (192.168.1.10:5000)
│
├── NAT A (203.0.113.1:40001)
│
▼
中继服务器 (8.8.8.8:8888)
│
▲
│
├── NAT B (198.51.100.1:40002)
│
客户端 B (10.0.0.20:6000)
步骤:
1. A 和 B 都向服务器注册(服务器记录各自的外部地址)
2. 服务器告诉 A:"B 的外部地址是 198.51.100.1:40002"
3. 服务器告诉 B:"A 的外部地址是 203.0.113.1:40001"
4. A 向 B 的外部地址发 UDP 包(在 NAT A 上打洞)
5. B 向 A 的外部地址发 UDP 包(在 NAT B 上打洞)
6. 双方的 NAT 都有了对方的映射,后续可以直接通信
限制:
- 对称 NAT(Symmetric NAT)穿透失败率高
- 需要一个公网中继服务器作为信令
7.5 KCP 与 FEC(前向纠错)
FEC(Forward Error Correction)可以在不重传的情况下恢复丢失的数据:
原理:
发送方:原始数据 + 冗余校验数据
接收方:用校验数据恢复丢失的原始数据
常用方案:Reed-Solomon 编码
例子(4+2,即 4 个原始包 + 2 个校验包):
发送:[D1] [D2] [D3] [D4] [F1] [F2]
[D2] 丢失,但收到 [D1] [D3] [D4] [F1] [F2]
→ 用 5 个包(需要至少 4 个)恢复 [D2]
→ 不需要重传!
KCP + FEC 的优势:
- 重传需要一个 RTT,FEC 不需要
- 在高丢包率下,FEC + KCP 比纯 KCP 延迟更低
- 代价:带宽增加(2/6 = 33% 冗余)
典型实现:
- kcp-fec(GitHub 开源项目)
- 自己实现:发送端用 RS 编码,接收端用 RS 解码
8. 其他基于 UDP 的可靠传输方案
8.1 QUIC(HTTP/3 的基础)
QUIC = UDP + 可靠性 + 加密 + 多路复用 + 连接迁移
由 Google 开发,IETF 标准化(RFC 9000/9001/9002)
Chrome/Edge/Firefox 默认启用
HTTP/3 就是基于 QUIC
QUIC vs KCP:
┌─────────────────┬──────────────┬──────────────┐
│ 特性 │ QUIC │ KCP │
├─────────────────┼──────────────┼──────────────┤
│ 代码量 │ 几万行 │ ~2000 行 │
│ 加密 │ 内置 TLS 1.3 │ 无 │
│ 多路复用 │ 有 │ 无 │
│ 连接迁移 │ 有 │ 无 │
│ 0-RTT │ 有 │ 无 │
│ 拥塞控制 │ BBR/CUBIC │ 类 Reno/可关 │
│ 流量控制 │ 连接级+流级 │ 连接级 │
│ 标准化 │ IETF RFC │ 社区开源 │
│ 适用 │ Web 通用 │ 游戏/嵌入式 │
└─────────────────┴──────────────┴──────────────┘
8.2 UDT(UDP-based Data Transfer)
专为高速网络设计(10Gbps+)
特点:
- 基于速率的拥塞控制(不是基于窗口)
- 适合大数据传输(科学计算、文件传输)
- 支持 IPv6
UDT vs KCP:
UDT:追求高吞吐量(10Gbps+)
KCP:追求低延迟(10ms 级别)
8.3 ENet
轻量级游戏网络库
特点:
- 内置可靠/不可靠通道
- 每个通道可以配置不同的可靠性
- 常用于游戏引擎(Godot)
ENet vs KCP:
ENet:更简单的 API,适合快速开发
KCP:更底层的控制,适合精细调优
8.4 RUDP(Reliable UDP)
RFC 3208 定义的可靠 UDP
特点:
- 类似 TCP 的可靠性,但基于 UDP
- 没有流行起来(被 QUIC 取代了)
8.5 选择指南
┌─────────────────────────┬──────────────┐
│ 场景 │ 推荐方案 │
├─────────────────────────┼──────────────┤
│ Web 应用 │ QUIC (HTTP/3)│
│ 游戏服务器(自研) │ KCP / ENet │
│ 远程桌面 │ KCP │
│ 弱网/物联网 │ KCP │
│ 大数据传输(10Gbps+) │ UDT │
│ 浏览器到服务器 │ QUIC │
│ 嵌入式设备 │ KCP │
│ 多人在线游戏 │ ENet / KCP │
│ 直播/视频会议 │ KCP + FEC │
└─────────────────────────┴──────────────┘
9. 实践中的常见问题
9.1 KCP 延迟没降下来
排查步骤:
1. 检查 ikcp_update() 调用频率
→ 如果每 100ms 才调用一次,那 RTO 最小也就 100ms
→ 建议 10ms 或更小
2. 检查 output 回调是否阻塞
→ output 里做了耗时操作(如 DNS 解析、同步写日志)
→ output 应该只做 sendto()
3. 检查 MTU 设置
→ MTU 太大导致 IP 分片
→ 一个分片丢了,整个 IP 包废了
→ 建议 MTU = 1400(留 40 字节给 IP+UDP 首部)
4. 检查网络状况
→ 用 ping 看实际 RTT
→ 如果 RTT 本身就是 100ms,KCP 也降不到 10ms
→ KCP 降低的是"额外延迟",不是物理延迟
9.2 KCP 带宽消耗太大
排查步骤:
1. 检查是否关闭了拥塞控制(nc=1)
→ 公网使用建议开启拥塞控制(nc=0)
2. 检查快速重传阈值
→ resend=1 太激进,可能导致大量不必要的重传
→ 建议 resend=2
3. 检查是否有大量重传
→ 如果重传率 > 5%,说明网络丢包严重
→ 加 FEC 可以减少重传
4. 检查窗口大小
→ 窗口太大,发送速率可能超过网络承载能力
→ 合理设置窗口 = BDP / MSS
9.3 KCP 丢包恢复慢
排查步骤:
1. 检查 resend 参数
→ resend=0 表示关闭快速重传,只能等 RTO 超时
→ 建议 resend=2
2. 检查 RTO 设置
→ nodelay=0 时 RTO 最小 100ms
→ 建议 nodelay=1,RTO 最小 10ms
3. 检查 interval
→ interval=100ms 意味着最多 100ms 才检测到超时
→ 建议 interval=10ms
4. 检查是否启用了 FEC
→ FEC 可以在不重传的情况下恢复丢包
→ 对高丢包率网络特别有效
9.4 KCP 内存泄漏
常见原因:
1. snd_buf 中的段没有被释放
→ ACK 处理逻辑有 bug,某些段永远收不到 ACK
→ 设置最大重传次数,超过就放弃
2. rcv_buf 中的乱序段堆积
→ 前序包丢了,后续包一直在等
→ 设置乱序超时,超时后丢弃
3. pbuf/内存池耗尽
→ 发送速率 > 接收速率,缓冲区堆积
→ 合理设置窗口大小
10. 总结
10.1 UDP 的定位
UDP = 最小开销的传输层
- 只加了端口号和校验和(8 字节首部)
- 不保证可靠性、顺序性、流量控制
- 适合:实时应用、简单查询、广播/多播
- 不适合:需要可靠传输的场景
10.2 KCP 的定位
KCP = 在 UDP 上构建的低延迟可靠传输
- 用户态实现,完全可控
- 核心优化:关闭 Nagle、快速重传、可选关闭拥塞控制
- 代价:带宽消耗比 TCP 多 10~20%
- 收益:延迟降低 30~40%,最大延迟降低 3 倍
- 适合:游戏、远程桌面、弱网、嵌入式
10.3 选择原则
1. 不需要可靠性 → UDP(视频、语音、游戏状态广播)
2. 需要可靠性 + 低延迟 → KCP(游戏、远程桌面)
3. 需要可靠性 + 高吞吐 → TCP(文件传输、Web)
4. 需要可靠性 + 低延迟 + Web → QUIC(HTTP/3)
5. 需要可靠性 + 高吞吐(10Gbps+)→ UDT
核心认知:
- TCP 是"最大化吞吐量"的设计
- KCP 是"最小化延迟"的设计
- UDP 是"什么都不管"的设计
- QUIC 是"互联网下一代标准"的设计
- 没有银弹,根据场景选择最合适的方案
10.4 KCP 的价值
KCP 的真正价值不在于"比 TCP 快",而在于:
1. 用户态实现:可以深度定制所有参数
2. 跨平台一致:Linux/Windows/macOS/嵌入式行为完全相同
3. 极简代码:~2000 行 C 代码,容易理解和移植
4. 灵活组合:可以和任何加密层、FEC、多路复用组合
KCP 不是要取代 TCP,而是提供了一个在特定场景下比 TCP 更好的选择。