[原創] KCP 原始碼分析(上)

hellozhangjz發表於2024-03-15

KCP 協議是一種可靠的傳輸協議,對比 TCP 取消了累計確認(延遲 ACK)、減小 RTO增長速度、選擇性重傳而非全部重傳。透過用流量換取低時延。 KCP 中最重要的兩個資料結構IKCPCB和IKCPSEG,一個IKCPCB對應一個 KCP 連線,透過這個結構體維護髮送快取、接收快取、超時重傳時間、視窗大小等。IKCPSEG 對應一個 KCP 資料包,包含該資料包的命令、資料、時間戳、資料長度等資訊。原始碼地址:https://github.com/skywind3000/kcp

KCP 資料包結構體:

struct IKCPSEG
{
	struct IQUEUEHEAD node;
	IUINT32 conv; 	// 會話 ID
	IUINT32 cmd;	// KCP 命令:
    				// IKCP_CMD_ACK:這是個 ACK 
    				// IKCP_CMD_WASK:傳送方探測接收方的視窗
    				// IKCP_CMD_WINS:接收方回應自己的視窗大小
    				
	IUINT32 frg;	// fragment分段號,如果是流模式:預設為 0
	IUINT32 wnd;	// 視窗大小
	IUINT32 ts;		// 傳送方:資料包的傳送時間戳。 
  					// 接收方(ACK):所接受資料包的傳送時間,而不是傳送 ACK 的時間,方便傳送方收到 ACK 後計算 rtt。
	IUINT32 sn;		// 傳送方:傳送資料包的序列號
  					// 接收方(ACK):ACK 號
	IUINT32 una;	// 未確認序列號:期待下次收到的資料包
	IUINT32 len;	// 資料包除去頭部的位元組數
  
/*-----------------以下成員不會實際傳送到網路中,主要是超時重傳和快速重傳計算的輔助資料-----------------*/
	IUINT32 resendts;	// = current + rto, 超時重傳的閾值, 當前時間超過resendts, 就要重發這個資料包
	IUINT32 rto;	// Retransmission Timeout, 下次超時重傳的間隔時間, 會隨著超時次數增加, 增加速率取決於是不是快速模式
	IUINT32 fastack;// 資料包被跳過次數, 快速重傳功能需要
	IUINT32 xmit;	// 該資料包傳送次數, transmit 的縮寫, ,次數太多判斷網路斷開
/*-----------------以上成員不會實際傳送到網路中,主要是超時重傳和快速重傳計算的輔助資料-----------------*/
  
	char data[1];	// 資料包攜帶的資料,大小根據ikcp_segment_new的引數決定
};

KCP 連線結構體:

struct IKCPCB
{
	IUINT32 conv; 	// 會話ID
	IUINT32 mtu; 	// 下層協議的最大傳輸單元, 一次傳送若干個kcp包, 這些包的總長度不超過mtu
	IUINT32 mss; 	// 一個KCP資料包的最大資料載荷, mss+head一定不超過mtu
	IUINT32 state; 	// 連線狀態

	IUINT32 snd_una; 	// snd_una之前的包對方(接收方)都已經收到了
	IUINT32 snd_nxt;  	// 下一個要從 send_que 發到 send_buf 的包序列號
	IUINT32 rcv_nxt;	// 下一個要從 rcv_buf 發到 rcv_que 的包序列號

	IUINT32 ts_recent; 	// 沒用到
	IUINT32 ts_lastack;	// 沒用到

	IUINT32 ssthresh;	// 擁塞視窗從慢啟動轉換到擁塞避免的視窗閾值
	IINT32 rx_rttval;	// 近4次rtt和srtt的平均差值,反應了rtt偏離srtt的程度
	IINT32 rx_srtt;		// 平滑的rtt,近8次rtt平均值
	IINT32 rx_rto;		// 系統的重傳超時時間
	IINT32 rx_minrto; 	// 最小重傳超時時間
	
	IUINT32 snd_wnd; 	// 傳送視窗大小
	IUINT32 rcv_wnd; 	// 接收視窗大小
	IUINT32 rmt_wnd; 	// 對方接收視窗大小
	IUINT32 cwnd; 		// 擁塞視窗大小
	IUINT32 probe;		// 探測視窗大小

	IUINT32 current;	// 當前時間戳
	IUINT32 interval; 	// 內部flush重新整理間隔
	IUINT32 ts_flush; 	// 下一次重新整理輸出的時間戳
	IUINT32 xmit;		// 該KCP連線超時重傳次數

