[原創] KCP 原始碼解析(下)

hellozhangjz發表於2024-03-15

ikcp_input

先從下層協議將資料讀出來,並將對應的包頭資訊解析出來,根據不同的包頭命令進入不同的處理邏輯。

int ikcp_input(ikcpcb *kcp, const char *data, long size)
{
	IUINT32 prev_una = kcp->snd_una;
	IUINT32 maxack = 0, 	// 收到的這組包裡最大的ack
			latest_ts = 0; // 以及最近的時間戳
	int flag = 0; // 接收到的資料包裡有CMD_ACK則為1

	if (ikcp_canlog(kcp, IKCP_LOG_INPUT)) {
		ikcp_log(kcp, IKCP_LOG_INPUT, "[RI] %d bytes", (int)size);
	}

	if (data == NULL || (int)size < (int)IKCP_OVERHEAD) return -1;

	// 迴圈將底層收到的資料讀取並解析
	while (1) {
		IUINT32 ts, sn, len, una, conv;
		IUINT16 wnd;
		IUINT8 cmd, frg;
		IKCPSEG *seg;

		// 資料包文長度小於 kcp 包頭大小, 頭部資訊不完整, 退出
		if (size < (int)IKCP_OVERHEAD) break;

		// 取得所有包頭資訊
		data = ikcp_decode32u(data, &conv);
		if (conv != kcp->conv) return -1;

		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;

		// 資料包文長度小於包頭指定的資料長度,資料被破壞,退出
		if ((long)size < (long)len || (int)len < 0) return -2;

		// 非法cmd, 退出
		if (cmd != IKCP_CMD_PUSH && cmd != IKCP_CMD_ACK &&
			cmd != IKCP_CMD_WASK && cmd != IKCP_CMD_WINS) 
			return -3;

		kcp->rmt_wnd = wnd;
		ikcp_parse_una(kcp, una);
		ikcp_shrink_buf(kcp);
		...
    }
}

先看一下最後兩行的 ikcp_parse_una 和 ikcp_shrink_buf,kcp 收到對方傳送的 una,說明 una 之前的包已經收到,那麼接收方就可以將 snd_buf裡的 una 之前的包刪掉,ikcp_parse_una 就是做這個的。

// 從snd_buf(雙向迴圈連結串列)中刪除小於una的包
static void ikcp_parse_una(ikcpcb *kcp, IUINT32 una)
{
	struct IQUEUEHEAD *p, *next;
	for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = next) {
		IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node);
		next = p->next;
		// 如果該sendbuf的序列號小於una, 則說明該包已經被確認, 從snd_buf中刪除
		if (_itimediff(una, seg->sn) > 0) {
			iqueue_del(p); // 把seg裡邊的node指標斷了
			ikcp_segment_delete(kcp, seg);
			kcp->nsnd_buf--;
		}	else {
			break;
		}
	}
}

上邊的函式把 snd_buf 裡的資料包刪了,對應的 snd_una 也需要更新,ikcp_shrink_buf 就是更新 snd_una 的。

// 呼叫這個函式之前刪除了一些被確認的包, 更新snd_una為當前傳送緩衝裡的最小序列號
static void ikcp_shrink_buf(ikcpcb *kcp)
{
	struct IQUEUEHEAD *p = kcp->snd_buf.next;
	if (p != &kcp->snd_buf) {
		IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node); // 傳送緩衝區第一個資料包
		kcp->snd_una = seg->sn;
	}	else {
		kcp->snd_una = kcp->snd_nxt;
	}
}

前期工作處理完,然後根據不同的命令來處理包。

IKCP_CMD_ACK

當收到的命令為IKCP_CMD_ACK,說明該包是一個 ACK,需要將被ACK的包從 snd_buf 裡刪除,然後更新 snd_una,用的還是上邊那兩個函式。然後是更新maxack(這組包裡最大的ack)和latest_ts(最近的時間戳),後邊用於計算snd_buf裡哪些包被跳過了(失序),決定是否快速重傳。

