[XLua]lua和c#交互

前言

在移动游戏发展到今天,热更技术已经有很多种渠道了,一种是使用lua,一种是c#更新dll,还有最新出来的TypeScript的热更新。这次我就简单的聊些使用率最多的lua热更新中的交互,也希望能说出一些交互上可以优化的东西。

知识大纲

  1. 函数
  2. 值类型
  3. 引用类型
  4. struct类型
  5. string类型
  6. lua和c的交互调用栈
  7. IntPtr类型

初始化

获取全局表

在lua初始化的时候会初始化一个luaEnv对象,他会去c里拿到一个LUA_REGISTRYINDEX这是一个registry的表的索引,这个表是c可以自由使用,但是lua代码不能访问他。

注册lua状态机

使用luaL_newstate创建了一个新的lua状态机(可以理解为lua虚拟机),主要用来为每一个lua线程创建独立的函数栈和线程栈,以及lua所需要的内存管理,字符串管理,gc管理。创建两个主要的数据结构lua_state和global_State。

  • lua_State:主线程栈结构
  • global_State:全局状态机,维护全局字符串,内存管理函数,gc等信息

加载lua标准库

使用luaL_openlibs用来加载lua的标准库到指定的lua状态机。

注册一个用来lua和c#交互的对象

通过创建一个ObjectTranslator对象来做lua和c#的对象交互,传入一个luaEnv对象,和一个lua状态机的指针。创建了一个弱value的一个table放在lua中。注册lua对象回收中__gc的回调方法。使用lua_pushlightuserdata创建一个自己管理的xlua标志位。注册所有被此应用所用的所有程序集到assemblies中

注册xlua全局表

translator.OpenLib(rawL);
import_type这个方法为lua中获取全局c#方法的回调函数

注册一个lua异常捕获

LuaAPI.lua_atpanic(rawL, StaticLuaCallbacks.Panic);使用这个方法注册一个在c#中去处置lua程序崩溃的方法

注册一个lua的print方法

LuaAPI.lua_pushstdcallcfunction(rawL, StaticLuaCallbacks.Print);LuaAPI.xlua_setglobal(rawL, "print")
注册一个print的方法注册到global全局表中的print

初始化Init脚本

注册一个全局的CS表,设置CS的元方法。设置xlua的元方法。

获取_G全局表

LuaAPI.xlua_getglobal(rawL, "_G");translator.Get(rawL, -1, out _G);获取_G

注册类

通过Wrap中的__Register的方法用来注册当前这个类的实例化函数,静态函数,属性,静态属性,实例化类的方法

注册函数

通过BeginObjectRegister在栈顶注册一个class.FullName名字的metatable表。给他塞入一个xlua的标志,覆盖两个关于“gc”和“tostring”的元方法。创建三个table,用来存放类的函数,设置属性方法,获取属性方法。调用RegisterFunc把所需要的函数方法注册进去。最后在结束的时候替换掉“index”和“newIndex”这两个元方法

注册静态函数

通过BeginClassRegister中把当前类的静态函数注册到table中,然后通过SetCSTable把当前类名的table放在cstable下,然后替换元方法__call为c#的函数,最后创建两个table用来放置静态属性;还是通过RegisterFunc把静态属性的获取和设置添加进去。

加载lua脚本

DoString

通过xluaL_loadbuffer编译lua脚本,通过lua_setfenv设置当前lua编译脚本为传进来的table的upvalue值,然后通过Lua_pcall执行编译之后的脚本,放回栈顶的对象。

Lua和c#交互

c#调用lua方法

在DoString之后把DoString所执行的脚本设置到一个luatable对象里面,然后通过这个luatable对象去lua栈里拿到luatable所在的table。在tryGetGetFuncByType发现我们要转换的类型不是我们定义的那些基础类型,所以通过GetObject去获取到一个转换委托ObjectCast这个委托函数里,然后通过需要转换到类型调用不同的转换函数,函数就是通过调用CreateDelegateBridge来创建一个action委托的,委托里包含了lua方法的索引。当函数调用的时候就是调用委托里的PCall来调用到lua中的方法。因为在tryGetGetFuncByType没有注册,所以使用了反射去创建当前类型的对象。

c#获取lua值类型

也是在tryGetGetFuncByType里把int ,bool,float这的值类型通过从lua栈里push出number,boolean,integer的值通过类型转换变换出来。

c#获取lua的table类型