	IUINT32 nrcv_buf; 	// rcv_buf的長度
	IUINT32 nsnd_buf;	// snd_buf的長度
	IUINT32 nrcv_que; 	// rcv_que的長度
	IUINT32 nsnd_que; 	// snd_que的長度

	IUINT32 nodelay;	// 是否啟用nodelay模式, ==2為快速模式
	IUINT32 updated;	// 是否呼叫過update函式
	IUINT32 ts_probe; 	// 下次探測視窗大小的時間戳
	IUINT32 probe_wait; // 探測視窗大小的間隔時間,每次探測對面視窗為0(失敗), 探測時間*1.5
	IUINT32 dead_link;	// 斷開連線的重傳次數閾值
	IUINT32 incr; 		// k*mss , 擁塞視窗等於floor(k)
	struct IQUEUEHEAD snd_queue;// 傳送佇列
	struct IQUEUEHEAD rcv_queue;// 接收佇列
	struct IQUEUEHEAD snd_buf; // 傳送快取, 還沒收到 ACK 的包都在這裡邊
	struct IQUEUEHEAD rcv_buf; // 接收快取, 將收到的資料暫存, 然後將其中連續的資料放到rcv_queue供上層讀取
	IUINT32 *acklist; 	// 一個整數陣列,存放要回復的ack,
  						// 結構為 [sn0(接收資料包的序號), ts0(接收資料包的傳送時間), sn1, ts1, ...]
	IUINT32 ackcount; 	// 本次需要回復的ack個數
	IUINT32 ackblock; 	// acklist的大小,會動態擴容,類似於 vector
	void *user;			// 使用者標識
	char *buffer; 		// 資料緩衝區
	int fastresend; 	// 快速重傳的失序閾值, 傳送方收到 fastresend 個冗餘ACK就觸發快速重傳
	int fastlimit;  	// 快速重傳的次數限制
	int nocwnd; 		// 0: 有擁塞控制, 1: 沒有擁塞控制
	int 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);
};

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 (!iqueue_is_empty(&kcp->snd_queue)) {
      // old:沒有被裝滿的包
			IKCPSEG *old = iqueue_entry(kcp->snd_queue.prev, IKCPSEG, node);
			if (old->len < kcp->mss) { // 前一個包沒塞滿, 粘包
				int capacity = kcp->mss - old->len;
				int extend = (len < capacity)? len : capacity;
				seg = ikcp_segment_new(kcp, old->len + extend);
				assert(seg);
				if (seg == NULL) {
					return -2;
				}
				iqueue_add_tail(&seg->node, &kcp->snd_queue);
				memcpy(seg->data, old->data, old->len); // 把old資料轉移到seg,然後把old刪了
				if (buffer) {
					memcpy(seg->data + old->len, buffer, extend);
					buffer += extend;
				}
				seg->len = old->len + extend;
				seg->frg = 0;
				len -= extend;
				iqueue_del_init(&old->node); 
				ikcp_segment_delete(kcp, old); // 刪除 old 節點
			}
		}
		if (len <= 0) {
			return 0;
		}
	}

	// 需要幾個包來裝len位元組的資料, 一個包最多裝mss位元組
	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;

	// 將buffer資料分段裝入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);
		if (seg == NULL) {
			return -2;
		}
		if (buffer && len > 0) {
			memcpy(seg->data, buffer, size);
		}
		seg->len = size;
    	// 上層資料包被分段後的段號,如果開啟流模式,預設段號都為 0
		seg->frg = (kcp->stream == 0)? (count - i - 1) : 0; 
		iqueue_init(&seg->node);
		iqueue_add_tail(&seg->node, &kcp->snd_queue); // 將資料包 push 進傳送佇列
		kcp->nsnd_que++;
		if (buffer) {
			buffer += size;
		}
		len -= size;
	}

	return 0;
}

ikcp_send 的主要邏輯就是將使用者資料分段組裝成 IKCPSEG 然後將其新增到傳送佇列。如果是流模式,則沒有段號,每個包都是滿的,資料有粘包需要使用者自己處理。

ikcp_update

上層定時呼叫,主要功能是設定當前時間戳、計算下一次update 事件以及呼叫 ikcp_flush,ikcp_flush 才是將資料從傳送佇列傳送到傳送快取的函式。

ikcp_flush

Step1、回應ACK

