技术博客

Resource 的引用计数与 ResourceCache:资源为什么不会重复加载

第 96 / 149 篇 · 2026-07-25 · godot · 资源管线源码分析内存管理

同一个 .tres 被十个场景引用,内存里只有一份——这个行为几乎每个 Godot 用户都享受过,但它背后是三套机制的配合:Ref<T> 的侵入式引用计数、ResourceCache 的全局路径表、ResourceLoader 的加载去重。我按 4.2.2 的源码(core/object/ref_counted.h、core/io/resource.cpp、core/io/resource_loader.cpp)把这条链走一遍,顺便解释几个常见的坑:为什么缓存里存的是裸指针、为什么 load() 同一个路径返回的是同一对象、CACHE_MODE_IGNORE 到底绕过了什么。

Ref:侵入式引用计数

Resource 继承 RefCounted,而 RefCounted 内部就是两个 SafeRefCount(原子计数器):

// core/object/ref_counted.h, 4.2.2
class RefCounted : public Object {
    GDCLASS(RefCounted, Object);
    SafeRefCount refcount;
    SafeRefCount refcount_init;
public:
    bool init_ref();
    bool reference();   // 计数已在 0 时会返回 false
    bool unreference();
};

Ref<T> 是包在裸指针外的一层壳,生命周期管理全在 unref() 里:

void unref() {
    if (reference && reference->unreference()) {
        memdelete(reference);   // 计数归零,直接析构
    }
    reference = nullptr;
}

和 std::shared_ptr 的控制块方案不同,这是侵入式计数——计数器存在对象自己身上,所以 Ref<T> 只有一个指针大小,赋值就是 reference() 加一、析构 unref() 减一。代价是 RefCounted 对象不能离开 Ref 单独裸奔:你如果 memnew 一个 RefCounted 派生对象又不包进 Ref,init_ref() 没被调用,后面谁 unreference() 谁炸。

侵入式和非侵入式的差别不只是省一个控制块。shared_ptr 的控制块是独立堆分配:每次 make_shared 以外的构造多一次堆分配,引用计数加减访问的是另一块内存,缓存行不在一起。侵入式把计数器放进对象头部,加减引用摸到的是对象自己的缓存行——资源热路径上(材质、纹理每帧被渲染器引用无数次)这个差别会积少成多。还有一条常被忽视的优势:对象析构时内存一次释放。shared_ptr 的对象和控制块可能在不同时刻释放(weak_ptr 还活着时控制块不能释放),Godot 的模型里计数归零即 memdelete,一块内存一次性归还,没有控制块残留。

代价同样明确:shared_ptr 可以从裸指针反向构造控制块(enable_shared_from_this),侵入式不行——计数器出生时就长在对象上,不存在"先裸奔后收编"的玩法。这就是 init_ref() 那个约束的本质:引用计数从构造那一刻起就必须有效,晚了就永远晚了。

还有一个细节常被忽略:reference() 的注释写着 "returns false if refcount is at zero and didn't get increased"。也就是说计数归零的瞬间对象已经半死,此时再想 Ref 它是拿不到的。这个行为后面在缓存清理里很关键。

ResourceCache:存裸指针的全局表

core/io/resource.cpp 的底部,ResourceCache 的全部家当:

// core/io/resource.cpp, 4.2.2
HashMap<String, Resource *> ResourceCache::resources;  // 路径 -> 裸指针
Mutex ResourceCache::lock;

第一眼会觉得离谱:一个缓存居然存裸指针,不怕悬垂吗?看 Resource::~Resource() 就懂了:

Resource::~Resource() {
    if (!path_cache.is_empty()) {
        ResourceCache::lock.lock();
        ResourceCache::resources.erase(path_cache);
        ResourceCache::lock.unlock();
    }
}

资源析构时自己从表里摘掉。缓存表不持有资源,只是登记——生命周期完全由 Ref 计数决定,缓存只在查找时短暂借用。

想清楚这一点,"为什么不用 Ref<Resource> 存表"这个问题就有答案了:如果缓存表自己持有 Ref,资源就永远活著——计数至少为 1,析构永远不发生,erase 也就永远不触发。表会变成泄漏源。正确的模型是所有权在调用方,缓存只做索引:这和 std::weak_ptr 的语义等价,但 weak_ptr 需要控制块提供的"对象已销毁"标志位,侵入式计数没有这个外部观察者位置,所以 Godot 用"析构函数主动注销"替代"弱引用查询时发现失效"。两种方案互为镜像:一个把失效检测放在查询时,一个放在析构时。析构时注销的前提是所有者唯一且析构点确定——引用计数恰好保证这一点。

