KCP源码解析 (3) 核心更新与刷新机制 (ikcp_update & ikcp_flush)

145 阅读8分钟

在上一章 KCP 控制块 (ikcpcb) 中,我们了解了 KCP 的ikcpcb 结构体,它静态地存储了连接的所有状态信息。但是,一个静止的控制数据结构是无法工作的。这就引出了一个核心问题:

我们调用 ikcp_send 后,数据只是被安安静静地放进了发送队列。那到底是谁、在什么时候,把这些数据真正打包发出去?如果数据包丢了,又是谁负责发现并重传它呢?

答案就是本章的主角:ikcp_updateikcp_flush。它们是 KCP 协议的“心跳”和“执行引擎”,负责驱动整个 KCP 状态机向前运转。

KCP 的“心跳”:ikcp_update

想象一下,你雇佣了一位非常勤奋的管家。但你必须告诉他每隔多久需要检查一次待办事项清单。你不能指望他自己“凭感觉”工作。你可能会设置一个闹钟,每 10 毫秒响一次,闹钟一响,管家就开始工作。

ikcp_update 就是这个“闹钟”触发器。它本身并不处理复杂的逻辑,它最核心的任务就是:根据用户传入的当前时间,判断是否到了该处理 KCP 待办事项的时刻

因此,作为 KCP 的使用者,你必须在一个循环中,以固定的频率(通常是 10ms 到 100ms)调用 ikcp_update

如何使用 ikcp_update

ikcp_update 的函数原型非常简单:

// ikcp.h
void ikcp_update(ikcpcb *kcp, IUINT32 current);
  • kcp: 我们熟悉的 KCP 控制块。
  • current: 当前的时间戳,以毫秒为单位。这是最重要的参数,KCP 内部的所有时间相关的判断都依赖于此。

一个典型的使用场景如下:

// 假设 kcp 实例已经创建
ikcpcb *kcp = ikcp_create(0x11223344, NULL);
// ... 设置 output 回调等 ...

// 获取当前时间的函数(需要用户自己实现)
IUINT32 get_current_ms();

// 主循环
while (1) {
    // 获取当前毫秒级时间戳
    IUINT32 current_time = get_current_ms();

    // 驱动 KCP 状态机
    ikcp_update(kcp, current_time);

    // ... 处理你的其他业务逻辑,比如调用 ikcp_recv ...

    // 休眠一小段时间,例如 10 毫秒
    sleep_ms(10);
}

重点:你必须持续、周期性地调用 ikcp_update。如果你停止调用它,KCP 就会“心跳停止”,所有待发送的数据将滞留在队列中,所有超时重传逻辑也将失效。

ikcp_update 幕后探秘

ikcp_update 的内部逻辑非常简单,我们可以把它看作一个时间检查器。

sequenceDiagram
    participant App as 应用主循环
    participant Update as ikcp_update
    participant Flush as ikcp_flush
    
    App->>Update: 调用 ikcp_update(kcp, 当前时间)
    Update->>Update: 检查当前时间是否超过了下次刷新时间(ts_flush)?
    alt 是,时间到了
        Update->>Flush: 调用 ikcp_flush(kcp)
        Flush-->>Update: 完成所有待办事项
        Update->>Update: 更新下一次刷新时间
    else 否,时间未到
        Update-->>App: 直接返回
    end

让我们看看 ikcp.c 中对应的简化代码:

// ikcp.c: ikcp_update 核心逻辑简化
void ikcp_update(ikcpcb *kcp, IUINT32 current)
{
	kcp->current = current; // 更新 kcp 控制块中的当前时间

	// 计算从上次刷新到现在的逝去时间
	IINT32 slap = _itimediff(kcp->current, kcp->ts_flush);

	// 如果时间到了 (slap >= 0),或者时间严重错乱
	if (slap >= 10000 || slap < -10000) {
		kcp->ts_flush = kcp->current;
		slap = 0;
	}

	if (slap >= 0) {
		// 计算下一次需要刷新的时间点
		kcp->ts_flush += kcp->interval;
		
		// 如果当前时间已经超过了计算出的下一次时间,
		// 就把下一次刷新时间设置为 "当前时间 + interval"
		if (_itimediff(kcp->current, kcp->ts_flush) >= 0) {
			kcp->ts_flush = kcp->current + kcp->interval;
		}
		
		// 时间到了,调用真正的“执行引擎”
		ikcp_flush(kcp);
	}
}

