技术博客

ObjectDB 与对象泄漏检测:退出时那堆 "ObjectDB instances leaked" 怎么读

第 18 / 149 篇 · 2026-05-25 · godot · 引擎架构内存管理调试

用编辑器跑游戏、关掉窗口,控制台经常喷出一段:

WARNING: ObjectDB instances leaked at exit (run with --verbose for details).
     at: cleanup (core/object/object.cpp:2209)

加 --verbose 再跑一遍,会得到一份清单:Object type: Node, path: /root/...,每条一个没被释放的对象。这段输出是引擎在退出前最后一次盘点:ObjectDB 里还有登记的对象,说明有人 memnew 了没 memdelete。这篇讲 ObjectDB 是什么、泄漏报告怎么读、和"Orphaned Nodes"提示的分工。源码引自 4.2.2-stable 的 core/object/object.cpp。

ObjectDB:全局对象户籍

引擎里每个 Object 子类实例出生和死亡时都要向 ObjectDB 登记(core/object/object.cpp):

// Object 构造时
Object::Object() {
    instance_id = ObjectDB::add_instance(this);  // 分配 ID,登记指针
}

// 析构时
Object::~Object() {
    ObjectDB::remove_instance(this);  // 注销
}

ObjectDB 内部是两张表:ObjectID → Object* 的正查表,和 Object* → ObjectID 的反查表。它的本职不是查泄漏,是支撑两个运行时机制:

  1. ObjectID 寻址:Variant 持对象引用存的是 ID(前面 Variant 篇讲过),ObjectDB::get_instance(id) 把 ID 换回指针,换不到说明对象已死;
  2. InstanceID 序列化:场景保存、RPC、undo 栈里对象都以 ID 引用,重启或回放时靠表解析。

泄漏检测是顺手的副产品:进程退出、Main::cleanup() 收尾时,表里还有条目就是泄漏。ObjectDB::cleanup() 遍历剩余实例,逐个打印类型和描述(--verbose 时),然后全部强删。

ObjectID 的分配细节

ObjectDB::add_instance 返回的 ObjectID 是 64 位无符号整数,内部实现是单调递增计数器(instance_counter),不回收到池。两个推论:

  • ID 不回用:64 位空间下不用担心绕回,"旧 ID 撞上复用内存的新对象"这类 ABA 问题在 ID 维度不存在——Variant 校验失效对象靠的是"表里查不到"而不是"ID 不匹配";
  • ID 的大小序 ≈ 出生序:调试时两个对象的 ID 差可以粗判创建间隔,泄漏报告里连续的 ID 段往往对应同一次批量创建(比如一次实例化)。

表本身是 HashMap<ObjectID, Object *> 加一把读写锁——多线程下 Object::Object() 和 ~Object() 都要过这把锁。高并发创建销毁对象(worker 线程池里批量 new Node)时这里是一个真实的竞争点,引擎的纪律是对象生灭尽量留在主线程,worker 只做纯数据。

这个纪律有个推论:worker 线程里连"临时 new 一个 Object 子类做计算"都应该避免。不是会崩(表操作是线程安全的),是这把全局锁会把 N 个 worker 的对象创建串行化,规模上去后 worker 池的并行收益被锁吃掉一大块。纯数据计算用 POD 结构体或 Packed*Array,它们不过 ObjectDB。

PREDELETE 通知:析构前的最后一站

Object 析构时会先广播 NOTIFICATION_PREDELETE 再真正回收。脚本侧的 _notification 里捕获它是"对象即将消失"的可靠钩子——比析构函数(GDScript 没有)更早也更可控。典型用途:

func _notification(what: int) -> void:
    if what == NOTIFICATION_PREDELETE:
        _cleanup_external_handle()   # 关文件句柄、注销第三方 SDK 资源

对泄漏排查的意义:在疑似泄漏的对象类型上挂 PREDELETE 断点,正常退出时它没被触发,就坐实了"根本没走到析构"。再配合 ObjectDB 报告里的 ID,可以在 ObjectDB::add_instance 上设条件断点(ID 匹配时断),反向找到创建点。

报告怎么读

verbose 模式下的输出长这样:

Leaked instance: Node:12345 - Node name: EnemySpawner - Node path: /root/Game/EnemySpawner
Leaked instance: Resource:12399 - Resource path: res://weapons/gun.tres

每条三个关键信息:类型、实例 ID、人类可读定位(Node 给场景路径,Resource 给资源路径)。排查路径:

  • Node 类泄漏:路径指明了挂在哪。最常见原因是代码里 add_child() 了但退出路径上没走 queue_free()——注意 queue_free() 只是把节点标记为"本帧末删除",如果主循环在删除前就被 break 掉(比如直接 OS::exit 或崩溃路径),节点就留在表里。正常 SceneTree::quit() 会清根节点,所以这类泄漏多半是"退出流程被抄了近路"。
  • Resource 类泄漏:RefCounted 的资源泄漏几乎等于循环引用。A 持 B 的 Ref,B 持 A 的 Ref,计数永远归不了零。经典现场:自定义 Resource 互相引用,或 Resource 里存了 Node(Node 不是 RefCounted,Resource 的析构不会释放它)。
  • 裸 Object 泄漏(非 RefCounted 非 Node):就是 memnew 了没配对 memdelete,翻 git blame 找创建点。

