lua和cs交互优化

整个思路的核心就是:

1、通过Lua_topointer,直接获取Lua table的内存指针。
2、由于Lua/LuaJIT的table内存结构是可以确认的,我们可以对照其C代码在C#中声明结构体,这样就可以通过table指针拿到array的指针以及array的长度。
3、但是,这里有一个难点,就是要处理Lua/LuaJIT的差异,以及在不同编译选项下产生出来的32位、64位的差异。所以可以看到我们是分LuaAdapter.cs和LuaJitAdapter.cs两套实现,并且各自提供了32/64位的结构体声明。
4、不管是Lua还是LuaJIT,array数组存储的不是int或者double,而是一个叫TValue的联合体,TValue除了存储数值本身,还存储了类型信息。我们在读写的时候,需要先判断类型信息,不然就会无法获得正确的结果。
5、在了解这些信息之后,整个过程就是:拿到table指针,用对应平台的结构体指针获得array指针,再通过数组index拿到array中正确位置的TValue,最后根据TValue的类型信息获得/写入int或者double。

文章最后提供了实现的下载链接,此处演示如何使用这个库。

Lua端:

C#端:

具体使用流程:

  • 参考附录中的方法将代码整合到游戏中。
  • local luaTable = LuaCSharpArr.New(123)会产生一个长123的Lua数组,长度仅仅是预分配,可以正常扩充,这个数组跟正常的数组是基本一样的,仅仅在里头内嵌了一个供C#使用的访问器LuaArrAccess类。
  • 在Lua端可以直接像普通数组一样访问,例如LuaTable[12] = 34.56。
  • local arrAccess = luaTable:GetCSharpAccess(),通过这个方式获得C#访问器
  • 自己实现一个C#函数并导出到Lua,用这个函数将C#访问器从Lua传递到C#。
  • 在C#中拿到这个访问器,然后可以使用arrAccess.SetInt(12, 34)、arrAccess.GetDouble(12)这样的方式去读写数据。    

如何应用

有了这个跨语言共享数据的方案,我们就可以大胆将主要的数据放在Lua层,而不用再为跨语言性能而过度牺牲热更代码的覆盖率。具体怎么设计代码的架构来利用这个数据共享方式呢?

以《赤潮》这样的产品为例:

  • 同屏角色200+,必须高度考虑性能。
  • 我们使用了帧同步,一旦线上发现不同步的bug是致命的,必须支持热更新修复。
  • 每月我们会推出新兵种,每隔几个月会推出新玩法地图和节日地图,无论兵种逻辑还是地图规则,都需要一定的热更新能力,减少发布完整包更新的必要性。

在以往的方案里,由于性能和热更新两者高度矛盾,要同时做到以上的点,十分困难。 而现在,借助跨语言数据共享,我们可以这样实现:

  • 常用的逻辑数据(比如角色属性/角色状态/buff信息等等),用数组存储放在Lua,并通过本文源码中的LuaArrAccess共享到C#内。
  • 主体战斗逻辑使用C#实现,保证基本性能。
  • C#逻辑直接通过LuaArrAccess读写Lua中的数据,而不是自己复制一份。这样,无论Lua还是C#,都可以自由访问这些共享的信息。
  • Lua申请一个巨大的数组,共享到C#中,C#可以通过这个数组回传事件消息,Lua可以响应来自C#的事件,性能比使用C#导出delegate更优。
  • Lua实现网络同步逻辑,以便支持随时的热更同步逻辑,以及热更网络协议。
  • Lua处理策划数据表,以便支持随时热更策划数据结构变更。
  • Lua处理整个玩法大规则,以便我们在推出新玩法的时候,可以做到热更新。一般而言,玩法大规则的运算量比较小,并非性能瓶颈,但需要更战斗逻辑有大量数据交互。
  • Lua作为战斗逻辑的大入口,有权限管理所有角色/地图/组件的开关以及生命周期,为hotfix提供最大的便利。
  • 对一些可以模板化的代码,比如技能逻辑/AI节点等,支持Lua/C#两种实现方式,以便做到新特性可以直接热更。

这一切之所以可行,得益于我们可以低消耗跨语言传递数据,否则,我们放在Lua的功能(网络同步/数据表/玩法规则/声明周期管理/技能AI模板节点),会因为需要频繁跟C#交互,产生巨大的性能瓶颈。 可见,高效地在两个语言之间共享数据,是非常重要的。


