简介
kcp是一个可靠传输协议,代码量不大,用来学习可靠传输协议是非常好的选择。在kcp中你可以看到滑动窗口,拥塞窗口,拥塞控制的四个阶段等实现。网上介绍kcp的文章很多,本文主要介绍作者在wiki中提到的六个特性。
- RTO翻倍 vs 不翻倍
- UNA vs ACK+UNA
- 选择性重传 vs 全部重传
- 快速重传
- 延迟ACK vs 非延迟ACK
- 非退让流控 下面逐一来分析,如有不对之处,请多加指正。
RTO翻倍 vs 不翻倍
TCP超时计算是RTOx2,这样连续丢三次包就变成RTOx8了,十分恐怖,而KCP启动快速模式后不x2,只是x1.5(实验证明1.5这个值相对比较好),提高了传输速度。
在ikcp_flush中源码分析如下:
// flush data segments
for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = p->next)
{
IKCPSEG *segment = iqueue_entry(p, IKCPSEG, node);
int needsend = 0;
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)
{
//超时(rto)未来收到ack,超时重传
needsend = 1;
segment->xmit++;
kcp->xmit++;
if (kcp->nodelay == 0)
{
segment->rto += kcp->rx_rto; //rto翻倍
}
else
{
segment->rto += kcp->rx_rto / 2; //rto*1.5
}
segment->resendts = current + segment->rto;
lost = 1; // 网络发生了丢包
}
}
UNA vs ACK+UNA
ARQ模型响应有两种,UNA(此编号前所有包已收到,如TCP)和ACK(该编号包已收到),光用UNA将导致全部重传,光用ACK则丢失成本太高,以往协议都是二选其一,而 KCP协议中,除去单独的 ACK包外,所有包都有UNA信息。 先学习下kcp的包头信息:
- conv:4字节,连接号
- cmd:1字节
- frg:1字节,分片数量,用户数据可能会被分成多个KCP包发送出去
- wnd:2字节,接受窗口大小,发送方的发送窗口不能超过接收方给出的数值
- ts:4字节,时间戳,timestamp
- sn:4字节,序列号
- una:4字节,下一个可以接收的序列号,也就是确认号。例如收到sn=10的包,una就是11
- len:4字节,用户数据长度
可以看到包头中既有sn也有una,这就是kcp通过ACK+UNA的方式将接受方的信息告知发送方。
选择性重传 vs 全部重传
TCP丢包时会全部重传从丢的那个包开始以后的数据,KCP是选择性重传,只重传真正丢失的数据包。
此特性和上面的ACK+UNA特性相关,当发送方收到接收方回复的任何包时,则根据una将una之前的包从snd_buf中移除,当发送方收到ACK回复时将序号时sn的包从snd_buf中移除,则snd_buff中剩余的就是未收到的包,则在丢包重传和快速重传中只会重传未收到的包。
int ikcp_input(ikcpcb *kcp, const char *data, long size)
{
while (1)
{
IUINT32 ts, sn, len, una, conv;
IUINT16 wnd;
IUINT8 cmd, frg;
IKCPSEG *seg;
if (size < (int)IKCP_OVERHEAD) break;
data = ikcp_decode32u(data, &conv);
if (conv != kcp->conv) return -1;
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);
kcp->rmt_wnd = wnd;
ikcp_parse_una(kcp, una); //通过una删除对端已经收到的包
ikcp_shrink_buf(kcp); //重置待确认包的序号snd_una
if (cmd == IKCP_CMD_ACK) // 收到ack包
{
if (_itimediff(kcp->current, ts) >= 0)
{
//通过rtt来计算rto
ikcp_update_ack(kcp, _itimediff(kcp->current, ts));
}
ikcp_parse_ack(kcp, sn); //删除当前sn的包
ikcp_shrink_buf(kcp); //重置待确认包的序号snd_una
}
}
}
通过una删除接收方收到的包ikcp_parse_una源码如下:
static void ikcp_parse_una(ikcpcb *kcp, IUINT32 una)
{
// 根据una从发送队列snd_buf中删除对方已经收到的包
struct IQUEUEHEAD *p, *next;
for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = next)
{
IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node);
next = p->next;
if (_itimediff(una, seg->sn) > 0) // 序号比una小的都删除掉
{
iqueue_del(p);
ikcp_segment_delete(kcp, seg);
kcp->nsnd_buf--;
}
else
{
break;
}
}
}
通过ack包中sn删除具体sn的包ikcp_parse_ack源码如下:
static void ikcp_parse_ack(ikcpcb *kcp, IUINT32 sn)
{
// 根据ack从发送队列snd_buf中删除对方已经收到的包
struct IQUEUEHEAD *p, *next;
for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = next)
{
IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node);
next = p->next;
if (sn == seg->sn) // sn号相等才删除
{
iqueue_del(p);
ikcp_segment_delete(kcp, seg);
kcp->nsnd_buf--;
break;
}
if (_itimediff(sn, seg->sn) < 0) // 快速跳出循环
{
break;
}
}
}
快速重传
发送端发送了1,2,3,4,5几个包,然后收到远端的ACK: 1, 3, 4, 5,当收到ACK3时,KCP知道2被跳过1次,收到ACK4时,知道2被跳过了2次,此时可以认为2号丢失,不用等超时,直接重传2号包,大大改善了丢包时的传输速度。
同样是在ikcp_flush中源码分析如下:
int resent = (kcp->fastresend > 0)? (IUINT32)kcp->fastresend : 0xffffffff;
// flush data segments
for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = p->next)
{
IKCPSEG *segment = iqueue_entry(p, IKCPSEG, node);
int needsend = 0;
if (segment->xmit == 0) //第一次发送当前包
{
...
}
else if (_itimediff(current, segment->resendts) >= 0) // rto超时
{
...
lost = 1; // 网络发生了丢包
}
else if (segment->fastack >= resent) // 快速重传
{
needsend = 1;
segment->xmit++;
segment->fastack = 0;
segment->resendts = current + segment->rto;
change++;
}
if (needsend)
{
...
}
}
...
// update ssthresh
if (change)
{
// 发生了快速重传,调整慢启动阈值和拥塞窗口后,进入快速恢复阶段
IUINT32 inflight = kcp->snd_nxt - kcp->snd_una; // 网络中正在传输的包数
kcp->ssthresh = inflight / 2; //慢启动的阈值降为当前发送窗口的一半
if (kcp->ssthresh < IKCP_THRESH_MIN)
kcp->ssthresh = IKCP_THRESH_MIN;
kcp->cwnd = kcp->ssthresh + resent; //拥塞窗口也降为一半。但是为什么要加resent?
kcp->incr = kcp->cwnd * kcp->mss;
}
延迟ACK vs 非延迟ACK
TCP为了充分利用带宽,延迟发送ACK(NODELAY都没用),这样超时计算会算出较大 RTT时间,延长了丢包时的判断过程。KCP的ACK是否延迟发送可以调节。
在接收端收到数据包后调用ikcp_input,源码如下:
if (cmd == IKCP_CMD_PUSH) //收到数据包
{
if (_itimediff(sn, kcp->rcv_nxt + kcp->rcv_wnd) < 0) // 序号合法性检查
{
//存储要发送的ack,然后在下一次flush时发送。此处没有立刻发送ack,就是延迟发送ack的特性
//延迟的时间是0ms到设置的flush刷新间隔时间之间
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);
}
// 将收到的包按照sn序列从小到大存放在rcv_buf中
// 则rcv_buf中存放的数据包有可能连续也有可能不连续
// 当数据包连续时,从rcv_buf移动到nrcv_que中,且更新rcv_nxt
ikcp_parse_data(kcp, seg);
}
}
}
下面来分析对于待发送ack的处理ikcp_ack_push
static void ikcp_ack_push(ikcpcb *kcp, IUINT32 sn, IUINT32 ts)
{
size_t newsize = kcp->ackcount + 1;
IUINT32 *ptr;
if (newsize > kcp->ackblock)
{
// 容量不足时,进行扩容,每次扩容为原来的一倍
// 依次为8,16,32,64等
......
}
ptr = &kcp->acklist[kcp->ackcount * 2];
ptr[0] = sn; // 存储收到包的的序号和发包时间
ptr[1] = ts; // 将发包时间回射回去,发送端用来计算rtt
kcp->ackcount++;
}
在ikcp_flush中进行发送
// flush acknowledges
count = kcp->ackcount;
for (i = 0; i < count; i++) // 发送ack
{
size = (int)(ptr - buffer);
if (size + (int)IKCP_OVERHEAD > (int)kcp->mtu)
{
ikcp_output(kcp, buffer, size);
ptr = buffer;
}
ikcp_ack_get(kcp, i, &seg.sn, &seg.ts); // 从acklist中获取sn和ts
ptr = ikcp_encode_seg(ptr, &seg);
}
kcp->ackcount = 0;
非退让流控
KCP正常模式同TCP一样使用公平退让法则,即发送窗口大小由:发送缓存大小、接收端剩余接收缓存大小、丢包退让及慢启动这四要素决定。但传送及时性要求很高的小数据时,可选择通过配置跳过后两步,仅用前两项来控制发送频率。以牺牲部分公平性及带宽利用率之代价,换取了开着BT都能流畅传输的效果。
简言之就是,使用TCP协议发送方可以发送的包数 = std::min(发送窗口,接收端的接收窗口,拥塞窗口)。
但是KCP取消流控发送方可以发送的包数 = std::min(发送窗口,接收端的接收窗口),两个因素决定,直接忽略了网络拥塞的情况。
同样在ikcp_flush中
// calculate window size
cwnd = _imin_(kcp->snd_wnd, kcp->rmt_wnd);
if (kcp->nocwnd == 0) {
cwnd = _imin_(kcp->cwnd, cwnd);
}
// 当nocwnd设置为1时,则取消了网络的拥塞控制
// 当interval设置为最小值是10ms时,下次进去ikcp_flush函数,接收方的接收端口没有更新,则发送窗口不变