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);
    }
}

版本化热重载主视觉

版本不变量

资源协调器拥有候选解码与提交权,场景节点只读取已提交快照。不可破坏的不变量有两个:activeversion 必须在同一临界区切换;票据不是最新值时,完成项只能丢弃,不能回滚当前资源。仅用文件时间戳比较看似省事,却会把时钟精度、重复写入和跨平台语义引入核心正确性。

票据描述的是进程内请求顺序,不试图充当资源内容版本。内容哈希、导入器版本和磁盘时间可以决定“是否发起重载”,但一旦异步任务已经启动,提交资格只由协调器递增序号决定。这样可以把检测策略与并发正确性分开验证。

// .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) 快照。

当多个资源必须成组生效,例如材质与纹理具有同一导入批次,票据应提升到批次级并一次提交整组句柄。逐资源独立提交会重新制造跨字段中间态;因此扩展单位由可见一致性边界决定,而不是由文件数量决定。

若提交后还要通知依赖节点,通知只能携带已经提交的版本快照,不能让监听者回读候选字段。这样失败候选既不会进入场景,也不会触发错误的缓存失效链。