GDScript 没有 GC,"一个对象什么时候死"全部由引擎的内存协议决定。这个协议比 C++ 的 shared_ptr 或 Python 的 GC 都简单,简单到能用一行不变式写完——但简单协议遇上 signal 连接和脚本属性,泄漏模式比想象中多。这篇先把不变式立起来,再用它推导所有踩坑案例。
一条不变式
RefCounted 的析构条件是引用计数归零即立即析构,没有延迟清扫、没有标记阶段:
$$T_{\text{析构}} = \min\{t : \mathrm{refcount}(o, t) = 0\}$$
这条不变式的推论直接决定写代码的规矩:任何让计数永远到不了零的结构都是泄漏,不管这个结构在业务逻辑里"已经没用了"。
Godot 为什么敢不用 shared_ptr:引用计数是手工维护的(ref()/unref(),封装在 Ref<T> 和脚本赋值里),引擎自己保证配对;而 shared_ptr 的 control block 分配和控制块本身的开销,对一个每帧要 new 几百个临时对象的引擎来说太贵。前面 RefCounted 那篇讲过引擎侧的约定,这里只看脚本侧的规则。
三类对象,三套生命周期
| 类型 | 生命周期由谁管 | 死后行为 |
|---|---|---|
Object(RefCounted 以外的) |
手动 free() |
不 free 就泄漏,ObjectDB 退出时报 |
RefCounted 子类(Resource、自定义类) |
引用计数归零即析构 | 自动,但怕循环引用 |
Node |
场景树所有权:父节点负责 free 子节点 | remove_child 后若不接管也不 free,泄漏 |
Node 常被误以为引用计数——它不是,Node extends Object,挂在树上由父节点拥有。这个区分是一切混淆的起点:Node 类型的变量赋值不增加任何计数,RefCounted 类型的赋值才 +1。
规则在混用时变成一句口诀:脚本里持有一个 Node 引用,这个引用是"观察者"不是"所有者";树结构才是所有者。remove_child(child) 之后如果你还想让它活着,必须 add_child 到别处或手动管理;如果没人接管,这一步就是泄漏。
循环引用的四种真实形态
形态一:两个 RefCounted 互相持有。 经典教科书案例。A 持有 B、B 持有 A,外部引用释放后两边计数都还剩 1,永远到不了零。GDScript 的 weakref() 是官方解法:弱引用不增加计数,把其中一个方向换成 weakref(b).get() 调用,环就断了。
形态二:signal 连接构成的环。 最容易漏的一种。a.some_signal.connect(b._handler) 时,引擎会对 callable 的目标对象做一次 ref()——连接是强引用。于是 a 持 b、b 又 connect 了 a 的信号时,两个对象的计数被连接本身托住,谁都死不了。我自己的排查记录里,UI 弹窗反复开关后内存只涨不落的案子,三次里有两次是这个:弹窗节点 connect 了全局管理器的信号,关掉时只 queue_free 了自己,连接没断,管理器一直托着它。修复就是 _exit_tree 里 disconnect,或者用 CONNECT_ONE_SHOT / CONNECT_REFERENCE_COUNTED 控制连接的计数语义。
形态三:闭包捕获 Node。 lambda 捕获局部变量是按值拷贝(lambda 那篇细讲),但捕获的是引用本身,不是深拷贝。lambda 被一个 RefCounted(比如一个 Resource 或存起来的 Callable)持有时,环就成型了:RefCounted → Callable → lambda → Node,而 Node 所属的子树被 free 后,如果那个 RefCounted 还活着,lambda 里就挂着一个指向已释放 Node 的引用。和形态二不同的是这里还有悬垂引用的风险。
形态四:静态变量持有。 static var registry := {} 里注册了实例,实例死了字典不清理(静态字典的生命周期是脚本对象的,长过任何实例),条目永久驻留。每局游戏注册一次,十局之后内存里躺着十份。
怎么发现
编辑器有现成工具:Debugger 的 Video Memory / Orphaned Nodes 面板看节点泄漏("orphan"特指脱离树又没被 free 的 Node);退出时的 "ObjectDB instances leaked" 日志配合 print_orphan_nodes() 打调用栈(ObjectDB 那篇的机制)。RefCounted 的环没有直接探针——计数不归零的对象进不了任何泄漏清单,它就是一个"合法存活的"对象。所以对形态一和形态四,代码评审时盯住"双向持有"和"静态集合"两个模式,比事后工具可靠。
收拢一句:GDScript 的内存规则就是那条不变式加"Node 归树管"的补丁。所有泄漏都是环,所有环都能画成一张对象间的持有关系图——画不出来的泄漏,多半是你以为不持有的地方(signal 连接、静态变量、闭包)在偷偷持。