« 10 连抽保底的概率模型 | 返回首页 | 跟踪数据结构的变更 »

一个简单的 lua 对象回收再利用方案

昨天在 review 我公司一个正在开发的项目客户端代码时,发现了一些坏味道。

客户端框架创建了一个简单的对象系统,用来组织客户端用到的对象。这些对象通常是有层级关系的,顶层对象放在一个全局集里,方便遍历。通常,每帧需要更新这些对象,处理事件等等。

顶层每个对象下,还拥有一些不同类别的子对象,最终成为一个森林结构,森林里每个根对象都是一颗树。对象间有时有一些引用关系,比如,一个对象可以跟随另一个对象移动,这个跟随就不是拥有关系。

这种设计方法或模式,是非常常见的。但是在实现手法上,我闻到了一丝坏味道。

由于类别很多,所以代码中充斥着模仿静态语言如 C++/C# 般的大量构造和析构函数。我一直对同事说 Lua 不是 C++ 大部分同事也认同我的观点,但落到实处,却逃不出过去的很多经验。

比如在这次具体问题上,为什么要实现一套带构造和析构函数的类别系统呢?核心驱动力是因为大部分逻辑对象是和场景关联在一起的,并且引用了 U3D Engine 中的 C# 对象,依赖 lua 的 gc 系统去回收资源延迟太大。而往往大部分时候,我们都可以明确的知道一个对象从场景中移除,几乎没有别的地方在引用它,所以需要立刻释放资源。

而临时对象很多,设计人员又想实现一套对象的 table 再利用的方案,让释放掉的对象在可能的情况下,能重新在新创建对象时再利用起来,减少 lua gc 的负担。

为了做到这点,代码框架模仿了 C++/C# 中的常见手法,在构造函数里建立对象的层级关系(子对象有一个叫 owner 的域指向父对象),在析构函数里调用其拥有的对象的 ondestroy 函数,一级级回收。对于非拥有关系,比如前面举例的 follow ,再给出 unfollow 函数用于解除引用。

最终的结果是,每个新的类中,都有十几行雷同的代码做这些枯燥的事情,而且还偶发 bug 。bug 主要出现在一些引用关系没有解对,引用了死对象(当对象被重用时,就错误引用了新对象),或是不该释放的对象被提前释放了,等等。

我认为在代码基中出现大量雷同的、和具体业务不相关的代码,还分布在不同的源文件中,这是极坏的味道:因为它相当于制定了一套复杂的约定,让开发人员遵守,而且这些机械性的代码内聚性很低,容易出错。


让我们重新分析一下需求。

核心问题是:对象的频繁生成和释放在实际测试中已经出现了问题:过多的占用临时内存,以及引擎内资源未能及时释放。

围绕这点,设计出来的框架的味道不太好,没有充分发挥 lua 的动态特性。

而实际上,我们需要的一个工作在 lua 虚拟机中的,更小集合的对象生命期管理系统。这套系统最好是内聚性高,不要侵入真正的业务代码,它能正确的管理对象树和弱引用(类 follow)关系。

针对它,我重新设计了一套简单的类型系统。这套系统支持开发人员预定义对象类型,把引用关系描述在类型定义中,并适当的留出简单成员变量的位置。让对象在释放后,可以尽可能的复用数据结构,避免重复构造新的表,依赖 gc 回收临时表。

我大约花了 200+ 行代码来实现它

比如在 test.lua 中我定义了这样一个类型:

ts.foo {
    _ctor = function(self, a)
        self.a = a
    end,
    _dtor = function(self)
        print("delete", self)
    end,
    a = 0,
    b = true,
    c = "hello",
    f = ts.foo,
    weak_g = ts.foo,
}

这个类型名叫 foo ,它描述了 a b c 三个简单类型的成员,分别是数字、布尔量、和字符串。并定义了默认值 0 true "hello" ,它们将在构造函数之前被赋值成默认值。

其中还定义了 f 和 g 两个引用成员。f 是一个强引用,引用类型也是 foo ;g 是一个弱引用,用 weak_ 前缀修饰。

我们可以为 foo 定义出构造函数 ctor ,这个 ctor 会传入 table self ,使用者不必关系 self 从哪里来,到底是新构造的表,还是过去释放的对象的表的再利用。框架会保证在 ctor 被调用前,其成员都赋值为默认值;其中的引用变量都将被赋为 false ,可以在构造函数里进一步赋值。注:这里是 false 不是 nil ,是希望可以在 self 中保留一个 slot 。

框架不会给 self 附加 metatable ,这样对使用者最为灵活,如果需要,可以在 ctor 中加上自己需要的 metatable 。

构造 foo 类的对象可以调用 ts.foo:new(...) ,它将在框架内的 root 集内添加一个新的 foo 对象,并返回。

如果想继续构造 foo 下的 f ,可以使用 f._ref.f(...) 。这个函数调用会调用对应的构造函数,并不需要指明构造类型,这是因为类型定义中已经指明了 foo.f 的类型。

这里使用了一个比较奇怪的语法:f._ref.f ,我们应该理解成对 f.f 的引用进行改写操作。这里并没有对 foo 对象设置 metatable 来提供更漂亮的语法糖,这是因为希望把 metatable 的弹性留给使用者。而且明确写 对象._ref.字段 可以显示的提示这段代码将对 “对象.字段” 的引用进行修改。

修改引用,让其引用到一个新对象可以用 f._ref.g = f 。这会把 f 的一个弱引用赋给 f.g 。强引用也能这样写 f._ref.f = f

那么,写 f._ref.f = ts.foo:new(...) 和写 f._ref.f(...) 有什么区别呢?