这段代码的核心思想是:ikcp_update 维护了一个 ts_flush 字段(下次刷新时间)。每次调用时,它都会用当前时间 currentts_flush 比较。一旦 current 越过了 ts_flush,就意味着“闹钟响了”,是时候调用 ikcp_flush 来干活了。

KCP 的“执行引擎”:ikcp_flush

如果说 ikcp_update 是“闹钟”,那么 ikcp_flush 就是那个被闹钟唤醒后,拿起待办事项清单开始逐一处理的“管家”。它是 KCP 所有主动操作的执行中心,负责将 KCP 的内部状态(队列、窗口等)变化,物化为实际的网络发包行为。

ikcp_flush 的工作可以概括为一个长长的清单:

  1. 发送 ACK:检查是否有已收到的数据包需要确认(ACK)?把它们打包发出去。
  2. 发送窗口探测:对方的接收窗口是不是为 0 了?如果是,需要发送探测包(Probe)询问对方窗口是否已更新。
  3. 更新本地窗口:是否收到了对方的窗口探测请求?如果是,需要告知对方自己当前的接收窗口大小。
  4. 数据转移:把应用层通过 ikcp_send 放入 snd_queue 的数据,转移到“待确认缓冲区” snd_buf,并分配序列号 sn
  5. 发送与重传:遍历 snd_buf,检查是否有:
    • 从未发送过的新数据包?—— 发送它!
    • 已发送但超时未收到 ACK 的数据包?—— 重传它!
    • 被判定需要快速重传的数据包?—— 重传它!
  6. 更新拥塞窗口:根据丢包情况,调整拥塞控制相关的参数(如 ssthresh, cwnd)。

所有这些操作最终都会把需要发送的数据(无论是 ACK、探测包还是业务数据)编码成二进制流,并通过我们在 KCP 控制块 (ikcpcb) 中设置的 output 函数发送出去。

ikcp_flush 幕后探秘

ikcp_flush 函数是 KCP 中最复杂的函数之一,但我们可以分步来理解它的核心逻辑。

第一步:数据从 snd_queue 转移到 snd_buf

ikcp_flush 首先会检查发送窗口是否还有空间。如果有,它就会把 snd_queueikcp_send 放数据的地方)里的数据段一个个取出来,赋予它们序列号(sn)、时间戳(ts)等关键信息,然后放入 snd_buf(已发送但待确认的缓冲区)。

// ikcp.c: ikcp_flush 中数据转移的简化逻辑
void ikcp_flush(ikcpcb *kcp) {
    // ... 省略其他逻辑 ...
    
    // 计算有效发送窗口大小
    IUINT32 cwnd = _imin_(kcp->snd_wnd, kcp->rmt_wnd);
    if (kcp->nocwnd == 0) cwnd = _imin_(kcp->cwnd, cwnd);

    // 只要发送窗口还有空间 (snd_nxt < snd_una + cwnd)
    while (_itimediff(kcp->snd_nxt, kcp->snd_una + cwnd) < 0) {
        if (iqueue_is_empty(&kcp->snd_queue)) break;

        // 从 snd_queue 队首取出一个数据段
        IKCPSEG *newseg = iqueue_entry(kcp->snd_queue.next, IKCPSEG, node);
        iqueue_del(&newseg->node);

        // 放入 snd_buf 队尾
        iqueue_add_tail(&newseg->node, &kcp->snd_buf);
        kcp->nsnd_que--;
        kcp->nsnd_buf++;

        // 为这个数据段分配序列号和其他元数据
        newseg->sn = kcp->snd_nxt++;
        newseg->ts = current;
        newseg->rto = kcp->rx_rto;
        // ...
    }
    
    // ... 后续逻辑 ...
}

