type
status
date
slug
summary
tags
category
icon
password
😀
在Unity中,使用Lua作为热更手段的时候,会有一个Lua虚拟机在跑。 那么Lua和c#交互的时候,怎么保证lua引用了一个c#对象,c#对象为什么不会被释放,反过来同理。然后是什么情况下才会释放,以及有没有内存泄漏的风险。 接下来就让我们来一起探讨下。
本篇讲解主要以XLua为基础,并且阅读本文需要一定的基础
文章原创,转载需标明出处

📝 Lua和C#的GC协同

基础介绍

Lua的GC使用的是增量标记-清除算法。这种算法分为两个阶段:标记阶段和清除阶段。在标记阶段,Lua的GC会从root(根)出发,标记所有可达的对象。
C#虽然官方使用的分代垃圾收集算法。Unity中目前不是跑的.Net,使用的是IL2cpp的计数,即把c#编译后的IL代码,转换为c++代码,c++本身具有跨平台性。Unity官方在IL2cpp中实现了一套自己的垃圾收集器,它使用了一种称为"Boehm-Demers-Weiser"的收集算法。这种算法是一种保守的标记-清除垃圾收集算法。
总的来说都是基于标记清除的算法,接下来就介绍下一方持有另一方的对象的时候会发生什么情况。

Lua持有C#对象

lua调用c#对象的原理简要说明

lua中想要持有c#的引用类型,都会使用userdata类型。
那lua如何访问c#中的字段和方法的呢,答案就是metatable
XLua的实现中,会给每个被访问的c#类型,创建一个元表,元表里面会有记录所有的成员变量和属性的get、set,以及所有的方法。总所周知,开发时都会使用生成wrap文件的方式来增加lua访问c#的效率,而上述说到的访问手段都会在wrap文件中体现(这里的讲解不考虑没有wrap走反射的情况)。例如以下。
可以看到以上生成的wrap代码中有个Utils.BeginObjectRegister的方法调用。这个方法其实就是为这个类型创建好一个table,它里面有__gc和__tostring还有这个类型的set、get、method等调用的方法。这个table的作用就是用于给lua访问的c#对象时,作为创建出来的c#对象的userdatametatable,这个table一个类型只有一个,一个c#对象对应一个userdata(重复访问还是这一个),这样就解释了为什么lua中能访问的了c#的对象。看一头雾水没关系,这个可以看下这张图:
notion image
看到这里的人肯定发现了这里有个__gc方法,那么这个__gc是做什么用的呢,这就要先介绍下lua的userdata了,以下引用chagpt的回答来简单介绍下。
在Lua中,userdata用于表示C语言中的任意数据。它允许Lua代码和C语言代码交互和共享数据。Lua中的userdata有两种类型:完全userdata(full userdata)和轻量userdata(light userdata)。
  1. 完全userdata(full userdata): 它是Lua中的一种复杂类型,可以带有元表,因此可以定义一些行为(比如算术运算、比较运算等)。完全userdata在Lua中表示为一个对象,其内容(C数据)存储在Lua的内存管理系统中,Lua的垃圾收集器会自动处理其生命周期。
  1. 轻量userdata(light userdata): 它是一种简单的C指针,本质上就是一个void*。轻量userdata不可以带有元表,不能定义行为,也不受Lua的垃圾收集器管理。轻量userdata主要用于表示一些简单的资源,如文件句柄、线程ID等
c#对象使用的就是full userdata,也就是这个userdata可以受到lua的gc管理,可以理解为userdata是lua中的一个可以垃圾回收的引用类型,当没有能够直接或者间接访问到这个userdata的时候(标记清除法),就被lua的gc回收掉,并且会调用元表中的__gc方法。

c#保持引用

lua访问c#对象时,XLua会把c#对象放入一个池子里,保持住这份引用,防止被c#的GC给释放掉,并且生成一个index数据绑定到userdata。然后后面lua调用c#的时候,先获取userdata的中的index值,然后在c#中通过这个index去池子中拿到这个对象,然后就可以调用了。
c#部分代码如下:
ObjectTranslator.cs
xlua.c源码如下:
所以可以看到当lua调用c#时,c#侧会缓存这个对象,并返回一个索引id,然后lua虚拟机生成一个userdata绑定这个索引id,并且给这个对象绑定上wrap里生成好的metatable。
这样当lua侧的这个userdata因为没有直接或者间接引用,而被lua虚拟机的GC释放掉的时候,会调用__gc方法,然后会从c#侧的对象池子中移除这个userdata对应的c#对象。
__gc对应的方法如下:
💡
总的来说,lua引用c#对象能够保持引用和释放,主要是通过c#侧的对象池子来实现的,然后池子会返回一个索引id,userdata其实绑定的就是这个索引id。然后userdata释放的时候通过__gc调用绑定好的c#侧的释放对象的方法。

