脚本预算:超时看门狗不能代替可调度预算

自动关联目录:脚本预算:超时看门狗不能代替可调度预算

核心判断:看门狗擅长在“已经卡死”后止损,预算调度则在卡死前限制每个脚本本帧能消耗多少 CPU、内存和原生调用成本。两者不是同一层机制。

一、实际演进:从进程级超时到单 VM 中断

早期实现由后台线程观察主线程帧时长,超时后给主 Lua 状态和一个运行协程安装 line hook;更严重的原生超时甚至直接请求退出进程。它能处理死循环,但运行协程、原 hook 和超时状态是进程级共享变量,多 VM 或并发协程下容易互相覆盖;超时发现也依赖帧已经长时间不返回。

后续控制台执行把截止时间放进 lua_State registry,用 Luau interrupt 在 safepoint 抛错,并用 RAII 恢复旧 interrupt。这个设计明显更好:状态随 VM 生灭,不同 VM 不串;用户用 pcall 吞掉第一次错误后,后续 safepoint 仍会再次打断;退出路径统一清理,避免过期截止时间污染下一次执行。

二、仍未解决:控制台保护不了整局脚本

控制台超时只包围一次命令执行。普通事件回调、行为逻辑、网络处理和定时任务是否受统一预算约束,不能由这条路径证明。即使 VM 有 interrupt,脚本长时间停在某个同步 C++ 绑定内部,也没有 Luau safepoint 可打断。

而“允许执行两秒再杀掉”也不是帧预算。它可能每隔几帧制造一次长卡顿,却永远达不到致命超时。内存无限增长、单帧大量 Spawn、同步文件读取和高成本射线查询同样不属于指令数问题。

三、目标设计:预算是多维资源账本

运行时调度应按回调和所有者记账:

ScriptBudget
  cpuMicrosPerFrame
  instructionQuota
  heapBytes
  nativeCostUnits
  spawnedObjects
  queuedTasks
  networkBytes
Result RunCallback(Task task, BudgetLedger& ledger) {
    if (!ledger.Admit(task.estimatedCost)) return Defer;
    InstallInterrupt(task.vm, ledger.cpuDeadline, ledger.instructions);
    NativeCallGate gate(ledger); // 查询、Spawn、IO 分别扣 cost unit
    Result r = ProtectedResume(task.coroutine);
    ledger.Charge(MeasuredCpu(), gate.Consumed(), HeapDelta());
    return r;
}

可暂停协程在超预算时延后;不可重入事件应失败并隔离该脚本实例;原生调用必须先准入,不能等返回后才发现本帧已经超支。连续超支采用“降频 -> 禁用实例 -> 终止运行域”的分级策略,避免一个脚本拖垮整局。

四、验证指标

需要分别压测纯 Lua 循环、深递归、用户 pcall、大量短任务、重原生调用、内存膨胀和取消后的迟到回调。指标看脚本 CPU 长尾、预算延期量、最大原子调用时长、每所有者内存、隔离次数和误杀率。

应保留控制台 RAII interrupt 作为优秀的局部实现,但平台级结论必须更严格:超时是熔断器,预算调度才是日常资源治理。