kcp特性源码赏析

436 阅读6分钟

注:特性来自github.com/skywind3000… 的 README.md 源码来自此仓库的代码

技术特性

TCP是为流量设计的(每秒内可以传输多少KB的数据),讲究的是充分利用带宽。而 KCP是为流速设计的(单个数据包从一端发送到一端需要多少时间),以10%-20%带宽浪费的代价换取了比 TCP快30%-40%的传输速度。TCP信道是一条流速很慢,但每秒流量很大的大运河,而KCP是水流湍急的小激流。KCP有正常模式和快速模式两种,通过以下策略达到提高流速的结果:

RTO翻倍vs不翻倍:

TCP超时计算是RTOx2,这样连续丢三次包就变成RTOx8了,十分恐怖,而KCP启动快速模式后不x2,只是x1.5(实验证明1.5这个值相对比较好),提高了传输速度。

static void ikcp_update_ack(ikcpcb *kcp, IINT32 rtt)
{
	IINT32 rto = 0;
	if (kcp->rx_srtt == 0) {
		kcp->rx_srtt = rtt;
		kcp->rx_rttval = rtt / 2;
	}	else {
		long delta = rtt - kcp->rx_srtt;
		if (delta < 0) delta = -delta;
		kcp->rx_rttval = (3 * kcp->rx_rttval + delta) / 4;
		kcp->rx_srtt = (7 * kcp->rx_srtt + rtt) / 8;
		if (kcp->rx_srtt < 1) kcp->rx_srtt = 1;
	}
	rto = kcp->rx_srtt + _imax_(kcp->interval, 4 * kcp->rx_rttval);
	kcp->rx_rto = _ibound_(kcp->rx_minrto, rto, IKCP_RTO_MAX);
}
//rto计算

void ikcp_flush(ikcpcb *kcp){
          // skip .......
		else if (_itimediff(current, segment->resendts) >= 0) {
			needsend = 1;
			segment->xmit++;
			kcp->xmit++;
			if (kcp->nodelay == 0) {
				segment->rto += _imax_(segment->rto, (IUINT32)kcp->rx_rto);
			}	else {
				IINT32 step = (kcp->nodelay < 2)? 
					((IINT32)(segment->rto)) : kcp->rx_rto;
				segment->rto += step / 2; //1.5倍增加
			}
			segment->resendts = current + segment->rto;
			lost = 1;
		}
		
          // skip .......
}

选择性重传 vs 全部重传:

TCP丢包时会全部重传从丢的那个包开始以后的数据,KCP是选择性重传,只重传真正丢失的数据包。

//1. 在接收到ack时,将不对应的包设置fastack+1
static void ikcp_parse_fastack(ikcpcb *kcp, IUINT32 sn, IUINT32 ts)
{
	struct IQUEUEHEAD *p, *next;

	if (_itimediff(sn, kcp->snd_una) < 0 || _itimediff(sn, kcp->snd_nxt) >= 0)
		return;

	for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = next) {
		IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node);
		next = p->next;
		if (_itimediff(sn, seg->sn) < 0) {
			break;
		}
		else if (sn != seg->sn) {
		#ifndef IKCP_FASTACK_CONSERVE
			seg->fastack++;
		#else
			if (_itimediff(ts, seg->ts) >= 0)
				seg->fastack++;
		#endif
		}
	}
}
//2.在flush中,fastack大于某值时,发送
void ikcp_flush(ikcpcb *kcp){
          // skip .......
          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) {
			needsend = 1;
			segment->xmit++;
			kcp->xmit++;
			if (kcp->nodelay == 0) {
				segment->rto += _imax_(segment->rto, (IUINT32)kcp->rx_rto);
			}	else {
				IINT32 step = (kcp->nodelay < 2)? 
					((IINT32)(segment->rto)) : kcp->rx_rto;
				segment->rto += step / 2;
			}
			segment->resendts = current + segment->rto;
			lost = 1;
		}
		else if (segment->fastack >= resent) {
			if ((int)segment->xmit <= kcp->fastlimit || 
				kcp->fastlimit <= 0) {
				needsend = 1;
				segment->xmit++;
				segment->fastack = 0;
				segment->resendts = current + segment->rto;
				change++;
			}
		}

		if (needsend) {
          // skip .......
}

