场景卸载完成后,共享资源为什么还不能释放

Hub 已经卸载,资源系统随即释放了它加载过的 ui-common。内存数字看起来下降得很干净,仍在运行的 Arena 却在下一次绘制中读到失效纹理。问题不在异步卸载是否结束,而在释放决策把“由谁发起加载”误当成了“现在还有谁使用”。
共享资源的生命周期不能挂在某一个场景对象上。场景可以发起预载,但只有已经通过激活屏障的场景才应持有资源租约;卸载一个场景只归还自己的租约,计数归零后资源管理器才拥有释放权。
预载完成还不是所有权转移
本次探针把请求状态与活动状态分开。BeginPreload 只创建带代次的 LoadRequest,完成回调把请求标成 Ready 或 Failed;共享依赖的租约直到 Activate 通过依赖与预算检查后才增加。实现位于 .tmp/daily-labs/2026-08-25-2000/Program.cs 的 SceneLeaseManager.Activate:
public ActivationResult Activate(LoadRequest request)
{
if (active.ContainsKey(request.Scene))
return ActivationResult.AlreadyActive;
if (!requests.TryGetValue(request, out var state) ||
state == RequestState.Cancelled)
return ActivationResult.Cancelled;
if (state != RequestState.Ready)
return ActivationResult.MissingDependency;
var spec = catalog[request.Scene];
if (active.Values.Sum(item => item.MemoryMb) + spec.MemoryMb > budgetMb)
return ActivationResult.OverBudget;
active.Add(spec.Name, spec);
foreach (var dependency in spec.Dependencies)
leases[dependency] = leases.GetValueOrDefault(dependency) + 1;
requests.Remove(request);
return ActivationResult.Activated;
}
这个提交点同时保护三件事:请求没有被取消、依赖已经完整、加入活动集合后不超过预算。BossRoom 的固定样本占 140 MB;在 150 MB 预算中已有 80 MB 的 Hub 时,即使预载完成也返回 OverBudget,活动集合仍只有 Hub。若加载回调在取消之后到达,CompletePreload 直接丢弃结果,既不增加租约,也不计入活动内存。
这里没有让预载阶段提前持有正式租约,因为被取消、失败或永不激活的候选会把资源长期钉在内存中。真实引擎若需要防止候选依赖在提交前被清理,可以另设短期加载保留项;它与活动租约必须是不同状态,并由请求取消或超时负责回收。
卸载归还租约,不直接释放资源
固定目录中,Hub 占 80 MB,依赖 ui-common 与 hub-geo;Arena 占 96 MB,依赖 ui-common 与 arena-geo。两者激活后,ui-common 的租约计数为 2,总活动内存为 176 MB。卸载逻辑只处理被移除场景贡献的租约:
public void Unload(string scene)
{
if (!active.Remove(scene, out var spec)) return;
foreach (var dependency in spec.Dependencies)
{
leases[dependency]--;
if (leases[dependency] == 0)
leases.Remove(dependency);
}
}

Hub 卸载后,hub-geo 的最后一份租约消失,ui-common 则从 2 降到 1;Arena 继续活动,总内存变为 96 MB。与之相对,按“Hub 加载过哪些资源”整包释放会在计数仍为 1 时销毁 ui-common,把 Arena 留在引用已释放对象的状态。
引用计数在这里表达的是活动场景所有权,不是资源对象被多少组件临时访问。若依赖图允许资源再依赖资源,计数应落在资源图的稳定节点上,并由一次场景激活事务统一取得、一次卸载事务统一归还;不能让各组件在 OnEnable 与 OnDisable 中各自猜测配对关系。重复激活同一场景也必须幂等,否则一次重入就会制造永远无法归零的幽灵租约。
内存预算也不能简单相加每个场景声明的资源大小。本文用 80 MB 与 96 MB 记录活动场景成本,是为了固定验证输入;生产实现应把独占节点计一次,共享节点也只计一次,再把候选加载保留项单列。否则两个场景都声明一张 24 MB 共享纹理时,预算器会虚增 24 MB,过早拒绝可成立的切换。反过来,若预算只看场景根节点而漏掉共享依赖,又会允许提交一个真实驻留量超限的活动集合。
租约归零也不是立即调用底层销毁的充分条件。渲染线程可能已经为当前帧提交引用该纹理的命令,GPU fence 尚未越过安全点。更完整的边界是:活动租约归零后进入待释放队列,渲染后端确认最后使用已完成,再销毁资源对象。场景管理器决定“不再需要”,资源管理器决定“可以进入回收”,渲染后端决定“现在销毁安全”,三者不应由一次场景回调越级完成。
失败必须停在活动集合之外
探针的八项检查覆盖共享依赖保留、最后租约释放、缺依赖拒绝、取消请求拒绝、迟到完成丢弃、重复激活幂等、未知场景卸载无副作用和预算拒绝。测试符号 SharedDependencySurvives 直接断言 Hub 卸载后 ui-common == 1;FailedActivationKeepsState 则确认 BossRoom 缺依赖时 Arena 不被替换。
cd "$WORK_DIR/.tmp/daily-labs/2026-08-25-2000"
dotnet build SceneResourceLeaseLab.csproj -c Release --no-restore
dotnet run --project SceneResourceLeaseLab.csproj -c Release --no-build
# 决定性输出
Build succeeded. 0 Warning(s). 0 Error(s).
before active=Arena,Hub ui-common-leases=2 memory_mb=176
after_unload_hub active=Arena ui-common-leases=1 memory_mb=96
naive_release=ui-common result=would_break:Arena
failed_activation=BossRoom reason=MissingDependency retained=Arena
tests pass=8 fail=0 skip=0 duration_ms=88.08
构建与运行退出码均为 0,墙钟耗时分别为 2.89s 与 2.50s。耗时只记录本次执行,不作为性能结论。探针也没有模拟 GPU fence、引擎对象销毁必须发生的线程或 AssetBundle 依赖图;这些约束会决定“计数归零”之后何时真正销毁,但不会改变释放权属于资源管理器这一边界。
因此,场景卸载完成只能证明该场景不再持有依赖,不能证明依赖已经无人使用。只要活动集合仍有租约,共享资源就必须保留;缺依赖、超预算与取消请求则必须在激活提交前失败。接入 Unity Addressables、Godot ResourceLoader 或自研流送器时,可以替换具体加载接口,但活动租约、候选状态与最终销毁队列不应重新混成一个布尔字段。