C#持有Lua引用类型数据

讲完lua持有c#对象时调用和GC的原理之后,再来讲讲c#持有lua的table和function,原理大体来说和上述讲解了lua持有c#差不多。
XLua在c#侧封装了LuaTableLuaFunction对象来作为调用lua的桥梁,这些C#调用lua的对象都继承了LuaBase,一个lua引用类型数据对应一个LuaBase。当c#需要调用lua的table或者function的时候,会再第一次生成这个对象,然后之后调用的时候直接用userdata转成对应的LuaBase对象。
C#调用lua的生成LuaTable和LuaFunction对象如下:
其中比较关键是luaL_ref函数,对应的C#函数如下:
luaL_ref 和 luaL_unref 是 Lua C API 中的函数,它们用于在 Lua 的注册表(registry)中创建和删除引用。LuaIndexes.LUA_REGISTRYINDEX是一个特殊的索引值,它被用于访问Lua的注册表,是在XLua初始化的时候从lua中读取的。
如果c#需要调用lua的table和function的对象,会先调用luaL_ref在lua的注册表持有住这个引用,并且返回一个索引值记在C#的LuaBase对象中,避免被Lua虚拟机的GC释放掉。
然后在这个LuaBase的Dispose函数中调用luaL_unref 释放掉注册表中持有的对象,如何没有调用Dispose,当LuaBase被GC掉的时候,会调用析构函数,最终也会调到Dispose,释放掉lua注册表的lua引用数据。
C#代码如下:
💡
综上所述,c#调用lua其实也是会在被调用方里用一个全局的表来注册持有住对象,防止被GC释放,然后如果c#侧的LuaBase被释放的时候,也会从lua侧的注册表中删除对应的引用数据。

Lua和C#相互引用(包含间接)

说到相互引用的情况,可以看一个开发中很常见的例子,例如一个lua table中持有一个Unity的Button对象,然后给这个Button添加table中的一个绑定了这个table的function,也就是一个带了self的闭包函数,会在C#生成一个DelegateBridge对象实现对这个闭包函数的桥接调用,如图下所示。
notion image
这是一个典型的具有相互的引用的示例,这种情况下,如果不做一些接触相互引用的操作,是不会被释放的。因为lua的table持有了Button的引用,Button又持有DelegateBridge的引用,然后DelegateBridge 又持有lua闭包函数的引用,闭包函数里带了table的引用,这样就形成了循环引用。
💡
如何打破这种循环引用,让GC能够回收这些对象呢。
其实很简单,就是c#对象接触对DelegateBridge 的引用,或者lua的table对象解除的对C#的Button对象的引用。

管理技巧

对于解除类似上述的lua和c#相互引用的情况,有很多运用情景,以下就谈谈作者的看法。
  • lua中管理UGUI的组件,并且能够方便的解除相互引用,如下面示例代码所示,其中名为ui的lua table的组件是从C#的ObjectBinding中序列化数据中读取的。
 
💡
这里示例情景是从Lua中移除c#类的引用,当然也可以从lua中移除c#的引用,主要还是看哪边去处理更加方便。如果lua中的table类对c#引用很多很分散,并且c#只是一两处引用了这个table,那么也是可以从c#处打断这个相互引用。 例如之前笔者由于不懂这块概念,做的以lua脚本为核心的技能系统中,就出现了相互引用的情况,其实可以完全在框架设计上就能处理掉这些问题,比如销毁技能的时候在c#层打破引用,但是由于经验不足,导致后期处理带了大量的工作。所以了解这个原理是非常重要的。

🤗 总结

综上所述,了解这个c#和lua相互引用下的GC原理是非常重要的,因为它涉及到程序的内存管理和优化。如果不理解 Lua 和 C# 中垃圾回收的原理,可能会导致内存泄漏和性能问题。相互引用的情况也需小心处理,否则会影响程序的内存使用情况。因此,程序员需要了解这些原理和技巧,以便在编写代码时正确处理对象引用和内存管理。
 
Notion示例文章游戏GC优化(1)-C#闭包函数的原理
Loading...