Skip to content

调度中心与任务总线 ​

下载一个整合包要跑几分钟,搜索 Java 要扫几万个目录,启动游戏要等 JVM 起来。这些长任务如果各自为政,界面就得为每一种任务写一套进度条代码。所以我们把它们登记到一处:TaskCenterService。

一个全局单例,三步生命周期 ​

Begin(title) 登记 → Update(task, progress, detail) 报进度 → Complete / Fail / Cancel 收尾。任何一步都会触发 Changed 事件,UI 侧订阅它刷新悬浮面板。

任务保留最近 60 条(MaxKept),启动器启动时 ClearAll() 清空——面板只显示本次会话的事。

每条任务自带一个 CancellationTokenSource(task.Cts)。用户点「取消」时 Cancel(task) 会取消它,而任务内部所有需要感知取消的地方都从 task.Cts.Token 取。这是关键设计:取消不是 UI 层发个标志位,而是真正掐断底层 IO。

任务收尾时会往通知中心写一条历史通知(NotificationCenter),带级别(Info / Warning / Error)和时间戳。通知中心保留最近 200 条,只在内存里,进程退出即清空——和任务中心同一个生命周期约定。

Changed 事件不是「刷新指令」

Changed 在每次 Update 时都会触发。满速下载时上报频率是 10 Hz,UI 收到的事件就是每秒 10 次。所以 UI 侧不能直接每个事件都重建一遍列表——要节流,通常是定时取 Snapshot() 快照。

同标题的运行中任务直接复用 ​

csharp
var existing = _tasks.FirstOrDefault(t =>
    t.State == AppTaskState.Running && t.Title == title);
if (existing is not null) return existing;

这条规则解决的是「Java 搜索」这类高频操作。用户点一次「检测 Java」,在页面里点两下、或者多个页面同时要 Java 列表,扫盘是同一件事,登记两条任务只会让面板出现两个一样的条目,还会让用户以为跑了两次。

复用也意味着两条调用共享同一个 task 对象——后完成的那条会先把任务置为 Completed,前一条再调 Complete 就是空操作(Finish 里检查 State != Running 直接返回)。

三条纪律,都是被刷爆的 UI 教出来的 ​

进度由调用方 10 Hz 聚合 ​

整文件下载的读缓冲是 512 KiB。满速时每秒几十兆,就是每秒上百次读——如果每读一块就 ReportDownloadBytes 一次,UI 会被刷爆,CPU 全花在布局上。

所以聚合在调用方做:

csharp
// 10 Hz 聚合上报:调度中心不需要每条 512KB 都算一次速度
if (Environment.TickCount64 - lastFlush >= TelemetryWindowMs)
{
    TaskCenterService.Instance.ReportDownloadBytes(pending);
    pending = 0;
}

TelemetryWindowMs = 100。调度中心收到的是增量字节,速度由它自己用指数滑动平均算(_emaSpeed = (_emaSpeed * 3 + inst) / 4)。调度中心不做去抖——去抖在调用方,因为只有调用方知道自己的读块节奏。

收尾循环单飞 ​

下载任务是常驻的:只要还有下载在跑,它就显示「资源下载」;等所有下载都停了再过 3 秒才完成。

第一版实现是每次上报字节都起一个 CloseWhenIdleAsync(),里面 Task.Delay(3000)。满速下载时每秒几百次上报,就是每秒几百个睡眠任务——虽然它们最终都会醒来发现「还有活动,继续等」,但这些任务本身是要占内存和调度开销的。

现在改成一个循环 + 一个计数器:

csharp
private void EnsureCloseLoop()
{
    if (Interlocked.CompareExchange(ref _closeLoop, 1, 0) != 0) return;
    _ = CloseWhenIdleAsync();
}

Interlocked.CompareExchange 保证同一时刻只有一个收尾循环在跑。循环每 3 秒结算一次 _active 计数:归零就完成常驻任务并退出,期间还有上报就继续等。

竞速进度按分片完成量累计 ​

多源竞速下载时,进度不能用「各源收到的字节数之和」来算——同一个分片可能两个源都下了一部分,加起来会超过实际大小,进度条会冲过 100%。

所以进度由 ReportChunkDone(token, chunkLength) 驱动:只有一个分片完整下载成功才计入,分片内各源抢到的部分不计入进度条(它们只驱动 Mirrors 里每个源的贡献条)。

csharp
var total = _racingFiles.Values.Sum(f => f.Size);
var done  = _racingFiles.Values.Sum(f => f.Done);
task.Progress = total > 0 ? Math.Min(1.0, (double)done / total) : null;

分片只成功一次,所以这个累加是单调的——进度条只会往前,不会来回跳。

竞速任务还支持多文件并发(整合包里好几个大文件同时在分片)。每个在跑的文件带一个 token,各自记账,最后一个文件收尾时整条任务才结束。早期版本用的是单文件全局字段,后开始的文件会把前一个的进度清零,表现为进度条来回跳。

取消怎么真的生效 ​

任务的 Cts 被 cancel 后,信号传到各处:下载流的 ct、阶段循环的检查点。Nova 的执行计划是典型例子:

csharp
var task = TaskCenterService.Instance.Begin($"Nova · {plan.Title}");
// 调度中心的「取消」得能真的掐掉这次执行 → 把任务自己的 Cts 与调用方的 ct 串起来
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, task.Cts.Token);
await plan.ExecuteAsync(linked.Token);

CreateLinkedTokenSource(ct, task.Cts.Token) 把「调用方的取消」和「用户在调度中心点取消」两个源串成一个 token——任何一边触发,整个执行链都停。Nova 这条链粒度刻意是「一条计划一条任务」,逐步登记会让抽屉被琐碎步骤淹没。

相关链接:下载引擎总览 · 分片与竞速 · Nova 许可与审计

基于 MIT 许可证(有修改)开源