int ikcp_input(ikcpcb *kcp, const char *data, long size)
{
	...
	// 迴圈將底層收到的資料讀取並解析
	while (1) {
		IUINT32 ts, sn, len, una, conv;
		IUINT16 wnd;
		IUINT8 cmd, frg;
		IKCPSEG *seg;
		...
		if (cmd == IKCP_CMD_ACK) {
			// ts為該資料包傳送時間, current為當前時間(收到該資料包的ack), 兩者之差為rtt
			if (_itimediff(kcp->current, ts) >= 0) {
                // 計算超時重傳時間
				ikcp_update_ack(kcp, _itimediff(kcp->current, ts));
			}

			ikcp_parse_ack(kcp, sn);
			ikcp_shrink_buf(kcp);

			// 下邊的程式碼就是更新maxack和latest_ts,
			if (flag == 0) { // 只有在解析第一個IKCP_CMD_ACK包進入這個分支
				flag = 1;
				maxack = sn;
				latest_ts = ts;
			}	else {
				if (_itimediff(sn, maxack) > 0) { // 這裡不能用max函式,因為有序號迴繞的問題
				#ifndef IKCP_FASTACK_CONSERVE
					maxack = sn;
					latest_ts = ts;
				#else
					if (_itimediff(ts, latest_ts) > 0) {
						maxack = sn;
						latest_ts = ts;
					}
				#endif
				}
			}
		}
		...
    }
}

// 計算超時重傳時間, 和計網自頂向下3.5.3節的公式一樣
static void ikcp_update_ack(ikcpcb *kcp, IINT32 rtt)
{
	// rtt:該資料包的往返時間
	// rx_srtt: 平滑的rtt,近8次rtt平均值
	// rx_rttval: 近4次rtt和srtt的平均差值,反應了rtt偏離srtt的程度
	// rx_rto: 重傳超時時間
	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); // rx_srtt + 4*rx_rttval表示一種比較壞的情況
	kcp->rx_rto = _ibound_(kcp->rx_minrto, rto, IKCP_RTO_MAX);
}

IKCP_CMD_PUSH

當收到的命令為IKCP_CMD_ACK,說明是資料包。新建一個 IKCPSEG,將頭資訊和data 複製進去。然後呼叫 ikcp_ack_push 將該資料包加入 acklist,以便傳送 ack 。

然後檢查該 seg 是否已經接受過,如果接收過則忽略,如果沒有接收過則將其加入 rcv_buf,之後將 rcv_buf 裡連續序號的包轉移到 rcv_que供上層讀取,透過檢查rcv_nxt即可判斷資料包是否連續。這個檢查過程是 ikcp_parse_data 實現的。

int ikcp_input(ikcpcb *kcp, const char *data, long size)
{
	...
	// 迴圈將底層收到的資料讀取並解析
	while (1) {
		IUINT32 ts, sn, len, una, conv;
		IUINT16 wnd;
		IUINT8 cmd, frg;
		IKCPSEG *seg;
		...
		else if (cmd == IKCP_CMD_PUSH) {
			if (_itimediff(sn, kcp->rcv_nxt + kcp->rcv_wnd) < 0) { // 判斷是否超出(大於)接收視窗
				ikcp_ack_push(kcp, sn, ts);
				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) {
						//    (to       , from, len)
						memcpy(seg->data, data, len); // 複製資料
					}
					// 檢查newseg資料包,如果沒有接受過,就將其放在接收快取內,
                    // 並將快取內連續的資料放到接受佇列裡,可供上層應用讀取。
					ikcp_parse_data(kcp, seg);
				}
			}
		}
		...
    }
}

// 收到對方傳送的sn號包,更新acklist,acknode的格式為(sn, ts), sn這個包的發出時間為ts
// 注意可能收到多個sn相同的包
static void ikcp_ack_push(ikcpcb *kcp, IUINT32 sn, IUINT32 ts)
{
	IUINT32 newsize = kcp->ackcount + 1;
	IUINT32 *ptr;

	// 申請更大的acklist空間
	if (newsize > kcp->ackblock) {
		IUINT32 *acklist;
		IUINT32 newblock;

		for (newblock = 8; newblock < newsize; newblock <<= 1); // 倍增的方式擴容
		acklist = (IUINT32*)ikcp_malloc(newblock * sizeof(IUINT32) * 2); // 申請newblock個acknode空間

		if (acklist == NULL) {
			assert(acklist != NULL);
			abort();
		}

		if (kcp->acklist != NULL) { // 將舊的acklist複製到新的acklist中
			IUINT32 x;
			for (x = 0; x < kcp->ackcount; x++) { // 從這裡看出來每個acknode佔用2個IUINT32
				acklist[x * 2 + 0] = kcp->acklist[x * 2 + 0];
				acklist[x * 2 + 1] = kcp->acklist[x * 2 + 1];
			}
			ikcp_free(kcp->acklist);
		}

		kcp->acklist = acklist;
		kcp->ackblock = newblock;
	}

	ptr = &kcp->acklist[kcp->ackcount * 2];
	ptr[0] = sn;
	ptr[1] = ts;
	kcp->ackcount++;
}

