浅谈新版UnLua的核心改变

UnLua是目前虚幻引擎下最主流的脚本方案之一,并且最近一年多以来更新频率提高了很多,修复了很多遗留问题。而从2.2版本开始,UnLua对内部的一些实现进行了较大的改动,但目前网络上还没有介绍最新版本的改动的。所以在这里我简单分析一下UnLua2.2之后的核心改动和需要关注的生命周期等问题

UnLua的历史版本

简单来说,UnLua1.0~2.1.4在核心实现和使用方法上基本没有太大变化,这些版本主要是修复bug。其中2.0起更新Lua到了5.4.2,对UObject Ref机制有所修改。

而UnLua2.2开始有了较大的改动,针对旧版本存在的较多严重问题通过重构进行修复。新版本的核心在于修复了数据结构设置不合理、野指针等问题,然后增加了多LuaState机制等特性。

UnLua的核心修改

这里简单介绍UnLua2.2之后的几个核心修改点

UObject生命周期

旧版

在老版本UnLua中,只要是Lua中获取的UObject,就会被UnLua的ObjectReferencer增加一个全局引用,以避免UObject被UE GC。这个引用去掉的时机依赖于对应的Lua userdata在Lua侧被GC。

看起来在Lua里写起来很爽,用到的UObject永远有效,但也带来了严重的问题——大量的UObject因为Lua侧的userdata仍存在引用而长时间无法释放。而且,在Lua中查找userdata的引用是非常困难的。

Lua中没有提供查询引用的接口,只能自己实现。UnLua中想做到查找完整的引用链几乎是不可能的,因为还存在协程、upvalue、UStruct、UE容器等等复杂的使用情况。当他们组合在一起,复杂度就指数级上升。这给修复内存泄漏带来了极大的挑战。

新版

新版本做了一个大胆激进的调整,直接去掉了对UObject的引用机制。当然,不是全部的UObject都不引用,涉及保持Delegate等用法的临时UObject还是需要的。

熟悉UnLua的朋友应该会立即想到,怎么保证Lua中访问的UObject是有效的呢?

UE的GC是有事件通知的,我们可以知道UObject什么时候被GC。首先,在Lua获取UObect时会把UObject** 作为light userdata放在Lua Registry中的ObjectMap里(弱引用),当UObject被GC时,就把userdata的指针赋值为一个名为ReleasedPtr的特殊指针地址,具体是0xDEAD

1
2
3
4
lua_pushstring(L, "Object");  
lua_rawget(L, -2);
void* Userdata = lua_touserdata(L, -1);
*((void**)Userdata) = (void*)LowLevel::ReleasedPtr;

这样在Lua里访问UObject时,UnLua就可以通过判断指针是不是ReleasedPtr来确定对象是否已经被释放。如果没有被释放,会通过UObject的各种Flag来判断是否处于GC流程中,能否使用。

1
2
3
4
5
6
bool IsUObjectValid(UObjectBase* ObjPtr)  
{
if (!ObjPtr || ObjPtr == LowLevel::ReleasedPtr)
return false;
return (ObjPtr->GetFlags() & (RF_BeginDestroyed | RF_FinishDestroyed)) == 0 && ObjPtr->IsValidLowLevelFast();
}

这样就保证了访问UObject时不会因为野指针而崩溃。不过准确的说,这样只能保证大部分情况,有些情况是规避不了的,这个后面再说

FClassDesc生命周期

FClassDesc是UnLua中用于存储UClass、UStruct描述信息的类。里面主要是存储一些指针

新版UnLua的FClassDesc结构:

1
2
3
4
5
6
private:  
TMap<FName, TSharedPtr<FFieldDesc>> Fields;
TArray<TSharedPtr<FPropertyDesc>> Properties;
TArray<TSharedPtr<FFunctionDesc>> Functions;
TArray<FClassDesc*> SuperClasses;
struct FFunctionCollection *FunctionCollection;

旧版

