0%

kcp源码解析

kcp是一个实现了类似tcp的重传、流控、拥塞控制等机制的传输协议,它没有指定传输协议,但是一般情况下都会用udp来传输,
相当于用udp实现的tcp协议。它在不稳定的网络环境中采用更激进的方法来获得比tcp更快的传输速度,代价就是会消耗更多的流量。
跟tcp一样,kcp采用滑动窗口机制来实现流控,通过重传机制保证了可靠性,通过慢启动,拥塞避免,快速重传和快速恢复来实现拥塞控制。

kcp支持两种模式:流模式和消息模式。

流模式具有更高网络利用率,更大的传输速度,但是解析数据相对更复杂。

消息模式具有更小的网络利用率,更小的传输速度,但解析数据更简单。

相关变量

字段 含义 注释
rcv_wnd receive window 接收窗口大小
rmt_wnd remote window 远端可接收窗口。发送端发送数据的时候带上,接收端接收到数据时更新(input的时候更新)
snd_wnd send window 发送窗口大小,默认 32,静态值。用于计算 cwnd
cwnd congestion window 拥塞窗口大小,会根据丢包或者ack调整,cwnd=min(snd_wnd,rmt_wnd),只有 nc==0 的时候才会更新并使用这个值
rcv_buf receive buffer 接收缓冲区,缓冲底层接收的数据,组装连续以后拷贝到接收队列
rcv_queue receive queue 接收队列,接收的连续数据包,上层应用可以直接使用,不能超过接收窗口大小
snd_queue send queue 发送队列,上层应用数据分片后加入发送队列,发送队列的数据不会立即发送,头部还没有完成初始化
snd_buf send buffer 发送缓冲区,已发送尚未确认的包
buffer 调用output发送的数据
probe ICKP_CMD_WASK IKCP_CMD_WINS
acklist sn,ts 待发送的ack列表
fastresend 快速重传门限,无须等待超时,如果n没收到ack但是收到了n+1,n+2,…n+fastresend那么立即重传n
ssthresh slow start thresh 慢启动拥塞窗口阈值,不小于2
incr 可发送的最大数据量,incr=cwnd*mss,因为拥塞避免阶段cwnd的增加不足一个包,所以需要维护这么个变量,当满了一个mss以后给cwnd自增
stream stream mode 是否使用流模式
mss max segment size 最大报文长度,mtu - (24 头部长度)
mtu max transfer unit 默认1400,不小于50

RTO计算(Retransmission timeout)

kcp的重传超时计算方法参考了tcp的实现Computing TCP’s Retransmission Timer,不过在参数设置上更加激进。
计算过程主要维护两个变量:

  • SRTT: smoothed round-trip time
  • RTTVAR: round-trip time variation

接收端收到确认包以后根据rtt更新这两个变量,并重新计算rto值。报文超时以后tcp的rto会翻倍,
kcp中声称采用的是1.5倍,然而代码中实际上是线性增加,不知道是作者有意为之还是实现bug。

Segment头部

含义 会话id 包类型 是否分片 发送端接收窗口 时间戳 包序列号 未确认包序号 数据长度 数据
字节数 4 1 1 2 4 4 4 4 len
字段名 conv cmd frg wnd ts sn una len data
全名 conversation id command fragment window timestamp segment number unacknowledge length data

报文字段含义:

字段名 含义
conv 通信双方需要保证相同的会话id
cmd ICKP_CMD_PUSH/IKCP_CMD_ACK/IKCP_CMD_WASK/ICKP_CMD_WINS
frg 分片ID,从大到小,0表示最后一个分片
wnd 剩余接收窗口大小(接收窗口大小 - 接收队列大小)
ts message发送时刻的时间戳
sn 分片segment序号
una 待接收消息序号(接收滑动窗口左端)
len 数据长度
data 数据
fastack 收到ack时计算的该分片被跳过的累积次数
xmit 分片发送的次数,每发送一次加1,超出dead_link,说明目标不可达
resendts 下一次超时重传的时间戳
rto 该分片的超时重传等待时间

API

kcp与底层交互

1
2
int ikcp_output(ikcpb *kcp, const void *data, int size)
int ickp_input(ikcpcb *kcp, const char *data, long size)

这两个结构是kcp与底层网络交互的接口。一般底层网络指的是UDP。
output将kcp打包的数据发往目的地址,而input是底层网卡收到udp数据以后喂给kcp层。

底层收到数据报文后调用input,将数据丢给kcp。

上层通过recv获得处理后的数据,循环从data中取出kcp包。直到剩下的长度小于包头长度退出循环。

input传入的数据包含kcp包头,kcp拿到以后会尝试去掉kcp包头,组合成一个完整的数据包:

  • 长度小于包头长度24,退出循环。
  • 构造包头,分别检查会话id,数据长度,包体类型。
  • 设置远程剩余接收窗口大小。
  • 将对方已经确认收到的包从发送缓存snd_buf中删除,相当于发送窗口右移。
  • 计算本地真实snd_una,也就是下一个等待确认的发送包。
    • 如果还有未确认的包,为发送窗口左端包序列号
    • 如果没有未确认包,即发送窗口为空,则为下一个发送包snd_nxt
  • 根据包的不同类型分别处理
    • ACK包:
      • 计算rtt,更新rto,更新算法参考rfc6298
      • 从发送窗口中删除对应的包,并更新此包之前未确认包的fastack信息
    • PUSH数据包:
      • 判断收到的包的sn是不是位于区间:[rcv_nxt, rcv_nxt+rcv_wnd), 是则继续,否则丢弃
      • 在接收窗口内,将其加入acklist,下一次flush的时候确认
      • 构造一个kcp包,判断是否重复,如果不是重复包,插入接收缓存rcv_buf中对应位置
      • 将rcv_buf中的已经收到的连续包移到接收队列rcv_queue中,供上层应用读取,并从rcv_buf中移除
    • WASK包:
      • 设置probe的TELL标记,flush的时候推送
    • WINS包:
      • 无须处理,因为之前已经设置了对方的剩余接收窗口大小

