木骰

关于tolua与C#之间复杂的引用关系

内容比较长,总结放开头

当lua从C#获取一个对象的时候,C#会把这个对象缓存到 ObjectTranslator 中的LuaObjectPool中,从而实现C#端可以根据lua传递的 Wrap对应的 userdata 拿回对应的C#对象。并且也保证了当lua端持有 userdata 引用的时候C# 对象不会因为在C#端无其它引用而被C# GC回收掉。缓存在ObjectPool中的引用将会在 lua 端 gc 清理了 userdata 之后,由userdata的元方法 __gc 触发移除缓存,从而释放C#对象的内存。
当C#获取一个lua对象的时候, 将会在C#端建立一个LuaBaseRef对象,并调用C API 将对应的lua对象放入Lua注册表,由注册表持有引用,从而保证了在C#端持有 lua对象的时候,lua 对象不会因为在lua端无其它引用而被 lua GC回收掉。 C API创建的引用将在C# 端 GC 清理了 LuaBaseRef引用 或 手动调用Dispose() 方法之后 解除,从而释放lua 对象的内存。

容易造成内存泄漏的地方

当在Lua端绑定方法给C#委托的时候,特别是绑定给继承自MonoBehaviour的组件中的委托,开发人员容易依赖MonoBehaviour的Destroy() 方法来释放C#对象,从而忽视了 清理绑定到C#中的委托事件,导致C API创建的引用无法解除,对应的lua 方法无法释放,导致lua端一片内存无法释放,再延伸,又会导致lua端对C# 对象的引用 即 userdata也无法释放 从而导致 C#端LuaObjectPool中的缓存无法移除,进而导致C#这边的内存也无法释放。 整个一个死循环。
所以开发过程中必须注意:
lua绑定到C#的委托 在不用的时候要及时清理
C#从lua端获取的对象,不再需要的时候要及时主动调用Dispose()方法,主动解除Lua 端对象的引用
lua端缓存的一些C# 的userdata,在不需要使用的时候也及时置为nil,从而调起__gc 元方法,清理C#端ObjectPool缓存,进而释放C#内存
3个注意点做到任意一个都能打破循环,释放内存。 但是尽量都做到,可以让各种引用的释放更及时,从而让内存释放也更及时,并且也能避免疏漏,降低出错的风险
为了理清这个关系,有必要先搞清楚Lua与C#之间的对象存取以及方法调用。

先来看看Lua是怎么获取一个C#对象的。

从TransformWrap.Find开始。
Lua通过Wrap获取到obj后调了一个Push方法,跟进去看到这个Push方法最终调到一个PushUserObject方法。
这里通过一个GetMetaReference方法获取了一个reference值,这个是C#类导出Wrap文件时创建的Wrap类对应的元表引用,这里这个元表使如何创建和获取的先不去管,只要知道这个地方是拿到Wrap类对应的元表(因为元表是Lua的东西,C#里不能直接获取元表,所以这里只能拿到元表在内存中的引用Id)。
然后拿到元表之后继续到了PushUserData方法。
通过lua虚拟机的引用ID获取到一个ObjectTranslator对象,这个ObjectTranslator是跟lua虚拟机一一对应的,在起lua虚拟机的时候创建,存在LuaState中。获取到ObjectTranslator对象后调了一个AddObject方法,把我们Find到的对象o add到了Translator中并拿回一个索引值index,然后用前面拿到的元表引用Id 和 这里的索引index调了tolua_pushnewudata方法,这里再进去是C的API了,看这个方法名的意思应该是创建了一个userdata并压入lua调用栈,传递的元表reference和索引index,应该是这个userdata以index为值,并设置reference为元表,所以此处可以理解到,lua中获取到的C#对象实际都是以C#导出Wrap类为元表的userdata,所以在lua中调用C#对象的接口实际都走到了Wrap的方法里。
到此,lua端就拿到了C#对象对应的一个userdata,然后我们要再跟进上面的AddObject方法,看看C#这边对这个o做了啥。
跟着进到这个AddObject方法中,又调了objects.Add,又把obj推给了objects,这个objects是ObjectTranslator中的一个成员,LuaObjectPool实例,,显然这个obj最终由这个LuaObjectPool存起来了。继续跟进去这个Add方法。
看到这里直接把obj缓存在了一个list里,并返回了obj在list中的索引。
这里的写法有点奇怪,结合一下创建list和head的构造方法 以及 下面的Remove方法,
可以看出这个写法是为了避免反复创建PollNode,复用缓存在list中的PoolNode对象(不过这里我有些不解的是list中1的位置好像没有用上诶,为啥1的位置也留空呢?)。
总结一下这个过程:
lua从C#获取对象的时候,实际上获取的是一个userdata,这个userdata以C#导出的Wrap类为元表,以实现lua对C#方法的调用。并且这个userdata对应的值是C#这边开的list列表中实际缓存了真正的C#对象的索引值。
也就是说:Lua从C#这边获取的对象,都将被缓存到ObjectTranlator中的LuaObjectPool中的list列表里。
这是为了C#这边可以根据lua调用时传的参数拿回C#对象实例。不过这里存在一个问题:若一个MonoBehaviour对象缓存在这个Pool里,当它被Destroy之后,Mono对象的引用仍旧被缓存在Pool里,若没有及时从Pool中清除引用的话将造成GC无法回收这个Mono对象,进而导致被这个Mono对象引用的一片内存都无法得到及时释放,从而导致内存泄漏。

