技术博客

RefCounted 引用计数与 memnew/memdelete:引擎为什么不用 shared_ptr

第 6 / 149 篇 · 2025-08-28 · godot · 内存管理源码分析引擎架构

C++ 标准库给了 std::shared_ptr,几乎所有现代 C++ 项目都会用它管理共享对象。Godot 没有——引擎里看不到一处 shared_ptr,取而代之的是 RefCounted 基类加 Ref<T> 智能指针,内存分配则是 memnew/memdelete 宏包着 malloc/free。这不是老代码的历史遗留(Godot 4 是 2022 年重写的),而是几个明确的设计决策。看懂这套选择,才能理解资源缓存、跨线程引用、编辑器热重载这些上层机制为什么是现在这个样子。

RefCounted:侵入式引用计数

core/object/ref_counted.h 里的核心结构:

class RefCounted : public Object {
    uint32_t refcount = 0;          // 强引用计数
    uint32_t refcount_init = 0;     // 初始化计数(防野指针)
    // ...
public:
    bool reference();               // 计数 +1,返回是否成功
    bool unreference();             // 计数 -1,到 0 时 delete this
    bool init_ref();                // 第一次引用时调用
    int reference_get_count() const;
};

template <typename T>
class Ref {
    T *reference = nullptr;
    // 构造/拷贝/赋值时调 reference(),析构时调 unreference()
};

和 shared_ptr 的非侵入式(计数器在控制块里,和对象分开分配)不同,RefCounted 是侵入式的:计数器直接在对象体内。这个选择带来三个直接后果。

内存局部性更好。 每次 unreference() 要读写计数器,侵入式意味着计数器和对象在同一次缓存行里(对象头 16 字节内),非侵入式的控制块是另一次堆分配,访问计数器要跳一次指针。对高频引用操作(资源每次被节点使用都 +1/-1),这个差异在 profiler 里可见。

对象必须继承 RefCounted 才能被 Ref 管理。 不能拿任意类型套 Ref<T>——T 必须继承 RefCounted。这是限制也是保证:资源类的继承关系在编译期就锁死了,不会出现"用 Ref 管理一个栈对象"这种 UB。

没有 weak_ptr 的等价物。 Godot 的弱引用靠 ObjectID + ObjectDB 查询(上一篇讲的 Variant::ObjData),不是控制块里的弱计数。这意味着 RefCounted 对象被释放后内存立刻回收,没有 shared_ptr 那种"弱引用阻止控制块释放"的延迟。代价是弱引用检查是一次哈希查找,比 weak_ptr 的 atomic load 贵。

memnew/memdelete:不只是语法糖

core/os/memory.h 里这组宏的定义:

#define memnew(m_class) _post_initialize(new (Memory::alloc_static(sizeof(m_class))) m_class)
#define memdelete(m_ptr) { if (m_ptr) { (m_ptr)->~m_class(); Memory::free_static(m_ptr); } }

值得注意的两层。第一层是 Memory::alloc_static:Godot 不用全局 operator new,而是走自己的内存统计包装(core/os/memory.cpp)。debug 构建下它会记录每次分配的文件和行号,退出时 Memory::cleanup() 报告泄漏;release 构建下这层被编译掉,直接退化成 malloc。这就是为什么 Godot 的内存泄漏报告能精确到"哪个文件哪一行 new 出来的",而 shared_ptr 项目只能靠 valgrind。

第二层是 _post_initialize:对 Object 派生类,它在 new 表达式求值后、返回指针前插一个钩子,调用 _initialize() 虚函数(core/object/object.h)。这个钩子的作用是让 Object 的构造函数里能安全地用 this——C++ 里基类构造函数中调虚函数会调到基类版本,Godot 用 post_initialize 把"对象完全构造好之后的初始化"延迟到构造函数链走完。这个机制和 Unreal 的 PostInitProperties 是同一种问题的不同解法。

为什么不用 make_shared 的单一分配优化(对象+控制块一次分配)?因为 RefCounted 的计数器就在对象体内,没有控制块可合并。memnew 的一次分配同时完成了"对象内存"和"引用计数内存"——这正是侵入式计数的红利。

为什么不用 shared_ptr:四个具体原因

把决策摊开,不用 shared_ptr 的理由是这四条,每一条都对应一个 shared_ptr 解决不了的问题:

  1. 要和 Object 体系打通。 RefCounted 继承 Object,意味着它自动获得 ClassDB 反射、信号、脚本绑定。shared_ptr<Resource> 做不到"资源被脚本持有时也能收到通知"。资源热重载的实现(编辑器里改纹理,所有引用它的材质实时刷新)依赖 Object 的通知系统,不是计数器能给的。

  2. 跨线程引用必须可控。 Godot 的 server 层(渲染/物理)在独立线程跑,资源引用可能在主线程和渲染线程之间传递。RefCounted 的计数操作是普通非原子操作,跨线程安全由调用方保证(server 层有自己的锁)。shared_ptr 的原子计数在这里反而是负担——server 层的引用传递频率高到原子操作的开销不可忽略,而单线程场景下原子性纯属浪费。

  3. ABI 边界友好。 GDExtension 传对象时传的是裸指针加 ObjectID,不传递任何 C++ 标准库类型。shared_ptr 的控制块布局是实现相关的(libstdc++ 和 libc++ 不兼容),跨动态库传递 shared_ptr 是 ABI 地雷。侵入式计数没有这个问题——计数器在对象体内,对象内存由引擎统一分配。

  4. 循环引用的处理方式不同。 shared_ptr 项目要手动打破循环(weak_ptr),Godot 的做法是资源树天然无环——Resource 不持有子资源的所有权(用 RID 或路径引用),Node 不继承 RefCounted(用 tree 结构管理生命周期)。真正会循环的脚本对象(GDScript 的 RefCounted 实例)在解释器层有弱引用语法。设计上把"防循环"的责任分散到各子系统,而不是在智能指针层统一解决。

