Contents

最早期实践-树形托管

在最早期的引擎基础库设计版本中,直接使用了new来实例化对象,通过保存裸指针来引用,为了不用手动的删除对象,采用了类似QT的父对象托管方法,整体对象结构呈树型,在全局处会有一个Root对象作为树根,当删除树中某一节点时,该节点下的所有对象都将会被删除。

这种树形的托管结构会增加开发者的内存管理心理负担,这个结构经过实践发现只适用于一部分场景,比如反序列化。反序列化需要从数据中新建对象,使用后只要销毁根节点还可以自动全部销毁。

引用问题

当销毁一个节点时该节点下的对象销毁的顺序问题,假如有个A节点下面有B和C两个子节点,B与C之间相互引用,此时A节点销毁,那么在先销毁B还是先销毁C上就是一个问题。所以这里最好的办法就是通过引入自定义生命周期的虚函数来当做销毁事件,在规范上禁止在虚构函数中使用引用的其他对象。

树下的节点与其他树下的节点之间相互的引用问题。A树下的某一节点引用B树下的某一节点时,当B树销毁,那么一定要有一种方法让A树下的引用知道,否则就会发生悬垂。可以通过封装模板以及订阅销毁事件等方式来达到,但是这就要求着开发者能较为清楚的知道当前类型的实例所在生命周期树中的位置。

共享问题

博主认为最经典的一个共享需求就是数据类的对象,比如A反序列化了一个数据挂在了自己节点下,其他对象也都可以访问该数据,当A销毁时数据也会跟随销毁,但其他对象可能并不希望该数据被销毁,那这里我就想了两种办法。一是在直接拷贝一份数据挂到自己节点下,但这样不容易同步更新,另一种是当收到目标节点的销毁事件时,将那个节点挪到自己的节点下来代为托管,而如果此时有多处引用都收到了销毁事件,那么仅转移到第一个请求转移的节点。

但如果仅使用以上解决方案又会引发其他问题,例如一个父物体希望完全拥有该对象生命周期的控制权,但又希望在生命周期内可以被其他对象引用和访问。那这里就要多新增一个限制条件来区分出可转移的共享节点和不可转移的完全控制节点。

以上的问题主要在于可能会出现大量的销毁事件订阅。另外就是对于反序列化这种返回新对象的生命周期无法自动销毁,因为子节点的销毁是依赖父节点的销毁,当一个对象没有用户定义的父节点时那默认就是Root为父节点,而我们总要去手动的销毁父节点。

树形托管与引用计数优化

移除了没有根默认将Root节点作为根的功能,因为任意一个计数变量都可以在没有引用时自动销毁,解决了总要手动销毁一个父节点的问题,即使是返回的新对象不去处理也可以在作用域结束时判断计数和销毁。Root节点只是移除了没有根自动挂过去的功能,但自身还是存在,用户可以手动将对象挂在Root节点上,来保证计数为1。

并且通过引用计数与弱引用可以很方便的解决裸指针所存在的共享问题。当父对象想创建一个完全由自己控制生命周期的成员并允许其他对象引用时,可以通过一个对象仅允许在父对象处以计数储存的方法,在父对象所控制该对象的生命周期内计数为1,之外为0。其他所有对象都通过弱引用的方式来储存,当需要使用的时候在去临时拿到原指针,如果已经销毁也不会造成悬垂非法访问。

第二就是不用再去考虑允许可转移控制权的对象,父对象只需要让其他对象可以保存计数变量即可。

另外,计数仅允许为1或者0的做法像极了unique_ptr,但标准中并没有unique_ptr的弱引用,所以这里依旧使用了共享计数。如果项目不使用这部分标准库的话,也可以考虑自己去实现。

树的局部扁平管理

在一些不符合树结构的地方,如一个Graph(图)的数据结构,两个结点之间相互连接,无法分辨出根和父子关系的情况下,可以将这些节点全部放在同一层级,这些节点都使用弱引用来相互引用,而所有节点都拥有一个共同的父节点,在这个父节点上拥有一切子节点的控制权,和上面说的一样,计数仅可以为1或者0。

引擎实现

引擎中混用了多种生命周期管理方案,在不同适合的方向使用着。虽然抛弃了树形结构,但以上的一些设计思路还保留着。

裸指针或unique_ptr

引擎内非常的少使用裸指针或unique,而使用的地方也都是作为完全自己控制的成员,并且这个成员对象生命周期通常和自身是一致的。

引用计数

因为引用计数有着自动销毁的方便特性,所以我的整个类型与反射系统都使用了共享引用计数,所有类型都是创建在堆上并且用shared_ptr来引用,所有对象都基于Object,一些基础值类型int等做成像java的包装类Integer一样,Integer也继承Object,当int转换为Integer这一步被称为装箱,Integer转为int称为拆箱,而拆箱后shared_ptr<Integer>计数为0将会自动销毁。

在我的系统中的全部类型都使用了引用计数,并且是强制的,因为这涉及到类型和反射系统的应用。

在工程内大部分用于序列化只储存数据的类型运行起来都没有什么问题。但很快就遇到了引用计数常见问题:循环引用。循环引用出现的内存泄漏难以防范。并且由于反射系统不支持弱引用,像资产这类对象还需要有强制卸载的功能,如果使用共享计数就无法强制卸载。

EngineObject继承自Object,在引擎中每一个EngineObject都拥有一个guid,这个guid就可以看做是这个对象的软引用,经过模板ObjectPtr<T>封装和对->运算符的重载,可以在运行时动态的查找目标对象,如果为空也不会悬垂,可能只是目标已经被卸载或者还没有被加载进内存,这样也很容易的使用guid查到还未加载到内存的资产然后进行加载。卸载也是通过DestroyObject函数手动进行卸载,因为资产、场景等内容更需要精准的加卸载逻辑,而卸载后所有软引用都将失效,重新加载后软引用都又会重新生效的特性,我认为正是我这个系统对资产和场景管理非常需要的。

该项目的基础库与引擎库是分开独立写的,基础库的Object提供了类型系统和反射功能,并强制对象为共享计数,而引擎层面为了方便寻址引用和解决循环引用问题而使用一个大哈希表来储存guid和shared_ptr<EngineObject>,像之前所有权所说,计数仅为1或0,其他任一对象引用都是使用ObjectPtr<T>这个软引用来动态访问,这样就兼容了两种内存管理方式,同时对于需要序列化的数据来说可以直接继承Object使用共享计数不需要继承EngineObject,而不需要反射对象甚至不需要继承Object并使用一般值类型或最传统的手动内存管理方式。

 

小总结:

裸指针、unique_ptr:完全控制生命周期的成员。

一般共享指针:用于在没有循环引用并且独立的系统中使用。

继承自Object:继承Object会强制要求使用共享指针来使用类型系统、反射系统、装拆箱等功能。在引擎系统内需要靠反射做序列化的数据类使用居多。

继承自EngineObject的软引用:EngineObject继承自Object,通过NewObject()与DestroyObject()来加载和卸载对象,这两个函数内部会去维护真正的共享计数对象。因为EngineObject软引用本身是个guid,所以资产、场景使用居多,可以序列化引用。