从TCP到可靠传输UDP

从TCP到可靠传输UDP

简介:在要求实时性的应用程序中报文结构更简单的UDP更受青睐。本文介绍了TCP可靠传输的技术细节,以及KCP协议的原理及简单应用。

引言

​ TCP在协议结构中就已经设计了完备的可靠传输机制。在微观角度上,TCP使用选择重传确保发送的字节流准确无误,使用收发窗口控制端到端的流量;在宏观上,使用多种策略对网络进行拥塞控制。这一可靠的传输机制仅限于一对一,在文件传输等对准确率有高要求的业务中有较大优势,但在直播推流、即时对战网游等对实时性有一定要求的业务中显得较为乏力。而面向报文的UDP,首部仅由8个字节组成,其简单的报文结构可以做到以较高的实时性进行数据通信。但相比于TCP,UDP在协议中只有简单的纠错功能,在可靠性上略等于无。所以由业务需求催生出了可靠传输的UDP协议:KCP。基于UDP在应用层实现的KCP协议,借鉴了TCP的可靠传输设计,使用10%-20%的带宽提高了近三成的传输速度。

原理及架构

一、TCP的可靠传输机制

ARQ协议

​ TCP协议通过32位的序号字段将待发送数据按照字节依次编号向对端发送数据,采用回退N帧方案。

​ 对于每个已经编号的字节,发送方都需要收到其对应的确认。在自动重传的最简单版本中,发送方每发送一个字节就会停止,直到收到确认才继续发送下一个字节。

​ 如图所示,最简单的停止等待ARQ。但这种方式在网络高丢包率场景下效率非常低下。如果服务器收到客户端的M1报文,但其对应的回应报文在超时重传时间内还未到达,发送端此时需要重新发送报文。

​ 基于以上方式,引入了回退N帧方案。即发送方与接收方同时维护自己的发送接受窗口,发送方每一次发送窗口大小的数据,接收方按照收到的数据发送确认。

​ 回退N协议的基本原理即尽可能的减少需要重传的数据。如图所示,A向B连续发送了8个序号的数据发送之指针依次向前移动,此时发送窗口指针指向9号,B收到0-3的数据并依次回应了确认报文。但针对4序号的回应报文在超时确认时间限度内还未收到回应,说明B只收到了3号及之前的数据,应该重新发送3号之后的数据,所以指针此时回退到4号从头发送数据。

​ 由此可知:TCP的回退N帧协议其高效的核心点在于超时重传时间的选择。如果超时重传时间过长,当有数据丢失需要等待相对长的一段时间发送方才会发起重传,这样会大大拖累网络的吞吐量。如果选择了很短的超时重传时间,会出现接收方的确认下一秒就会到达但已经超过了超时重传时间,发送方只好回退重新发送数据。

​ 所以在TCP协议中有一套完整的超时重传时间计算方法,因为篇幅原因此处不再赘叙。只需要直到超时重传时间(RTO)是基于往返时延(RTT)计算可知,且发生过超时重传之后的RTO值会翻倍。

流量控制与拥塞控制

​ 在TCP中,点对点的通信吞吐量由发送接受窗口控制。这两个窗口并不总是固定的值,受网络波动影响这两个值会综合调控以做到最高效率地发送数据。而拥塞控制是对网络的宏观调控用于控制整个网络的通信流量的通畅,避免在某一个网络时段里面出现大量的数据传输导致网络死锁。

​ 因为KCP在流量控制和拥塞控制所采用的策略大致相同,就不再此处展开介绍。

二、KCP协议简介

重传策略

​ 与TCP协议类似收发两端同时都维护两个窗口,发送方一次性发送窗口大小的数据。但在出现数据丢失时,KCP将已经收到的数据存储并只要求发送方重传丢失的数据,即选择重传协议。

​ 如图所示在0-8序列的数据中4号序列出现丢失,因为已经接收到的数据已有缓存只需要重传未收到确认的数据且不需要指针回退。

​ 除此之外,KCP在确保实时性方面做了更多的提升。

​ 对于RTO的选择KCP在出现超时后将RTO变为1.5倍,相较于TCP能够更快地进行数据重传。

​ 并且在选择重传的基础上KCP引入了快速重传概念:其基本逻辑,当发送端确认某一个包被跳过接受的次数超过阈值,立马认为该包丢失即刻重传。例如:当快速重传值设为2,发送端依次发送1、2、3、4、5序号的数据包,接收端仅接受到1、3、4、5,当发送方依次收到ACK1、ACK3、ACK4时,2号包在ACK3、ACK4分别被跳过了两次,达到了快速重传阈值。发送方认为2号包已经丢失,即刻重传。