// 檢查newseg資料包,如果沒有接受過,就將其放在接收快取內,並將快取內連續的資料放到接受佇列裡,可供上層應用讀取。
void ikcp_parse_data(ikcpcb *kcp, IKCPSEG *newseg)
{
	struct IQUEUEHEAD *p, *prev;
	IUINT32 sn = newseg->sn;
	int repeat = 0;
	
	// 新資料包在接收視窗之外, 直接丟棄
	if (_itimediff(sn, kcp->rcv_nxt + kcp->rcv_wnd) >= 0 || 
		_itimediff(sn, kcp->rcv_nxt) < 0) {
		ikcp_segment_delete(kcp, newseg);
		return;
	}

	// 判斷是否接受過sn包
	for (p = kcp->rcv_buf.prev; p != &kcp->rcv_buf; p = prev) {
		IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node);
		prev = p->prev;
		if (seg->sn == sn) {
			repeat = 1;
			break;
		}
        // 當前包序號大於被檢查的包,之後就不可能有 seg->sn 了,因為 rcv_buf 的連結串列 sn 是遞增的。
		if (_itimediff(sn, seg->sn) > 0) {
			break;
		}
	}

	if (repeat == 0) { // 沒有接受過
		iqueue_init(&newseg->node);
		iqueue_add(&newseg->node, p);
		kcp->nrcv_buf++;
	}	else {
		ikcp_segment_delete(kcp, newseg);
	}


	// 將收到的連續資料包從rcv_buf 轉移到 rcv_queue 供上層應用讀取
	while (! iqueue_is_empty(&kcp->rcv_buf)) {
		IKCPSEG *seg = iqueue_entry(kcp->rcv_buf.next, IKCPSEG, node);
        // 透過檢查rcv_nxt即可判斷資料包是否連續
		if (seg->sn == kcp->rcv_nxt && kcp->nrcv_que < kcp->rcv_wnd) { //資料包連續 且 rcv_queue 不滿
			iqueue_del(&seg->node);
			kcp->nrcv_buf--;
			iqueue_add_tail(&seg->node, &kcp->rcv_queue);
			kcp->nrcv_que++;
			kcp->rcv_nxt++;
		}	else {
			break;
		}
	}
	...
}

IKCP_CMD_WASK

當收到的命令為 IKCP_CMD_WASK,說明對方想探測你的視窗大小。只需要在本地標記一下,等到傳送的時候會檢查這個標記然後傳送視窗大小。

else if (cmd == IKCP_CMD_WASK) { // 傳送方探測接收方的視窗大小
    // ready to send back IKCP_CMD_WINS in ikcp_flush
    // tell remote my window size
    kcp->probe |= IKCP_ASK_TELL;
}

IKCP_CMD_WINS

當收到的命令為 IKCP_CMD_WASK,說明對方回答了你的視窗探測,在這裡不需要做處理。

檢查失序(冗餘)ACK

當收完資料包,KCP 會檢查哪些資料包被跳過了,然後標記其被跳過的次數。不過有人會發現透過 maxack 去檢查哪個資料包被跳過會不會有問題?因為小於 maxack 的包可能也收到了,但也要記錄一次被跳過。答案是不會的,因為在收到IKCP_CMD_ACK的時候會呼叫ikcp_parse_ack; ikcp_shrink_buf ; 這兩個函式,而這兩個函式會把所有收到ack 的包從 snd_buf 刪除掉,那麼在ikcp_parse_fastack 中也就不會檢查到這個包了。

int ikcp_input(ikcpcb *kcp, const char *data, long size)
{
	...
	// 迴圈將底層收到的資料讀取並解析
	while (1) {
		...
		if (cmd == IKCP_CMD_ACK)  ...
		else if(cmd == IKCP_CMD_PUSH)  ...
        else if(cmd == IKCP_CMD_WASK)  ...
        else if(cmd == IKCP_CMD_WINS)  ...
        else return -3;  
		...
    }
    // 只要收到了IKCP_CMD_PUSH,flag 就為 true
    if (flag != 0) {
		ikcp_parse_fastack(kcp, maxack, latest_ts);
	}
}

// 計算快速重傳的引數fastack(傳送快取裡的包被跳過的總次數,冗餘ACK)
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;

	// 統計maxack之前的有幾個沒有被ack包
	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
		}
	}
}

計算擁塞視窗

上邊的工作做完後,需要計算並更新擁塞視窗,

