Hugo Future Imperfect Slim

L-Lawliet's Blog

记录游戏开发的点点滴滴

xlua Delegate 泄漏检查

xlua Delegate 泄漏检查

Lawliet

3 分钟

Colourful

排查和定位xLua中Delegate没有销毁的情况

前言

笔者在开发项目时,发现在xLua Dispose时总是会有DelegateBridge没有移除的情况,这有很多原因导致的,其中除了有之前讨论过的《UnityEvent引起的内存泄漏》,也不乏一些日常写业务逻辑疏忽导致的。但无论哪种情况,在销毁xLua虚拟机前调用多次GC释放,而仍然存在的DelegateBridge就代表原来Lua逻辑有未正常销毁的情况,这体现其背后可能存在内存泄漏等问题,而本篇文章就是研究如何去找到没有释放所引用的Lua代码位置。

InvalidOperationException: try to dispose a LuaEnv with C# callback! 

源码方案

xLua在虚拟机销毁前,会先调用多次GC,然后再释放ObjectTranslator的对象。

//调用多次GC是为了让Lua和C#之间没有引用关系的对象得以释放。
public void Dispose()
{
    FullGc();
    System.GC.Collect();
    System.GC.WaitForPendingFinalizers();

    Dispose(true);

    System.GC.Collect();
    System.GC.WaitForPendingFinalizers();
}

而在Dispose ObjectTranslator时,会释放掉所有的DelegateBridge。在释放每一个DelegateBridge都会先确认是否存活(IsAlive),如果有一个则会抛出异常。

if (!translator.AllDelegateBridgeReleased())
{
    throw new InvalidOperationException("try to dispose a LuaEnv with C# callback!");
}

由于信息有限,所以并不知道是哪个对象哪句代码所注册Delegate,因此需要扩展对应的检查。

输出Delegate信息

首先较为简单的方案就是把没有释放的Delegate信息输出出来,其中包括Method的名称、类型等。

//Class DelegateBridgeBase
public string GetMessage()
{
    StringBuilder builder = new StringBuilder();

    if(bindTo != null)
    {
        foreach (var item in bindTo)
        {
            if (item.Value != null)
            {
                builder.AppendFormat("key:{0} methodName:{1} FullyQualifiedName:{2}\n", item.Key, item.Value.Method.Name, item.Value.Method.DeclaringType.Name);
            }
        }
    }
    else if(firstValue != null)
    {
        builder.AppendFormat("key:{0} methodName:{1} FullyQualifiedName:{2}\n", firstKey, firstValue.Method.Name, firstValue.Method.DeclaringType.Name);
    }

    
    return builder.ToString();
}
//Class ObjectTranslator
public bool AllDelegateBridgeReleased(IntPtr L, ref string message)
{
    StringBuilder builder = new StringBuilder();

    bool result = true;

    foreach (var kv in delegate_bridges)
    {
        if (kv.Value.IsAlive)
        {
            builder.AppendLine(delegateBridgeBase.GetInfo());
        }
    }

    message = builder.ToString();

    return result;
}

这样就可以获取到所有没有释放的Delegate信息了。

debug.traceback

虽然获取Delegate信息能找到一些蛛丝马迹,但其实还远远不够,因为相同类型的Delegate实在太多了,而且如果是反射Warp的方式,类似methodName:__Gen_Delegate_Imp7 FullyQualifiedName:XLuaGenDelegateImpl0的信息一点用都没有。这时候就需要增加其他方法来获取更精确的信息了,例如获取Lua堆栈信息。

lua有一个debug.traceback()的API是可以获取到当前Lua逻辑中的堆栈信息的。

//在C#端增加以下逻辑
private string GetStack(RealStatePtr L)
{
    var oldTop = LuaAPI.lua_gettop(L);
    
    int debug = LuaAPI.xlua_getglobal(L, "debug");

    LuaAPI.xlua_pushasciistring(L, "traceback");
    LuaAPI.xlua_pgettable(L, -2);
    var index = LuaAPI.lua_pcall(L, 0, 1, 0);
    string luaStack = LuaAPI.lua_tostring(L, -1);
    LuaAPI.lua_pop(L, 2);
    LuaAPI.lua_settop(L, oldTop);

    return luaStack;
}

尝试在ObjectTranslator.CreateDelegateBridge()调用,输出日志:

stack traceback:
    [C]:in local 'loadFun'
    Script/B:12: in field'Fun2'
    Script/A:32: in field'Fun1'
    ......

这样就可以在创建DelegateBridge时获取到调用的Lua代码堆栈,然后保存起来,等销毁时把没有释放的堆栈信息输出。

//将原来的CreateDelegateBridge方法改成私有,把weakReference作为out结果返回。
private object CreateDelegateBridge(RealStatePtr L, Type delegateType, int idx, out WeakReference weakReference)
{
    ...
}

//增加新的CreateDelegateBridge提供给外部调用
public object CreateDelegateBridge(RealStatePtr L, Type delegateType, int idx)
{
    WeakReference weakReference = null;

    var stack = this.GetStack(L);
    var result = CreateDelegateBridge(L, delegateType, idx, out weakReference);
    if(weakReference != null)
    {
        int hash = weakReference.GetHashCode(); //利用weakReference的哈希值来作为key
        
        bridgesReferenceInfos[hash] = stack;
    }

    return result;
}

AllDelegateBridgeReleasedReleaseLuaBase也需要根据weakReference.GetHashCode()处理(获取信息和释放),这里就不再赘述了。

debug.getinfo