​ 除此之外非退让流控也大大提高了KCP的实时性。在公平退让法则下,发送方的窗口大小取决于:发送缓存大小接受端剩余接受缓存大小丢包退让慢启动四项要素决定。而非退让流控在收发数据时仅使用前两项因素决定发送频率,牺牲了部分公平性和带宽利用率得到了更高的传输及时性。

协议头

​ 一个完整的KCP协议头如图所示,各字段值含义如下:

偏移 (Byte) 字段名 长度 (Byte) 数据类型 含义 示例值
0 conv 4 uint32 会话标识 (Conversation ID),收发双方必须一致,用于区分不同 KCP 连接。 0x12345678
4 cmd 1 uint8 控制命令类型: • IKCP_CMD_PUSH (81): 数据包 • IKCP_CMD_ACK (82): ACK 确认包 • IKCP_CMD_WASK (83): 窗口探测请求 • IKCP_CMD_WINS (84): 窗口大小通知 81 (数据包)
5 frg 1 uint8 分片编号: • 0 表示最后一个分片 • N 表示剩余分片数(如 2 表示后面还有 2 个分片) 0 (无分片)
6 wnd 2 uint16 发送方剩余接收窗口大小(即还能接收多少包),用于流量控制。 256 (窗口容量)
8 ts 4 uint32 时间戳(毫秒),用于计算 RTT 和超时重传。 0x5F2A1B00
12 sn 4 uint32 数据包序列号 (Sequence Number),按发送顺序递增。 1001
16 una 4 uint32 发送方当前未确认的最小 SN(即 snd_una),用于告知对端哪些包需要重传。 950
20 len 4 uint32 数据部分长度(不包含协议头)。若为 0 表示无数据(如纯 ACK 包)。 1400
24 data 可变 byte[] 实际负载数据(可选),长度由 len 字段指定。 "Hello KCP"

​ 其中,conv在数据收发的两端必须一致,常用于配置同步以及进行握手协议;cmd用于区分数据包的类型,各种不同的包类型在KCP连接中代表着不同的作用(ACK类型报文仅携带确认信息);frg用于表示在数据发送中分片的偏移量(当使用流模式时值始终为0);una用于表示累计确认。

初始化

​ KCP在网络层基于udp进行数据传输,而在应用层使用KCP实例进行数据的收发控制,可以理解为socket的udp作为管道,而KCP作为运输工将数据打包用于接收和发送。

​ 如图所示,KCP起点在用户层调用kcp_create()开始。

	ikcpcb* kcp_create(uint32_t conv, void *user);

kcp_create()需要在收发两端创建conv id,一般情况下采用uuid算法生成唯一值;kcp_create()还需要一个用于事件处理的回调函数。

	void ikcp_update(ikcpcb *kcp, uint32_t current);

kcp_update()在主函数中循环处理一个kcp实例,作为数据收发的中转站,所有的数据处理都要通过它进行驱动。同时也负责管理发送队列、超时定时器、窗口参数等全局状态,超时重传基于时间戳的对比计算,该状态同样由kcp_update() 批量遍历发送队列时统一检查。

发送数据

​ 数据发送的逻辑如下所示:

​ 当用户将待发送的1900字节的数据交给kcp_send(),程序按照所设定的MTU将数据分片并组装头部。被组装好的数据存入发送队列(snd_queue),被放入发送队列中的数据等待发送窗口的调度。

​ snd_buf用于存放发送窗口大小的数据,其中snd_nxt表示当前待发送的序号、snd_una表示当前还未接收到确认号中的的第一个序号、cwnd表示当前的拥塞窗口用于控制一次性发送包的数量。综上,发送窗口 = min(本地缓存剩余, 接收端窗口, cwnd)

​ 所有数据准备完成后,调用sendto()发包。发包结束后触发callback再次调用kcp_send()检查snd_queue中是否有剩余分片。通过回调实现流水线发送数据,无需等待kcp_update()轮询。

​ 发送端通过kcp_input()解析到接收端发来的ACK更新发送窗口中的状态,同时与kcp_update()交互更新窗口状态。若有数据包通过kcp_update()检测到超时则将该序号重新放入snd_queue再次进行发送。

接收数据

​ kcp接收数据流程如图所示:

​ 当接收端收到被UDP包装的KCP协议数据。首先将UDP包进行解包,去掉UDP头并解析KCP头部信息。在头部信息中,可提取出如图conv、cmd、frg等信息。紧接着接收端会依据sn值去与acklist中的记录进行对比,如果有重复的序号出现,说明这个已经接收到的包为重复接受包,直接将其丢弃。并在其过程中向发送端发送ACK回应。经过去重处理的数据包被放入rcv_buf中,若收到的数据并不是按照序号排序,rcv_buf会保留数据直到缺失的数据到达。直到有连续的一段数据包到达rcv_buf会将数据包按顺序插入到rcv_queue。最后将rcv_queue组成完整的用户数据,通过kcp_recv()组合用户数据传输到用户层。