前者通过调用 ts.foo:new 构造出一个 foo 对象,然后赋给了 f.f 。但是它会在 root 集内也添加一个这个新对象,当日后从 root 集移除 f 时,这个新对象依旧被 root 集引用。

而后者也是构造了一个新的 foo 对象赋给 f.f ,但它不会在 root 集添加这个对象,并且新对象中会自动生成一个 .owner 字段指向 f 。

如果想清除一个引用,可以写 f._ref.f = nil 。不过再次读 f.f 的时候,会发现值是 false 而不是 nil 。这是为了在数据结构中保持一个 slot ,也可以确保用户加的 metatable 可以正确工作。

如果只想用读取 f 下属对象,就直接写 f.f 或 f.g 即可。不过这里 g 是一个弱引用,所以通常使用前应该做一次判断 if f.g then ... end 。


从 root 集移除一个对象,可以用 ts.delete(f) 。但是这个 delete 操作绝对不会触发对象的终结函数 dtor ,它做的仅仅是把对象从 root 集中移除。

上面反复谈到了 root 集。这是个很有用的集合,比如,你可以简单理解为,它就是场景,而构造出来的对象都默认放在了场景中。

我们可以用 for obj in ts.each() do ... end 来遍历这个集合,取出所有的对象处理。也可以单独删选一种类型的对象遍历: for obj in ts.each(ts.foo) do ... end 。

当一些对象移除 root 集,或是对象树内部的引用关系改变后,你可以调用 ts.collectgarbage() 来寻找哪些对象已经不再被引用,框架会用一个 mark-sweep 算法把不再被 root 集引用的对象回收再利用。在回收前,如果对象有 dtor ,也会调用。


另外,每个对象都有一个唯一的数字 id ,可以用 obj._id 获得。及时对象被收回,id 也不会重复。所以、当你在这个系统外想引用系统内的对象时,就应该用 id 来保持一个弱引用。之后,可以通过 ts.get(id) 来转换为真正的对象。如果对象已经被回收, ts.get 会返回 nil 。

ts.type(obj) 可以获得一个对象的类型名字,如果对象不是这个系统内的对象,则返回 nil 。

Comments

在 function new_object 中使用缓存的对象, 没有对这类缓存的对象的 _mark 标志置为 not markflag, 这样会导致后续使用这类缓存的对象时, 调用 ts.delete(obj) 并且跟着第一次调用 ts.collectgarbage() 之后没有立即被收集, 而是需要再调用第二次ts.collectgarbage() 才会触发 release_obj .
文中"也可以单独删选一种类型的对象遍历: for obj in ts.each(ts.foo) do ... end 。" ——实际测试不能遍历到ts.foo构造出来的对象
@forgetme @Cloud 谈到云风写的书,我想问问云风现在的游戏里还用那些书里的一些手段吗?比如你提到的脏矩形分块技术。
@蒙面黑衣人 没贡献?我毕业后看云风哥的书长大的,给了我非常多的启示,帮助我在编程道路上成长了很多。目前做过几款游戏,有销售上千万套的,有ign评分9.5以上的。你可以不同意不喜欢他的分享但做一个无脑喷子就是没品。以你的人品能看出你的成就,我敢说你没资格评头评足。
为了体现自己的价值,非要在成熟引擎里强加一些自己的东东。刷存在感吗?要不是热更新,更本就没必要用lua,就算要用,也没必要这样重度去用呀。脚本逻辑就是逻辑应该就是些简单过程。调调引擎暴露的对象。内存管理什么的应该引擎去做。你每发现一个坑,逻辑程序员都要重改代码,惨哦
喷人很简单,先了解下lua在云风贡献下有多少游戏使用再说话,无论是server端还是客户端 lua-pbc,有多少人就是因为你这种网络鲨鱼不敢分享
@glcolor 可以看看aardio,可以说是带类的lua
@terra 写复杂逻辑用aardio比较方便,兼顾了c++和lua的优点
大哥,我大学时期的榜样就是你~~我还专门百科了你~~很佩服~~我也才刚毕业,虽然不一定能有你的成就大,毕竟你学的时间比我长~~但是也是努力啦,今天也就才认知到~~不能再玩了,要发奋努力了。祝你元宵节快乐哈~~
有人推荐我来看博主的文章,我看博主也只是个懂些吸金的奇巧淫技的工具而已,更谈不上为社会做了什么贡献了。
c++发展一日千里,一扫十年前的颓势,新特性出来后,基本上以前的python,lua的优势,都被c++具备了。c++本身的优点还继续保持。python,lua除非抢下javascript的地盘,不然,穷途末路,只能扫进历史堆里。
把软件写好从来不是件容易的事情,每种语言都有它的使用惯例,或者叫模式。 所谓设计模式,脱离了语言就什么都不是了。 不要觉得一门语言用顺手了,别的语言就自然可以上手。
越来越觉得在游戏开发中用好Lua不是一件简单的事情了,尤其是把Lua作为主力语言的时候。感觉Lua像是以前学的C语言一样,写小程序的时候简单轻松,但在代码规模变大以后,就越来越不称手,而倾向于寻找某种更高程度的抽象了,很多人喜欢在Lua中搞面向对象,估计也是遇到了相同的问题吧。
@terra: 现在开发都是糙快猛的时代,先不论稳定性美观性,先出原型,快出效果才是上策. "写好后基本上很少会改动"的思想落后了,连微软都抛弃只发SP1,SP2大版本的概念,现在win10每几天就发个build供测试. 而目前由于各种条件所限,脚本语言才更方便迭代更新.
游戏代码中有一些逻辑写好后基本上很少会改动,为啥这块代码也要用Lua写呢?感觉C++写复杂逻辑的时候比Lua用起来更方便。

Post a comment

非这个主题相关的留言请到:留言本