此外,如果你的项目本身是用Lua做的,要迁移到C#,你也可以利用这个共享机制很轻松地将局部代码转移过去。

性能对比

由于在Lua和C#间传递数据的方法很多,我们对比一下通过传统C#版Lua导出传参,以及在C导出API给Lua直接读写C内存的性能,进行对比。

环境:Windows + i7 + Unity 2018.3.11f1,Lua使用5.3.5,LuaJIT使用2.1.0beta3(启用interpreter模式)

1、可以看到,新方案(操作1/2和操作3/4)比起传统直接Lua调用C#传递数据(操作6),性能优化非常大。新方案在两个语言都做到近乎原生读写的性能,读写成本基本不再是瓶颈。因此,将代码按需求分布在Lua和C#之间,将成为可能。

2、其中,C#端的操作3/4性能会较慢,主要原因在于两方面: 

  • Lua和LuaJIT都区分int和double存储,C#操作需要进行判断。其中LuaJIT的判断较为复杂,耗时也会更多。
  • 为了提高易用性和安全性,本文的源码增加了一些保护,这些保护并非必须。如果希望追求更高的效率,可以参考附录自行调整。

3、而Lua原生读写数组(操作1)的效率也比通过C API访问共享内存(操作5)要高一些,提升效率大概是2~3倍的水平。而C#端我们没有对比,因为我们的实现本质就是访问共享内存的方式,所以性能本质上是一样的,完全看共享内存存储方式的具体实现(比如是否像Lua一样需要判断数据类型)。另外考虑到我们访问不需要编译C代码,所以这个方案也是非常有优势的。

4、另外,由于数据是共享的,就没有必要在Lua和C#之间分配两套内存空间存数据,也就提供了节省内存的可能性。

5、事实上,了解Lua底层的朋友会知道,Lua数组(采用1~n连续整数键值的表)比Lua hashtable(即有带命名字段的表)访问起来要快,且节省内存(TValue的内存占用大概是Node+Key/Value的四分之一)。所以即使使用面向对象的方式开发,从性能最优的角度,我们也鼓励用Lua数组的方式来替代Lua hashtable的方式,如下代码所示: 

 

附录:使用细节说明及源码下载

1. 如何整合插件

a) 由于代码使用了unsafe code,所以需要在Unity的player settings勾选Allow unsafe code。
b) 下载本文附件中的代码,将LuaAdapter.cs和LuaJitAdapter.cs拷贝到工程的任意目录。
c) 代码默认按照xLua的标准开发,但如果你使用的不是xLua也很容易集成。

  • 将xLua相关的API替换为对应的API;
  • 向Lua导出LuaArrAccessAPI/LuaArrAccess/LuaTablePin64/LuaJitTablePin共4个类;
  • 由于xLua导出的C#类在Lua中都是按CS.XXX的命名调用,所以需要将LuaCSharpArr.lua.txt内的CS.LuaTableCSharpAccess替换为正确的命名,比如uLua为LuaTableCSharpAccess。

d) 在Lua初始化后,调用LuaTableCSharpAccess.RegisterPinFunc(L);注册函数,其中参数L是lua state的IntPtr。
e) LuaCSharpArr.lua.txt中包含了LuaCSharpArr的整个定义声明,可以直接require使用,如果你的Lua代码require机制不同,将代码复制到你的Lua可以访问到的地方即可。
f) 参考示例代码LuaTestScript.lua.txt以及LuaTestBehaviour.cs使用Lua端的LuaCSharpArr和C#端的LuaArrAccess。
g) 如果想运行测试用例,在空场景中建立一个GameObject,将LuaTestBehaviour拖进去,并将里头的LuaScript字段附上LuaTestScript.lua.txt即可。

2. 理解Lua array与hashtable

a) 你需要知道,Lua table实际内部是包含一个数组(array)和hashtable来共同存储所有数据的。其中array用于存储键值为1~n的数据,且1~n必须连续。而其他的数据,会被存放在hashtable内。
b) 必须要注意,Lua数组要求key必须是连续的整数(1~n),如果中间有空洞,那么可能出现的情况是后面的数据会被放到hashtable存储,也就无法在LuaArrAccess读取,所以我们提供了预分配机制,防止自己插入的时候出现失误。

3. 安全读写与访问