Ref 的拷贝开销与移动语义

Ref<T> 的拷贝构造函数:

Ref(const Ref &p_from) {
    ref(p_from);  // 调 reference(),计数 +1
}
void ref(const Ref &p_from) {
    if (p_from.reference == reference) return;
    unref();                      // 旧对象计数 -1
    reference = p_from.reference;
    if (reference) reference->reference();  // 新对象计数 +1
}

每次拷贝是两次计数操作(一减一加),都是普通整数自增,没有原子指令。这和 shared_ptr 的拷贝(一次 atomic increment)对比:单线程场景 Ref 更快(无锁前缀指令),多线程共享场景 shared_ptr 正确而 Ref 是 UB(引擎里靠"server 层不跨线程传 Ref"的约定规避)。

C++11 移动语义的支持是 4.x 才补上的(Ref(Ref &&) 和 operator=(Ref &&)),移动时直接偷指针不清计数,省掉两次整数操作。这个优化对"函数返回 Ref"的场景有效——返回值不再触发计数增减。

内存布局对比

RefCounted(侵入式)vs shared_ptr(非侵入式)的内存布局 Godot: Ref<T> + RefCounted 对象体(含 4B 引用计数) 一次堆分配,计数器在对象头 标准库: shared_ptr<T> 对象体 不含计数器 控制块 强+弱原子计数 make_shared 可合并 关键差异的实际影响 缓存行:侵入式计数器与对象同缓存行,高频 ref/unref 少一次内存跳 ABI:RefCounted 传裸指针 + ObjectID,无标准库类型跨库边界 线程模型:计数操作非原子,跨线程安全由 server 层锁保证,单线程无原子开销 泄漏检测:memnew 记录文件/行号,退出时精确报告,shared_ptr 无此能力 弱引用:ObjectID + ObjectDB 哈希查询替代 weak_ptr,内存立即回收但检查更贵 代价:T 必须继承 RefCounted,不能管理任意类型;循环引用靠设计规避而非工具检测

实战:什么时候该用 Ref、什么时候不用

从源码用法能总结出引擎自己的纪律:

  • 资源一律用 Ref:Texture、Mesh、Shader、Animation——任何继承 Resource 的类,生命周期由 Ref 管;
  • Node 不用 Ref:继承 Object 不继承 RefCounted,生命周期由场景树管(add_child 转交所有权,queue_free 延迟释放);
  • server 层内部用裸指针:RenderingServer 的实现里到处是 RID_Owner 管理的裸指针,因为 RID 本身就是句柄,不需要 C++ 层的所有权语义;
  • 临时计算用栈对象:Vector3、Transform3D 这种值类型从不堆分配,更不进 Ref。

最容易犯的错是把 Node 包进 Ref——Ref<Node> 编译能过(Node 继承 Object),但 Node 的析构是 SceneTree 管的,Ref 析构时调的 unreference() 对 Node 是 no-op(Node 的 refcount 字段不被 SceneTree 维护),结果就是以为有引用计数保护、实际没有。反过来,把 Resource 当栈对象用(Texture tex;)编译报错,因为 Resource 的构造函数受保护,强制你走 Ref<Texture>(memnew(Texture)) 的堆分配路径。

这套纪律不是文档写的,是类型系统强制的:编译器通过继承关系和访问权限把"该怎么管"编码进了类型里。这是 Godot 内存管理设计里最值得借鉴的一点——不靠约定靠类型。

init_ref:解决"刚 new 出来就被赋给 Ref"的竞态

RefCounted 还有一个容易被忽略的接口:init_ref()。它存在的原因是下面这个经典场景:

Ref<Texture> tex = memnew(Texture);  // 看似原子,实则两步

memnew(Texture) 返回裸指针,赋值给 Ref<Texture> 时才调 reference() 计数 +1。如果这两步之间(理论上)有其他代码拿到这个对象并调 unreference(),计数会从 0 减到 -1,行为未定义。init_ref() 的语义是"第一次引用":对象创建时 refcount_init 为 1(不是 refcount),init_ref() 检查"还在初始状态"才把 refcount 置 1 并清 refcount_init。引擎里 Ref<T> 从裸指针构造时走的是 ref_pointer(),内部先尝试 init_ref(),失败(说明已被引用过)再走普通 reference()。

GDScript 侧也有对应物:脚本里 var r = Resource.new() 返回的 Variant 走的是 Variant::_ObjData 加 init_ref 的组合,保证"对象创建到脚本变量接住"之间没有计数空洞。这就是为什么 Godot 的对象创建返回的是"已经持有一次引用"的指针,而不是 shared_ptr 那种"裸指针出来、自己负责包"的模型。

循环引用在 GDScript 里的实际表现

理论部分说"资源树天然无环",但 GDScript 的 RefCounted 对象可以随便互相引用,环是真实存在的:

class A extends RefCounted:
    var b: B
class B extends RefCounted:
    var a: A

var a = A.new()
var b = B.new()
a.b = b
b.a = a
# 函数返回后 a、b 都泄漏——计数各为 1,永远不会归零

Godot 的对策不是环检测(开销太大),而是 WeakRef:var weak = weakref(a) 创建的弱引用不参与计数,weak.get_ref() 在对象存活时返回强引用,死亡后返回 null。这和 Objective-C 的 weak、Rust 的 std::rc::Weak 是同一个工具。纪律是:双向引用关系里,"被拥有"的一侧必须存 WeakRef。引擎自己的代码里这种模式出现在信号连接(CONNECT_DEFERRED 的回调持有弱引用)和缓存(ResourceCache 不持有强引用,只负责索引)。

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