UnLua invalid property问题定位与修复

现象

其实从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。

Pasted image 20231025195838

关键点是先获取REQUIRED_MODULE,然后local p = mt[k]。获取元表之后用key去访问,触发元表的Class_Index元方法。这个元方法是来自这个UObject的UClass的元表,是首次绑定UObject时为UClass注册的,里面会在运行时缓存这个UClass的property。

Pasted image 20231025200459

Class_Index核心是GetField,直接进去看。

Pasted image 20231025201231

GetField是先获取REQUIRED_MODULE的metatable(就是放在Lua registry里的一个table,可以通过UE.XXX来访问,里面存储UClass的缓存信息FClassDesc等),然后看里面有没有这个property。

首次访问肯定是nil啊,那么看看GetFieldInternal做了什么。

Pasted image 20231025201312

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中拿然后再缓存了。

Pasted image 20231025203438

这里就把访问property的流程讲完了。

问题分析

那么问题可能出现在哪里?

  1. property是自己UClass中的失效的缓存
  2. property是父类UClass中的失效的缓存

就这两种情况。而且实际上,这两种问题是同时存在的。

首先,只有非Native的UClass才会被gc,其property才有可能失效。所以肯定都是蓝图类型的对象。

其次,不管是不是父类,缓存都存在property所属的UClass的metatable。

那么问题就是为什么UClass失效了,它的metatable没有被清理?

UClass的metatable是在NotifyUObjectDeleted时通过FClassRegistry::StaticUnregister清理的。

我们应该知道,UE的gc是有过程的,UObject被标记为没有引用到真正被gc清理是需要时间的。所以问题大概率是出在这里。

Pasted image 20231025205025

验证

我们可以构造一个环境,每帧创建蓝图对象,访问其property,然后移除引用等待gc。并且在UnLua蓝图类型的UClass注册和清理的地方增加日志查看时序。

另外问题2是来自父类,所以我们还要让蓝图对象继承自另外一个蓝图。

这样构造之后其实比较容易能够复现出来两个问题。

修复

问题1

问题1的原因是绑定UObject时会PushMetatable将对应UClass的metatable设置上去,但是这里并没有检查对应UClass的有效性,也就是UClass已经标记为代清理,但还没触发NotifyObjectDeleted事件,所以导致UObject绑定之后立即访问就遇到了失效的property缓存。

因此,在PushMetatable里面增加检查即可。

Pasted image 20231025205630

这个问题在去年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。

Pasted image 20231025210430

这个代码目前也提交到了UnLua的Github:
修正:访问来自非native父类的property时检查有效性 #661 by jozhn · Pull Request #664 · Tencent/UnLua (github.com)