其实使用traceback已经能完美解决问题了,为什么还有下文呢?这是因为笔者在接入到项目后,发现项目变的很卡,特别是在创建模块(场景、界面)时。在一轮抽丝剥茧后,发现了debug.traceback是一个性能消耗巨大的API,在Editor下十次调用就有100ms,这即使在Editor下也是不可接受的。

所以在一轮查找后,笔者找到了debug.getinfo的方法来实现功能。

-- 获取当前调用层级所在的lua文件路径
-- 1就是第一层
debug.getinfo(1, "S").source 

-- 获取当前调用层级(代码堆栈)所在的行号
-- 1就是第一层
debug.getinfo(1, "l").currentline

在C#侧增加了接口,替代了原来的GetStack()

private string GetInfo(RealStatePtr L, int maxLevel = 5)
{
    luaStackBuffer.Clear();

    var oldTop = LuaAPI.lua_gettop(L);

    int debug = LuaAPI.xlua_getglobal(L, "debug");

    LuaAPI.xlua_pushasciistring(L, "getinfo");

    LuaAPI.xlua_pgettable(L, -2);

    for (int i = 0; i < maxLevel; i++)
    {
        LuaAPI.lua_pushvalue(L, -1);

        int level = i + 2; //1为C端,没必要输出

        LuaAPI.xlua_pushinteger(L, level); //参数

        LuaAPI.xlua_pushasciistring(L, "S"); //参数

        LuaAPI.lua_pcall(L, 2, 1, 0);

        if (LuaAPI.lua_isnil(L, -1)) //当前节点是nil,表明没有上一层
        {
            LuaAPI.lua_pop(L, 1);
            break;
        }

        LuaAPI.xlua_pushasciistring(L, "source");

        LuaAPI.xlua_pgettable(L, -2);

        string luaPath = LuaAPI.lua_tostring(L, -1);

        LuaAPI.lua_pop(L, 2);

        LuaAPI.lua_pushvalue(L, -1);

        LuaAPI.xlua_pushinteger(L, level); //参数

        LuaAPI.xlua_pushasciistring(L, "l"); //参数

        LuaAPI.lua_pcall(L, 2, 1, 0);

        LuaAPI.xlua_pushasciistring(L, "currentline");

        LuaAPI.xlua_pgettable(L, -2);

        int line = LuaAPI.xlua_tointeger(L, -1);

        luaStackBuffer.AppendFormat("{0}: {1}\n", luaPath, line);

        LuaAPI.lua_pop(L, 2);
    }

    LuaAPI.lua_pop(L, 2);

    LuaAPI.lua_settop(L, oldTop);

    return luaStackBuffer.ToString();
}

@Script/B: 12
@Script/A: 32

测试了一下性能,比traceback好一点,但也有80ms/10次。这也是万万不可接受的。 所以调试了其他方案,最后可以使用linedefined来替代currentlinelinedefinedcurrentline不一样的地方就是,只能获取到当前function开始所在的行号,而不能获得代码精确的行号。

-- 获取当前调用层级所在的lua文件路径
-- 1就是第一层
debug.getinfo(1, "S").source 

-- 获取当前调用层级调用函数所在的行号
-- 1就是第一层
debug.getinfo(1, "S").linedefined

修改原来的GetInfo()

private string GetInfo(RealStatePtr L, int maxLevel = 5)
{
    luaStackBuffer.Clear();

    var oldTop = LuaAPI.lua_gettop(L);

    int debug = LuaAPI.xlua_getglobal(L, "debug");

    LuaAPI.xlua_pushasciistring(L, "getinfo");

    LuaAPI.xlua_pgettable(L, -2);

    for (int i = 0; i < maxLevel; i++)
    {
        LuaAPI.lua_pushvalue(L, -1);

        int level = i + 2; //1为C端,没必要输出

        LuaAPI.xlua_pushinteger(L, level); //参数

        LuaAPI.xlua_pushasciistring(L, "S"); //参数

        var index = LuaAPI.lua_pcall(L, 2, 1, 0);

        if(LuaAPI.lua_isnil(L, -1)) //当前节点是nil,表明没有上一层
        {
            LuaAPI.lua_pop(L, 1);
            break;
        }

        LuaAPI.xlua_pushasciistring(L, "source");

        LuaAPI.xlua_pgettable(L, -2);

        string luaPath = LuaAPI.lua_tostring(L, -1);

        LuaAPI.lua_pop(L, 1);

        LuaAPI.xlua_pushasciistring(L, "linedefined");

        LuaAPI.xlua_pgettable(L, -2);

        int line = LuaAPI.xlua_tointeger(L, -1);

        luaStackBuffer.AppendFormat("{0}: {1}\n", luaPath, line);

        LuaAPI.lua_pop(L, 2);
    }

    LuaAPI.lua_pop(L, 2);

    LuaAPI.lua_settop(L, oldTop);

    return luaStackBuffer.ToString();
}
@Script/B: 5
@Script/A: 30

改进后的方案所得到的堆栈信息可能没有那么的准确(因为没有精确的堆栈行号),但有lua代码地址和函数名已经能大大缩小其范围,相对耗时(一百倍)来说,具有极高的性价比。 以下是三种方式的对比:

方案耗时(ms/10次)
traceback112
getinfo(精确行号)83
getinfo(函数行号)1

总结

在通过一系列测试,最后采用了debug.getinfo的方式来获取堆栈,并且采用一个消耗极低的方式获取到对应的函数行号,从而方便定位到问题的模块。如果想要更精确可以和debug.traceback结合处理,日常使用低耗版本监控,有需要定位时采用精确定位。

说些什么

评论

还沒有留言。

最新文章

Colourful

HLSL

分类

关于

记录游戏开发的点点滴滴