快速重传:

发送端发送了1,2,3,4,5几个包,然后收到远端的ACK: 1, 3, 4, 5,当收到ACK3时,KCP知道2被跳过1次,收到ACK4时,知道2被跳过了2次,此时可以认为2号丢失,不用等超时,直接重传2号包,大大改善了丢包时的传输速度。(跟选择性重传 代码位置差不多)

延迟ACK vs 非延迟ACK:

TCP为了充分利用带宽,延迟发送ACK(NODELAY都没用),这样超时计算会算出较大 RTT时间,延长了丢包时的判断过程。KCP的ACK是否延迟发送可以调节。

void ikcp_flush(ikcpcb *kcp)
{
	IUINT32 current = kcp->current;
	char *buffer = kcp->buffer;
	char *ptr = buffer;
	int count, size, i;
	IUINT32 resent, cwnd;
	IUINT32 rtomin;
	struct IQUEUEHEAD *p;
	int change = 0;
	int lost = 0;
	IKCPSEG seg;

	// 'ikcp_update' haven't been called. 
	if (kcp->updated == 0) return;

	seg.conv = kcp->conv;
	seg.cmd = IKCP_CMD_ACK;
	seg.frg = 0;
	seg.wnd = ikcp_wnd_unused(kcp);
	seg.una = kcp->rcv_nxt;
	seg.len = 0;
	seg.sn = 0;
	seg.ts = 0;
.....................
//其实就是每次update时,都ack

UNA vs ACK+UNA:

ARQ模型响应有两种,UNA(此编号前所有包已收到,如TCP)和ACK(该编号包已收到),光用UNA将导致全部重传,光用ACK则丢失成本太高,以往协议都是二选其一,而 KCP协议中,除去单独的 ACK包外,所有包都有UNA信息。 目测就是ack重传那里

非退让流控:

KCP正常模式同TCP一样使用公平退让法则,即发送窗口大小由:发送缓存大小、接收端剩余接收缓存大小、丢包退让及慢启动这四要素决定。但传送及时性要求很高的小数据时,可选择通过配置跳过后两步,仅用前两项来控制发送频率。以牺牲部分公平性及带宽利用率之代价,换取了开着BT都能流畅传输的效果。

int ikcp_input(ikcpcb *kcp, const char *data, long size){
// skip .....
	if (_itimediff(kcp->snd_una, prev_una) > 0) {
		if (kcp->cwnd < kcp->rmt_wnd) {
			IUINT32 mss = kcp->mss;
			if (kcp->cwnd < kcp->ssthresh) {
				kcp->cwnd++;
				kcp->incr += mss;
			}	else {
				if (kcp->incr < mss) kcp->incr = mss;
				kcp->incr += (mss * mss) / kcp->incr + (mss / 16);
				if ((kcp->cwnd + 1) * mss <= kcp->incr) {
				#if 1
					kcp->cwnd = (kcp->incr + mss - 1) / ((mss > 0)? mss : 1);
				#else
					kcp->cwnd++;
				#endif
				}
			}
			if (kcp->cwnd > kcp->rmt_wnd) {
				kcp->cwnd = kcp->rmt_wnd;
				kcp->incr = kcp->rmt_wnd * mss;
			}
		}
	}
}

void ikcp_flush(ikcpcb *kcp){
//skip ....
	cwnd = _imin_(kcp->snd_wnd, kcp->rmt_wnd);
	if (kcp->nocwnd == 0) cwnd = _imin_(kcp->cwnd, cwnd);
//skip ....
	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;
		kcp->incr = kcp->cwnd * kcp->mss;
	}

	if (lost) {
		kcp->ssthresh = cwnd / 2;
		if (kcp->ssthresh < IKCP_THRESH_MIN)
			kcp->ssthresh = IKCP_THRESH_MIN;
		kcp->cwnd = 1;
		kcp->incr = kcp->mss;
	}

	if (kcp->cwnd < 1) {
		kcp->cwnd = 1;
		kcp->incr = kcp->mss;
	}

附:主要数据结构注释

struct IKCPSEG
{
	struct IQUEUEHEAD node;
	IUINT32 conv; //用来标记这个seg属于哪个kcp
	IUINT32 cmd;//这个包的指令是: // 数据 ack 询问/应答窗口大小
	IUINT32 frg; //分包时,分包的序号,0为终结
	IUINT32 wnd;//发送这个seg的这个端的 窗口大小--> 远端的接收窗口大小
	IUINT32 ts; //我不知道为什么要用时间轴,这个都1秒,有什么用 ??
	IUINT32 sn;//相当于tcp的ack
	IUINT32 una;//una 远端等待接收的一个序号
	IUINT32 len; //data的长度
	IUINT32 resendts;//重发的时间轴
	IUINT32 rto;//等于发送端kcp的 rx_rto->由 计算得来
	IUINT32 fastack;//ack跳过的次数,用于快速重传 ??
	IUINT32 xmit;// fastack resend次数
	char data[1];//当malloc时,只需要  malloc(sizeof(IKCPSEG)+datalen) 则,data长=数据长度+1 刚好用来放0
};

struct IKCPCB
{
	//会话ID,最大传输单元,最大分片大小,状态   mss=mtu-sizeof(IKCPSEG)
	IUINT32 conv, mtu, mss, state;
	//第一个未接收到的包,待发送的包(可以认为是tcp的ack自增),接收消息的序号-> 用来赋seg的una值
	IUINT32 snd_una, snd_nxt, rcv_nxt;
	//前两个不知道干嘛  拥塞窗口的阈值 用来控制cwnd值变化的
	IUINT32 ts_recent, ts_lastack, ssthresh;
	//这几个变量是用来更新rto的
	// rx_rttval 接收ack的浮动值
	// rx_srtt 接收ack的平滑值
	// rx_rto 计算出来的rto
	// rx_minrto 最小rto
	IINT32 rx_rttval, rx_srtt, rx_rto, rx_minrto;
	//发送队列的窗口大小
	//接收队列的窗口大小
	//远端的接收队列的窗口大小
	//窗口大小
	//probe 用来二进制标记 要干嘛
	IUINT32 snd_wnd, rcv_wnd, rmt_wnd, cwnd, probe;
	//时间轴 时间间隔 下一次flush的时间  xmit发射多少次? 看不到有什么地方用到
	IUINT32 current, interval, ts_flush, xmit;
	//接收到的数据seg个数
	//需要发送的seg个数
	IUINT32 nrcv_buf, nsnd_buf;
	//接收队列的数据 seg个数
	//发送队列的数据 seg个数
	IUINT32 nrcv_que, nsnd_que;
	//是否为nodelay模式:如果开启,rto计算范围更小
	//updated 在调用flush时,有没有调用过update
	IUINT32 nodelay, updated;
	//请求访问窗口的时间相关 当远程端口大小为0时
	IUINT32 ts_probe, probe_wait;
	IUINT32 dead_link, incr;
	//发送队列
	struct IQUEUEHEAD snd_queue;
	//接收队列
	struct IQUEUEHEAD rcv_queue;
	//待发送队列
	struct IQUEUEHEAD snd_buf;
	//待接收队列
	struct IQUEUEHEAD rcv_buf;
	//用来缓存自己接收到了多少个ack
	IUINT32 *acklist;
	IUINT32 ackcount;
	IUINT32 ackblock;

	//用户信息
	void *user;
	//好像就用来操作数据的中转站
	char *buffer;
	//快速重传的阈值
	int fastresend;
	//快速重传的上限
	int fastlimit;
	//是否无视重传等其它设置窗口
	//steam模式的话,会将几个小包合并成大包
	int nocwnd, stream;
	int logmask;
	int (*output)(const char *buf, int len, struct IKCPCB *kcp, void *user);
	void (*writelog)(const char *log, struct IKCPCB *kcp, void *user);
};