所有包处理完毕以后,根据对方确认包的信息,更新拥塞窗口大小。

  • 如果拥塞窗口小于对方剩余接收窗口,则需要更新
  • 如果拥塞窗口尚未达到ssthresh(慢启动门限),cwnd++
  • 如果大于ssthresh,

kcp与应用层交互

1
int ikcp_recv(ikcpcb *kcp, char *buffer, int len)
  • 将接收队列中的消息传递给上层应用,因为消息会被拆分成kcp包,所以消息长度需小于接收队列长度*报文长度。
  • 将接收缓冲中的连续报文拷贝到接收队列。
  • 如果接收队列从满到不满,推送窗口通知消息给发送端,通知有接收窗口,可以继续发送新的报文。
  • ikcp_recv返回大于0的数的时候,buffer里面必然是一个完整的包。所谓完整的包,也就是对应发送方调用ikcp_send的时候发送的buffer。应该是一一对应的。原本我以为如果发送的包需要分段,那么需要把长度加到buffer前面,其实是没有必要的。因为peeksize就保证了,必须是一个完整的包才会返回大于0的数。
  • stream=0的话可以保证收到的是一个 send时传过去的完整的包。流模式和包模式的问题
1
int ikcp_peeksize(const ikcpcb *kcp)

检查接收队列中是否有一个完整的消息,并返回消息的长度。

1
int ickp_send(ikcpcb *kcp, const char *buffer, int len)

发送应用层的数据,会根据mtu大小分片,每个分片加上segment头部24字节,加入发送队列snd_queue。

根据stream字段区分流模式和消息模式。不同模式组包的机制不一样。如果是消息模式,分片数据的frg字段从count-1到0,0表示分片结束。发送的数据最大不能超过 128 * mss 大小。kcp-go 为了突破这个限制,在应用层对包的大小进行了切分,每次只发一个mss,这样就调用 write 的时候就可以发送很大的数据。为什么有这个限制呢,这是因为 kcp 支持包模式,包模式依赖一个 frg 来组装大包,这个id 在 kcp 头部占用的字节就是一个。

1
void ikcp_update(ikcpcb *kcp, IUINT32 current)

根据传入的时钟,决定是否调用flush。kcp维护一个ts_flush表示下一次flush的时间,update的时候如果发现当前时间已经大于等于ts_flush,则执行flush。

1
int ikcp_nodelay(ikcpcb *kcp, int nodelay, int interval, int resend, int nc)

配置参数。interval的范围是10ms到5000ms。默认是100ms。interval决定了实际调用flush的频率。
nc 0: normal congestion control(default), 1: disable congestion control 。这个值控制 kcp 是否进行拥塞控制。
nc 表示 nocwnd,表示是否尊重拥塞控制。默认是 0,会尊重拥塞控制,在发生丢包或者重复确认的时候减小拥塞窗口。
如果配置为 1 ,则表示不遵守拥塞控制。所以默认情况下 kcp 也可以像 tcp 那样遵守公共规则,但是如果打开这个开关,
kcp 就不遵守公共规则,表现的比较自私。

cwnd 表示可以 inflight 的包的数量,这个值越大,kcp 发包越多。类似于压测工具的 pipeline 参数。能够 pending 的包的个数。这个参数收到两个参数的严格控制,一个是自己的发送窗口,一个是对端的接收窗口。如果考虑到拥塞控制,还应该受到拥塞窗口的控制。kcp 通过 nc 选项来决定是不是遵守拥塞控制规则,即ss,cc,fastack,fast recovery等拥塞控制计算出来的拥塞窗口。

kcp 会将 cwnd 个数的包挂在 snd_buf 队列中。

1
void ikcp_flush(ikcp *kcp)

实际发送数据的接口。

  • 发送acklist中的ack消息
  • 如果对方接收窗口为0,需要发送IKCP_CMD_WASK消息,检查probe对方接收窗口是否ready
  • 发送自己的接收窗口大小
  • 发送数据

snd_buf 是一个带 sentinel 的循环链表。

ikcp_parse_una 接收端接收到数据包,解析出对端尚未确认的包,那么小于una的说明都已经确认,如果发送缓冲中还在,直接删除。
同时,由于接收端接收到了新的数据,所以需要更新自己的 send_una 也就是尚未确认收到的包。如果发送缓冲中有包,那么就是发送缓冲中的第一个包的sn。
否则就是下一个需要发送的sn:kcp->snd_nxt。

kcp-go

go 版的 kcp ,会话有一个 writeDelay 的标识。表示有数据是是否立即发送,还是等到下一次 update 的时候发送。默认是不 delay,立刻发送。
当 waitsnd < snd_wnd 时,要发送的数据直接挂到 kcp 缓存中。然后再次检查 wait_snd 是否超过 snd_wnd ,如果是,则立刻 flush 。
所以有两种情况下,Write 是立刻发送的:一个是 waitSnd 大于 snd_wnd ,另一个是会话的默认配置,不 delay。

rmt_wnd 每个包都会带上一个wnd,收到对面的wnd,就是rmt_wnd。
kcp.cwnd 不会超过 rmt_wnd。

如果 rmt_wnd 为0,表示对面已经没有能力接收。隔 7 秒发送一个 probe window size。每次1.5倍增加试探时间。最多增加到 120 秒。

参考文献