渲染资源延迟释放:代际租约必须阻断槽位误删
场景分块退出视野后,渲染线程把资源释放命令排入帧尾队列;同帧内槽位又被 Boss 场景复用。若队列只保存整数槽位,旧命令执行时会删除新资源。证据标识 2026-08-01-2000 的 C# 探针把句柄定义为槽位与代际的组合。
// .tmp/daily-labs/2026-08-01-2000/RenderLeaseRegistry.cs
// RenderHandle / RenderLeaseRegistry fields
public readonly record struct RenderHandle(int Slot, int Generation);
public sealed class RenderLeaseRegistry
{
private sealed record Entry(int Generation, string Owner);
private readonly Dictionary<int, Entry> active = [];
private readonly Dictionary<int, int> generations = [];
private readonly Queue<RenderHandle> deferredReleases = [];
private readonly Stack<int> freeSlots = [];
private int nextSlot;
}
所有权边界
注册表唯一拥有槽位分配、当前代际和活动 owner;调用方只持有不可变 RenderHandle;延迟队列只拥有释放意图,不能直接解释槽位。两个不变量是:活动槽位至多对应一个代际;释放仅在槽位与代际同时匹配时生效。设备后端真正销毁 RID 或 GPU 对象属于边界外动作,必须位于匹配成功之后。

用全局递增 ID 并永不复用也能避免误删,却把长期运行的标识空间和诊断表持续放大;只检查 owner 字符串则把正确性依赖交给可变业务命名。每槽位代际只增加一个整数比较,直接绑定被复用的物理位置。
分配与释放
Allocate 优先复用空闲槽,并在写入活动表前增加该槽位代际。ReleaseNow 是唯一删除入口;延迟释放与立即释放最终都经过它,因此不存在一条绕过代际校验的兄弟路径。
// .tmp/daily-labs/2026-08-01-2000/RenderLeaseRegistry.cs
// Allocate / ReleaseNow / DrainDeferred
public RenderHandle Allocate(string owner)
{
var slot = freeSlots.TryPop(out var reused) ? reused : nextSlot++;
var generation = generations.GetValueOrDefault(slot) + 1;
generations[slot] = generation;
active.Add(slot, new Entry(generation, owner));
return new RenderHandle(slot, generation);
}
public bool ReleaseNow(RenderHandle handle)
{
if (!active.TryGetValue(handle.Slot, out var entry) ||
entry.Generation != handle.Generation)
return false;
active.Remove(handle.Slot);
freeSlots.Push(handle.Slot);
return true;
}
public (int Released, int Stale) DrainDeferred()
{
var released = 0;
var stale = 0;
while (deferredReleases.TryDequeue(out var handle))
{
if (ReleaseNow(handle)) released++;
else stale++;
}
return (released, stale);
}
正常时序是分配、排队、帧尾匹配、释放;失败时序是旧代际排队、立即释放、槽位复用、帧尾拒绝旧命令。拒绝不是异常恢复,而是队列消费的合法结果,因此返回统计而非抛出异常。
复用回归
回归测试故意制造最危险的顺序:先保存 gen 1 的延迟命令,再立即释放并把同一槽位分给 gen 2。队列清空后必须得到 released=0, stale=1,并且当前 owner 仍为 boss-arena。另一个用例伪造未来代际,证明“代际不同”一律不能修改活动表。
// .tmp/daily-labs/2026-08-01-2000/RenderLeaseRegistryTests.cs
// StaleReleaseCannotFreeReusedSlot / ForgedGenerationIsRejected
public static void StaleReleaseCannotFreeReusedSlot()
{
var registry = new RenderLeaseRegistry();
var old = registry.Allocate("terrain-a");
registry.DeferRelease(old);
True(registry.ReleaseNow(old));
var current = registry.Allocate("boss-arena");
Equal(old.Slot, current.Slot);
Equal(old.Generation + 1, current.Generation);
Equal((0, 1), registry.DrainDeferred());
Equal("boss-arena", registry.OwnerOf(current));
}
public static void ForgedGenerationIsRejected()
{
var registry = new RenderLeaseRegistry();
var current = registry.Allocate("hud-atlas");
var forged = current with { Generation = current.Generation + 4 };
True(!registry.ReleaseNow(forged));
Equal("hud-atlas", registry.OwnerOf(current));
}

固定行为
探针使用 .NET 9 标准库,不引入外部包。Release 构建为 0 警告、0 错误;三个用例全部通过,测试逻辑耗时 5.841 毫秒,完整运行进程耗时 0.78 秒,退出码为 0。这些数字只证明当前固定输入的行为,不用于声称引擎帧耗改善。
$ cd "$WORK_DIR/.tmp/daily-labs/2026-08-01-2000"
$ dotnet restore --ignore-failed-sources
$ dotnet build -c Release --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
$ /usr/bin/time -p dotnet run -c Release --no-build --no-restore
PASS MatchingDeferredReleaseFreesSlot
PASS StaleReleaseCannotFreeReusedSlot
PASS ForgedGenerationIsRejected
tests=3 passed=3 failed=0 skipped=0 duration_ms=5.841
real 0.78
user 0.33
sys 0.17
exit_code=0
{
"evidenceId": "2026-08-01-2000",
"oldHandle": { "slot": 0, "generation": 1 },
"currentHandle": { "slot": 0, "generation": 2 },
"drain": { "released": 0, "stale": 1 },
"currentOwner": "boss-arena",
"activeCount": 1
}
容量边界
分配、立即释放和单条队列消费的字典操作平均为 O(1);清空 Q 条释放命令为 O(Q),活动表空间为 O(A),其中 A 是活动资源数。当前整数代际在进程生命周期内不回绕;若协议把代际压缩为 16 位,65536 次同槽复用就会重新碰撞,必须改用更宽字段或附加设备 epoch。
正常:allocate gen 1 -> defer gen 1 -> drain -> release gen 1
失败:defer gen 1 -> release -> allocate gen 2 -> reject gen 1
当前边界:单进程注册表,帧尾队列,32 位有符号代际
演进触发:设备丢失、跨线程销毁、代际压缩、跨进程句柄传递
下一结构:device epoch + generation + 后端销毁完成栅栏
多个生产线程直接调用后端销毁看似减少一层队列,却会失去渲染线程顺序和完成栅栏。当前方案保留单消费者队列,以陈旧命令计数作为生命周期竞态信号;当 stale 持续增长时,应检查重复释放或资源切换节奏,而不是取消代际门。
架构结论
渲染资源槽位可以复用,释放权限不能随槽位号自动转移。句柄必须携带代际,所有释放路径必须汇入同一个匹配入口,延迟队列执行时再次确认当前 owner。该合同解决的是旧销毁命令误伤新资源,与“旧句柄不能读取新资源”互补;设备重建或跨线程后端出现时,再把设备 epoch 与完成栅栏并入同一租约即可。