旧版本中,访问UObject时会去创建FClassDesc,然后通过引用计数去管理,当这个UClass的UObject数量为0或UClass被GC时或对应的metatable被GC时就析构FClassDesc,然后清理UnLuaManager中存储的各种UClass相关的指针,并且清理Lua侧全局的UE4.XXX,里面存的是FClassDesc指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**  
* Unregister a class
**/
bool FReflectionRegistry::UnRegisterClass(FClassDesc *ClassDesc)
{ if (GReflectionRegistry.IsDescValid(ClassDesc, DESC_CLASS))
{
FName Name(ClassDesc->GetName());
UStruct* Struct = ClassDesc->AsStruct();

delete ClassDesc;

// clear classdesc registry
Name2Classes.Remove(Name);
Struct2Classes.Remove(Struct);
}
return true;
}

看起来没啥问题,但是非常奇葩,FClassDesc的生命周期竟然被三种东西管理,这是导致老版本UnLua存在大量野指针崩溃的罪魁祸首。

实际上,引用计数、UClass、metatable的生命周期是非常不一样的,这可能导致正在使用的FClassDesc被析构,从而导致崩溃。

首先是引用计数的问题。因为UClass本身也是个UObject对象,如果在Lua中先获取一个UClass,那么这个UClass对应的FClassDesc引用计数就会+1。然后再创建一个UObject,这个FClassDesc引用计数就会变成2。然而如果没有从Lua获取UClass,而是通过C++接口在C++侧创建UObject传给Lua,那么FClassDesc引用计数就只有1,没有LoadClass的那个计数。两种情况下的UClass GC时机居然不一样!而LoaClass加引用计数就会导致如果这个UClass一直没GC,就不会清理FClassDesc了。

只要是Lua设置metatable,都会加引用计数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// other class,check classdesc  
FClassDesc* ClassDesc = GReflectionRegistry.FindClass(MetatableName);
if (!ClassDesc)
{
UnLua::FAutoStack AutoStack;
ClassDesc = RegisterClass(L, MetatableName);
}
Type = luaL_getmetatable(L, MetatableName);
if (Type != LUA_TTABLE)
{
lua_pop(L, 1);
}
else
{
lua_setmetatable(L, -2);// set the metatable directly
ClassDesc->AddRef();
}

其次是metatable的GC问题。metatable是在Lua中管理,它的GC依赖Lua GC的条件。实际游戏中Lua内存的增长是比较缓慢的,不会频繁触发Lua GC,这就导致metatable的GC会很晚。在真机上使用BinnedMalloc进行内存分配时,很容易把相同类型的对象分配到相同的内存地址上。比如一个FClassDesc被释放之后,另一个新的FClassDesc有很大概率会分配到刚才的地址上。那么此时如果metatable被GC了,它里面存储的FClassDesc指针地址就和现在新的一样!这就会导致正在使用的FClassDesc被析构,然而他们并不是一个!析构之后再去访问就会导致崩溃或者找不到FProperty之类的情况。

即使UnLua通过一些tricky的方式想规避这种情况,比如判断FClassDesc里面的UClass类型是不是一样的,但是并没有根本解决问题。因为实际游戏中,相同类型UClass对应的FClassDesc也可能会被分配到相同地址。在反复创建删除UObject的情况下极易复现。

tricky且没起到根本作用的判断方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bool FReflectionRegistry::IsDescValid(void* Desc, EDescType type)  
{
EDescType* TypePtr = DescSet.Find(Desc);
return TypePtr && (*TypePtr == type);
}

bool FReflectionRegistry::IsDescValidWithObjectCheck(void* Desc, EDescType type)
{
bool bValid = IsDescValid(Desc, type);
if (bValid)
{ switch (type)
{ case DESC_CLASS:
bValid = bValid && ((FClassDesc*)Desc)->IsValid();
break;
case DESC_FUNCTION:
bValid = bValid && ((FFunctionDesc*)Desc)->IsValid();
break;
case DESC_PROPERTY:
bValid = bValid && ((FPropertyDesc*)Desc)->IsValid();
break;
case DESC_ENUM:
bValid = bValid && ((FEnumDesc*)Desc)->IsValid();
break;
default:
bValid = false;
} }
return bValid;
}

最终结果就是,要么有FClassDesc因为地址被其他类型对象复用一直没被清理,要么有正在使用的FClassDesc被误清理。

新版

