Lua和C#交互开销探究
前言
最近又看了一下ToLua相关的东西,终于稍微看明白了一点点,在此作下笔记。
过程
Lua每个用到的C# object都会分配一个ID与之对应,ObjectTranslator类起到关键作用。
ObjectTranslator译为对象翻译者,为什么作者会起这个名,原因就在于ObjectTranslator会把object与ID的匹配关系存起来。每次Lua需要调用C#的时候,最终都会转换成通过ID在ObjectTranslator中找对应object,然后调用object的对应方法。
举个例子:
假设我们是第一次调用:
obj.transform.position = pos
首先是obj.transform这一部分,本质上是调用了GameObjectWrap的get_transform方法。
static int get_transform(IntPtr L)
{
object o = null;
try
{
o = ToLua.ToObject(L, 1);
UnityEngine.GameObject obj = (UnityEngine.GameObject)o;
UnityEngine.Transform ret = obj.transform;
ToLua.Push(L, ret); // 这里并不是把transform本身push到栈中,而是会生成一个userdata的结构,把这个userdata push到栈中
return 1;
}
catch(Exception e)
{
return LuaDLL.toluaL_exception(L, e, o, "attempt to index transform on a nil value");
}
}
先会把obj转换为已有的ID,之后查找dictionary获得obj本体。之后为transform分配ID并存入dictionary,并在Lua分配一个userdata记录ID以表示返回给Lua的transform(Lua这边实际上不会持有真实的object,而是持有一个table,这个table有着与object对应的方法)。这个userdata还会被设置metatable以使得可以调用obj.transform的各种方法。最后把这个userdata push入内存栈,即表示返回了transform。
之后transform.position = pos,本质上是调用了TransformWrap的set_position的方法。
static int set_position(IntPtr L)
{
object o = null;
try
{
o = ToLua.ToObject(L, 1);
UnityEngine.Transform obj = (UnityEngine.Transform)o;
UnityEngine.Vector3 arg0 = ToLua.ToVector3(L, 2);
obj.position = arg0;
return 0;
}
catch(Exception e)
{
return LuaDLL.toluaL_exception(L, e, o, "attempt to index position on a nil value");
}
}
同理首先通过ID查dictionary获取transform本体。之后把传过来的参数(Lua自己的Vector3)通过LuaDLL.tolua_getvec3方法获得x、y、z三个值。最后transform.position = new Vector3(x, y, z)。
分析
通过上面可以看出Lua调用C#开销很大,所以基本上各种Lua框架都是利用缓存机制,用一个ID表示C#的对象,在C#中通过dictionary来对应ID和object。同时因为有了这个dictionary的引用,也保证了C#的object在Lua有引用的情况下不会被垃圾回收掉。说是说dictionary,但实际上如今的Lua框架都是自行维护一个存储映射关系的结构(ToLua用的就是List<PoolNode>),并采用了各种优化方式,如对象池等。
所以说,现如今说的Lua和C#交互开销大,大就大在每一步操作的取值、入栈、类型转换、内存分配、GC这些操作。如果不是第一次访问某个对象,那还好,只需要做个查找操作。但如果是第一次访问某个对象,那么上述的这些操作一个都不会少。
如果是只是临时的对象访问,那么在Lua GC后对对象userdata的引用就会释放,这也意味着后续的调用会导致该对象的userdata和ID映射关系又需要重新生成一遍。
比如说,obj.transform就是一个隐性的巨大陷阱,因为transform只是临时使用一下,很快就会被Lua释放掉,这就可能导致之后每次调用obj.transform又需要重新进行一次分配。无意中增加了很大的开销。
再比如,一个常见的场景,在Lua中临时new一个C#对象,然后当参数传给方法。这个临时new的对象由于没有被引用,导致后续GC的时候,userdata和ID映射关系被清除。如果需要频繁的new,那么就会增加非常多的开销。
方案
首先肯定要减少临时对象的访问,需要频繁使用的对象最好引用住。
其次就是减少对象传参。当然能不传对象最好不传,修改接口,这样连查找映射关系的时间都省了。但如果做不到,那传对象也可以,但是不要传临时的对象,最好引用住对象,减少频繁生成userdata和映射关系。
对于传参,不仅仅是上面说的对象传参会存在开销问题,Lua常用的bool、string、table传参也会有开销问题。bool和string这两个结构在C和C#中的内存表示不一样,意味着从C传递到C#时需要进行类型转换,降低性能,而且string还要考虑内存分配。table是Lua专有的数据结构,对应转换为数组的时候需要一个值一个值进行拷贝。由此可见,对于Lua频繁调用的C#函数,参数越少越好。
对于上述例子,一个简单的优化方案就是提供静态方法。
obj.transform.position = pos
-- 把上面代码改为下面代码
Utils.SetPos(obj, pos.x, pox.y, pos.z)
-- 如果自己维护一套id与object的映射,那么还可以优化为以下代码
Utils.SetPos(objId, pos.x, pox.y, pos.z)
这样既避免了transform临时对象,又省掉了Lua Vector3的转换。
内存泄漏
只要Lua对对象的userdata有引用,C#的映射关系也就会一直存在dictionary中。这就导致object一直无法被GC释放,这也就是Lua和C#交互导致内存泄漏的主要原因之一。
最常见的就是在一开始引用了某个component或者gameobject,然后一直没有释放引用。
对于这种问题也好解决,我们遍历一下映射关系就可以直接知道那些没有释放,然后去修改相应的代码即可。
小结
这里直接简单的探究一下Lua和C#交互到底开销在哪,对于Lua框架还有很多可以挖掘深究的地方。之后有时间继续研究。
参考
https://blog.uwa4d.com/archives/USparkle_Lua.html