主线程预算:每帧最多 N 个任务不是时间预算

自动关联目录:主线程预算:每帧最多 N 个任务不是时间预算

核心判断:数量预算只能限制吞吐,不能限制帧耗时。只要任务成本分布有长尾,一个“每帧一个”的 Mesh、Material 或脚本回调仍可能独占整帧。

一、业务冲突:异步完成越快,主线程反而越容易尖峰

资源下载、解码和准备可以并行,但设置 Mesh、注册 Component、创建 MID、修改 Slate/脚本对象必须回到游戏线程。大量请求在相近时间完成时,若一次性回放,便把后台并行转成主线程尖峰;若只按数量分帧,便默认每个任务成本近似相等,这对动态内容不成立。

二、实际实现:两条完成队列暴露了能力差异

实践中的网格完成队列同时具有时间上限和数量上限,区分正常绑定与失败回调,因为失败回调同样可能进入脚本。它只在一帧 Drain 一次,捕获开始时的任务数,回调重入产生的新任务延迟到下一轮;Reset 增加 Generation,使旧任务失效;任务在回调前先从堆中移出,避免回调重置队列后重复执行。

材质应用队列则只有数量上限。上限关闭时可以同步执行或整批排空;它处理了重入和 Generation,却没有截止时间、优先级和单任务成本诊断。同一工程中的两条队列因此表达了不同的帧稳定性保证。

三、值得保留:队列生命周期设计是正确的

  • Generation 拒绝场景重载前的迟到任务。
  • 每个引擎帧只获得一次额度,Reset 不能在同帧重新拿预算。
  • Drain 只消费初始批次,回调重入不会无限延长本帧。
  • 失败通道也有界,错误处理不会绕过性能预算。
  • 强引用保护等待应用的 UObject,执行后再交给组件所有权。
  • 队列等待、应用耗时、最老任务和停止原因都有可观察数据。

四、结构性缺陷:截止时间只能在不可抢占任务之间检查

即便有时间预算,当前任务开始后也不能中途抢占。设截止时间为BB,已执行耗时为SS,下一个任务耗时为CnextC_{next},只有事后才知道:

S<B但S+Cnext≫BS < B \quad\text{但}\quad S+C_{next} \gg B

因此时间预算是软上限。若某个任务内部同步创建大量组件、触发脚本或重建渲染状态,队列检查再准确也救不了这一帧。优先级队列还可能让低优先级任务长期饥饿;只看队列深度则无法区分“很多便宜任务”和“一个超重任务”。

五、目标设计:后台 Prepare,小步 Commit,成本准入

任务必须拆成可估价的提交单元。耗时的格式转换、材质参数整理和依赖检查在后台或 Prepare 阶段完成;游戏线程只做不能移走的 UObject/Component 变更。调度器同时使用剩余时间、任务估价、最大单任务上限和等待年龄。

while (queue.HasReady()) {
    Job& j = queue.Peek();
    if (j.generation != currentGeneration) { queue.Drop(); continue; }
    if (!budget.CanAdmit(j.estimatedUs) && !j.IsStarving()) break;

    const TimePoint begin = Now();
    queue.Pop().CommitOnGameThread();
    budget.Observe(j.kind, Now() - begin); // 更新分类型估价
}

无法拆分且经常超预算的任务不能留在通用队列,应改造底层 API 或在加载遮罩期明确执行。饥饿任务可以提升优先级,但仍不能悄悄突破“最大单任务”门禁。

六、验证:同时看帧成本与排队延迟

至少记录每类任务 P50/P95/P99 应用耗时、单任务最大值、每帧 Drain 总时长、队列深度、最老等待时间、过期丢弃率和 Ready 空洞帧数。合成示例:每帧限制 4 个任务,三个各 0.1 ms、一个 6 ms,总耗时仍为 6.3 ms;数量预算没有提供帧时间保证。示例不代表历史配置。

最终决策规则:数量上限控制洪峰,时间上限控制总量,任务切分控制长尾;三者缺一不可。