再看lua调用C#方法的时候C#这边是如何拿回C#对象实例的

还是Transform的Find方法。
这里根据lua调用时传递的第一个参数,也就是 self 调用 CheckObject 方法拿回调用Find方法的节点Transform对象。由上边的过程可以得出这里C#传递过来的 self 实际只是一个 userdata, 并且这个 userdata 的值 是 此处的 Transform 节点 obj 在LuaObjectPool 中缓存的索引下标。
跟进这个CheckObject方法:
又调了一个C API,从lua调用栈中拿到userdata的值,也就是前面说的 LuaObjectPool中缓存的索引值,
根据这个索引值从ObjectTranslator中拿回缓存的C#对象。

嘿 这个过程还算简单!

再看一下Wrap文件创建的元表

从TransformWrap的BeginClass开始。
跟进BeginClass方法
调用C API tolua_beginclass 看意思应该是创建了一个table,并且根据Wrap的这个类是否有父类,绑定了一个baseMetaRef元表,这里有点继承的意思,所以这个方法名也叫beginclass了。这里返回的reference就是table的引用ID了,也就是前面说的元表的引用ID。表创建好之后绑定了一个__gc元方法,指向C#这边的Collect方法,当元表也即lua端持有的userdata(前面说的Wrap类实例对应的userdata),被lua gc回收的时候就会调起这个元方法,走到Collect,前面的LuaObjectPool中缓存的C#对象引用 也将在这个Collect方法中得到释放。必须要走完Collect方法,C#这边的内存才能被回收。跟绑定元方法类似的做法,Wrap将C#类中的成员方法和成员变量绑定到元表中,供lua端调用。对成员变量的获取和设置实际上都是构建两个方法,通过转调到C#方法来实现的。
这里需要注意的一点是:从lua访问一个引用类型的成员的时候,虽然也是通过调用C# get_func方法,但是在将获取到的C#对象压回lua栈的时候,会走一遍上面lua获取C#对象的流程,构建一个userdata出来,产生Lua GC开销,并且在C#这边还会增加一个Pool缓存的增加和清除过程,所以从lua端访问一个引用类型的成员的话最好在lua端做一下缓存,避免频繁创建userdata。

C#获取Lua对象

C#从Lua获取的对象主要是lua方法和table。
获取Lua方法一般都是lua这边绑定一个方法到C#的委托,看下Button的onClick事件,Button点击事件的添加时通过onClick的addListener,onClick是ButtonClickedEvent实例,而ButtonClickEvent继承自UnityEvent,所以看下UnityEventWrap的AddListener方法。
通过CheckDelegate方法获取到Lua方法对象。继续跟进。
调用ToLuaFunction从Lua栈中获取到方法引用并创建一个对应C#的LuaFunction,其实到这里 Lua的方法就已经拿到了,被封装在这个LuaFunction中,调用LuaFunction就能调到对应的Lua方法。而下一行的DelegateTraits.Create 只是把获取到的LuaFunction再封装成泛型T对应的委托类型,也就是AddListener调过来的UnityAction,这里不去管它,主要是这个LuaFunction。跟进ToLuaFunction方法。
这里调了一个toluaL_ref 的C API, 这个接口会创建一个当前栈顶的对象的引用并返回,就是这个reference,而在上面那句lua_pushvalue接口调过之后,将Lua方法的栈索引重新压栈,也就是说创建的当前栈顶的引用,这个reference 现在是指向的是Lua方法的栈索引。之后再以这个reference调用GetFunction方法,获取LuaFunction。
跟进最终走到LuaState的GetLuaFunction方法。
若当前Lua方法没有缓存,这里就会以这个Lua方法的栈索引引用创建一个LuaFunction对象。并缓存到funcRefMap中。注意这里缓存的是LuaFunction的弱引用对象,这样LuaFunction对象就不会因为被funcRefMap持有引用而导致无法被C# GC回收,也就是说这个funcRefMap纯粹是用来做一个缓存,避免同一个LuaFunction的反复创建用,不会干扰LuaFcuntion的内存释放。那么在没有将LuaFunction赋给其他地方的情况下,这里创建的LuaFunction最终将只会被onClick对应的UnityEvent对象持有,也就是存在了Button中,当onClick事件清除,或者Button被GC回收,LuaFunction将会被一并回收掉。
到这里有个问题需要思考一下: 在lua里经常会给C#的委托例如这里的Button.onClick 绑定一个匿名方法,在调过C#接口传递了这个匿名方法后,并没有再将这个匿名方法赋值到其它地方,那么这个匿名方法在Lua里就是一个无引用的对象,但是它却不会被lua GC回收掉。这里回想一下刚才toluaL_ref创建的lua方法的栈索引,猜想应该就是这个接口将lua方法存入了Lua注册表,来保持lua对象不被回收。 那么在C#这边使用完这个Lua方法之后,要想它在lua端能够被回收,还需要调用一个接口来移除注册表里的引用。
继续跟进new LuaFunction,看看LuaFunction的实现。
LuaFunction中除了构造函数缓存了lua方法的引用reference,以及一个Dispose方法, 其它的都是一些方法调用相关的接口。而Dispose应该用来释放LuaFunction内存的。看它实现知识调了一下基类接口,跟进看看。
看实现 这个Dispose方法应该是做了一个引用计数的处理,当引用计数为0的时候调了一个重载的Dispose方法来释放,并且这个Dispose方法在LuaBaseRef(忘了说LuaFunction的基类就是这个LuaBaseRef)的析构方法中也被调了,看来这个就是真正释放内存的接口了。
看到Dispose中调了LuaState的CollectRef方法。
注意到这里有个isGCThread标记,回看一下刚从Dispose() 与 析构 中调过来传递的参数,可以看出,Dispose调过来的是非GC线程环境调用,而析构方法则是由GC Collect触发的,是由GC线程而非主线程环境调过来的,所以下面区分了 若是GC 线程调过来的 则将Collect加入到一个gcList队列,并做了线程安全处理。但是他们最终都会走到Collect方法。
这个方法比较长,但基本上只是做了一些移除缓存的操作。不过这里调了一个ToLuaUnRef方法。
最终调到了toluaL_unref 接口,与toluaL_ref 接口成对,释放了lua端注册表对lua方法的引用,到此,lua端开辟的匿名方法将会被lua GC 回收。
以上是C#获取一个lua方法对象的过程, 获取lua table的过程也基本差不多,因为LuaTable 也是继承的这个LuaBaseRef, 所以它们在流程上是一致的。

