KCP技术特性源码分析

940 阅读6分钟

简介

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的包头信息: image.png

  • 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函数,接收方的接收端口没有更新,则发送窗口不变

资料

完整的源码分析链接