xLua的obj引用分析
为了防止c#和lua两端的内存泄漏,有必要了解xLua是怎样处理2端的引用关系的,尤其是在扩展xLua时,处理不得当很容易造成引用丢失或者内存泄漏。
一个c#的obj是不能直接传递到lua,需要一个中间层,这个中间层就是userdata。xLua会为每个传递到lua的obj生成唯一的一个userdata,并将2者绑定起来(具体绑定方式后面分析)。
这样就有2个基本问题:
- 双方查询
- 可以通过obj查找到其对应的userdata(一般是在将obj压到lua时,要先查询其有没有对应的userdata,保证同一个obj对应同一个userdata)
- 可以通过userdate查找到其对应的obj(在wrap文件中大量使用,lua下通过userdata调用其c#层的函数,那c#层就需要通过这个userdata找回其obj,完成函数调用)
- GC管理
- obj在c#层被GC了,其对应的userdata也应该赋
nil
。 - userdata在lua层被GC了,清理其对应的obj的引用。
- obj在c#层被GC了,其对应的userdata也应该赋
双方查询
考虑到lua无法直接保存c#对象的引用,所以一般的做法就是先在c#层为obj产生一个唯一id,然后和lua交互时都是直接传输的这个唯一id,但通过一个数字是无法模仿面向对象的使用方式的,最终做法就是通过将数字记录在userdata中,再设置userdata的元表来达到模拟面向对象的使用方式。
下面的代码显示了创建这个userdata的过程:
// 文件:xlua.c
static void cacheud(lua_State *L, int key, int cache_ref) {
// 将userdata放入cache表中
// 下面代码可以理解为:cache[key] = userdata
lua_rawgeti(L, LUA_REGISTRYINDEX, cache_ref);
lua_pushvalue(L, -2);
lua_rawseti(L, -2, key);
lua_pop(L, 1);
}
// 下面函数是第一次将csobj压入lua栈时调用,函数内部会先为csobj产生一个userdata
// 其中参数key就是csobj的唯一id
LUA_API void xlua_pushcsobj(lua_State *L, int key, int meta_ref, int need_cache, int cache_ref) {
// userdata就是一个大小为int的内存
int* pointer = (int*)lua_newuserdata(L, sizeof(int));
// 在userdata的内存里写入key
*pointer = key;
if (need_cache) cacheud(L, key, cache_ref);
lua_rawgeti(L, LUA_REGISTRYINDEX, meta_ref);
lua_setmetatable(L, -2);
}
还要注意到,上面代码还将userdata放入了cache表,而且key就是obj的唯一id,这是为了后续方便快速判定一个obj是否已经产生过userdata了。
在xLua中,基本是通过2个容器来管理obj,在ObjectTranslator中:
// 文件:ObjectTranslator.cs
public partial class ObjectTranslator
{
// key是obj唯一id
// value是obj
internal readonly ObjectPool objects = new ObjectPool();
// key是obj
// value是obj唯一id
internal readonly Dictionary<object, int> reverseMap = new Dictionary<object, int>(new ReferenceEqualsComparer());
// ...
}
其中ObjectPool
采用了一种叫FreeList的数据结构来保存userdata和obj,除了不方便遍历外,其他操作如:增、删、查询都是
O
(
1
)
O(1)
O(1)的复杂度,这里可以简单认为其就是一个Dictionary<int, object>
。
下面代码显示了obj唯一id的产生过程。当一个obj第一次传递到lua之前,会调用ObjectTranslator.addObject
函数来设置这objects
和reverseMap
这2个容器,并产生一个唯一id:
// 文件:ObjectTranslator.cs
int addObject(object obj, bool is_valuetype, bool is_enum)
{
// 这个index可以理解为就是obj的唯一id(其实就是obj在ObjectPool的数组下标)
// 后续可以通过objects.Get(index)查询到这个obj
int index = objects.Add(obj);
if (is_enum)
{
enumMap[obj] = index;
}
else if (!is_valuetype)
{
// 反向引用,后续会讲到
reverseMap[obj] = index;
}
return index;
}
当obj真正被传到lua时,会调用到下面的ObjectTranslator.Push
函数:
// 文件:ObjectTranslator.cs
public void Push(RealStatePtr L, object o)
{
if (o == null)
{
LuaAPI.lua_pushnil(L);
return;
}
int index = -1;
Type type = o.GetType();
bool is_enum = type.IsEnum;
bool is_valuetype = type.IsValueType;
bool needcache = !is_valuetype || is_enum;
// 首先通过reverseMap找到obj的唯一id
if (needcache && (is_enum ? enumMap.TryGetValue(o, out index) : reverseMap.TryGetValue(o, out index)))
{
// 这时可以直接将cache里的userdata传给lua
if (LuaAPI.xlua_tryget_cachedud(L, index, cacheRef) == 1)
{
return;
}
}
bool is_first;
int type_id = getTypeId(L, type, out is_first);
//如果一个type的定义含本身静态readonly实例时,getTypeId会push一个实例,这时候应该用这个实例
if (is_first && needcache && (is_enum ? enumMap.TryGetValue(o, out index) : reverseMap.TryGetValue(o, out index)))
{
if (LuaAPI.xlua_tryget_cachedud(L, index, cacheRef) == 1)
{
return;
}
}
// 为obj产生唯一id
index = addObject(o, is_valuetype, is_enum);
// 为obj产生userdata,放入cache,再传给lua
// 可以看到,第二个参数传的就是index,对应c代码里的key参数
LuaAPI.xlua_pushcsobj(L, index, type_id, needcache, cacheRef);
}
此时,可以理解第1个查询问题,c#层可以通过reverseMap
查询obj的唯一id,lua可以根据这个唯一id在cache表里找到对应的userdata了。
同时也可以理解第2个查询问题了,通过userdata可以获取到obj的唯一id,然后可以根据这个唯一id在objects
中找到对应的obj,随便查看一个wrap文件,有如下类似代码:
// wrap文件,此时ud在栈的位置是1
object __cl_gen_to_be_invoked = translator.FastGetCSObj(L, 1);
// 文件:ObjectTranslator.cs
internal object FastGetCSObj(RealStatePtr L,int index)
{
// xlua_tocsobj_fast函数会返回index位置的userdata里保存的值,也就是之前我们存入的obj唯一id
return getCsObj(L, index, LuaAPI.xlua_tocsobj_fast(L,index));
}
// 参数udata就是obj的唯一id了
private object getCsObj(RealStatePtr L, int index, int udata)
{
object obj;
if (udata == -1)
{
if (LuaAPI.lua_type(L, index) != LuaTypes.LUA_TUSERDATA) return null;
Type type = GetTypeOf(L, index);
if (type == typeof(decimal))
{
decimal v;
Get(L, index, out v);
return v;
}
GetCSObject get;
if (type != null && custom_get_funcs.TryGetValue(type, out get))
{
return get(L, index);
}
else
{
return null;
}
}
else if (objects.TryGetValue(udata, out obj)) // 这里通过udata查找obj并返回
{
#if !UNITY_5 && !XLUA_GENERAL
if (obj != null && obj is UnityEngine.Object && ((obj as UnityEngine.Object) == null))
{
throw new UnityEngine.MissingReferenceException("The object of type '"+ obj.GetType().Name +"' has been destroyed but you are still trying to access it.");
}
#endif
return obj;
}
return null;
}
可以看下xlua_tocsobj_fast
的源码,实现也非常简单:
// 文件:xlua.c
LUA_API int xlua_tocsobj_fast (lua_State *L,int index) {
int *udata = (int *)lua_touserdata (L,index);
if(udata!=NULL)
return *udata;
return -1;
}
可以发现,obj的唯一id在c#和lua之间扮演了很重要的中间层,2端都是通过这个唯一id来交互的。
这里整理一下这个过程:
- 通过
objects.Add
来产生唯一id - 通过
xlua_pushcsobj
将id存放在userdata中 - 通过
xlua_tocsobj_fast
获取到存放在userdata中的唯一id - 通过
objects.Get
来根据唯一id获取到obj
引用分析
首先看下userdata的引用。在交互过程中,会对userdata产生引用的地方就是在xlua_pushcsobj
函数中会将userdata存放在cache表中,但仔细查看cache表的创建,可以发现这个表是个弱表(weak table),不会对userdata产生任何的引用。
具体可以看ObjectTranslator的构造函数:
public ObjectTranslator(LuaEnv luaenv,RealStatePtr L)
{
// some code ...
// 下面代码可以理解为:
// cache = setmetatable({}, { __mode = 'v' })
LuaAPI.lua_newtable(L);
LuaAPI.lua_newtable(L);
LuaAPI.xlua_pushasciistring(L, "__mode");
LuaAPI.xlua_pushasciistring(L, "v");
LuaAPI.lua_rawset(L, -3);
LuaAPI.lua_setmetatable(L, -2);
cacheRef = LuaAPI.luaL_ref(L, LuaIndexes.LUA_REGISTRYINDEX);
initCSharpCallLua();
}
然后看下obj的引用。在交互过程中,会对obj产生引用的地方是ObjectTranslator.addObject
函数里,objects
和reverseMap
都对obj产生了引用。
GC分析
上面讲到在交互过程中没有产生对userdata的额外引用,所以一个userdata传递给lua后,如果在lua下没有地方引用这个userdata了,那么它就会被GC了。这一点非常重要,因为如果需要一个userdata的生命周期要跟随obj的生命周期的话,obj就必须要自己对这个userdata生成一个引用,否则这个userdata就有可能因为在lua下没人引用它而导致被GC。举个例子就是Peer机制,由于peer是绑在userdata上的,如果userdata被GC了,其peer也会丢失,这样下次obj被传递到lua时,就会生成一个新的userdata,从而丢失了其peer内容。
userdata的GC处理是通过__gc
这个metamethod实现的(lua5.1只支持userdata的__gc
,table都不支持),xLua会把所有userdata的metatable都设置一个统一的__gc
函数,即StaticLuaCallbacks.LuaGC
,看下面的代码:
// 文件:StaticLuaCallbacks.cs
[MonoPInvokeCallback(typeof(LuaCSFunction))]
public static int LuaGC(RealStatePtr L)
{
try
{
// udata就是obj的唯一id
int udata = LuaAPI.xlua_tocsobj_safe(L, 1);
if (udata != -1)
{
ObjectTranslator translator = ObjectTranslatorPool.Instance.Find(L);
translator.collectObject(udata);
}
return 0;
}
catch (Exception e)
{
return LuaAPI.luaL_error(L, "c# exception in LuaGC:" + e);
}
}
// 文件:ObjectTranslator.cs
internal void collectObject(int obj_index_to_collect)
{
object o;
if (objects.TryGetValue(obj_index_to_collect, out o))
{
// 清理objects
objects.Remove(obj_index_to_collect);
if (o != null)
{
int obj_index;
bool is_enum = o.GetType().IsEnum;
if ((is_enum ? enumMap.TryGetValue(o, out obj_index) : reverseMap.TryGetValue(o, out obj_index))
&& obj_index == obj_index_to_collect)
{
if (is_enum)
{
enumMap.Remove(o);
}
else
{
// 清理reverseMap
reverseMap.Remove(o);
}
}
}
}
}
注意cache表不需要清理,因为它是弱表!
最后来看obj的GC处理。上面讲到objects
和reveresMap
都会对obj产生引用,也就是说这个obj就算c#层其他地方都不引用它了,它也不会被GC,直到lua层也没人引用obj对应的userdata了,userdata被GC导致objects
和reverseMap
被清理,obj才能被GC。所以,只要lua层还有地方引用这个obj(对应的userdata),那么这个obj就不会被GC,这是很重要的一点,为了防止内存泄漏,lua层的引用必须管理好,否则会导致c#层的内存泄漏。
不过有一种情况,就算lua层还存在引用,obj仍然会被GC,这种情况就是这个obj是UnityEngine.Object
派生的!由于可以通过GameObject.Destroy
等函数显式删除一个UnityEngine.Object
,此时c#层所有引用这个UnityEngine.Object
的变量的值都会变成null。
这其实是Unity的一个trick,Unity重载了UnityEngine.Object
的==
运算符,当一个UnityEngine.Object
被Destroy后,通过==
判断会发现其和null相等,但其实这个obj并没有真正被GC。
为了处理这种情况,xLua采用了一个GC轮询,当发现一个UnityEngine.Object
被Destroy后,会对其userdata做一些清理。看下面的代码:
// LuaEnv.cs
#if !XLUA_GENERAL
int last_check_point = 0;
int max_check_per_tick = 20;
static bool ObjectValidCheck(object obj)
{
// 如果obj是UnityEngine.Object而且==null,则任何其已经被Destroy了
return (!(obj is UnityEngine.Object)) || ((obj as UnityEngine.Object) != null);
}
Func<object, bool> object_valid_checker = new Func<object, bool>(ObjectValidCheck);
#endif
public void Tick()
{
#if THREAD_SAFT || HOTFIX_ENABLE
lock (luaEnvLock)
{
#endif
var _L = L;
lock (refQueue)
{
while (refQueue.Count > 0)
{
GCAction gca = refQueue.Dequeue();
translator.ReleaseLuaBase(_L, gca.Reference, gca.IsDelegate);
}
}
#if !XLUA_GENERAL
last_check_point = translator.objects.Check(last_check_point, max_check_per_tick, object_valid_checker, translator.reverseMap);
#endif
#if THREAD_SAFT || HOTFIX_ENABLE
}
#endif
}
public void GC()
{
Tick();
}
可以看到,在Tick函数中,会把ObjectValidCheck
函数传给objects,objects.Check
函数内部会遍历指定数量的obj,通过ObjectValidCheck
判断其是否已经失效(被Destroy)了,如果失效的话,对应的obj会被赋null。这样外部通过userdata查询到的obj就会是null。
要注意到,userdata自身不是nil,只是其对应的obj是null,所以在lua下通过判断变量是否为nil是不能发现unity obj被删除了,可以编写一个简单函数来判断一个unity obj对应的userdata是否失效了。
-- unity对象判断为空, 如果你有些对象是在c#删掉了,lua不知道
-- 判断这种对象为空时可以用下面这个函数。
function IsNil(uobj)
return uobj == nil or uobj:Equals(nil)
end
最后的最后,分析一个容易出现的循环引用问题:
上面提到,如果obj需要保证userdata的生命周期,会存储一个userdata的引用,那这个引用应该什么时候释放呢?xLua里的LuaBase
类也负责了lua引用的管理,它是采用Dispose
和析构函数的方式来释放lua引用的。那我们也可以为obj实现Dispose
和析构函数来释放引用吗?答案是不行的,这样会造成循环引用。由于obj自身被objects
和reverseMap
引用着,这个引用必须等到userdata被GC才会释放,而userdata却又被obj引用了,从而产生循环引用,所以obj根本不可能触发到析构函数,也无法释放userdata引用,最后造成内存泄漏。
怎么解决呢?可以通过提供一个统一的Destroy
函数来做释放。所有Object不用时都需要调用这个函数来做清理工作,调用这个函数后,就可以认为其已经不能再使用了,这样就可以在这个Destroy函数里清理userdata的引用。