第二步:遍历 snd_buf,决定发送或重传

完成数据转移后,ikcp_flush 会遍历 snd_buf 里的每一个数据段,根据多种条件判断它是否需要被发送。

// ikcp.c: ikcp_flush 中发送与重传的简化逻辑
void ikcp_flush(ikcpcb *kcp) {
    // ... 省略 ACK 和数据转移逻辑 ...
    
    // 遍历发送缓冲区
    for (p = kcp->snd_buf.next; p != &kcp->snd_buf; p = p->next) {
        IKCPSEG *segment = iqueue_entry(p, IKCPSEG, node);
        int needsend = 0;

        // 条件1:这是第一次发送
        if (segment->xmit == 0) {
            needsend = 1;
        }
        // 条件2:超时重传
        else if (_itimediff(current, segment->resendts) >= 0) {
            needsend = 1;
        }
        // 条件3:快速重传
        else if (segment->fastack >= kcp->fastresend) {
            needsend = 1;
        }

        if (needsend) {
            // 如果需要发送,更新它的发送次数和时间戳
            segment->xmit++;
            segment->ts = current;
            segment->resendts = current + segment->rto;
            // 将这个 segment 的内容编码到发送缓冲区 buffer 中
            // ...
        }
    }

    // 将最终打包好的 buffer 通过 output 函数发送出去
    // ... ikcp_output(kcp, buffer, size); ...
}

这个过程完美地诠释了 KCP 的可靠性:snd_buf 中的数据段只有在被确认为止,才会从这个缓冲区中移除。在此之前,ikcp_flush 会在每次执行时都检查它们的状态,确保它们在丢失或延迟的情况下能被及时重传。

一个重要的优化:ikcp_check

你可能会想,如果网络空闲,没有任何数据收发,那么每 10ms 调用一次 ikcp_update(进而可能调用 ikcp_flush)是不是有点浪费 CPU?

KCP 的作者也考虑到了这一点,因此提供了一个辅助函数 ikcp_check

// ikcp.h
IUINT32 ikcp_check(const ikcpcb *kcp, IUINT32 current);

这个函数可以告诉你:下一次需要调用 ikcp_update 的精确时间点

它的返回值是下次需要执行 ikcp_flush 的时间戳。你可以用这个返回值来设置一个定时器,而不是使用固定的 sleep。当定时器触发时,再调用 ikcp_update。这种方式在需要管理大量 KCP 连接(成千上万个)的服务器上尤其有用,可以显著降低 CPU 的空转消耗。

对于大多数应用场景,特别是客户端,使用固定的 sleep 循环调用 ikcp_update 已经足够简单高效。

总结

本章我们学习了驱动 KCP 运转的核心机制:

  • ikcp_update 是 KCP 的“心跳”或“闹钟”。你必须在应用主循环中,以固定的时间间隔(如 10ms)调用它,并传入当前时间。
  • ikcp_update 的主要工作是检查时间,判断是否到了执行待办事项的时刻。
  • ikcp_flush 是 KCP 的“执行引擎”或“管家”。它被 ikcp_update 在特定时间点调用,负责执行所有主动的网络操作,包括:
    • 发送 ACK 确认。
    • snd_queue 的数据移入 snd_buf
    • 发送新数据、重传超时数据、进行快速重传。
    • 处理窗口探测等。
  • ikcp_send 只是把数据放入队列,真正将数据发出去的工作是由 ikcp_flush 完成的。这个机制保证了 KCP 的可靠性和实时性。

我们现在知道了数据是如何被主动发送出去的。但是,ikcp_sendikcp_flush 这些操作背后,数据到底是在哪些队列之间流转的呢?下一章,我们将深入 KCP 的“队列系统”——数据流转与队列管理,详细看看 snd_queue, rcv_queue, snd_buf, rcv_buf 这四个核心队列是如何协同工作的。