三、KCP源码解析及应用

基本结构

​ kcp协议头如下图所示:

​ 使用结构体按照字节大小表示各字段:

	struct IKCPSEG
{
    struct IQUEUEHEAD node;
    IUINT32 conv;   // 会话编号,确保双方需保证conv相同,相互的数据包才能被接收.conv唯一标识一个会话
    IUINT32 cmd;    // 区分不同功能的分片.IKCP_CMD_PUSH数据分片;IKCP_CMD_ACK:ack分片;IKCP_CMD_WASK:请求告知窗口大小;IKCP_CMD_WINS:告知窗口大小
    IUINT32 frg;    // 标识segment分片ID,用户数据可能被分成多个kcp包发送, 为0时代表使用流式传输  
    IUINT32 wnd;    // 剩余接收窗口大小
    IUINT32 ts;     // 发送时刻的时间戳
    IUINT32 sn;     // 分片segment的序号,按1累加递增
    IUINT32 una;    // 待接收消息序号.对于未丢包的网络来说,una是下一个可接收的序号
    IUINT32 len;    // 数据长度
    IUINT32 resendts;   // 下次超时重传时间戳
    IUINT32 rto;        //该分片的超时等待时间
    IUINT32 fastack;    // 收到ack时计算该分片被跳过的累计次数,此字段用于快速重传,自定义快速重传的阈值
    IUINT32 xmit;       // 发送分片的次数,每发一次加1.发送的次数对RTO的计算有影响
    char data[1];		//存放用户数据
};

发送数据

​ 通常情况下用户数据会在ikcp_send()中被分片成多个frag。并将其按顺序放入snd_queue中。

//ikcp_send 部分代码
	//计算数据可以被最多分成多少个frag  
	if (len <= (int)kcp->mss) count = 1;
	
	//将数据分配空间并插入到snd_queue中
	for (i = 0; i < count; i++) {
		int size = len > (int)kcp->mss ? (int)kcp->mss : len;
		seg = ikcp_segment_new(kcp, size);
		assert(seg);
		memcpy(seg->data, buffer, size);	// 拷贝数据
		seg->len = size;                   // 每seg 数据大小
		seg->frg = (kcp->stream == 0)? (count - i - 1) : 0; // frg编号 
		iqueue_init(&seg->node);
		iqueue_add_tail(&seg->node, &kcp->snd_queue);	// 发送队列
		kcp->nsnd_que++;           	 // 发送队列++
		buffer += size;
		len -= size;
	}

​ 所有的数据处理完成被放入snd_buf中后,由ikcp_update()统一调度触发ikcp_flush()发送数据。

ikcp_update()负责驱动KCP状态更新和定时刷新操作:

​ 将时间戳同步到当前KCP实例中,用于后续超时重传的判断基准。当KCP实例首次调用ikcp_update(),将update标志置为1,并设置下次刷新时间为当前时间,使得首次调用即触发刷新。

	...
	kcp->current = current;        

	if (kcp->updated == 0) {
    	kcp->updated = 1;
    	kcp->ts_flush = kcp->current;
	}
	...

​ 当当前时间超过ts_flush进行刷新,基于刷新逻辑实现发送ACK确认、超时重传等功能。

​ 若ikcp_update()刷新触发,调用ikcp_flush()发送数据。该函数主要从acklist中读取需要应答的包序号:

	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); // 应答包 把时间戳发回去是为了能够计算RTT
		ptr = ikcp_encode_seg(ptr, &seg);       // 编码segment协议头
	}

​ 探测对端的接收窗口,以及通知对端自己的接收窗口:

	...	
	if (kcp->probe & IKCP_ASK_SEND) {
		seg.cmd = IKCP_CMD_WASK;        // 窗口探测  [询问对方窗口size]
		size = (int)(ptr - buffer);
		if (size + (int)IKCP_OVERHEAD > (int)kcp->mtu) {
			ikcp_output(kcp, buffer, size);
			ptr = buffer;
		}
		ptr = ikcp_encode_seg(ptr, &seg);
	}
	
	if (kcp->probe & IKCP_ASK_TELL) {
		seg.cmd = IKCP_CMD_WINS;    // [告诉对方我方窗口size], 如果不为0,可以往我方发送数据
		size = (int)(ptr - buffer);
		if (size + (int)IKCP_OVERHEAD > (int)kcp->mtu) {
			ikcp_output(kcp, buffer, size);
			ptr = buffer;
		}
		ptr = ikcp_encode_seg(ptr, &seg);
	}
	...

