[rUDP] KCP梳理

背景

先介绍下写这篇博客时的背景。无意间就看到了某网站需要懂KCP、UDT的RD,随即投了份简历。因此,这篇博客算是一份对过往知识的梳理,同时也算是一次面试的准备过程。至于我为什么会接触RUDP这块,应该也算是研究生阶段的研究方向。当时刚上研究生的时候,VR、AR还是热门话题,而导师是研究ARQ、QoS出身的,便让我试着用RaptorQ去开发一套视频传输工具去加速大容量视频数据的传输。之后,就顺便学习了市面上一些主流RUDP的技术方案。
这里先罗列下以前学习过的技术方案,以后有机会的我们再慢慢一个一个来梳理;
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相关的“宏观”知识会更有助于理解。

  1. 在TCP连接建立完成后发出的每个数据包中,都携带了有效的ACK序号,表示到该序号为止的所有数据包均已经成功接收;对此,KCP采取了类似的思路,在KCP中则是以“una”字段表示;
    1. 所不同的点在于,KCP中以数据包为基本单位进行编号;而TCP则是以字节byte为基本单位进行编号;
    2. UNA这种累积确认的方式,在出现“hole”这种情形时并不是十分有效;举例来说,当接收端收到1,3,4,5之后,UNA只能一直ACK 2,而毫无办法(这里其实又牵涉到了"快重传”,这个我们稍后再提);
  2. 对于“hole”这种情况,TCP后来引入了SACK选择确认,其中每个hole用一个[begin, end)序号对表示,序号对被存放在TCP首部选项中;TCP首部选项的最大长度仅为40字节,因此可以选择确认的"Hole" 个数有限;而在KCP中,有一种专门的数据包,其数据内容是一个数组,数组中的每个元素是要确认数据包的序号;因此,KCP中选择确认采用的单位是“接收到的数据包”,而不是TCP中缺失的“hole”;
  3. 既然我们刚刚在1中提到了“快重传”,那我们顺便来理一下。在TCP中,当接收端收到失序的数据包时,将会立即发送ACK,其目的是尽早通知发送方填补上这个缺口;当发送方连续收到3个重复的ACK时,将会触发“快重传”逻辑,立即重传(本质上,快重传应同SACK结合使用,只有借助SACK中的信息,我们才能准确地、有针对性地进行对缺失部分进行填补);如第2点所述的,KCP中有一种专门的SACK包,收到该SACK包的发送方首先可以根据包中的UNA字段以及SN字段清理掉一部分接收方成功接收的数据包;然后UNA字段以及所有SN值中的最大值正好就构成了一个区间[UNA, MaxSN);到此为止,发送窗口中所有位于上述区间的数据包,其实就是“失序”的数据包,毕竟人家序号为MaxSN的包都已经被接收端成功接收了;因此,位于上述区间内的所有发送缓存数据包,均会被记录“失序”次数;当失序次数达到上限值时,将会触发重传逻辑,而不是等待超时重传;当然,相比TCP中的3,KCP中的值是可以人为设定的;
  4. 目前,新的TCP中其实引入了Early Retransmit机制,其本质上是为了解决Duplicate ACK个数不足无法触发快重传的问题(参考:http://perthcharles.github.io/2015/10/31/wiki-network-tcp-early-retrans/);因此,我可以思考下KCP SACK数据包发送的时机?
  5. KCP同时支持字节流stream和消息message这两种传输形式;简便期间,我们仅分析消息message这种情形。
  6. KCP以“宏链表”为核心数据结构,串起整个work flow;按照数据流动的方向,依次是snd_queue(缓存待发送数据)、snd_buf(发送滑动窗口)、rcv_buf()、rcv_queue();
  7. 从用户使用的角度来看,在发送用户数据时只需要调用KCP的ikcp_send()函数即可;在接收用户数据时只需要调用KCP的ikcp_recv()函数即可;
  8. 从设计上来说,KCP并未指定底层所使用的传输层协议,而是通过ikcp_flush()、ikcp_input()与底层传输协议(多是UDP)交互;ikcp_flush()将需要发送的KCP分段交给UDP;而ikcp_input()从网络上获取UDP数据包,处理后进而上交给KCP(ikcp_feed()这名字会不会更贴切点,把数据喂给KCP);
  9. 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/

你可能感兴趣的:(c,KCP注释)