基于UDP的可靠信息传输——对TCP的深入理解[第一节]
引言
因为自己对游戏还比较感兴趣,之前在学UDP的时候也听闻:在游戏中常使用:UDP实现可靠信息传输。但它究竟如何实现在我眼里却一直都是个迷。
在一次学习中,偶然遇到了KCP的代码。虽然代码不长,但逻辑非常清晰,一下子就让我对基于UDP的可靠数据传输拨云见雾,同时竟然也对TCP的理解也愈加深刻了,实在是比较开心,因此在这里做一次简单分享聊以慰藉。
最后是希望可以写一个小demo模拟一下UDP可靠传输的过程,当然,仅仅是模拟过程,不会有真正的udp等传输过程(那个看kcp就行),等到后面空闲下来,会自己实现一个udp可靠传输的过程。
知识预备——TCP
如果大家对TCP的可靠信息传输有过了解,那对于滑动窗口——SR(select repeat)肯定也不会太陌生。KCP或者UDP实现可靠信息传输说的主要思想其实就是基于TCP的思想的基础上进行延伸和扩展的。
正所谓大道三千殊途同归,若是我们能够对TCP有比较清晰的认识,对于我们揭开UDP可靠信息传输的面纱会有很大的帮助。
所以在深入了解基于UDP的可靠信息传输之前,我们先来看看TCP是怎么做的。
TCP怎么保证数据可靠传输
在讨论TCP是如何保证可靠消息传输之前,我们肯定要对所谓“可靠”有一个比较清晰的认知。
在我看来,所谓可靠无异于三个方面:不丢、不错、不乱。
什么情况下会丢失?信道丢失分组或者网太卡等。
什么情况下会错误?在传输过程中因为信道的问题发生了位错误等问题。
什么情况下会乱序?由于信道的不可预知和丢包重发的机制,都会导致乱序的发生。
如何解决上述存在的问题——RDT3.0
1、解决包错误:通过校验和以及重传的机制可以保证包不错。
2、解决包乱序:通过序列号和ACK应答的方式保证乱序、重传等问题。
3、解决包丢失:通过超时重传的机制保证包丢失时可以通过一定手段重新获得。
现在虽然保证了信息的可靠传输,但是上述是一个一个发包的,这样太慢了。如果想要追求信息传输的速度,就势必要引入流水线的机制,但这样又会存在问题:主要就是乱序到达的分组和重传分组如何解决的问题。
简单点说就是:乱序到达的分组,我们是丢弃呢?还是通过某种手段把它存起来呢?如果存起来我又怎么知道和前面的分组对应起来呢?
重传分组我怎么知道是重传呢?接收到重传分组怎么处理呢?
为了应对流水线引入的问题,TCP引入了滑动窗口的概念,比如GBN、SR等。
这里重点研究一下SR——接收方和发送方都设置缓存窗口,接收方对每个分组单独进行确认。 这正是KCP中实现udp可靠数据传输的思想。
滑动窗口协议——SR
先看一下SR简单示意图,结合之前对SR的介绍:接收方和发送方都设置缓存窗口,接收方对每个分组单独进行确认。我们简单分析一下SR究竟是如何处理数据的。详见:中国大学mooc
发送方将窗口内的包发送,如果接受的包是窗口最左边期望的,就可以移动窗口,由于乱序的问题,可能后面的包早已接受到了ACK,所以在移动的窗口的时候并不是一个一个的移动,而是根据接收包的情况进行移动。
接收方和发送方类似,缓存在窗口内的包,直到是期望的包后才进行窗口滑动。
具体细节这里不多做介绍,大家记住这两个队列即可,后面的KCP代码分析中会对这两个队列着重分析。(SR更多细节详见:中国大学mooc)
UDP的可靠信息传输——结合KCP源码分析
KCP发送方
设计分析
在了解源码之前,还是一样思考几个问题:
1、如果包很大怎么办?比如说一个包10M。
2、窗口是怎么知道哪些包需要发送,哪些包已经收到了ack不需要发送,哪些包需要超时重传呢?
3、我们是如何得知包在窗口内还是在窗口外呢?
带着这几个问题,我们便能很自然的得倒我们的解决方案。
第一个问题:可以借鉴TCP分片的思想,既然包很大,那我就把他切片,一次发一个片就可以解决了。
第二个问题:可以设置两个队列——发送队列和窗口队列。发送队列记录的是待发送的新数据,窗口队列是真正的滑动窗口,并用send_next和window_size记录窗口大小(即窗口左边界+窗口大小);超时可以用定时器或者时间戳解决;是否收到ack用标志位记录即可,快速重传使用segment->xmit(包的发送次数)即可。
第三个问题:窗口队列是真正的滑动窗口,并用send_next和window_size记录窗口大小;只有序列号在[send_next,send_next+windows_size]之间才认为在窗口内。
下面结合之间我们的分析,得出发送方发送数据的流程图:
源码分析
在分析源码之前,其实是要分析kcp的头部的数据结构的,但这部分一来网上已经很多博主进行相关的分享,二来我们讨论的只是它的一个局部,也就是说我们只需要知道它的部分含义即可,所以这里就不多做说明,感兴趣的同学可以去看看kcp源码:KCP C版本。
在KCP中,和上述过程对应的就是send和flush两个函数,下面我们分别看看这两个函数的具体内容,揭开我们对UDP可靠数据传输的神秘面纱。
ikcp_send
int ikcp_send(ikcpcb *kcp, const char *buffer, int len)
{
IKCPSEG *seg;
int count, i;
assert(kcp->mss > 0);
if (len < 0) return -1;
//流模式,先不用关注
if (kcp->stream != 0) {
//...
}
//****** 第一部分 *********
if (len <= (int)kcp->mss) count = 1;
else count = (len + kcp->mss - 1) / kcp->mss;
//关于拥塞控制的,先不用关注
if (count >= (int)IKCP_WND_RCV) return -2;
if (count == 0) count = 1;
//******* 第二部分 *********
// fragment
for (i = 0; i < count; i++) {
int size = len > (int)kcp->mss ? (int)kcp->mss : len;
seg = ikcp_segment_new(kcp, size);
assert(seg);
if (seg == NULL) {
return -2;
}
if (buffer && len > 0) {
memcpy(seg->data, buffer, size);
}
seg->len = size;
seg->frg = (kcp->stream == 0)? (count - i - 1) : 0;
iqueue_init(&seg->node);
iqueue_add_tail(&seg->node, &kcp->snd_queue);
kcp->nsnd_que++;
if (buffer) {
buffer += size;
}
len -= size;
}
return 0;
}
前面if (kcp->stream != 0)是关于流方式的逻辑,可以先不用关注,直接看到第一部分:
第一部分是在干嘛呢?其实就是在做我们流程中提到的分片过程,每一个分片大小是mss,那么要分片数量就是count。——至此我们算出了要分多少片。
第二部分又是在做什么呢?
(1)第一个size是控制最后一片或者是出现长度不到一个mss的情况。
(2)之后通过ikcp_segment_new新建一个segment对象,再之后memcpy(seg->data, buffer, size);把buffer的size个字节的内容copy到data中。
(3)然后就是对kcp头部的说明:seg->len记录这个片的长度,seg->frg记录这个片是第几个片,方便后面组装,同时要注意的是这个片最后一片是0。
(4)最后就是通过iqueue_add_tail函数将这个片放到发送队列的队尾,然后让发送队列长度+1,即kcp->nsnd_que++。
第二部分总结一下就是流程图里叙述道的:将数据封装到segment里面,然后加入到发送队列中去。
细心的同学可能会比较疑惑:怎么send把数据加到队列里面去就不管了呢?那到底是由谁发送的呢?
真正的发送工作是由flush这个函数完成,这个函数会被周期性的调用,进行数据的发送。下面我们来看看这个函数的具体细节。
ikcp_flush
void ikcp_flush(ikcpcb *kcp)
{
//********** 第一部分 ************
//初始化定义下面用到的数据结构:略
// 'ikcp_update' haven't been called.
if (kcp->updated == 0) return;
//初始化赋值部分:略
//...
//********** 第二部分 ************
// flush acknowledges
count = kcp->ackcount;
for (i = 0; i < count; i++) {
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);
ptr = ikcp_encode_seg(ptr, &seg);
}
kcp->ackcount = 0;
//********** 第三部分 ************
if