注册发生在 Resource::set_path():

void Resource::set_path(const String &p_path, bool p_take_over) {
    ResourceCache::lock.lock();
    if (!path_cache.is_empty()) {
        ResourceCache::resources.erase(path_cache);
    }
    path_cache = p_path;
    if (!path_cache.is_empty()) {
        ResourceCache::resources[path_cache] = this;
    }
    ResourceCache::lock.unlock();
    _resource_path_changed();
}

查找的一侧 ResourceCache::get_ref() 有一段防御逻辑,正好用上前面 reference() 的语义:

Ref<Resource> ResourceCache::get_ref(const String &p_path) {
    Ref<Resource> ref;
    lock.lock();
    Resource **res = resources.getptr(p_path);
    if (res) {
        ref = Ref<Resource>(*res);   // 试图 +1
    }
    if (res && !ref.is_valid()) {
        // 这个资源正在被删除(计数已归零、还没析构),当作不存在
        (*res)->path_cache = String();
        resources.erase(p_path);
        res = nullptr;
    }
    lock.unlock();
    return ref;
}

多线程加载场景下,资源 A 的最后一个 Ref 刚释放、析构函数还没跑到 erase,另一个线程就来查表——这段代码就是堵这个窗口的。读到这里的注释 "This resource is in the process of being deleted, ignore its existence" 时我停下来想了半分钟,这是整个缓存里唯一的竞态点。

这个竞态窗口值得精确描述,因为它是理解整套设计的钥匙。计数归零到析构函数执行 erase 之间有一段非零的时间缝隙:unreference() 返回 true → memdelete → 析构函数体开始跑 → 跑到 erase。缝隙期间缓存表里的条目指向一个逻辑上已死、物理上还在的对象。此时查表方拿到裸指针调 reference() 会怎样?计数从 0 加回 1,对象"复活"——但析构函数马上要在另一个线程跑完,复活的对象随即被析构,彻彻底底的 use-after-free。reference() 在计数为 0 时直接返回 false,就是从语义上封死复活路径:计数归零是不可逆的单向门,进了这扇门就是死人,查表方看到的是 ref.is_valid() == false,顺手把条目清掉。一个 bool 返回值,把"复活竞态"整个类别消灭了。

ResourceLoader:去重发生在加载之前

load("res://a.png") 第二次调用为什么不读盘?答案在 ResourceLoader::_load_start()(core/io/resource_loader.cpp),删减后的关键路径:

Ref<ResourceLoader::LoadToken> ResourceLoader::_load_start(...) {
    String local_path = _validate_local_path(p_path);   // uid:// 也在这里解析

    // 已在加载中的同路径任务,直接复用 token
    if (thread_load_tasks.has(local_path)) {
        load_token = Ref<LoadToken>(thread_load_tasks[local_path].load_token);
        if (load_token.is_valid() && p_cache_mode != ResourceFormatLoader::CACHE_MODE_IGNORE) {
            return load_token;
        }
    }

    ThreadLoadTask load_task;
    // ...
    if (p_cache_mode != ResourceFormatLoader::CACHE_MODE_IGNORE) {
        Ref<Resource> existing = ResourceCache::get_ref(local_path);
        if (existing.is_valid()) {
            load_task.resource = existing;          // 命中缓存,直接返回
            load_task.status = THREAD_LOAD_LOADED;
            thread_load_tasks[local_path] = load_task;
            return load_token;
        }
    }
    // 没命中才真正起加载任务
}

三层去重一目了然:先是 thread_load_tasks(正在加载中的同路径任务直接复用),再是 ResourceCache(加载过的直接返回同一对象),都 miss 才走 _load() → 遍历 loader[] 数组找第一个 recognize_path() 匹配的 ResourceFormatLoader 去读盘。同步 load() 和 load_threaded_request() 殊途同归,都汇到 _load_start,只是线程模式参数不同。

三层去重的顺序是按"发现成本"排的:查 thread_load_tasks 是一次哈希查找,查 ResourceCache 也是一次哈希查找(但要抢一把全局锁),读盘则是毫秒级 I/O 加解析。去重逻辑的全部意义在于让最贵的操作只在"确认没有任何便宜路径"之后发生。注意第二层和第三层之间没有独立的锁窗口——thread_load_tasks 本身也受锁保护,两张表的查询在同一临界区内完成,所以"任务表里没有、缓存表里有"这类组合状态不会出现不一致。