通过上面的分析,我们可以思考一下,FClassDesc是否有必要被清理?一方面FClassDesc里面只是存了一些指针,且没有对UClass有引用,不占什么内存,反而是反复创建销毁会导致Lua侧产生FClassDesc的野指针。另一方面,大部分UClass都是C++ Native的,不会释放的,这种UClass对应的FClassDesc更没必要delete了。少部分也是我们常用来绑定Lua的蓝图类型BluprintGeneratedClass是会被GC的,我们应该将FClassDesc里面的数据和UClass生命周期绑定,而不是整体绑定。

根据这个思路,新版做出了这样的修改:FClassDesc一旦创建了就一直存在,然后UClass GC时清理里面的内容,并且把Lua侧的metatable清理掉(里面保存了旧的FPropertyDesc指针,所以有必要清理)。

这样可以保证Lua侧一定没有FClassDesc野指针,并且UClass被GC了说明UObject也被GC了,那么Lua侧对应的metatable自然也不会被使用到,清理也不会产生新的问题。

然后还有兜底的机制,即使Lua侧的metatable还有其他地方引用并访问,访问到的FClassDesc也是有效的,里面的指针被清理之后会重新Load新的UClass,以保证Lua侧访问正常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void FClassDesc::Load()  
{
if (Struct.IsValid())
{ return;
}
if (GIsGarbageCollecting)
{ return;
}
UnLoad();

FString Name = (ClassName[0] == 'U' || ClassName[0] == 'A' || ClassName[0] == 'F') ? ClassName.RightChop(1) : ClassName;
UStruct* Found = FindObject<UStruct>(ANY_PACKAGE, *Name);
if (!Found)
Found = LoadObject<UStruct>(nullptr, *Name);

Struct = Found;
RawStructPtr = Found;
}

FPropertyDesc/FFunctionDesc

上面提到的FClassDesc中会存储UClass中被Lua访问过的Property和Function的描述信息,也就是FPropertyDesc/FFunctionDesc。

旧版

旧版本FClassDesc中直接存储了裸指针,然后裸指针会被Push到Lua的UClass对应的metatable,这就存在了野指针的风险。当metatable没有随UClass GC释放时,或者被不知道哪里的Lua引用了然后访问了就会崩溃。

1
2
3
4
5
6
7
8
9
private:  
TMap<FName, FFieldDesc*> Fields;
TArray<FPropertyDesc*> Properties;
TArray<FFunctionDesc*> Functions;

TArray<FString> NameChain;
TArray<UStruct*> StructChain;

struct FFunctionCollection *FunctionCollection;

新版

一方面数据结构改成了存储智能指针,另一方面当访问时发现已经不是Valid状态时可以返回nil,然后报错,这样可以准确知道Lua中哪里访问了已经被释放的UObject的Property或Function。

FPropertyDesc的检查:

1
2
3
4
5
6
7
FORCEINLINE virtual void GetValue(lua_State *L, const void *ContainerPtr, bool bCreateCopy) const {  
if (UNLIKELY(!IsValid()))
{ UE_LOG(LogUnLua, Warning, TEXT("attempt to read invalid property %s"), *Name);
lua_pushnil(L);
return;
} GetValueInternal(L, Property->ContainerPtrToValuePtr<void>(ContainerPtr), bCreateCopy);
}

UFunction覆写机制

其实各种脚本方案的覆写机制不会有太大区别,本质上都是模仿蓝图的实现方式,把已有的拿下来存在某个地方,然后创建一个新的UFunction加到UClass中。

旧版

老版本的UnLua是通过创建新的UFunction,然后在蓝图函数字节码中插入函数地址实现的。并且把老的UClass、UFunction裸指针存到UnLuaManager里面。

之前提到FClassDesc的生命周期跟UClass没对齐,所以这导致UnLuaManager在清理那堆裸指针时容易清理到野指针或者有效的但是复用了相同内存地址的新指针。

新版

新版本使用了一种更好的方式,新建了一个UFunction子类ULuaFunction。然后将覆写数据存在ULuaFunction中,比如被覆写的UFunction的智能指针、FFunctionDesc的智能指针。优势是UClass或者UFunction被GC时这些数据自动移除,不会留下野指针。放在UnLuaManager里面无法清理干净。

此外增加了FunctionRegistry类,在UFunction被GC时移除相关结构体,不再依赖UnLuaManager在CleanUpByClass时统一清理了(standalone模式非常容易出现野指针)。

然后增加了转发给原函数的功能,避免Lua模块加载失败时调用不到。

