现象
其实从UnLua1.0起就会偶尔遇到访问UObject上面的property是nil的情况,而且都是刚创建出来的UObject,就遇到了这个问题。
很显然,UnLua并没有每次都通过反射重新读UObject上面的property,而是读取了property的缓存。那就需要研究一下property的生命周期与UnLua是怎么管理并访问property的缓存的。
访问property原理
首先回顾一下UnLua是怎么访问一个UObject的property的。讲这个的文章太多了,UnLua从1.0以来这里核心逻辑其实没什么变化。这里就简单看一下,也不讲绑定了,可以自己看代码int FObjectRegistry::Bind(UObject* Object)
。
在Lua代码中访问UObject的property时,会先走到UObject的Lua实例的元表的Index元方法(2.0起这些代码被放在了UnLuaLib.cpp中)。
这个元表就是我们实现UnLua接口GetModuleName
中返回那个Lua在require
后的Lua module(实际是一份拷贝,而不是那个module本身,目的是避免同时绑定到子类时冲突,可以看bool UUnLuaManager::BindClass(UClass* Class, const FString& InModuleName, FString& Error)
的实现),存在package.loaded
里面。下面可以简称REQUIRED_MODULE
。
这里可以忽略循环找Super,因为一般不会多重继承Lua。
关键点是先获取REQUIRED_MODULE
,然后local p = mt[k]
。获取元表之后用key去访问,触发元表的Class_Index
元方法。这个元方法是来自这个UObject的UClass的元表,是首次绑定UObject时为UClass注册的,里面会在运行时缓存这个UClass的property。
undefined
Class_Index
核心是GetField
,直接进去看。
GetField
是先获取REQUIRED_MODULE
的metatable(就是放在Lua registry里的一个table,可以通过UE.XXX来访问,里面存储UClass的缓存信息FClassDesc等),然后看里面有没有这个property。
首次访问肯定是nil啊,那么看看GetFieldInternal
做了什么。
GetFieldInternal
比较长,我们看截图里这部分就够了。先把刚才拿到的nil pop出去。然后通过mt.__name
来拿这个UClass的名字,是蓝图的话一般是/Game/xxx/xxx.xxx_C
这样的名字。FieldName就是刚才lua中的mt[k]
里的k,是property的名字。接着把ClassName pop出去。
下面通过ClassName获取ClassDesc,没有的话就会注册(其实既然已经绑定了不可能不存在)。然后通过ClassDesc获取这个property对应的Field。
再下面是关键,这里判断了Field->IsInherited()
,如果这个变量是继承来的,就需要到父类的metatable中拿,因为生命周期是跟随父类UClass的。
如果父类metatable中有缓存,就说明是bCached
的,也就是有缓存的。没有缓存的话就会走下面PushField
重新从Class中拿然后再缓存了。
这里就把访问property的流程讲完了。
问题分析
那么问题可能出现在哪里?
- property是自己UClass中的失效的缓存
- property是父类UClass中的失效的缓存
就这两种情况。而且实际上,这两种问题是同时存在的。
首先,只有非Native的UClass才会被gc,其property才有可能失效。所以肯定都是蓝图类型的对象。
其次,不管是不是父类,缓存都存在property所属的UClass的metatable。
那么问题就是为什么UClass失效了,它的metatable没有被清理?
UClass的metatable是在NotifyUObjectDeleted时通过FClassRegistry::StaticUnregister
清理的。
我们应该知道,UE的gc是有过程的,UObject被标记为没有引用到真正被gc清理是需要时间的。所以问题大概率是出在这里。
验证
我们可以构造一个环境,每帧创建蓝图对象,访问其property,然后移除引用等待gc。并且在UnLua蓝图类型的UClass注册和清理的地方增加日志查看时序。
另外问题2是来自父类,所以我们还要让蓝图对象继承自另外一个蓝图。
这样构造之后其实比较容易能够复现出来两个问题。
修复
问题1
问题1的原因是绑定UObject时会PushMetatable
将对应UClass的metatable设置上去,但是这里并没有检查对应UClass的有效性,也就是UClass已经标记为代清理,但还没触发NotifyObjectDeleted事件,所以导致UObject绑定之后立即访问就遇到了失效的property缓存。
因此,在PushMetatable
里面增加检查即可。
这个问题在去年9月提交给了UnLua的Github :
修复PushMetatable时会使用旧的metatable的问题 by jozhn · Pull Request #515 · Tencent/UnLua (github.com)
问题2
问题2原因和1很相似,但是复现概率会小一些,而且蓝图继承蓝图真的很少用。此外,频繁创建销毁也是不合理的操作,不过从逻辑上来说UnLua还是存在漏洞。
这个原因是蓝图B继承了蓝图A,在频繁创建销毁的某一次,B的实例创建之后访问继承自A的property,而A的类型处于BeginDestroy状态,但还没触发NotifyObjectDeleted。因此读到了A类型metatable中缓存的property。
这里为什么会用到旧的metatable呢,理论上GetFieldInternal里面会检查ClassDesc的有效性,无效就会注销并且清理metatable。但是有些情况下,通过FClassDesc::Load
函数触发的重新加载UClass信息,不会清理metatable,所以产生了漏网之鱼。这也是这个问题复现概率更小的原因。
解决办法是针对访问的property来自继承的非Native的UClass时,检查其有效性(实际就是检查UClass的有效性),无效的话就忽略这个缓存,重新PushField
,以取到最新的property。
这个代码目前也提交到了UnLua的Github:
修正:访问来自非native父类的property时检查有效性 #661 by jozhn · Pull Request #664 · Tencent/UnLua (github.com)
我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!