int ikcp_input(ikcpcb *kcp, const char *data, long size)
{
	...
	/*  
		接收時,擁塞視窗增加
		擁塞視窗控制, 慢啟動:每收到一個ACK,擁塞視窗就加1,一個RTT擁塞視窗會翻倍增長。 
		擁塞避免:每收到一個ACK,擁塞視窗就加1/cwnd, 一個RTT擁塞視窗加1
		下邊傳送時,擁塞視窗減少
	*/
	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;
				// 達到閾值的時候 cwnd*mss = incr
			}	else { 	// 擁塞視窗大於慢啟動閾值,擁塞避免模式
				if (kcp->incr < mss) kcp->incr = mss;
				// 經過計算,差不多是每個RTT增加一個mss, 間隔略小於一個RTT,也就是更快地增加擁塞視窗
				// incr = k*mss , 擁塞視窗等於floor(k)
				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;
			}
		}
	}
}

ikcp_recv

ikcp_recv 是使用者呼叫的介面,期待收到長度最多為 len 的訊息。

int ikcp_recv(ikcpcb *kcp, char *buffer, int len)
{
	struct IQUEUEHEAD *p;
	int ispeek = (len < 0)? 1 : 0; // 如果為真,只把資料複製給使用者,不從接收佇列中刪除資料
	int peeksize;
	int recover = 0;
	IKCPSEG *seg;
	assert(kcp);

	if (iqueue_is_empty(&kcp->rcv_queue))
		return -1;

	if (len < 0) len = -len;
	
	peeksize = ikcp_peeksize(kcp); // 檢視使用者訊息大小,主要檢查buffer大小能不能裝下這個訊息

	if (peeksize < 0) 
		return -2;
	
    // 使用者的 buffer 不夠裝
	if (peeksize > len) 
		return -3;
	
	// 這裡發現接收佇列裡資料太多,則標記一下,將會向對方傳送一個視窗大小防止對面太快的發資料
	if (kcp->nrcv_que >= kcp->rcv_wnd)
		recover = 1;

	// 將一個資料包複製進使用者 buffer
	for (len = 0, p = kcp->rcv_queue.next; p != &kcp->rcv_queue; ) {
		int fragment;
		seg = iqueue_entry(p, IKCPSEG, node);
		p = p->next;

		if (buffer) {
			memcpy(buffer, seg->data, seg->len);
			buffer += seg->len;
		}

		len += seg->len;
		fragment = seg->frg;

		if (ispeek == 0) {
			iqueue_del(&seg->node);
			ikcp_segment_delete(kcp, seg);
			kcp->nrcv_que--;
		}
		// 非位元組流模式 : 讀到最後一段,已經組裝出一個完整的上層資料包
		// 位元組流模式:每個包的frg都為0,每次recv只會讀到一個包
		// 也有可能遇不到fragment為0的包,訊息的其他部分還在接收快取或者網路中
		if (fragment == 0) 
			break;
	}

	assert(len == peeksize);

	// move available data from rcv_buf -> rcv_queue
	// ikcp_input裡已經轉移過一次了, 使用者把資料讀走後rcv_que變小了, 這裡再轉移一次
	while (! iqueue_is_empty(&kcp->rcv_buf)) {
		seg = iqueue_entry(kcp->rcv_buf.next, IKCPSEG, node);
		if (seg->sn == kcp->rcv_nxt && kcp->nrcv_que < kcp->rcv_wnd) {
			iqueue_del(&seg->node);
			kcp->nrcv_buf--;
			iqueue_add_tail(&seg->node, &kcp->rcv_queue);
			kcp->nrcv_que++;
			kcp->rcv_nxt++;
		}	else {
			break;
		}
	}

	// fast recover
	if (kcp->nrcv_que < kcp->rcv_wnd && recover) {
		// ready to send back IKCP_CMD_WINS in ikcp_flush
		// tell remote my window size
		kcp->probe |= IKCP_ASK_TELL;
	}

	return len;
}


// 返回訊息的大小(應用層的一個資料包大小, 如果分段, 則要計算所有段的和),如果是流模式,返回一個KCP包的大小
int ikcp_peeksize(const ikcpcb *kcp)
{
	struct IQUEUEHEAD *p;
	IKCPSEG *seg;
	int length = 0;

	assert(kcp);

	if (iqueue_is_empty(&kcp->rcv_queue)) return -1;

	seg = iqueue_entry(kcp->rcv_queue.next, IKCPSEG, node);
	if (seg->frg == 0) return seg->len;
	
    // 如果接收佇列裡的資料不足一個包,返回-1
	if (kcp->nrcv_que < seg->frg + 1) return -1;

	for (p = kcp->rcv_queue.next; p != &kcp->rcv_queue; p = p->next) {
		seg = iqueue_entry(p, IKCPSEG, node);
		length += seg->len;
		if (seg->frg == 0) break;
	}

	return length;
}

相關文章