技术博客

线程化资源加载:`load_threaded_request` 的状态机与令牌

第 98 / 149 篇 · 2025-09-16 · godot · 资源管线多线程源码分析

load() 是同步的:大资源在主线程加载,期间整个游戏冻住。Godot 3.x 的对策是 InteractiveLoader(分片轮询),4.x 换成了真正的后台线程方案 load_threaded_request。这篇把 4.x 这条链拆开:状态机、任务表、主线程取结果的契约,以及方案的真实边界。

公开接口与状态机

三个 API,分工清晰:

  • ResourceLoader.load_threaded_request(path):提交加载任务,立即返回,不阻塞。
  • ResourceLoader.load_threaded_get_status(path):查询进度,返回四态之一。
  • ResourceLoader.load_threaded_get(path):取结果,阻塞到加载完成(或返回已缓存的结果)。

四态的状态机是理解全部行为的钥匙:

        request()          加载完成
INVALID ──────────→ IN_PROGRESS ──────────→ LOADED
                          │                    │
                          │ 加载中抛错          │ get() 取走
                          ▼                    ▼
                       FAILED              INVALID(任务从表删除)

两个细节容易踩。第一,LOADED 不是终态——load_threaded_get() 取走资源后任务从任务表删除,同路径再查状态回到 INVALID。如果你在 get 之后又拿原路径查 status 看到 INVALID 以为加载失败了,那只是令牌已被消费。第二,IN_PROGRESS 期间对同一路径再次 request 是幂等的,不会起两个线程加载同一份资源;但不同路径指向同一物理文件会起两个任务,引擎不做内容级去重。

内部结构:一张任务表加线程池

提交的任务进 ResourceLoader 里的线程加载任务表(按路径索引),每个条目是一个 ThreadLoadTask:路径、加载参数、状态字段、完成通知。执行体交给 WorkerThreadPool(多线程那篇的主角)或专门的加载线程跑,加载过程本身——打开文件、解析、构建资源对象——发生在后台线程,主线程完全无感。

取结果的瞬间是有代价的。 load_threaded_get 拿到后台线程产出的 Resource 后,有一步"主线程侧收尾":把资源登记进 ResourceCache(Resource 那篇的全局缓存表)和触发加载完成回调。这一步必须在主线程做,因为下游可能有脚本回调、信号连接、实例化触发,都假设自己跑在主线程。所以"线程化加载"消掉的是解析和 IO 的大头,不是全部——get 那一刻仍有小峰值,对几百 MB 的场景资源,实测收尾在百微秒级,可接受,但不是零。

与 3.x InteractiveLoader 的本质差异

3.x 的 load_interactive() 不分线程:它在主线程上把加载切成可轮询的小片,poll() 每次推进一点,靠"每帧 poll 几次"把卡顿摊平。它的天花板很明显——加载总量不变,只是切片;而且不是所有 loader 都实现了分片,遇到没实现的资源类型,poll 一步就走完全部,卡顿原样回来。

4.x 的线程化方案把总量挪出主线程,主线程只剩状态查询和收尾。代价是线程安全要求倒灌给 loader 实现:loader 在后台线程跑,解析时碰到的子资源加载、脚本编译都可能触达全局状态,引擎靠加载互斥和"子资源也在加载线程内完成"的约定兜底。自定义 ResourceFormatLoader 想支持线程化,得确认自己的解析逻辑没碰非线程安全的全局单例——否则偶发的后台崩溃会很难归因。

实战用法与边界

典型的进度条用法是请求一批资源后每帧轮询:

for path in level_paths:
    ResourceLoader.load_threaded_request(path)

# 每帧 _process 里
for path in level_paths:
    match ResourceLoader.load_threaded_get_status(path):
        ResourceLoader.THREAD_LOAD_LOADED:
            var res = ResourceLoader.load_threaded_get(path)
            _install(path, res)
        ResourceLoader.THREAD_LOAD_FAILED:
            push_error("加载失败: " + path)

三个边界要记住。其一,轮询间隔决定进度条精度,不决定加载速度——后台线程全速跑,status 只是观察口。其二,load_threaded_get 的阻塞语义意味着你不能在没确认 LOADED 时调用它然后指望主线程还能响应——它不是异步的,get 就是"等到好"。想完全不阻塞,只有轮询 status 一条路。其三,依赖链自动内联加载:加载场景资源会连带加载它的子资源,子资源不再单独进任务表,进度只体现在父任务上——进度条按"父任务数"设计,别按文件数设计。

解决不了的部分也要说清:线程化加载管的是"资源进内存"这一段。实例化场景(instantiate)、把贴图传 GPU、shader 首次编译,都不在这条链上——它们是主线程工作,大场景首次进入时的卡顿大头常常在实例化和 GPU 上传,那部分要靠分帧实例化和渲染管线预热另外治。

load_threaded_request 状态机 INVALID 任务不在表中 request() IN_PROGRESS 后台线程加载中 完成 LOADED 结果待领取 get() 取走 + 主线程收尾 INVALID 任务删除 加载抛错 FAILED get() 返回 null 线程分工 后台线程:打开文件 → 解析 → 构建资源对象(IO 与解析的大头移出主线程) 主线程:get() 时登记 ResourceCache + 触发完成回调(仍有百微秒级峰值) 不在链上:场景实例化、贴图上传 GPU、shader 首次编译——卡顿另治