KCP 的滑动窗口TCP 大体相同。倘若深入探究源码,便会发觉之前那些难以理解的理论,在实现方面只有寥寥几行代码。接下来,看看 KCP 是怎样达成这些机制的。
TCP的可靠传输的实现–滑动窗口
倘若您未曾了解过滑动窗口,不妨观看以下 12 分钟的视频。我敢赌 5 块钱,您看完后肯定能理解滑动窗口机制。
https://www.bilibili.com/video/BV194411h71z/?spm_id_from=333.337.search-card.all.click
KCP中的滑动窗口
窗口大小
kcp和TCP一样,也是全双工的,所以不单有发送窗口,也有接收窗口。
发送窗口由远端窗口和拥塞窗口共同决定。
//发送窗口,调用ikcp_wndsize配置,默认32,
kcp->snd_wnd = IKCP_WND_SND;
//接收窗口,调用ikcp_wndsize配置,默认128,同时必须要大于128
kcp->rcv_wnd = IKCP_WND_RCV;
//对端窗口,接收端ack时,会将wnd带过来
kcp->rmt_wnd = IKCP_WND_RCV;
//拥塞窗口,动态计算,后边会讲到
kcp->cwnd = 0;
发送方的滑动窗口控制
-
发送方维护一个
snd_buf
队列,保存已经发送但尚未确认的 Segment。 -
当发送新的 Segment 时,会检查
snd_wnd
和cwnd
,确保在窗口范围内发送。 -
每次发送数据时,发送窗口滑动,新的数据包进入窗口,并放入
snd_buf
中。
接收方滑动窗口控制
-
接收方维护一个
rcv_buf
队列,保存接收到但尚未处理的 Segment。 -
当接收到一个新的 Segment 时,首先检查其是否在接收窗口范围内 (
rcv_wnd
)。 -
接收窗口滑动,将新的数据包放入
rcv_buf
,并从窗口中移除已经确认的数据包。
窗口滑动的实现(以发送窗口为例)
滑动窗口犹如节假日时的高铁站。在安检处,有一位工作人员把控着人员的入场。安检处外,人员有序地排着队,而安检处内同样存在一个小队伍,大家都在有条不紊地进行安检。这个小队伍的最大长度,就是窗口的大小,倘若安检处里的人员过多,工作人员便会禁止人员入场;一旦有一部分人完成安检得以放行,工作人员就会再度允许人员进入。
我们把第一个进入高铁站的人设置编号为0,然后接下来排队的人编号递增,
定义 snd.una为下一个进入高铁站的人员编号
定义 snd.nxt 为下一个进入安检口的人员编号,
那么 snd.nxt - send.una 就是安检处的人数,也就是发送了但未收到确认的的数据包的个数,
snd.una + snd.wnd - snd.nxt 就是还可以进入安检的人数,也就是可用窗口的大小。
编号小于snd.una的人,意味着已经进入高铁站了,我们就不用管了,
具体可以结合如下图示理解。
接收发送窗口的基本实现和发送窗口一致,具体参考下图,
滑动窗口的源码解析
滑动窗口中的队列
名称 | 作用 | 说明 |
---|---|---|
snd_buf | 存储已经发送但尚未收到 ACK 确认的数据段 | 当一个数据包被发送后,它会被放入 snd_buf 中,直到收到对端的确认 (ACK)。KCP 通过这个队列来追踪未确认的数据包,并在需要时进行重传 |
rcv_buf | 存储接收到但尚未被应用层处理的数据段 | 当 KCP 接收到一个数据段时,如果该段是乱序到达的(即序列号不连续),它会被放入 rcv_buf 中。接收窗口在等待正确的序列号时,乱序数据会保留在这个队列中,直到可以被应用层处理 |
snd_queue | 存储待发送的数据段 | 应用层调用 ikcp_send() 将数据传递给 KCP 协议栈后,数据会被切分成多个数据段并放入 snd_queue 中。KCP 会根据窗口大小和拥塞控制机制,从这个队列中取出数据段进行发送 |
rcv_queue | 存储已经按照正确顺序接收的数据段,等待应用层处理 | 一旦数据段的序列号符合接收窗口的预期(即接收方期望的下一个序列号),这些数据段会被从 rcv_buf 移动到 rcv_queue 中,并等待应用层读取 |
snd_nxt的更新
只要发送窗口有空闲,就把send_queue中的数据移动到snd_buf
void ikcp_flush(ikcpcb *kcp)
{
.....
// calculate window size
cwnd = _imin_(kcp->snd_wnd, kcp->rmt_wnd);
if (kcp->nocwnd == 0) cwnd = _imin_(kcp->cwnd, cwnd);
//当发送窗口有空闲,把kcp->snd_queue的数据移动到kcp->snd_buf
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++;
...
//snd_nxt++,表示发送但未确认的包+1
newseg->sn = kcp->snd_nxt++;
...
}
......
}
snd_una的更新
snd_una的更新主要依赖ikcp_shrink_buf函数,
static void ikcp_shrink_buf(ikcpcb *kcp)
{
//获取snd_buf的首个包
struct IQUEUEHEAD *p = kcp->snd_buf.next;
//如果首个包和头节点不一致,说明snd_buf不为空,snd_una更新为该段的序列号 seg->sn。这表示当前最早未确认的段的序列号。
if (p != &kcp->snd_buf) {
IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node);
kcp->snd_una = seg->sn;
//如果首个包和头节点一致,说明snd_buf为空,所有的包都得到了ack,snd_una = snd_nxt,
} else {
kcp->snd_una = kcp->snd_nxt;
}
}
ikcp_shrink_buf执行的时机有两个,
-
ikcp_parse_una
调用后的ikcp_shrink_buf
ikcp_parse_una(kcp, una);
解析了对端传来的 UNA(未确认的最小序列号),这个步骤会将所有序列号小于una
的数据包从snd_buf
中移除。因此需要调用ikcp_shrink_buf
来更新snd_una
。
-
ikcp_parse_ack
调用后的ikcp_shrink_buf
ikcp_parse_ack(kcp, sn);
解析了对端传来的 ACK 确认包,确认了某些已经发送的包。这可能会导致snd_buf
中的更多数据包被移除,尤其是当一个数据段的确认号正好匹配snd_buf
中某个数据段的序列号时。此时snd_buf
的状态可能再次变化,因此再次调用ikcp_shrink_buf
来确保snd_una
反映最新的发送缓冲区状态。
rcv_nxt 的更新
当接收到一个正确序列号的数据包后,rcv_nxt会递增,所以重点在于针对接收到的序列号做不同的处理,
检查数据包的序列号 sn
是否在接收窗口的范围内 (rcv_nxt
到 rcv_nxt + rcv_wnd
)。
如果 sn
小于 rcv_nxt
,表示该包已经接收并处理过,直接丢弃。
如果 sn
在窗口范围内,但不等于 rcv_nxt
,表示该包是乱序到达,先放入 rcv_buf
,等待正确序列的数据包到达后再处理。
//---------------------------------------------------------------------
// parse data
//---------------------------------------------------------------------
void ikcp_parse_data(ikcpcb *kcp, IKCPSEG *newseg)
{
struct IQUEUEHEAD *p, *prev;
IUINT32 sn = newseg->sn;
//用于标识是否已经在接收缓冲区中找到相同序列号的数据包。
int repeat = 0;
//检查数据包的序列号 `sn` 是否在接收窗口的范围内 (`rcv_nxt` 到 `rcv_nxt + rcv_wnd`)。
if (_itimediff(sn, kcp->rcv_nxt + kcp->rcv_wnd) >= 0 ||
_itimediff(sn, kcp->rcv_nxt) < 0) {
ikcp_segment_delete(kcp, newseg);
return;
}
//rcv_buf是用于乱序数据包的处理的,所以可能存的是序号为1,3,5,7这样的数据,所以这里是遍历整个rcv_buf,把收到的包找到一个合适的位置插入
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;
}
//sn小于seg->sn,说明找到了合适的位置,结束循环
if (_itimediff(sn, seg->sn) > 0) {
break;
}
}
if (repeat == 0) {
//添加到p节点的后面
iqueue_init(&newseg->node);
iqueue_add(&newseg->node, p);
kcp->nrcv_buf++;
} else {
ikcp_segment_delete(kcp, newseg);
}
// 检查接收缓冲区中是否有按序到达的包,如果有,将其移到接收队列中
while (! iqueue_is_empty(&kcp->rcv_buf)) {
//取出接收缓冲区的第一个包
IKCPSEG *seg = iqueue_entry(kcp->rcv_buf.next, IKCPSEG, node);
if (seg->sn == kcp->rcv_nxt && kcp->nrcv_que < kcp->rcv_wnd) {
//从rcv_buf中删除
iqueue_del(&seg->node);
kcp->nrcv_buf--;
//添加到接收队列中
iqueue_add_tail(&seg->node, &kcp->rcv_queue);
kcp->nrcv_que++;
//接收窗口右移
kcp->rcv_nxt++;
} else {
break;
}
}
#endif
}