当把table定义成一个list对象的时候,它也和调用函数一样回去判断有没有在tryGetGetFuncByType里定义了这个list类型的ObjectCast,如果没有也是通过反射在genCaster里去创建这个对象,把lua栈内的值取出来。

c#获取lua的string

当需要在lua中使用到c#的string时,把string转成byte[]传到lua中。
当把lua中的string传到c#的时候,也是把string转成byte[]接收过来,转成string

lua调用函数

在init的时候生成的时候会设置import_type为CS的index方法中的查找到c#里的ImportType去根据className去translator.FindType找到当前这个className到类型,然后通过GetTypeId去找放回类型的唯一id,如果这个类型没有缓存,那就会去生成一个当前类型名字的metatable,然后通过DelayWrapLoader中注册的loader去加载生成的wrap类的Register生成的当前所需要的方法的metatable,给lua调用,然后把类型的id传到交互栈中。当调用这个方法的时候就可以通过在RegisterFunc的时候push进去的lua_pushstdcallcfunction来掉到这个函数了。

lua调用值类型

当lua需要设置值类型的时候,同样会定义一个属性赋值方法,通过函数调用的方法调用到这个方法,然后通过lua_tointeger去栈里拿到这个方法一起传过来的number类型的值,然后赋值过去。
当lua需要设置值的时候,还是定义一个属性获取的方法,通过函数调用先通过Get方法从userdata中获取到这个需要赋值的对象,然后通过UnPack来把这个需要赋值的属性取出来,然后修改掉对应的属性;最后通过push把属性塞到lua栈中

lua调用struct类型

当lua需要获取一个struct类型的值时,因为lua中没有struct这样的结构类型所以会把struct包成一个object传到lua栈中当userdata存下来。
当lua需要设置一个struct类型的值时,获取到栈内的userdata然后存成ObjectCast然后转成struct,取lua栈内第二个参数来设置struct的值

但是在xlua中vector和color这两种struct会做一次处理,会把里面的struct的结构内容给拆分出来,通过基础的值类型进行传递。

lua调用引用对象

通过addObject把创建出来的object存在objecttranslator中,返回一个存放的index,然后把这个index传到xlua_pushcsobj里去创建一个userdata并且把这个对象的方法,属性都传进去做metatable属性。然后就可以获取到这个userdata赋值给lua。

lua和c#相互引用对象的生命周期

通过上面知道在c#中需要被lua所调用到的引用类型对象都需要在objecttranslator中存起来,然后把所生成的index传到lua的userdata中去,然后给这个userdata设置了一个元表,修改了gc这个元方法,所以当lua的userdata不在被引用的时候就会触发lua的gc,然后调用到gc这个元方法,调用到ObjectTranslator中的collectObject然后保存列表中清理掉。
在c#获取到lua的对象需要创建一个object的对象,然后把lua中的reference通过luaL_ref获取到保存下来,然后当这个对象不在被引用的时候,调用到c#的dispose函数,然后在里面去LuaAPI.lua_unref释放掉这个lua对象的引用。

总结

通过上面的数据交互,我们可以知道,在lua和c#的交互,我们需要注意到引用类型的交互,因为他们会分别在lua端和c#端创建一个objcet和table(userdata)对象,而这两种对象都会产生gc压力,还有一个就是struct对象也是需要注意的,虽然在xlua中vextor会拆成基础值类型传递,但是大部分我们自定义的struct还是会被包装成一个object传递的,这里还产生了一个装箱操作,所以消耗会更大。

[lua]upvalue和闭包

概念

当一个函数内部A嵌套另一个函数B定义时,内部的函数体可以访问外部的函数的局部变量,这种特征我们称为词法定界,B中被A访问到的变量我们称为upvalue。而闭包是指一个函数加上它可以正确访问到的upvalue。技术上来讲,运行时,我们执行的是闭包,而不是函数,函数仅仅是闭包的一个原型声明;闭包在完全不同的上下文中也是很有用途的。因为函数被存储在普通的变量内我们可以很方便的重定义或预定义函数。通常当你需要原始函数有一个新的实现时可以重定义函数。upvalue你可以理解成是一块特殊的内存,独立管理内部数据的,所以可以在多个闭包之间提供一种数据共享的机制。

示例解释

upvalue

function f1(n)
    local function f2()
        print(n)
    end
    return f2
end
local g1 = f1(11)
g1()                    --打印11
local g2 = f2(22)
g2()                    --打印22
g1()                    --打印11