​ 获得收发窗口后,snd_queue中对应的数据包拷贝到snd_buf中:

	...
	while (_itimediff(kcp->snd_nxt, kcp->snd_una + cwnd) < 0) {
		IKCPSEG *newseg;
		if (iqueue_is_empty(&kcp->snd_queue)) break;
		//  从snd_queue读取segment
		newseg = iqueue_entry(kcp->snd_queue.next, IKCPSEG, node);

		iqueue_del(&newseg->node);
		// 插入到snd buf 发送窗口
		iqueue_add_tail(&newseg->node, &kcp->snd_buf);  // 从发送队列添加到发送缓存
		...
	}
	....

​ 最后将snd_buf中的数据发送:

	...
	size = (int)(ptr - buffer);     // 剩余的数据
	if (size > 0) { // 内部调用sendto
		ikcp_output(kcp, buffer, size);  // 最终只要有数据要发送,一定发出去
	}
	...

​ 在ikcp_flush()中还涉及一些超时重传的操作,篇幅原因进行省略。

接收数据

ikcp_input()同样是由ikcp_update()统一调度,从recvfrom()中取到数据缓存,首先将segment进行解析:

	...
	data = ikcp_decode32u(data, &conv);     // 获取segment头部信息
	if (conv != kcp->conv) return -1;		// 特别需要注意会话id的匹配

	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);

	size -= IKCP_OVERHEAD;
	...

​ 根据包中的各种信息处理acklist中的各项参数:

	kcp->rmt_wnd = wnd;         // 携带了远端的接收窗口 IKCP_CMD_WINS
	ikcp_parse_una(kcp, una); // 删除小于snd_buf中小于una的segment, 意思是una之前的都已经收到了
	ikcp_shrink_buf(kcp);    // 更新snd_una为snd_buf中seg->sn或kcp->snd_nxt ,更新下一个待应答的序号

​ 根据ACK包计算RTO,将已经确认的seg从snd_buf中移除:

	...
	if (cmd == IKCP_CMD_ACK) {
			 //更新rx_srtt,rx_rttval,计算kcp->rx_rto
			ikcp_update_ack(kcp, _itimediff(kcp->current, ts));

             //遍历snd_buf中(snd_una, snd_nxt),将sn相等的删除,直到大于sn  
			ikcp_parse_ack(kcp, sn);    // 将已经ack的分片删除
			ikcp_shrink_buf(kcp);       // 更新控制块的 snd_una
			...
	}
	...

​ 保存需要应答的序号和时间:

	...
	else if (cmd == IKCP_CMD_PUSH) {	//接收到具体的数据包
			if (ikcp_canlog(kcp, IKCP_LOG_IN_DATA)) {
				ikcp_log(kcp, IKCP_LOG_IN_DATA, 
					"input psh: sn=%lu ts=%lu", (unsigned long)sn, (unsigned long)ts);
			}
			if (_itimediff(sn, kcp->rcv_nxt + kcp->rcv_wnd) < 0) {
				ikcp_ack_push(kcp, sn, ts); // 对该报文的确认 ACK 报文放入 ACK 列表中
				//  判断接收的数据分片编号是否符合要求,即:在接收窗口(滑动窗口)范围之内
				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);
					}

                    ikcp_parse_data(kcp, seg); 
				}
			}
		}
		...

​ 被放入rcv_buf中的数据通过ikcp_parse_data()放入到recv_queue中等待用户程序读取。

集成kcp实现聊天室功能

​ 在对KCP有一定了解之后,如何在应用中使用KCP进行数据的收发呢?简单的架构如下图所示:

总结

​ 本文深入探讨了从TCP到可靠UDP传输协议(特别是KCP)的技术演进与应用实践。文章首先分析了TCP可靠传输机制的优缺点,指出其在实时性要求高的场景(如直播、游戏)中的局限性;然后重点介绍了KCP协议如何基于UDP实现可靠传输,通过选择性重传、快速重传、非退让流控等创新设计,在保证可靠性的同时显著提升传输效率(相比TCP提升30%速度,仅增加10-20%带宽开销)。文章详细解析了KCP的协议头结构、核心工作流程和关键源码实现,包括数据分片、窗口控制、ACK机制等关键技术点,并提供了将KCP集成到实际应用(如聊天室)的架构设计方案。

posted @ 2025-05-30 16:02  +_+0526  阅读(274)  评论(0)    收藏  举报