void ikcp_flush(ikcpcb *kcp)
{
    char *buffer = kcp->buffer; // 資料緩衝區
	char *ptr = buffer;
 	IKCPSEG seg;
    seg.conv = kcp->conv;
	seg.cmd = IKCP_CMD_ACK; // 命令為 ACK
	seg.frg = 0;
	seg.wnd = ikcp_wnd_unused(kcp); // 設定視窗大小
	seg.una = kcp->rcv_nxt;
	seg.len = 0;
	seg.sn = 0;
	seg.ts = 0;
    ...
	count = kcp->ackcount; // 需要回復的 ack 個數
	for (i = 0; i < count; i++) { 
		size = (int)(ptr - buffer);
        // 如果buffer 放不下 seg 的 head ,那就先把 buffer 中的資料先發到網路中
		if (size + (int)IKCP_OVERHEAD > (int)kcp->mtu) {
			ikcp_output(kcp, buffer, size); // 將buffer 中的資料先發到網路中
			ptr = buffer;
		}
        // 從 acklist 中取出要傳送 ack 的 sn 和 ts
		ikcp_ack_get(kcp, i, &seg.sn, &seg.ts);
        // 只將 seg 的 head 複製到資料緩衝區裡,注意回應 ack 的報文沒有 data。
		ptr = ikcp_encode_seg(ptr, &seg); 
	}

	kcp->ackcount = 0;
  	...
}

這段程式碼主要功能就是回覆 ACK,在接收資料的時候 kcp 會把需要回復的 ACK 放入 acklist,在這裡檢查kcp->ackcount,發現需要回復 ACK就從 acklist 中取出要傳送 ack 的 sn 和 ts存入 seg 然後傳送。

Step2、探測視窗

void ikcp_flush(ikcpcb *kcp)
{
    char *buffer = kcp->buffer; // 資料緩衝區
	char *ptr = buffer;
 	IKCPSEG seg;
    ...
	// 對面沒有接收快取,等待probe_wait
	if (kcp->rmt_wnd == 0) {
		if (kcp->probe_wait == 0) { // 初始化探測視窗
			kcp->probe_wait = IKCP_PROBE_INIT;
			kcp->ts_probe = kcp->current + kcp->probe_wait;
		}	
		else {
			if (_itimediff(kcp->current, kcp->ts_probe) >= 0) {  // 已經到了探測時間
				if (kcp->probe_wait < IKCP_PROBE_INIT) 
					kcp->probe_wait = IKCP_PROBE_INIT;
				kcp->probe_wait += kcp->probe_wait / 2; // 每次探測間隔增長 0.5 倍
				if (kcp->probe_wait > IKCP_PROBE_LIMIT)
					kcp->probe_wait = IKCP_PROBE_LIMIT;
				kcp->ts_probe = kcp->current + kcp->probe_wait;
				kcp->probe |= IKCP_ASK_SEND; // 標記需要探測視窗
			}
		}
	}	else { // 一旦對方有,則重置探測時間和探測間隔
		kcp->ts_probe = 0;
		kcp->probe_wait = 0;
	}
	
    // 標記需要傳送探測
	if (kcp->probe & IKCP_ASK_SEND) {
		seg.cmd = IKCP_CMD_WASK; // 命令設為 IKCP_CMD_WASK,其他頭資訊不需要
		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);
	}
  	...
}

當傳送方發現對方視窗大小為 0,需要傳送探測命令詢問對方視窗大小,每次探測間隔都會增長0.5 倍,一旦對方有接收視窗,則重置探測時間和探測間隔。

Step3、回應探測視窗

void ikcp_flush(ikcpcb *kcp)
{
    char *buffer = kcp->buffer; // 資料緩衝區
	char *ptr = buffer;
 	IKCPSEG seg;
    ...
    // 需要回應視窗大小
	if (kcp->probe & IKCP_ASK_TELL) {
		seg.cmd = IKCP_CMD_WINS;  // 命令設為 IKCP_CMD_WINS,其他頭資訊不需要
		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);
	}
    ...
}

Step4、傳送資料