CACHE_MODE_IGNORE 就是跳过这两个查表,强制重新读盘——reload_from_file() 用的就是它。所以"load 同一个路径返回同一对象"不是魔法,是默认 CACHE_MODE_REUSE 下的查表命中;而同一个路径如果两次都用 CACHE_MODE_IGNORE 加载,你会得到两份互不相干的资源,改一个不影响另一个,这个坑在写热重载工具时很容易踩。

整条生命周期可以画成一张状态图:

一份资源的生命周期:状态与迁移条件(4.2.2) 未加载 加载中 thread_load_tasks 登记 已加载(缓存命中态) ResourceCache 登记 + Ref 计数 ≥ 1 半死窗口(竞态点) 计数归零,析构未 erase;reference() 返回 false 已释放 memdelete 完成 load 未命中 set_path 注册 最后一个 Ref 释放 析构函数 erase 自己 → memdelete 已加载态被 load 再次命中:计数 +1,不迁移 CACHE_MODE_IGNORE = 跳过两张表,从「未加载」重新走一遍,产出第二份独立资源 退出时表非空 → ResourceCache::clear() 打印 "Resources still in use at exit"(泄漏清单)

线程化加载:状态机与令牌

load_threaded_request() 走的是同一套 _load_start,只是 p_thread_mode 不同:任务被丢进 WorkerThreadPool,路径登记进 thread_load_tasks,同时发一个 LoadToken 给用户侧。load_threaded_get_status() 查的就是任务状态机(THREAD_LOAD_IN_PROGRESS/LOADED/FAILED),load_threaded_get() 收尾并释放令牌。

resource_loader.cpp 里有一段值得知道的死锁自救:_load_complete_inner() 发现自己要等的任务卡在 WorkerThreadPool 更深一层的调用栈里时(ERR_BUSY),会用 CACHE_MODE_IGNORE 在当前线程重新发起一次加载,绕开互相等待。注释里写得很坦白:"Attempting to avoid deadlock by re-issuing the load now"。代价是这份资源可能被加载两次——线程加载不是零成本的银弹,依赖链复杂的资源(场景引用场景)最好控制 use_sub_threads 的颗粒度。

死锁自救的存在揭示了线程化加载的真实风险模型:场景 A 依赖场景 B,两个加载任务在 WorkerThreadPool 的不同线程上,A 的工作线程同步等待 B 完成,而 B 排在队列里等着 A 让出工作线程——循环等待。引擎的对策是检测到这个局面后放弃复用、当场重载,用一次重复加载换不死锁。这是工程上很典型的取舍:正确性(不卡死)优先于效率(不重复),而重复加载之所以可接受,恰恰因为 CACHE_MODE_IGNORE 这条逃生通道早就存在。

串起来的完整生命周期

一份纹理资源的典型一生:

  1. 首次 load("res://tex.png"):_load_start 双查表 miss → ImageTexture 的 FormatLoader 读盘构造对象 → 加载完成时调 set_path() 把自己登记进 ResourceCache → Ref 计数 1;
  2. 各场景引用它:每次 load 走缓存命中,计数 N;
  3. 场景逐个释放:计数递减,但即使减到 0 之前,任何地方的 load 都还能命中同一份;
  4. 最后一个 Ref 释放:unreference() 归零 → memdelete → 析构函数从 ResourceCache 摘除自己。

退出引擎时如果缓存表非空,ResourceCache::clear() 会打一条 "Resources still in use at exit"——用 --verbose 跑能看到具体是哪些资源、什么类型,排查泄漏时这条日志是第一手线索。

两个收尾的实用结论:

  • 资源的共享粒度是"路径"。 想让两个实例各持有一份材质,要么 duplicate(),要么加载时 CACHE_MODE_IGNORE,要么勾 local_to_scene(那是另一套 duplicate_for_local_scene 机制,大纲里后面会单写)。
  • ResourceCache 不是 LRU,没有淘汰策略。 资源只跟着引用计数走,不用了就真的释放。要做"最近最少用"的贴图池,得自己在上面包一层。

本文代码引自 Godot 4.2.2-stable 的 core/object/ref_counted.h、core/io/resource.cpp、core/io/resource_loader.cpp,均有删减。