这里先罗列下以前学习过的技术方案,以后有机会的我们再慢慢一个一个来梳理;
KCP https://github.com/skywind3000/kcp
UDT http://udt.sourceforge.net/
QUIC Multipath Extension https://github.com/qdeconinck/mp-quic
KCP
相比UDT,KCP我最喜欢的一点就是简洁:一方面体现在使用的语言上;另一方面体现在代码行数上。抛去边边角角的无关紧要的函数,KCP核心代码就集中在4个函数中,分别是ikcp_send(), ikcp_flush(), ikcp_input(), ikcp_recv()四部分。接下来,我们以“一发->一收”这种场景来对代码进行梳理。
值得提的一点是,KCP源码当中的辅助函数名字真的有点“迷”,你不ctrl b跳转进去,真的一下子搞不灵清那个函数到底想干嘛;此外,KCP中代码中充斥着宏链表,有时候还是挺耽搁理解的。因此,在后面的分析梳理过程中,我们能用伪代码的尽量用伪代码吧。
在开始进入具体的实现细节之前,铺垫一下KCP相关的“宏观”知识会更有助于理解。
在TCP连接建立完成后发出的每个数据包中,都携带了有效的ACK序号,表示到该序号为止的所有数据包均已经成功接收;对此,KCP采取了类似的思路,在KCP中则是以“una”字段表示;
所不同的点在于,KCP中以数据包为基本单位进行编号;而TCP则是以字节byte为基本单位进行编号;
UNA这种累积确认的方式,在出现“hole”这种情形时并不是十分有效;举例来说,当接收端收到1,3,4,5之后,UNA只能一直ACK 2,而毫无办法(这里其实又牵涉到了"快重传”,这个我们稍后再提);
对于“hole”这种情况,TCP后来引入了SACK选择确认,其中每个hole用一个[begin, end)序号对表示,序号对被存放在TCP首部选项中;TCP首部选项的最大长度仅为40字节,因此可以选择确认的"Hole" 个数有限;而在KCP中,有一种专门的数据包,其数据内容是一个数组,数组中的每个元素是要确认数据包的序号;因此,KCP中选择确认采用的单位是“接收到的数据包”,而不是TCP中缺失的“hole”;
既然我们刚刚在1中提到了“快重传”,那我们顺便来理一下。在TCP中,当接收端收到失序的数据包时,将会立即发送ACK,其目的是尽早通知发送方填补上这个缺口;当发送方连续收到3个重复的ACK时,将会触发“快重传”逻辑,立即重传(本质上,快重传应同SACK结合使用,只有借助SACK中的信息,我们才能准确地、有针对性地进行对缺失部分进行填补);如第2点所述的,KCP中有一种专门的SACK包,收到该SACK包的发送方首先可以根据包中的UNA字段以及SN字段清理掉一部分接收方成功接收的数据包;然后UNA字段以及所有SN值中的最大值正好就构成了一个区间[UNA, MaxSN);到此为止,发送窗口中所有位于上述区间的数据包,其实就是“失序”的数据包,毕竟人家序号为MaxSN的包都已经被接收端成功接收了;因此,位于上述区间内的所有发送缓存数据包,均会被记录“失序”次数;当失序次数达到上限值时,将会触发重传逻辑,而不是等待超时重传;当然,相比TCP中的3,KCP中的值是可以人为设定的;
目前,新的TCP中其实引入了Early Retransmit机制,其本质上是为了解决Duplicate ACK个数不足无法触发快重传的问题(参考:http://perthcharles.github.io/2015/10/31/wiki-network-tcp-early-retrans/);因此,我可以思考下KCP SACK数据包发送的时机?
KCP同时支持字节流stream和消息message这两种传输形式;简便期间,我们仅分析消息message这种情形。
KCP以“宏链表”为核心数据结构,串起整个work flow;按照数据流动的方向,依次是snd_queue(缓存待发送数据)、snd_buf(发送滑动窗口)、rcv_buf()、rcv_queue();
从用户使用的角度来看,在发送用户数据时只需要调用KCP的ikcp_send()函数即可;在接收用户数据时只需要调用KCP的ikcp_recv()函数即可;
从设计上来说,KCP并未指定底层所使用的传输层协议,而是通过ikcp_flush()、ikcp_input()与底层传输协议(多是UDP)交互;ikcp_flush()将需要发送的KCP分段交给UDP;而ikcp_input()从网络上获取UDP数据包,处理后进而上交给KCP(ikcp_feed()这名字会不会更贴切点,把数据喂给KCP);
KCP多用在手机视频、手游等弱网环境,在这种网络环境下使用FEC是种常规做法;而鉴于第4点,我们实际上可以很轻松地在KCP和UDP之间再插入一层FEC编码层;这也是“分层模型”架构上带来的好处;
在有了上述的这些铺垫之后,让我们从代码层面着手开始梳理,首当其冲的便是ikcp_send()。ikcp_send()的功能十分简单,只需要将用户待发送的数据根据MSS值切分成段即可,切好的KCP段被插入到snd_queue中等待进一步处理;
int ikcp_send(ikcpcb *kcp, const char *buffer, int len)
{
// 根据MSS(Maximum Segment Size)计算Segment的个数;
int count = 0;
if (len <= (int)kcp->mss) count = 1;
else count = (len + kcp->mss - 1) / kcp->mss;
// 在KCP中,一个KCP分段由一个IKCPSEG结构体表示;
// 这部分代码,对用户数据进行分段,分段对应的结构体被插入到snd_queue当中;
for (int i = 0; i < count; i++) {
int size = len > (int)kcp->mss ? (int)kcp->mss : len; // mss, mss, ..., len
IKCPSEG *seg = ikcp_segment_new(kcp, size); // malloc
memcpy(seg->data, buffer, size); // 数据从用户buffer复制到KCP内部的IKCPSEG结构中;
seg->len = size; // 之后,每片用户数据都以IKCPSEG结构的形式出现;
seg->frg = (kcp->stream == 0)? (count - i - 1) : 0; // 该分段之后的分段数,类似TCP的设计;
iqueue_init(&seg->node); // 插入到snd_queue中
iqueue_add_tail(&seg->node, &kcp->snd_queue);
kcp->nsnd_que++;
buffer += size;
len -= size;
}
return 0;
}
在讲完了ikcp_send()之后,我们接着来讲讲ikcp_flush()。相比ikcp_send(),ikcp_flush()会显得老长老长的;当然,这也是不可避免的,毕竟KCP可靠性相关的处理逻辑都被放在了这个函数中。为此,我们一段一段地去看,化整为零。值得注意的是,在ikcp_flush()函数的前部,有ACK、窗口探测相关的逻辑;针对这部分,我们暂且跳过,因为这部分其实牵涉接收过程(一个主机既可以是发送方,也可以是接收方,)
void ikcp_flush(ikcpcb *kcp) {
/*
flush acknowledges
...
*/
/*
probe window size (if remote window size equals zero)
...
*/
/*
flush window probing commands
...
*/
/*
calculate window size
...
*/
// snd_queue是用户待发送分段的队列,snd_buf是KCP的滑动窗口( 窗口内序号范围为: [snd_una, snd_una + cwnd) )
// 这部分的逻辑就是,只要发送窗口允许,就尽可能地从snd_queue搬运待发送分段;
while (_itimediff(kcp->snd_nxt, kcp->snd_una + cwnd) < 0) {
IKCPSEG *newseg;
if (iqueue_is_empty(&kcp->snd_queue)) break;
newseg = iqueue_entry(kcp->snd_queue.next, IKCPSEG, node);
// 简单地从snd_queue搬运分段seg
iqueue_del(&newseg->node);
iqueue_add_tail(&newseg->node, &kcp->snd_buf);
kcp->nsnd_que--;
kcp->nsnd_buf++;
// 前面的ikcp_send()函数,只是为每个分段套了一个IKCPSEG结构体外壳;
newseg->conv = kcp->conv; // 用conv字段区分每次KCP会话
newseg->cmd = IKCP_CMD_PUSH; // IKCP_CMD_PUSH表明该KCP包携带用户数据;
newseg->wnd = seg.wnd; // 发送端当前的窗口值,供接收端发送数据时做流控;
newseg->ts = current; // 发送时间戳
newseg->sn = kcp->snd_nxt++; // 分配sn序列号
newseg->una = kcp->rcv_nxt; // 累积确认,una编号之前的所有数据包均已收到,也就是发送端期望收到的数据包;
newseg->resendts = current;
newseg->rto = kcp->rx_rto; // 超时重传时间(Retransmission TimeOut, RTO)
newseg->fastack = 0; // 用于快重传,被ack略过的次数;
newseg->xmit = 0; // 已发送次数
}
// calculate resent
resent = (kcp->fastresend > 0)? (IUINT32)kcp->fastresend : 0xffffffff;
rtomin = (kcp->nodelay == 0)? (kcp->rx_rto >> 3) : 0;
// 遍历滑动窗口中的每一个分段
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) { // Case 1: 全新的分段,上面代码段中刚插入进来的;
needsend = 1; // 将该分段标记为“需要发送”
segment->xmit++; // 发送次数+1
segment->rto = kcp->rx_rto;
segment->resendts = current + segment->rto + rtomin; // 超时重传时刻 ??
}
else if (_itimediff(current, segment->resendts) >= 0) { // Case 2: 超时,触发重传逻辑;
needsend = 1;
segment->xmit++;
kcp->xmit++;
if (kcp->nodelay == 0) {
segment->rto += kcp->rx_rto; // 超时发生时,RTO 2倍退避(TCP style)
} else {
segment->rto += kcp->rx_rto / 2; // 超时发生时,RTO 1.5倍退避(KCP style)
}
segment->resendts = current + segment->rto; // 计算超时重传时间
lost = 1;
}
else if (segment->fastack >= resent) { // Case 3: 该分段被多次乱序ACK,触发快重传逻辑;
needsend = 1;
segment->xmit++;
segment->fastack = 0;
segment->resendts = current + segment->rto;
change++;
}
if (needsend) { // 如果满足上述任一Case,发送该分段;
int size, need;
segment->ts = current;
segment->wnd = seg.wnd;
segment->una = kcp->rcv_nxt;
size = (int)(ptr - buffer);
need = IKCP_OVERHEAD + segment->len;
if (size + need > (int)kcp->mtu) {
ikcp_output(kcp, buffer, size);
ptr = buffer;
}
ptr = ikcp_encode_seg(ptr, segment);
if (segment->len > 0) {
memcpy(ptr, segment->data, segment->len);
ptr += segment->len;
}
if (segment->xmit >= kcp->dead_link) {
kcp->state = -1;
}
}
}
// flash remain segments
size = (int)(ptr - buffer);
if (size > 0) {
ikcp_output(kcp, buffer, size);
}
// update ssthresh
}
参考文献:
https://wetest.qq.com/lab/view/391.html
http://kaiyuan.me/2017/07/29/KCP源码分析/
http://perthcharles.github.io/2015/10/31/wiki-network-tcp-early-retrans/