a) 要大规模在工程中使用,那么代码的安全性就很重要,将lua底层数据结构暴露这个事情,本身会破坏Lua的安全性,错误的操作可能会导致严重的内存错误。因此我们提供的实现做了一些机制避免出现问题。

  • LuaArrAccess(C#访问器)会正确地响应LuaCSharpArr(Lua端数组)被GC的情况。当LuaCSharpArr被GC时会触发LuaArrAccess.OnGC(),将C#对LuaCSharpArr的引用指针置空。此时LuaArrAccess处于InValid状态,读取数值会返回0,写入数值会被忽略。
  • Lua分配过的内存是保证地址的可持续性的,也就是你用指针引用的数据不会突然间被转移到其他的内存位置。
  • 前文提到LuaCSharpArr.New会进行预分配,防止数组空洞。
  • LuaArrAccess会检查数组越界。

b) 使用注意

  • 你可以在Lua中引用LuaCSharpArr,但是不要在Lua直接强引用LuaCSharpArr:GetAccess返回的LuaArrAccess。强引用LuaArrAccess会导致C#端不能正确响应Lua array被GC的情况。
  • Lua array的扩展只能发生在Lua端。也就是如果Lua array长128,你不能在C#端通过LuaArrAccess设置第129项来扩展数组长度。
  • LuaArrAccess:GetArrayCapacity()返回的长度是Lua底层预分配的长度,并不是你在Lua中用#运算符获取的数组长度。一般GetArrayCapacity返回的值是2的n次方,比#返回的值更大。在这个范围内读写是内存安全的。
  • 你可以通过Lua的#运算符获取数组长度,确认数组是否有空洞。如果有空洞,则#返回的长度只会等于空洞前面的长度。
  • LuaArrAccess使用与Lua一致的index,也就是从1开始,而不是从0开始。但注意,LuaJIT允许从0开始,这个也是LuaArrAccess支持的。

4. 进一步的性能提升

a) LuaArrAccess的代码为了易用性和安全性,牺牲了相当程度的性能,读者如果对性能有更高要求并且有意愿修改源码的话,可以尝试以下方法提高性能。

  • 跳过Index检查以及null指针检查:如果代码能够保证数组非固定长度;
  • 使用GetIntFast和GetDoubleFast函数:该系列函数不检查index范围,不检查double和int类型,性能极致高效,但是读者使用需要相当注意,尤其是int和double的处理要十分小心;
  • 使用C语言重新实现LuaArrAccess:使用C可以获得比C#更好的性能,但是需要读者去修改和编译Lua/LuaJIT的C代码,限于篇幅关系,这里不提供详细说明,读者可以参考LuaArrAccess的代码直接翻译到C代码。

5. 其他问题

a) 目前提供的实现,只支持数组存储int/double,不支持其他类型(bool/string等)。由于Lua/LuaJIT在默认编译方式下使用double存储浮点数,所以不提供float相关的接口。
b) 代码只支持读写数组,无法读写hashtable。这里也额外提一点,Lua hashtable虽然使用便利,但是在读写效率以及内存占用上,都比lua array要差不少。所以在我们的项目实践中,会有大量的Lua class使用array来存储字段。
c) 事实上这个方法可以举一反三,推广到用于直接在C#绑定访问lua中的某个表的一个字段,或者访问完整的key-value table,不过由于table访问的复杂性,实现起来会相对复杂一些,不在本文讨论的范围内,读者可以进一步探索。
d) 本文提供的代码并非文中提到的《赤潮》所使用的版本,因为《赤潮》使用的代码基于ulua+luajit2.1.0beta2,也未实现文中所提到的安全访问特性,且需要修改LuaJIT源码,所以代码会有大量不同。
e) 本文的源码已通过以下平台测试(xLua 2.1.14 + unity2018.3.5)。

  • Windows x86/x64,Lua+LuaJIT
  • Android il2cpp armv7/arm64,LuaJIT
  • Ios il2cpp arm64,LuaJIT

f) 另由于Lua/LuaJIT、xLua/sLua/uLua多版本差异以及Mono/il2cpp/跨平台带来的复杂性,本文代码无法确保完全覆盖所有版本和所有平台的组合情况,如果使用中遇到问题,欢迎在下方留言反馈。

原文地址:https://www.cnblogs.com/chenggg/p/11186032.html