Godot C# 异步资源热重载的版本化提交
提交边界
Godot C# 工具在编辑器或开发构建中监听资源变化时,多个解码任务可能乱序完成。若完成顺序直接决定激活顺序,较旧文件会覆盖较新文件。时段 2026-07-28-1930 的 .NET 探针把问题压缩为 VersionedResourceGate:请求获得单调票据,解码在边界外执行,只有最新票据能原子替换资源与版本。
// .tmp/daily-labs/2026-07-28-1930/Program.cs · 状态所有者
sealed class VersionedResourceGate(string initial)
{
private readonly object sync = new();
private long requested;
private string active = initial;
private int version;
public string Active { get { lock (sync) return active; } }
public int Version { get { lock (sync) return version; } }
public (string Resource, int Version) Read()
{
lock (sync) return (active, version);
}
}

版本不变量
资源协调器拥有候选解码与提交权,场景节点只读取已提交快照。不可破坏的不变量有两个:active 与 version 必须在同一临界区切换;票据不是最新值时,完成项只能丢弃,不能回滚当前资源。仅用文件时间戳比较看似省事,却会把时钟精度、重复写入和跨平台语义引入核心正确性。
票据描述的是进程内请求顺序,不试图充当资源内容版本。内容哈希、导入器版本和磁盘时间可以决定“是否发起重载”,但一旦异步任务已经启动,提交资格只由协调器递增序号决定。这样可以把检测策略与并发正确性分开验证。
// .tmp/daily-labs/2026-07-28-1930/Program.cs · ReloadAsync
public async Task<bool> ReloadAsync(
string candidate,
int delayMs,
bool failDecode)
{
var ticket = Interlocked.Increment(ref requested);
await Task.Delay(delayMs);
if (failDecode) return false;
lock (sync)
{
if (ticket != requested) return false;
active = candidate;
version++;
return true;
}
}
正常时序
固定时序先提交慢速 v2 请求,再提交快速 v3 请求。v3 在五毫秒后完成并成为版本一;v2 在三十毫秒后到达提交门,但票据已经过期,只返回失败。场景树在加载期间持续读取 v1,提交瞬间才同时看到 v3 与版本一,不存在资源已换而版本仍旧的中间态。
正常路径的关键不是更快任务必然胜出,而是更新请求在语义上覆盖旧请求。即使 v2 更早完成,只要 v3 已经登记,旧票据也不再拥有提交权;这避免一帧先激活旧候选、下一帧再切换新候选的可见抖动。
| 事件 | 票据 | 解码延迟 | 结果 |
|---|---|---|---|
| 请求 v2 | 1 | 30 ms | 过期丢弃 |
| 请求 v3 | 2 | 5 ms | 原子提交 |
| 场景读取 | - | 提交前 | v1 / 0 |
| 场景读取 | - | 提交后 | v3 / 1 |

失败保持
解码失败在进入锁之前返回,因此不会污染活跃快照。测试还在异步任务未完成时读取旧资源,证明“加载中”不是一个半提交状态。若把候选先写入共享字段、随后再校验,节点可能在一帧内观察到破损资源;这种做法即使最终回滚,也已经破坏读侧不变量。
// .tmp/daily-labs/2026-07-28-1930/Program.cs · 失败与读侧回归
await Run("FailedDecodePreservesActive", async () =>
{
var gate = new VersionedResourceGate("stable");
var committed = await gate.ReloadAsync("broken", 1, true);
Check(!committed, "解码失败不得提交");
Check(gate.Active == "stable" && gate.Version == 0,
"失败必须保留活跃资源");
});
await Run("SceneReadsAtomicSnapshot", async () =>
{
var gate = new VersionedResourceGate("old");
var pending = gate.ReloadAsync("new", 10, false);
Check(gate.Read().Resource == "old", "加载期间只能读取旧快照");
await pending;
Check(gate.Read() == ("new", 1), "资源与版本必须同时切换");
});
运行证据
Release 运行退出码为零,三项测试全部通过;测得探针内部耗时 47.8459 毫秒。该数字只用于确认固定延迟时序实际发生,不作为性能结论。evidence.json 记录三个版本、30/5 毫秒延迟和版本提交合同,技术图由同一数据绘制。
cd "$WORK_DIR/.tmp/daily-labs/2026-07-28-1930"
dotnet run -c Release
# exit_code=0
# passed=3 failed=0 skipped=0
# elapsed_ms=47.8459
# fixed_input versions=3 slow_ms=30 fast_ms=5
# invariant=resource_and_version_commit_atomically
{
"slot": "2026-07-28-1930",
"passed": 3,
"failed": 0,
"skipped": 0,
"fixed_input": {
"versions": 3,
"slow_ms": 30,
"fast_ms": 5
},
"tests": [
"NewestVersionCommits",
"FailedDecodePreservesActive",
"SceneReadsAtomicSnapshot"
]
}
扩展边界
单协调器的临界区只包围票据校验与两字段赋值,容量上限首先受解码并发和资源内存峰值约束,而非锁长度。并发候选导致峰值超过预算时,应加入有界取消与候选资源释放;需要跨线程交付 Godot 对象时,再把最终提交调度到主线程。无论执行器如何演进,向场景树交付的接口仍应是不可分割的 (Resource, Version) 快照。
当多个资源必须成组生效,例如材质与纹理具有同一导入批次,票据应提升到批次级并一次提交整组句柄。逐资源独立提交会重新制造跨字段中间态;因此扩展单位由可见一致性边界决定,而不是由文件数量决定。
若提交后还要通知依赖节点,通知只能携带已经提交的版本快照,不能让监听者回读候选字段。这样失败候选既不会进入场景,也不会触发错误的缓存失效链。