void ikcp_flush(ikcpcb *kcp)
{
    char *buffer = kcp->buffer; // 資料緩衝區
	char *ptr = buffer;
 	IKCPSEG seg;
    ...
    // 計算可以發多少資料
	cwnd = _imin_(kcp->snd_wnd, kcp->rmt_wnd);
    // kcp->nocwnd == 1 則關閉流控(擁塞控制)
	if (kcp->nocwnd == 0) cwnd = _imin_(kcp->cwnd, cwnd);
    // 如果 snd_nxt(下一個要從 send_que 發到 send_buf 的包序列號) 在傳送視窗內
    // 就一直從 snd_que 中取出資料包放到 snd_buf 中, 直到snd_buf滿或者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);

		iqueue_del(&newseg->node);
		iqueue_add_tail(&newseg->node, &kcp->snd_buf);
		kcp->nsnd_que--;
		kcp->nsnd_buf++;

		newseg->conv = kcp->conv;
		newseg->cmd = IKCP_CMD_PUSH;
		newseg->wnd = seg.wnd;
		newseg->ts = current;
		newseg->sn = kcp->snd_nxt++;
		newseg->una = kcp->rcv_nxt;
		newseg->resendts = current;
		newseg->rto = kcp->rx_rto;
		newseg->fastack = 0;
		newseg->xmit = 0;
	}

	// resent:收到 resent 個失序 ACK 就會觸發快速重傳,TCP 裡是冗餘 ACK。
    // 如果沒開啟快速重傳,則 resent 為 inf。
	resent = (kcp->fastresend > 0)? (IUINT32)kcp->fastresend : 0xffffffff;
    // 超時重傳的最小超時時間
	rtomin = (kcp->nodelay == 0)? (kcp->rx_rto >> 3) : 0;

	// 遍歷snd_buf裡的資料包是否需要傳送
	for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = p->next) {
		IKCPSEG *segment = iqueue_entry(p, IKCPSEG, node);
		int needsend = 0; 
		/* 
			該資料包是否需要傳送,三種情況:
			1. 該資料包沒傳送過
			2. 超時沒有收到ack,觸發超時重傳
			3. 收到resent次冗餘ack,觸發快速重傳
		*/

		// 1. 該資料包第一次傳送
		if (segment->xmit == 0) {
			needsend = 1;
			segment->xmit++;
			segment->rto = kcp->rx_rto; // 超時重傳時間
			segment->resendts = current + segment->rto + rtomin;
		}

		// 2. 該資料包超時沒有收到ACK, 觸發超時重傳
		else if (_itimediff(current, segment->resendts) >= 0) { 
			needsend = 1;
			segment->xmit++;
			kcp->xmit++;
			if (kcp->nodelay == 0) { // 普通模式,超時時間*2
				segment->rto += _imax_(segment->rto, (IUINT32)kcp->rx_rto);
			}	else { // 快速模式,超時時間*1.5
				IINT32 step = (kcp->nodelay < 2)? 
					((IINT32)(segment->rto)) : kcp->rx_rto;
				segment->rto += step / 2;
			}
			segment->resendts = current + segment->rto;
			lost = 1;
		}

		// 3. 該資料包被跳過的次數超過了fastresend, 觸發快速重傳
		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) {
			int need;
			segment->ts = current;
			segment->wnd = seg.wnd;
			segment->una = kcp->rcv_nxt;

			size = (int)(ptr - buffer);
			need = IKCP_OVERHEAD + segment->len; // 該資料包長度, 最大為head+mss

			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;
			}
			// 某個資料包的傳輸次數超過了dead_link,則判斷當前連線斷開。
			if (segment->xmit >= kcp->dead_link) {  // 斷開連線
				kcp->state = (IUINT32)-1;
			}
		}
	}

	// 把沒有資料緩衝區的資料傳送出去
	size = (int)(ptr - buffer);
	if (size > 0) {
		ikcp_output(kcp, buffer, size);
	}

	// 如果觸發了快速重傳,減小擁塞視窗(快速恢復)
	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;
	}

	// 超時重傳,丟包了,重置擁塞視窗和ssthresh。
	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;
	}
    ...
}

首先確定傳送視窗 cwnd = min(kcp->snd_wnd, kcp->rmt_wnd); 接著檢查是否開啟流控(擁塞控制) if (kcp->nocwnd == 0) cwnd = _imin_(kcp->cwnd, cwnd);透過取消擁塞控制可以進一步降低延遲。

然後從snd_queue中取出資料包放到snd_buf中, 直到snd_buf滿或者snd_queue為空。然後依次檢查snd_buf 裡的資料包需不需要傳送,需要傳送有三種情況:

  1. 該資料包首次傳送。
  2. 觸發超時重傳:時間超過超時重傳的閾值。超時重傳普通模式下:每次超時,超時重傳的時間就翻倍。在快速模式下:每次超時的重傳時間翻 0.5 倍。降低傳輸時延同時重置擁塞視窗和ssthresh。
  3. 觸發快速重傳:收到了該資料包的resent次的失序(冗餘)ACK。該資料包被跳過的次數超過了fastresend, 觸發快速重傳。同時減小擁塞視窗為原來的一半,ssthresh也設定為這個值。

相關文章