事故:一次收菜把主线程按住了 200ms
《潮汐消消》的家园版本做过一次「一键收菜」:玩家点一下,客户端要结算 200 多个装饰的产出、刷新列表、更新货币、播放收获特效。案例设定中,上线后低端机出现明显迟滞——点击后约 0.2 秒没有反馈,随后画面跳一下才恢复。
最初判断是渲染压力:200 个装饰同时出现,粒子太多。团队把分辨率从 1.0 降到 0.75,卡顿几乎没变。把逻辑耗时打出来后才发现,单帧逻辑结算达到 28ms,叠加输入与渲染后连续 6 帧超过 30fps 的 33.3ms 预算;输入事件排在长任务后面,所以点击要等结算跑完才被处理。
这不是渲染管线问题,而是主线程任务没有切片:一个不可中断的逻辑块占满了帧预算,输入、渲染、提交只能排队。降低分辨率只会让画面变糊,不会让 28ms 的逻辑变短。
更麻烦的是它连续发生了 6 帧。单帧 28ms 只是掉一帧,连续 6 帧超预算就会让输入延迟累积到 200ms 量级;玩家感知的不是「偶尔掉帧」,而是「点了没反应」。
一帧的四段:输入、逻辑、渲染、提交
把一帧拆成四段,不是为了画架构图,而是为了回答「超预算时该动哪一段」。
| 阶段 | 典型工作 | 能否跨帧切片 | 超预算时的表现 |
|---|---|---|---|
| 输入 | 触摸、手势、传感器采样转意图 | 不切,最多合并 | 点击延迟,操作不跟手 |
| 逻辑 | 结算、AI、动画状态、UI 数据刷新 | 可以,在安全点切 | 画面停顿,输入排队 |
| 渲染 | 场景与 UI 提交绘制命令 | 不跨帧,单帧内完成 | 掉帧,GPU 等待 |
| 提交 | present、交换缓冲、同步 | 不切 | 帧末阻塞,整体节拍后移 |
四段共享同一份帧预算:30fps 为 33.3ms,60fps 为 16.7ms。预算不是给四段各分 25%,而是先扣除输入、渲染、提交这些不可切片的部分,剩下的才是逻辑可以使用的空间。
这个顺序很重要。很多团队把「逻辑预算」当成固定配额,先给逻辑 10ms,再让渲染自己想办法;正确做法是先保证渲染与提交能完成,再把剩余时间交给可切片的逻辑。否则逻辑一忙,渲染就错过提交窗口,表现为整帧掉。
长帧归因:先量再切
长帧不是一个数。至少要区分三种情况:
- 单次尖峰:偶发一帧 50ms,前后正常;多半是资源解码、GC 或一次性结算,重点看触发条件。
- 连续超预算:连续多帧超过预算,玩家能感知为停顿;这类问题必须切片或降负载。
- 固定阶段超预算:输入、渲染、提交本身已经超过预算,逻辑再切片也救不回来;必须先减固定成本。
模拟器把每帧的切片、已用时间、超预算量与「哪个阶段顶穿预算」都记录下来。判断长帧原因时,先看 crossedStage,再看 byStage。
| 场景 | 输入 | 逻辑 | 渲染 | 提交 | 预算 | 结论 |
|---|---|---|---|---|---|---|
| 正常帧 | 2ms | 4ms | 5ms | 1ms | 16.7ms | 未超预算 |
| 渲染顶穿 | 3ms | 0ms | 14ms | 2ms | 16.7ms | 超 2.3ms,降分辨率可能有效 |
| 固定阶段已超 | 3ms | 6ms | 14ms | 2ms | 16.7ms | 固定阶段 19ms,切逻辑无用 |
第三行是关键反例:即使把 6ms 逻辑切成 3 份,固定阶段仍然超过预算,长帧不会消失。此时应该减少渲染批次、合并提交或降低特效,而不是继续切逻辑。
优先级:先保输入与渲染,再保逻辑完成
同为逻辑任务,优先级也不一样。调度器至少要区分四层,避免后台任务把玩家正在等的结算挤到后面。
| 优先级 | 任务 | 调度规则 | 超时处理 |
|---|---|---|---|
| P0 | 输入、关键 UI 反馈 | 每帧帧首执行 | 不允许排队 |
| P1 | 渲染与提交 | 每帧预留时间 | 不允许被逻辑挤占 |
| P2 | 玩家触发的结算 | 优先使用剩余逻辑空间 | 给即时反馈,跨帧完成 |
| P3 | 预加载、统计、非可见刷新 | 只在预算有余量时执行 | 可暂停或延后 |
优先级的意义是「超预算时先牺牲谁」。P3 任务可以先停,P2 任务可以跨帧,P1 不能少,P0 不能被推迟。没有优先级,所有任务按提交顺序排队,后台刷新就可能挡在点击结算前面。
切片原则:安全点、批次与让帧
切片不是把任意函数拆成两半。切错位置会留下半更新状态,下一次输入读到脏数据,问题比卡顿更难查。
| 任务类型 | 是否切片 | 安全点 | 失败后果 |
|---|---|---|---|
| 单实体结算 | 可以 | 实体之间,状态完整提交后 | 重复结算或漏结算 |
| 批量产出 | 可以 | 每批实体处理完 | 货币与列表不一致 |
| 跨实体事务 | 不切 | 整段完成后再让帧 | 中间态被其他系统读到 |
| 动画与特效 | 可以 | 按播放批次 | 特效跳变 |
| 输入处理 | 不切 | 帧首一次处理 | 操作延迟 |
| 渲染与提交 | 不切 | 单帧内完成 | 画面撕裂或丢帧 |
切片粒度按时间定,不按条数定。案例模型用 2–4ms 作为单次逻辑切片上限:太小会让调度开销占比上升,太大则一次切片就可能顶穿预算。这个范围是模型参数,项目要按自己的逻辑耗时分布调整。
「让帧」是切片后的动作:当前帧只跑允许的切片,剩余部分留到下一帧,输入与渲染照常执行。让帧不是 sleep,也不是把任务丢到后台线程;它只是把同一段逻辑拆到多个帧,主线程在每帧仍然先处理输入。
状态一致性要靠事务边界保证。以收菜为例,可以按装饰分批结算,但「扣除体力、增加货币、标记已收取」必须作为一个小事务完成;不能出现体力已扣、货币未加的中间态。批量任务要么按实体隔离,要么在批次之间保存可恢复的快照。
调度顺序:输入优先、预留渲染、逻辑填缝
模型按固定顺序排一帧:输入 → 逻辑 → 渲染 → 提交。逻辑只使用「预算减去输入、渲染、提交」后的剩余空间。
这个顺序有两个直接后果:
- 输入永远在帧首处理,不会被逻辑切片挤到下一帧;玩家的点击至少能被看到。
- 渲染与提交先被预留,逻辑只能在缝隙里填;固定阶段超预算时,长帧被明确记录,而不是被逻辑悄悄吃掉。
| 帧 | 输入 | 逻辑切片 | 渲染 | 提交 | 已用 | 结果 |
|---|---|---|---|---|---|---|
| 0 | 2ms | 3ms | 4ms | 1ms | 10ms | 剩余 3ms 逻辑留到下一帧 |
| 1 | 2ms | 3ms | 4ms | 1ms | 10ms | 继续 |
| 2 | 2ms | 3ms | 4ms | 1ms | 10ms | 继续 |
| 3 | 2ms | 1ms | 4ms | 1ms | 8ms | 逻辑完成 |
如果固定阶段已经超过预算,逻辑切片会被标成「无法单独装入」,仍然执行但记录长帧;这样报告不会假装问题已经解决。模型宁可暴露长帧,也不把超预算藏进平均值。
切片的代价:调度、状态与延迟
切片不是免费的。每多切一刀,就多一次调度、一次状态保存和一次跨帧恢复;切片过度会把一个 6ms 的任务拆成六个 1ms 的小任务,总耗时反而上升。
| 代价 | 来源 | 控制方法 |
|---|---|---|
| 调度开销 | 每帧多次入队、出队与恢复 | 单次切片 2–4ms,短任务保持原子 |
| 状态保存 | 跨帧任务需要快照或可恢复游标 | 按实体隔离,批次之间提交 |
| 完成延迟 | 任务跨多帧,玩家可见结果被推迟 | 先给即时反馈,再异步完成 |
| 调试复杂度 | 调用栈跨帧,日志难以串联 | 任务 id 贯穿日志,记录帧号与偏移 |
最容易被忽略的是完成延迟。把收菜拆成 10 帧完成,帧率可能稳定了,但玩家点击后要等 10 帧才看到货币变化,体感仍然慢。正确做法是先给一个即时反馈(按钮按下、进度圈、音效),把结算结果分帧完成;玩家感知的是「有响应」,而不是「所有数字立刻到位」。
一个可操作的切片流程
切片可以按固定步骤落地,避免一上来就改代码:
- 先用 9.1 的窗口口径确认是持续超预算,而不是一次资源尖峰。
- 把一帧拆成输入、逻辑、渲染、提交,记录各阶段耗时与
crossedStage。 - 标出不可切阶段:输入、渲染、提交,以及跨实体事务。
- 只对超过单帧剩余空间的逻辑任务切片,短任务保持原样。
- 按 2–4ms 定切片上限,按事务边界定切点,不按代码行数切。
- 预留渲染与提交时间,逻辑只填剩余空间;固定阶段超预算时直接报警。
- 用合成任务验证时间账本,再用真机长任务验证状态一致性与体感延迟。
第 7 步不能省。合成任务只能证明「账算对了」,不能证明「切点安全」;真实业务里的跨系统依赖,往往在合成用例里不存在。
什么时候不该切片
切片只适用于「可中断、可恢复、对完成时间不敏感」的逻辑。下面几类任务不应该切:
- 短任务:本身只有 1–2ms,切片后的调度开销可能超过收益。
- 原子事务:扣体力、加货币、写背包必须一次完成,切开会产生中间态。
- 输入处理:切片会把点击延迟到下一帧,直接损害操作感。
- 渲染与提交:跨帧会破坏画面完整性,应该减负载而不是切。
- 强实时反馈:技能命中、受击反馈需要当帧完成,延迟比掉一帧更伤。
不能切时,替代手段是减少工作量:合并重复计算、缓存中间结果、降低特效数量、把可并行的纯计算移到工作线程。工作线程不是万能解,跨线程通信与状态同步会引入新的复杂度,只有在任务足够大且数据边界清晰时才值得。
观测与回归
切片上线后,至少观测四类指标,才能判断「长帧少了」还是「长帧被摊薄了」。
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 长帧数 | 单帧超过预算 2 倍的次数 | 切片后不降,说明固定阶段仍超预算 |
| 任务跨帧数 | 一个逻辑任务横跨的帧数 | 持续增大,说明切片太小或预算被挤占 |
| 最大切片耗时 | 单次逻辑切片的峰值 | 接近预算,说明切片上限设得太大 |
| 任务年龄 | 从入队到完成的帧数 | 超过玩家可感知阈值,需要即时反馈或提优先级 |
回归要同时跑合成用例与真机采样。合成用例锁定边界:固定阶段超预算、跨实体事务、最后一片不满、帧数上限;真机采样锁定体感:点击延迟、任务完成时间、长帧是否集中在特定场景。
可运行模型:frame-slicing-demo.ts
完整实现仍在 src/runtime/frame-budget.ts,与 9.1 共用同一份文件。下面的 demo 把 10ms 逻辑按 3ms 切片,观察四帧内逻辑如何让帧、渲染如何每帧照常执行。为了在少量帧内展示切片,demo 把帧预算缩小到 10ms;真实 30fps 预算是 33.3ms,10ms 只是演示参数。
// file: fixtures/frame-slicing-demo.ts
// tier: L1
// verify: node fixtures/frame-slicing-demo.ts
import { sliceFrames } from "../src/runtime/frame-budget.ts";
const report = sliceFrames([
{ id: "tap", stage: "input", costUs: 2_000 },
{ id: "settle", stage: "logic", costUs: 10_000 },
{ id: "scene", stage: "render", costUs: 4_000 },
{ id: "present", stage: "submit", costUs: 1_000 },
], { frameBudgetUs: 10_000, maxLogicSliceUs: 3_000 });
console.log(JSON.stringify({
frames: report.frames.map((frame) => ({
index: frame.frameIndex,
usedUs: frame.usedUs,
logicSlices: frame.slices.filter((slice) => slice.stage === "logic").length,
deferred: frame.deferredTaskIds,
})),
longFrames: report.longFrames,
byStage: report.byStage,
deferredLogicUs: report.deferredLogicUs,
}, null, 2));
运行 node fixtures/frame-slicing-demo.ts,输出可以读成一张四帧计划表:
| 帧 | 已用时间 | 逻辑切片数 | 剩余逻辑 | 说明 |
|---|---|---|---|---|
| 0 | 10000us | 1 | settle | 固定阶段 7000us,逻辑只剩 3000us |
| 1 | 10000us | 1 | settle | 继续让帧 |
| 2 | 10000us | 1 | settle | 继续让帧 |
| 3 | 8000us | 1 | 无 | 最后 1000us 逻辑完成 |
报告里的 longFrames 为 0,说明四帧都在 10ms 预算内;byStage 显示输入 8000us、逻辑 10000us、渲染 16000us、提交 4000us,是四帧累计值。判断瓶颈时要按帧平均,不能直接拿累计值比较,否则固定阶段会被帧数放大。
配套测试 test/runtime/frame-budget.test.ts 覆盖四阶段归因、10ms 逻辑跨四帧、长帧阶段归属、固定阶段超预算、CPU/GPU 分类与帧数上限。运行命令是 node --test test/runtime/frame-budget.test.ts。
模型只模拟时间账本,不执行真实任务,也不测量 GC、锁竞争或系统调度。这里是模型,不是真机测量结果;真实长帧仍要回到设备上用同一套阶段口径采样。
双案例:方舟的 16.7ms 更紧
方舟默认 60fps,帧预算只有 16.7ms,战斗逻辑与技能结算更重。照搬消消的 3ms 切片上限可能切得太碎,调度开销反而上升;它需要按战斗帧边界重新定安全点。
消消的单局只有 90 秒,切片优先保证点击反馈;方舟单场 8–12 分钟,切片还要考虑长时间运行后的状态一致性。两者可以共用模型,不能共用参数。
与相邻章节的关系
9.1 决定档位与分辨率,本节解释「为什么超预算、该切哪一段」;9.3 处理长时间运行后的温控降频。分层边界与依赖方向可以参考分层职责 与依赖方向 ,UI 批次与渲染开销可以参考UI 性能与合批 ,长帧与卡顿的观测口径可以参考崩溃与卡顿 。
切片逻辑本身也应该有边界测试:给定固定阶段、逻辑总量与切片上限,输出帧数、长帧数与超预算阶段必须稳定。把这类测试放在模块层,业务改动时就不必每次重跑整局;模块化与依赖整理可以参考模块化 ,分层测试的组织方式可以参考分层测试 。
小结
- 一帧由输入、逻辑、渲染、提交四段共享预算;先预留固定阶段,再把剩余时间给可切片逻辑。
- 长帧要分清单次尖峰、连续超预算与固定阶段超预算;固定阶段已超时,切逻辑救不了。
- 切片只在安全点进行,按 2–4ms 量级分批,跨实体事务不切;让帧不是丢任务,而是把剩余切片留到下一帧。
- 调度顺序固定为输入优先、渲染预留、逻辑填缝;超预算要记录
crossedStage,不能藏进平均值。 - 模型只验证时间账本与归因逻辑,真实 GC、锁竞争与系统调度仍要真机采样。
阅读导航:上一节:9.1 帧率分级与动态分辨率 · 下一节:9.3 功耗与发热的验收方法 。
常见错误与边界
- 把长帧都当成渲染问题。先看
crossedStage与阶段耗时;逻辑顶穿时降分辨率只会牺牲画质,不会缩短长帧。 - 在任意语句之间切片。半更新状态会被下一次输入读到,轻则重复结算,重则货币与背包不一致;切片必须落在事务边界。
- 切片粒度只按条数定。200 个装饰每个 0.1ms 和 20 个装饰每个 1ms 是两种负载;按时间切片才能控制单帧占用。
- 把让帧实现成
sleep或后台线程。主线程让帧是「下一帧继续」,不是阻塞等待;丢到后台还会引入跨线程状态问题。 - 固定阶段超预算仍继续切逻辑。渲染 14ms、提交 2ms 已经超过 16.7ms 时,切逻辑不会减少长帧,应先减固定成本。
- 只记录帧率不记录任务年龄。切片后帧率可能稳定,但结算结果要等十几帧才出现;任务年龄必须和帧时间一起观测。
- 所有任务共用一个队列。后台预加载挡在玩家结算前面时,帧率可能正常,操作却明显延迟;至少分出 P0–P3 四层。
- 切片后不做状态回归。帧率改善但重复结算、漏发奖励,是切点落在事务中间;必须补状态一致性用例。
- 用条数而不是时间衡量切片。不同实体的结算成本差异很大,按条数切会让单帧占用忽高忽低。
- 切片上限跟着逻辑总量放大。逻辑从 6ms 涨到 20ms 时仍用旧的 4ms 上限,会让帧数变多但单帧更满,必须重新标定。
- 边界:本节不写引擎渲染管线,也不测量真实 GC 与调度。模型输出是时间账本,真机长帧要用同一阶段口径重新采样。
- 把让帧当成免费。让帧只是把成本挪到下一帧,任务年龄仍然在涨;没有年龄上限的让帧,最终会在结算界面集中爆发。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。