最后是覆写的还原机制,能够支持PIE下和Game下退出游戏时正常还原覆写的函数,避免错误的数据被保存到蓝图中

多LuaState

终于支持了多Lua虚拟机共存,不过老版本的获取LuaState的接口还保留着,所以升级没什么问题。但是老项目如果想改造成多LuaState,改动起来成本还是很高的。

这个是比较大的改动,不过不用细说了。

Lua模块

Lua模块除了升级到Lua5.4.3,还有改成了预编译的版本,为了支持C++模式编译。但如果需要修改源码,还要自己在各个平台编译一下,比较麻烦。所以Lua模块我们还是使用老版本的源码方式。

再谈UObject的生命周期

上面分析了新版UnLua中UObject的生命周期的变化,一句话来说就是UnLua不再影响你使用的UObject的生命周期了,你需要使用UE的GC机制去管理。

管理UObject生命周期

在UE中创建的UObject要在UE中加UPROPERTY标签来引用,在Lua中创建的UObject可以自己封装接口AddToRoot或者用UnLua的FLuaEnv::AddManualObjectReference

不过还有个更优雅的方式:

1
2
3
local Class = UE.UClass.Load("xxx")
local Object = NewObject(Class)
local Handler = UnLua.Ref(Object)

其中UnLua.Ref是把UObject加到ObjectReferencer中,并且返回于这个UObject对应的userdata,当这个userdata被Lua GC,就会把UObject的引用去掉。

这样就基本相当于遵循了旧版本UnLua的引用机制,但是可以比较容易地控制使用范围,避免大范围无感知的引用

最终,这样的改动带来的结果是我们项目中再也没有那种不知道怎么引用到的对象泄露了,尤其是UI、图集。

UObject的访问保护

当然,前面还提到了UObject的访问需要保护,仅从GC流程的角度还是不能覆盖全的

首先从GC流程上面只能覆盖到被Lua访问过的UObject,这些UObject会存到ObjectMap。这就有了隐患。如果是没有被Lua访问过的UObject呢,比如UE容器中的UObject指针。

如果某个时刻Lua中先获取了一个TArray<UObject*> 然后存起来,之后再去访问TArray中的成员,这样就会出现野指针。因为里面的UObject没有出现在ObjectMap中,UnLua对它们的GC是无感知的!

不仅是容器中的对象会失效,就连容器本身也会失效。比如某个结构体里面的TArray,你在Lua中直接缓存了TArray的引用,而结构体某个时刻被释放了,但之后你在Lua中仍会访问TArray的成员。

老版本的UnLua也存在这个问题,但是因为没有及时释放容器而导致短时间内不会出错(负负得正了属于是),但长时间运行不知道什么时候就崩了。

还好,最新的UnLua在最新的develop分支增加了针对上面情况的保护,一个是访问TArray的成员时检查有效性,另一个是增加悬垂指针的标记,避免访问已经失效的UStruct/UObject中的成员(尤其是容器)。

UnLua的升级建议

一般可以用最新的release版本2.3.0。有条件的可以用develop分支最新的,因为总会有些小bug出现,需要修修补补。

如果项目本身没有对UnLua有什么改动的话,升级成本其实很小。如果改得很多但仍有一些崩溃问题,建议放弃之前的修改,因为老版本的那些bug在新版都已经解决了。一般自己的修改都是打补丁,很多地方修改并不彻底,反而会遗留难以注意到的bug。

然后影响最大的就是上面说的Lua模块,如果继续用老版本的Lua模块(建议用2.1.4的)会简单很多。

其他方面,为了兼容老版本的使用,UnLua保留了旧版本的主要接口,还提供了一些设置选项可以切换。具体到我们项目的话,从最开始1.0一路升级到现在最新版,中间有很多魔改,最终还是升级到了最新版。

从结果上来看,升级之后UnLua相关的崩溃率降低了很多,基本没有那种不知道为什么的野指针崩溃了,推荐还在用旧版本的朋友们一起使用。

总结

新版的UnLua带来的改变是很大的,不仅修复了旧版设计问题导致的历史遗留bug,还通过重构梳理了代码结构,现在的代码比以前清晰很多,也没有了乱七八糟的补丁代码。这里必须感谢UnLua开发者xuyanghuang的努力。

也欢迎使用UnLua的朋友加入官方QQ群936285107交流。