在上面的例子里,我们可以看出来当g1 = f1(11)这个被执行完的时候,局部变量n的生命周期应该是结束了的。但是因为它成为了f2函数的upvalue,所以它的生命周期跟着g1的生命周期一起了,被延长了。下面的g2是重新执行了一遍f2所以它重新包了一个n做upvalue,这个时候upvalue的数据没有共享。

闭包

function f1(n)
    local function f2()
        print(n)
    end
    n = n+10
    return f2
end
g1 = f1(10)
g1()                    --打印20

在上面的这个例子里,我们发现n在包在f2内部的时候,成为f2的upvalue,但f1还没执行完成n还是f1的局部变量,所以执行了n = n+10的这个的时候,n的变化也在f2执行的时候被表现出来的。

upvalue的数据共享

function f1(n)
    local function f2()
        print(n)
    end
    local function f3()
        n = n +10
    end
    n = n+1
    return f2,f3
end
local g2,g3 = f1(1)
g2()                        --打印2
g3()
g2()                        --打印12

从上面的例子我们可以发现,当f1执行结束之后,lua会把内部所引用的n做成一个upvalue来作为f2和f3的upvalue,所以当我们g3调用了n = n+10的时候,n的改变也就改了那共同upvalue内的n,所以可以被g2函数所打印出来变化。

function f1(n)
    local function f4()
        local function f2()
            print(n)
        end
        local function f3()
            n = n +10
        end
        return f2,f3
    end
    n = n+1
    return f4
end
local g1 = f1(1)
local g2,g3 = g1()
g2()                    --2
g3()
g2()                    --12

所以我们就很容易理解上面这个例子为啥还是一样的打印结果,因为upvalue在一个函数内就只会有一份相同的。

总结

闭包这个概念在很多语法中都有的,之前有一点点了解,最近在做一些lua的内存泄漏优化的时候,发现因为一些全局表的函数引用,导致了一些传入进去的值被引用到,没办法释放,引起了一些内存上的增长,所以我们在给函数传值的时候,需要注意到这个函数什么时候被释放的,是不是不会被释放掉,如果它在一个全局table中,那么它所引用到的值我们就需要手动释放。关于upvalue的lua中是怎么实现的,之后有机会讲解一下源码。

[lua]关于lua中函数点和冒号的区别

前言

在lua中创建一个可以被外部访问的函数有两种方式,一种是table.function一种是table:function这两种函数的调用也是可以使用点和冒号两种方式调用的。

第一种使用table.function创建函数

local f = {}
function f.test(self,x,y) 
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require(f)
f.test(f,3,4)

打印出来的值是,self为f这个对象,x为3,y为4

使用冒号去访问

local f = require(f)
f:test(3,4)

打印出来的self还是这个对象,x为3,y为4

结论

当我们在创建一个方法的时候,我们时候点创建然后用冒号访问的时候,会默认把当前所被使用的对象给传进来。

第二种使用table:function创建函数

local f = {}
function f:test(x,y)
    print(self)
    print(x)
    print(y)
end
return f

使用点去访问

local f = require("test")
t.test(3,4)

打印出来的值为:3,4,nil;

local f = require("test")
t.test(f,x,y)

这个时候打印出来值为f,3,4

使用冒号去访问

local f = require("test")
t:test(3,4)

打印出来的是:f,3,4

结论

在这个测试中可以表明,使用冒号创建一个函数的时候,在使用点去调用的时候,会默认在第一个参数前加一个self的参数。

[编程基础]luaGC简介

前言

在研究了一段时间的GC后,发现自己使用的两种语言,lua和c#的GC实现分别是两种高级GC算法,一个是增量式,一个是分代式。前者是标记清除,后者是标记压缩。

luaGC

在lua中,GC为标记清除增量式的,系统管理着所有已经创建了的对象,每个对象都有对其他对象的引用。root集合代表着已知的系统级别的对象引用。我们从root出发,就可以访问到系统引用到的所有对象。而没有被访问到的对象就是垃圾对象,需要被销毁。

三色垃圾回收

在GC中我们把所有的对象分为三个状态:
1. White状态,也就是待访问状态。表示对象还没有被垃圾回收的标记过程访问到。
2. Gray状态,也就是待扫描状态。表示对象已经被垃圾回收标记过程访问到了,但是对象本身对于其他对象的引用还没有进行遍历访问
3. Black状态,也就是已扫描状态。表示对象已经被访问到了,并且也已经遍历了对象本身对其他对象的引用。
基本的算法可以描述如下:

当前所有对象都是White状态;
将root集合引用到的对象从White设置成Gray,并放到Gray集合中;
while(Gray集合不为空)
{
    从Gray集合中移除一个对象0,并将0设置成Black状态;
    for(0中每一个引用的对象01){
        if(01在White状态){
            将01从White设置成Gray,并放到Gray集合中;
        }
    }
}
for(任意一个对象0){
    if(0在white状态)
        销毁对象0;
    else
        将0设置成White状态;
}

Increment Garbage Collection

上面的算法如果一次性执行,在对象很多的情况下,会执行很长时间,严重影响程序本身的响应速度。其中一个解决办法:把上面的算法分步执行,这样每个步骤所消耗的时间就比较小了。我们可以将上述的算法改为以下几个步骤。
首先标示所有root对象:
1. 当前所有对象都是White状态;
2. 将root集合引用到的对象从White设置成Gray,并且放在Gray集合中。
遍历访问所有gray对象。如果超出了本次计算量上限,退出等待下一次遍历:

while(Gray集合不为空,并且没有超出本次计算量的上限){
从Gray集合中移除一个对象0,并将0设置成Black状态;
for(0中每一个引用到的对象01){
if(01在White状态){
将01从White设置成Gray,并放到Gray集合中;
}
}
}

销毁垃圾对象:

for(任意一个对象0){
    if(0在White状态)
    销毁对象0;
    else
    将0设置成White状态;
}

在每个步骤之间,由于程序可以正常执行,所以会破坏当前对象之间的引用关系。black对象表示已经被扫描的对象,所以他应该不可能引用到一个white对象。当程序的改变使得一个black对象引用到一个white对象时,就会造成错误。解决这个问题的办法就是设置barrier。barrier在程序正常运行过程中,监控所有的引用改变。如果一个black对象需要引用一个white对象,存在两种处理办法:
1. 将white对象设置成gray,并添加到gray列表中等待扫描。这样等于帮助整个GC的标示过程向前推进了一步。
2. 将black对象改成gray,并添加到gray列表中等待扫描。这样等于使整个GC的标识过程后退了一步
这种回收垃圾回收方式被称为“Incremental Garbage Collection”(简称“IGC”)lua所采用的就是这种方法。使用“IGC”并不是没有代价的。IGC所检测出来的垃圾对象集合比实际的集合要小,也就是说,有些在GC过程中变成垃圾的对象,有可能在本轮GC中检测不到。不过这些残余的垃圾对象一定会在下一轮GC被检测出来,不会造成泄漏。

GCObject

lua使用union GCObject来表示所有的垃圾回收对象:

union GCObject{
    GCheader gch;
    union TString ts;
    union Udata u;
    union Closure cl;
    struct Table h;
    struct Proto p;
    struct UpValue uv;
    struct lua_state th;
}

#define CommonHeader GCObject *next;lu_byte tt;lu_byte marked

typedef struct GCheader {
    CommonHeader;
}GCheader;

marked这个标志用来记录对象与GC相关的一些标志位,其中0和1用来表示对象的white状态和垃圾状态。当垃圾回收的标识阶段结束后,剩下的white对象就是垃圾对象。由于lua并不是立即清除这些垃圾对象,而是一步一步逐渐清除,所以这些对象还会在系统中存在一段时间。这就需要我们能够区分同样为white状态的垃圾对象和非垃圾对象。lua使用两个标志位来表示white,就是为了高效的解决这个问题。这个标志位会轮流被当作white状态标志,另一个表示垃圾状态。在global_Static中保存着一个currentwhite,来表示当前是哪个标志位用来标识white。每当GC标识阶段完成,系统会切换这个标志位。这样原来为white的所有对象不需要遍历就会变成垃圾对象,而真正的white对象则使用新的标志位标识。
第二个标志位用来表示black状态,而既非white也非black也就是gray状态。
除了short string 和open upvalue之外,所有的GCObject都是通过next被串接到全局状态global_State中的allgc链表上。我们可以通过遍历allgc链表来访问系统中的所有GCObject,short string 被字符串标单独管理,open upvalue会在被close时也连接到allgc上。

引用关系

垃圾回收过程通过对象之间的引用关系来标识对象。以下是lua对象之间在垃圾回收标识过程中需要遍历的引用关系:

所有字符串对象,无论长串还是短串,都没有对其他对象的引用。
uesdata对象会引用到一个metatable和一个env table。
Upval对象通过v引用一个TValue,再通过这个TValue间接引用一个对象。在open状态下,这个v指向stack上的一个TValue。在close状态下,v指向Upval自己的TValue。
table对象会通过key,value引用到其他对象,并且如果数组部分有效,也会通过数组部分引用。并且table会引用一个metatable对象。
lua closure会引用到proto对象,并且会通过upvalue数组引用到Upval对象。
c closure会通过upvalues数组引用到其他对象,这里的upvalue与lua closure的upvalue完全不是一个意思。
Proto对象会引用到一些编译器产生的名称,常量,以及内嵌于本Proto中的Proto对象。
thread对象通过stack引用其他对象

barrier

在igc的mark阶段,为了保证所有black对象都不会引用white对象这个不变性,需要使用barrier。
barrier被分为“向前”和“向后”两种。
luaC_barrier_函数用来实现“向前”的barrier。“向前”的意思是当一个black对象需要引用一个white对象时,立刻mark这个white对象。这样white对象就变成gray对象,等待下一步的扫描。这也就是帮助GC向前标识一步。luaC_barrier_函数被用在以下引用变化处:

  • 虚拟机执行过程中或者通过api修改close upvalue对其他对象的引用
  • 通过api设置userdata或table的metatable引用
  • 通过api设置userdata的env table引用
  • 编译构建proto对象过程中proto对象对其他编译产生对象的引用

luaC_barrierback_函数用来实现“后退”的barrier。“向后”的意思就是当一个black对象引用一个white对象时,将已经扫描过的black对象再次变成gray对象,等待重新扫描。这也就是把gc的mark后退一步。luaC_barrierback_目前只用来监控table的key和value对象引用的变化。table是lua中最主要的数据结构,练全局变量都是被保存在一个table中,所以table的变化是比较频繁的。并且同一个引用可能被反复设置成不同的对象。对table的引用使用“向前”的barrier,逐个扫描每次引用变化的对象,会造成很多不必要的消耗。而使用“向后”的barrier就等于将table分成了“未变”和“已变”两种状态。只要一个table改变了一次,就将它变成gray,等待重新扫描。被变成gray的table在被重新扫描之前,无论引用再发生多少次变化也都无关紧要了。
引用关系变化最频繁的要数thread对象了。thread通过stack引用其他对象,而stack作为运行期栈,在一直不停地被修改。如果要监控这些引用变化,肯定会造成执行效率严重下降。所以lua并没有在所有的stack引用变化处加入barrier,而是直接假设stack就是变化的。所以thread对象就算被扫描完成,也不会被设置成black,而是再次设置成gray,等待再次扫描。

Upvalue

Upvalue对象在垃圾回收中的处理是比较特殊的。
对于open状态的upvalue,其v指向的是一个stack上有TValue,所以open upvalue与thread的关系非常紧密。引用到open upvalue的只可能是其从属的thread,以及lua closure。如果没有lua closure引用这个open upvalue,就算他一定被thread引用着,也已经没有实际意义了。应该被回收掉。也就是说thread对于open upvalue的引用完全是一个弱引用。所以lua没有将open upvalue当作一个独立的可回收对象,而是将其清理工作交给从属的thread对象来完成。在mark过程中,open upvalue对象只使用white和gray两个状态,来代表是否被引用到。通过上面的引用关系可以看到,有可能引用open upvalue的对象只可能被lua closure引用到。所以一个gray的open upvalue就代表当前有lua closure正在引用它,而这个lua closure不一定在这个thread的stack上面。在清扫阶段,thread对象会遍历所有从属自己的open upvalue。如果不是gray,那就说明没有lua closure引用这个open upvalue,可以被销毁。
当退出upvalue的语法域或者thread被销毁,open upvalue会被close。所有close upvalue与thread已经没有弱引用关系,会被转化为一个普通的可回收对象,和其他对象一样进行独立的垃圾回收。

__GC

对于lua5.0以后的版本支持userdata,它是可以带有__gc方法,当userdata被回收时会调用这个方法。所以一遍标记是不够的。不能简单的把变成垃圾的userdata简单剔除,那样就无法正确的调用__gc了。所以标记流程需要分两个阶段做。第一阶段把包括userdata在内的死亡对象剔除出去。然后在死对象中找回有__GC方法的,对它们再做一次标记复活相关的对象,这样才能保证userdata的__gc可以正确运行。执行完__gc的userdata最终会在下一轮gc中释放(如果没有在__gc中复活)。userdata有一个单向标记,标记__gc方法是否有运行过,这可以确保userdata的__gc只会执行一次,即使在__gc中复活(重新被root集合引用),也不会再次分离出来反复运行finalizer。也就是说,运行过finalizer的userdata就永久变成了一个没有finalizer的userdata了。