来自LuaLooper的Collect

luaFromeWork 中有个LuaLooper组件,在Lua启动的时候 默认会被挂载,并且LuaLooper在Update方法中每帧调用了一个Collect方法。
回顾一下刚才LuaBaseRef的析构调到的CollectRef方法中,若是来自C# GC 线程,则将Collect操作添加到gcList,等待在主线程中进行Collect。这个gcList的处理就是在LuaLooper的Update方法中每帧处理的。所以在C#这边引用全部清理完毕,GC也回收完了之后, Lua引用的清理还需要以来这个LuaLooper来完成最后一步。否则 lua 端的内存 还是无法得到完全释放。

总结一下以上两个过程

当lua从C#获取一个对象的时候,C#会把这个对象缓存到 ObjectTranslator 中的LuaObjectPool中,从而实现C#端可以根据lua传递的 Wrap对应的 userdata 拿回对应的C#对象。并且也保证了当lua端持有 userdata 引用的时候C# 对象不会因为在C#端无其它引用而被C# GC回收掉。缓存在ObjectPool中的引用将会在 lua 端 gc 清理了 userdata 之后,由userdata的元方法 __gc 触发移除缓存,从而释放C#对象的内存。
当C#获取一个lua对象的时候, 将会在C#端建立一个LuaBaseRef对象,并调用C API 在lua端创建一个对象引用,并持有在C环境,从而保证了在C#端持有 lua对象的时候,lua 对象不会因为在lua端无其它引用而被 lua GC回收掉。 C API创建的引用将在C# 端 GC 清理了 LuaBaseRef引用 或 手动调用Dispose() 方法之后 解除,从而释放lua 对象的内存。

容易造成内存泄漏的地方

当在Lua端绑定方法给C#委托的时候,特别是绑定给继承自MonoBehaviour的组件中的委托,开发人员容易依赖MonoBehaviour的Destroy() 方法来释放C#对象,从而忽视了 清理绑定到C#中的委托事件,导致C API创建的引用无法解除,对应的lua 方法无法释放,导致lua端一片内存无法释放,再延伸,又会导致lua端对C# 对象的引用 即 userdata也无法释放 从而导致 C#端LuaObjectPool中的缓存无法移除,进而导致C#这边的内存也无法释放。 整个一个死循环。
所以开发过程中必须注意:
lua绑定到C#的委托 在不用的时候要及时清理
C#从lua端获取的对象,不再需要的时候要及时主动调用Dispose()方法,主动解除Lua 端对象的引用
lua端缓存的一些C# 的userdata,在不需要使用的时候也及时置为nil,从而调起__gc 元方法,清理C#端ObjectPool缓存,进而释放C#内存
3个注意点做到任意一个都能打破循环,释放内存。 但是尽量都做到,可以让各种引用的释放更及时,从而让内存释放也更及时,并且也能避免疏漏,降低出错的风险
— 于 共写了6662个字
— 文内使用到的标签:

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*