一个容易误读的点:报告里的对象是退出瞬间仍存活的,不等于"程序运行期间一直泄漏"。有些对象设计上就活到最后一刻(全局单例、引擎自己的 static 对象),引擎已知的良性残留会被明确豁免(ObjectDB::add_instance 前的白名单逻辑),所以看到少量残留条目先查它是不是引擎内部对象,别急着改自己的代码。

与 Orphaned Nodes 的分工

运行中(编辑器里跑游戏时)另一套提示是 "Orphaned Nodes":调试器的 Monitors 面板有 Object > Orphan Node Count,退出时也有 print_orphan_nodes() 输出。

分工是:

  • Orphaned Nodes 检测的是"脱离了场景树但还活着的 Node"。节点 remove_child() 之后没 free,就成了孤儿——它不再被树管理(不会被 _process,不会被自动释放),但内存还在。这是运行中就能观测的泄漏,编辑器调试面板实时可见。
  • ObjectDB 泄漏报告是退出时的总账,覆盖所有 Object 子类,包括不挂在树上的 Resource 和裸对象。

排查顺序建议:先看 Orphaned Nodes(运行时可观测、定位到场景路径),再看退出报告(处理 Resource 循环引用这类树外问题)。GDScript 侧对应工具是 Node.print_orphan_nodes() 和 Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)。一个实用的运行时监控写法:

func _process(_delta: float) -> void:
    if OS.is_debug_build():
        var orphans := Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
        if orphans > 0:
            push_warning("orphan nodes: %d" % orphans)

编辑器里更直接:Debugger 面板的 Monitors 页签有 orphan node 曲线,曲线上跳的时刻对照游戏内操作,基本能锁定泄漏的操作路径。再在怀疑的代码段前后各插一次 print_orphan_nodes(),两次输出的差集就是漏网之鱼。

机制示意

ObjectDB 的登记与退出盘点 memnew / Ref 创建 Object 构造函数 ObjectDB::add_instance 分配 ObjectID,双向登记 运行期间 get_instance(id) 寻址 memdelete / 计数归零 析构时 remove_instance 退出时 Main::cleanup() → ObjectDB::cleanup() 表里还有条目 → 打印 "ObjectDB instances leaked at exit" --verbose 下列出每条的类型 / ID / 路径,然后强制删除 Node 泄漏 → 查退出路径没走 queue_free Resource 泄漏 → 查 Ref 循环引用;裸 Object 泄漏 → 查 memnew/memdelete 配对

退出报告不是唯一的账

ObjectDB 的盘点发生在 Main::cleanup() 里服务器关闭之前——这意味着渲染、物理服务器持有的 RID 对象不在 ObjectDB 的账上(RID 有自己的分配器审计)。如果退出日志干净但显存/内存仍在涨,嫌疑方向要换到 RID 侧:RenderingServer.free_rid() 漏调是独立的一类泄漏,检测手段是 RenderingServer.get_rendering_info() 观察对象计数曲线,而不是 ObjectDB。

实战推论

  • 退出报告要在干净退出路径上读。调试器里强杀进程(stop 按钮)打出的泄漏清单全是误报——主循环没走完,queue_free 队列里的节点都算"泄漏"。要可信的报告,让游戏走正常 get_tree().quit()。
  • 循环引用的拆法:Resource 互持的场景,把其中一边改成弱引用(4.x 有 WeakRef,weakref(obj) 获得,用时 get_ref() 取回,取回失败说明对方已死)或在 _notification(NOTIFICATION_PREDELETE) 里手动断开一边。
  • 自定义 C++ 模块也要遵守户籍纪律:任何 memnew 的 Object 子类,析构路径必须可达。模块里常见的泄漏源是静态持有(static MyObj *g_thing 永不释放)——要么登记为引擎单例并在 unregister 时删,要么接受退出报告里永远有它一条。
  • 泄漏检测的开销:ObjectDB 的表操作有锁,debug 构建里每次对象生灭都过两道哈希表。这也是为什么 release 模板比 release_debug 快一小截的原因之一,但不是主要矛盾,别为此绕开 ObjectDB。
  • 退出报告是礼物不是噪音。商用项目上线前值得把"干净退出 + verbose 报告为零"做成 CI 检查项:泄漏不会立刻崩游戏,但长时间运行的专用服务器上,孤儿节点的内存增长是唯一稳定单调的曲线,运维侧看到它涨就知道该重启了——把泄漏清零比写重启策略便宜。

本文代码引自 Godot 4.2.2-stable 的 core/object/object.cpp(ObjectDB 命名空间、Main::cleanup() 段)、scene/main/node.cpp(orphan 检测),均有删减。