保存快照:后台保存不能重读活对象树

自动关联目录:保存快照:后台保存不能重读活对象树

核心判断:后台保存的输入必须是主线程一次冻结得到的不可变快照。若 JSON、二进制和 Manifest 分别读取持续变化的对象树,它们即使都写成功,也可能描述三个不同瞬间。

一、业务问题:线程安全不是唯一风险

创作者边编辑边自动保存。后台线程开始序列化后,主线程又修改一个属性、删除一个节点或切换关卡。如果后台任务在不同阶段重新读取活对象树,结果可能是 JSON 包含新属性,二进制仍是旧拓扑,Manifest 却记录了更新后的摘要。

给对象树加锁可以避免数据竞争,却会让长序列化阻塞编辑;复制若不在同一个逻辑时刻完成,也只是把不一致复制到后台。

二、实际设计:保存上下文冻结二进制与身份

现有实现把一次发布保存需要的项目根、根对象、场景版本、Schema、编码版本和二进制 Payload 放进不可变上下文。协调器明确禁止为该制品重新读取 Lua 活树。后台保存对象也不是复制整个协调器,而是只带 Catalog、当前关卡、主关卡、项目 lineage、基础 generation/revision 和保存序号,不携带活场景回调或待处理事务。

后台结果回主线程时,会重新比较 owner、项目根、基础 generation/revision、活动关卡和 lineage;任一变化都拒绝应用。这是正确的乐观并发控制:后台工作可以继续,但过期结果没有提交权。

三、值得保留与仍需改进

值得保留的是“捕获”和“提交”分离,以及二进制制品绑定保存上下文。旧式流程常在保存函数中遍历 Actor/PB 数据并立即编码,调用路径短,但内容规模和并发编辑上来后很难证明一致性。

当前方案仍应进一步统一 JSON 与二进制的来源。最理想的路径是先冻结规范中间表示,再由它派生所有编码:

snapshot = CaptureOnGameThread(root, sceneRevision)
snapshotHash = Hash(snapshot.canonicalBytes)

worker:
    json = EncodeJson(snapshot)
    binary = EncodeBinary(snapshot)
    manifest = Describe(snapshotHash, json, binary)

gameThread:
    require(currentRevision == snapshot.sceneRevision)
    CommitGeneration(json, binary, manifest)

若捕获本身过长,应使用写时复制、脏块快照或分片冻结,而不是让后台回读 UObject/Lua 表。

四、验证结论

在捕获后、编码中、写文件前和回主线程前分别注入编辑、切关和关闭项目。必须证明旧任务只能产生孤立候选,不能覆盖新 Revision;JSON 与二进制解码后的规范树摘要必须一致;后台失败也要回传恢复状态,不能只丢一个布尔值。

这里最值得总结的经验是:异步保存的难点不是“把工作放到线程池”,而是为这项工作冻结一个之后永远不会变化的事实边界。