万字长文:从源码学习GopherLua与工程实践

作者:norvallu,腾讯IEG运营开发

| 导语 本文先简单介绍GopherLua和使用场景,然后从GopherLua源码去分析介绍lua的虚拟机原理,接着介绍GopherLua在项目中要如何设计虚拟机缓存和如何设计脚本模块以,最后介绍lua性能优化的一般方法。本文的许多讲解都在源码注释中,需要用户有一定的golang和lua基础。本文适合有一定golang和lua开发经验的读者。

一、GopherLua简介

1.1 GopherLua起源

全称:VM and compiler for Lua in Go,作者是一个日本人,作者用纯go重写了lua虚拟机,可读性要比c语言版本好很多。

1.2 GopherLua使用场景

GopherLua适用于golang语言环境的项目开发中,虽然也可以用cgo去引用c语言版本的lua,但是总是没那么纯粹,使用起来也没那么方便。 如果一个系统的工作流程比较固定,但是各个流程内在逻辑多变,或者业务要求支持快速验证,快速变更,热加载上线,那么就非常适合引入lua语言。在系统逻辑中,逻辑比较固定的部分或逻辑比较复杂的部分,可以用go语言写好模块。系统启动后,提前加载模块到lua虚拟机,多变的业务需求部分,可以用lua脚本去实现,lua只负责穿针引线,这样,整个系统可以达到性能和灵活之间的平衡。

1.3 GopherLua性能

据测试,在golang语言环境的所有脚本中,GopherLua性能目前也是最高的,详细性能对比可以看Yaegi,让你用标准 Go 语法开发可热插拔的脚本和插件。这里粘贴了文章中性能测试对比图:

官网的benchmark,测试对比如下图:

lua性能为什么这么高,得益于lua虚拟机足够简单和lua虚拟机指令的设计方式上。

1.3.1 Stack Based VM

目前,大多数的虚拟机都采用传统的 Stack Based VM,如 JVM、Python,该模型的指令一般都是在当前 stack 中获取和保存操作数。例如,一个简单的加法赋值运算 a=b+c,对于该模型,一般会被转化成如下的指令。

push b; // 将变量b的值压入stack
push c; // 将变量c的值压入stack
add; // 将stack顶部的两个值弹出后相加,然后将结果压入stack顶
mov a; // 将stack顶部结果放到a中

由于 Stack Based VM 的指令都是基于当前 stack 来查找操作数的,这就相当于所有操作数的存储位置都是运行期决定的,在编译器的代码生成阶段不需要额外为在哪里存储操作数费心,所以 stack based 的编译器实现起来相对比较简单直接,也正因为这个原因,每条指令占用的存储空间也比较小。但是整体指令的长度会增加很多。内存复制的动作也会大大增加。

1.3.2 Register Based VM

Lua 采用的是Register Based VM,指令都是在已经分配好的寄存器中存取操作数,对于上面的运算,Register Based VM 一般会使用如下的指令。

add a b c; // 将b与c对应的寄存器的值相加,将结果保存在a对应的寄存器中

Register Based VM 的指令可以直接对应标准的 3 地址指令,用一条指令完成了上面多条指令的计算工作,并且有效地减少了内存复制操作,这样的指令系统对于效率有很大的帮助。

二、从源码学习虚拟机原理

2.1 GopherLua基本功能和用法

要阅读一个项目源码,首先可以体验一下他的使用方法和主要功能。GopherLua的入门,请参考文章《当go遇上lua》

2.2、GopherLua的核心数据结构

2.2.1. 虚拟机对象LState

虚拟机对象可以随意的创建,对象与对象之间是完全隔离的。lua的大部分api的第一个参数就是虚拟机对象。

type LState struct {G       *Global //全局变量,详情请看后面的注释Parent  *LState //父协程,在协程模式时指向父协程Env     *LTable //虚拟机的环境,正常会赋值G.GLobal表    //省略部分非关键字段 ...reg          *registry //虚拟寄存器组,实际上里面就是一个TValue数组封装成一个栈对象,这是函数帧运行的舞台stack        callFrameStack //调用栈,函数调用帧组成的栈currentFrame *callFrame //当前正在运行的函数帧uvcache      *Upvalue //闭包变量链表,实际运行的时候,闭包变量都会被getupval指令拷贝到函数帧中mainLoop     func(*LState, *callFrame) //虚拟机执行指令的函数}type Global struct {MainThread    *LState //指向主虚拟机CurrentThread *LState //指向当前的虚拟机,用在lua协程中Registry      *LTable //注册表,里面存储的是预注册的模块或模块加载器Global        *LTable //著名的_G表,存储lua所有全局变量builtinMts map[int]LValue //内置的某些数据类型的元表,如string的元表tempFiles  []*os.File //临时文件}

2.2.2 函数栈帧对象

虚拟机对象中的stack成员对象的基本单元就是函数帧,虚拟机执行的时候,总是会从栈帧中取出最top的函数帧开始执行。直到所有的函数帧执行完毕,虚拟机mainloop函数退出。

type callFrame struct {Idx        int //在帧栈中的快速索引Fn         *LFunction //函数原型对象Parent     *callFrame //父栈帧Pc         int //当前的字节码索引Base       int //栈帧用到的寄存器的基地址索引,正常情况,Base指向的寄存器存放的就是函数对象LocalBase  int //栈帧的临时变量在寄存器的基地址索引ReturnBase int //本函数栈帧返回值存储的基地址索引NArgs      int //参数个数NRet       int //返回值个数}

2.2.3 函数原型

在lua中,函数是一个执行单元,任何文件或提供给DoString的脚本字符串,最终都会编译成LFunction,LFunction中最重要的成员对象是proto *FunctionProto,proto中有编译好的字节码数组,常量列表,子函数原型等等。可以认为Lfunction是编译好的静态对象,而上一节的callFrame是Lfunction的运行时对象。

type LFunction struct {IsG       bool  //是否宿主函数(go函数)Env       *LTable //函数执行环境,正常情况会被赋值为虚拟机的env,也可以自己把他replace掉Proto     *FunctionProto //字节码GFunction LGFunction //用户宿主函数指针,只有IsG=true才有意义,后面宿主函数我们也可以把他叫G函数Upvalues  []*Upvalue //闭包变量}type FunctionProto struct {NumUpvalues        uint8 //有几个闭包变量?NumParameters      uint8 //函数有几个参数?IsVarArg           uint8 //是否不定参数?NumUsedRegisters   uint8 //使用到多少个寄存器?Code               []uint32 //字节码数组Constants          []LValue //常量列表FunctionPrototypes []*FunctionProto //子函数原型    //省略部分非关键字段 ...stringConstants []string //字符串常量}//临时变量在哪里?临时变量都在运行时的寄存器中,临时变量的顺序,体现在字节码的操作数中,操作数往往是寄存器的index,这在编译过程就已经确定好了。

2.2.4 基本对象

GopherLua的基本对象有LNil,LNumber,LTable,LFunction,LString,LUserData,LState,LChannel,比C语言版本的lua多了一个channel对象。以上对象均实现了

type LValue interface 

的接口

2.2.4.1 LTable

type LTable struct {Metatable LValue //元表,相当于面向对象继承中的父类,lua定义了不少类型对象的固有的元表,还预定义了符号类操作的key,如:__add,__sub,__mul,__div,__mod,__pow。其他常见的如__call,__index,__nexindex,__len等。array   []LValue //table兼容数组类型dict    map[LValue]LValue //任意类型的mapstrdict map[string]LValue //key为字符串的mapkeys    []LValue // key数组k2i     map[LValue]int // key索引数组下标,这个字段是为了迭代器next使用的,使得可以按顺序遍历map}

LTable是非常非常重要的数据结构,lua中支撑虚拟机运行的全局变量都是Ltable类型,模块表,_G表,注册表,元表等 lua用table表示一切对象,为了实现面向对象和继承等概念,lua使用了元表,元表也是一个table,表示一个对象继承的父对象。LUserDate,LFunction,Ltable都可以设置元表。

2.2.4.2 协程对象LState

协程对象跟虚拟机对象LState数据结构是一模一样的,但是lua协程和lua虚拟机还是有区别的,这在后面的工程实践中会介绍到。 协程库的代码在coroutinelib.go中,核心函数是resume和yield。

resume是在父协程中调用的,resume会switch父协程到子协程,同时传递参数到子协程的栈中,子协程此时要么刚刚开始运行(此时运行的参数就是resume传过来的),要么从yield返回,并得到yield的返回值,yield的返回值就是父协程resume的时候传进来的参数。

yield是在子协程中调用的,子协程yield的时候,会把yield的参数传递到父协程的栈中,并切换到父协程继续执行,父协程此时从resume返回,返回值便是子协程yield传递的参数。

这种resume和yield参数传递和协程切换的工作,都是在vm.go中的callGFunction中实现的。callGFunction函数的详细解析可以参考2.5.6节

--生产者消费者问题local newProductor--生产function productor()    local i = 0    while true do        i = i + 1         coroutine.yield(i) --将生产的物品发给消费者,生产者把值发送出去之后,就把自己的这个协同程序给挂起,yield会让出协程,切换到父协程执行,同时把参数传给父协程,父协程从resume中返回                endend--消费function consumer()    while true do         local status,value = coroutine.resume(newProductor)    --从生产者哪里拿到物品,resume会调度子协程运行,当resume返回的时候,返回值就是子协程yield时候的参数        print(value)    endend--启动程序newProductor = coroutine.create(productor)      --创建协同程序newProductor,(创建时执行productor()方法)consumer()

协程的作用,不仅仅是协程对象之间的相互协作工作,协程实际上是一种共享父协程资源的一个独立可执行单元,所以协程还可以用作虚拟机池。详情请看3.3.2节的线程池设计方案

2.2.4.3 LChannel

channel是golang语言在语言层面上就支持的对象,这足以说明channel的重要性,所以GopherLua的作者特意底层实现了LChannel基本对象。源码在chnnellib.go中。这里不多做介绍。

2.2.4.4 LNubmer,LBool,LFunction,LString

基本类型,Lbool,LString略过不提,Lfunction就是2.2.3节介绍的函数对象

而需要注意的类型是LNumber,使用LNumber容易踩坑(此坑在lua5.3虚拟机中已经被修复,lua5.3特意新增了interger类型的数值表示)。gopherLua实现的是lua5.1的虚拟机,LNumber的内部实现是float64,所以当用户用到int64或uint64的数值类型的时候,在lua内部转换成LNumber的时候且数值非常大的时候会精度损失,比如计算hash值的时候,hash值返回int64,这个精度损失会导致hash计算出错。例如:

func BKDRHash31(str string) int64 {seed := int64(31) // 31 131 1313 13131 131313 etc..hash := int64(0)for i := 0; i < len(str); i++ {hash = (hash * seed) + int64(str[i])}return hash}//提供一个bkdr31函数给lua虚拟机func bkdr31(l *lua.LState) int {if l.GetTop() < 1 {l.Push(lua.LNumber(0))return 1}input := l.CheckString(1)ret := BKDRHash31(input)//这里要转成string返回,否则,转成Lnumber返回,会精度损失l.Push(lua.LString(strconv.FormatInt(ret, 10))) return 1}//数学操作函数,支持加减乘除取余,原因是lua的LNumber,不支持太大的int64,故把大的数字转换成字符串传入func mathOp(l *lua.LState) int {if l.GetTop() < 3 {l.RaiseError("usege:mathOp(op, a, b)")}op := l.CheckString(1)a := l.Get(2)b := l.Get(3)var a1, b1, ret int64var err errorif lv, ok := a.(lua.LString); ok {if a1, err = strconv.ParseInt(string(lv), 10, 64); err != nil { //传入的参数是字符串,字符串转int类型,没有精度损失l.TypeError(2, lua.LTString)}} else {a1 = l.CheckInt64(2)}if lv, ok := b.(lua.LString); ok {if b1, err = strconv.ParseInt(string(lv), 10, 64); err != nil { //传入的参数是字符串,字符串转int类型,没有精度损失l.TypeError(3, lua.LTString)}} else {b1 = l.CheckInt64(3)}if op == "+" || op == "add" {ret = a1 + b1} else if op == "-" || op == "sub" {ret = a1 - b1} else if op == "/" || op == "div" {if b1 == 0 {l.RaiseError("when div, divider must not be 0")}ret = a1 / b1} else if op == "*" || op == "mul" {ret = a1 * b1} else if op == "%" || op == "mod" {if b1 == 0 {l.RaiseError("when mod, divider must not be 0")}ret = a1 % b1} else {l.RaiseError("not suport op " + op)}l.Push(lua.LNumber(ret))return 1}//...省略部分代码gLua.SetGlobal("bkdr31", gLua.NewFunction(bkdr31)) //载入虚拟机gLua.SetGlobal("mathOp", gLua.NewFunction(mathOp)) //载入虚拟机

lua使用上述函数的例子:

local hash=bkdr31("o1A_Bju5jgG9m4rdE4hMacWr4_cw") --hash 得到字符串形式的64位整数local mod=mathOp("%",ret.hash,100)   --取模

2.2.4.5 LUserData

顾名思义,LUserData可以用于代表任何用户自定义对象,LUserData在传递用户数据的时候,非常高效。下面举个例子:

// 这个函数每次调用lua函数都要做参数大量的拷贝,性能低func TestTableParam() {l := lua.NewState()s := `--ctx是个table     function tableOp1(ctx)          print(ctx.a)          ctx.b=10     end`if err := l.DoString(s); err != nil { //执行一次,tableOp1就被注册到_G表了fmt.Println(err.Error())}vFunc := l.GetGlobal("tableOp1") //获取tableOp1函数param := map[string]string{"a": "5"} //函数参数//因为lua不识别map数据结构,故需要把map转换成lua的LTable对象。//拷贝参数到tab,如果param是个很大的map,那么这里的拷贝就是个性能灾难tab := l.NewTable()for k, v := range param {tab.RawSet(lua.LString(k), lua.LString(v))}//调用函数l.CallByParam(lua.P{Fn:   vFunc,NRet: 0,}, tab)tab.ForEach(func(key, value lua.LValue) {fmt.Printf("%v-->%v
", key, value)})}//TestUserData函数展示了传递参数可以用UserData封装,他可以减少拷贝,还可以自定义用户操作方法//每次调用lua函数传递一个userData,性能高func TestUserData() {l := lua.NewState()s := `--ctx是个userData     function tableOp2(ctx)          print(ctx.a)          ctx.b=10     end`if err := l.DoString(s); err != nil { //执行一次,tableOp2就被注册到_G表了fmt.Println(err.Error())}vFunc := l.GetGlobal("tableOp2") //获取函数param := map[string]string{"a": "5"}//拷贝参数到tab,仅仅是param的指针拷贝userData := l.NewUserData()userData.Value = param//为了让userdata支持查询和写入操作,给他设置一个元表,实现其中的__index和__newindex接口,__index接口会在找不到对象成员的时候被调用,__nexindex接口会在设置对象成员数值的时候被调用mt := l.NewTable()  //mt元表可以初始化一次,作为全局变量复用,不必每次都new一个mt.RawSet(lua.LString("__index"), l.NewFunction(func(l *lua.LState) int {userData := l.CheckUserData(1)m := userData.Value.(map[string]string)key := l.CheckString(2)l.Push(lua.LString(m[key]))return 1}))mt.RawSet(lua.LString("__newindex"), l.NewFunction(func(l *lua.LState) int {userData := l.CheckUserData(1)m := userData.Value.(map[string]string)key := l.CheckString(2)value := l.CheckString(3)m[key] = valuereturn 0}))userData.Metatable = mt //给UserData设置元表//调用函数l.CallByParam(lua.P{Fn:   vFunc,NRet: 0,}, userData)for key, value := range param {fmt.Printf("%v-->%v
", key, value)}}

关于go对象的lua封装,这里介绍一个比较好的GopherLua的封装库,gopher-luar,luar利用go的反射原理,封装了go对象(任何对象)到lua对象的操作,而且api及其简单。例如,上面的TestUserData中传递map参数就变成:

    //省略部分代码.....vFunc := l.GetGlobal("tableOp2") //获取函数param := map[string]string{"a": "5"}//调用函数,luar.New返回一个LUserData,跟上一段的代码例子效果一样,luar还会缓存对象类型的元表,不必每次都重新生成元表。l.CallByParam(lua.P{Fn:   vFunc,NRet: 0,}, luar.New(param))for key, value := range param {fmt.Printf("%v-->%v
", key, value)}

luar的另外一个例子,传递给虚拟机一个golang对象:

func add(a, b int) int {return a + b}type obj struct {A stringD int}func (t *obj) Foo(a, b int) int {return a + b + t.D}func Testluar() {l := lua.NewState()//luar封装golang普通函数l.SetGlobal("add", luar.New(l, add))o := &obj{}//实例化obj一个对象,并传递到lua虚拟机中,lua中操作这个对象只需要引用obj即可l.SetGlobal("obj", luar.New(l, o))        s := `         obj.A="4"         obj.D=5         print(obj:Foo(1,2))        `if err := l.DoString(s); err != nil {fmt.Println(err.Error())}fmt.Printf("obj.A=%s", o.A)}

输出:

8obj.A=4

2.3 阅读起点

源码阅读,一般是从项目的main函数开始的,lua没有main函数,任何文件或脚本,lua都会解析成一个函数,都可以执行。

lua源码的阅读,可以从lua.NewState函数和lua.DoString函数开始读起。

func (ls *LState) DoString(source string) error {if fn, err := ls.LoadString(source); err != nil { //编译解析字符串源码,生成函数对象return err} else {ls.Push(fn) //push到寄存器中,return ls.PCall(0, MultRet, nil)  //0输入参数,虚拟机自己计算输出值个数。 创建新的函数帧,调用mainloop执行字节码。}}

2.4 词法解析和编译字节码

lua.Dostring里面会解析字符串脚本得到ast语法树,从ast语法树编译得到函数原型和二进制字节码,最后虚拟机运行,执行函数(里面的字节码)

如果你对语法解析感兴趣,那可以去读读parse文件夹下的parse.go.y,一个lua语法解析器的yacc范本,总共400多行,经典的呈现了一个完备的语言解析器的写法。 语法解析和编译源码非重点,可以稍微了解。

2.5 虚拟机核心源码

lua的虚拟机原理,公司内外已经有不少文章写的很好,附录中列举了几篇非常优秀的虚拟机原理的文章。本文主要是从源码上分析,会更注重细节一些。

GopherLua的虚拟机是按照lua5.1的官方的虚拟机规范实现的。数据结构与指令都非常相似。仅仅是二进制trunk格式不一致。首先上一张经典的lua虚拟机图

图片来自网络图中CallInfo对象对应golang中的callFrame对象

从源码上分析,只需要阅读vm.go中OP_CALL,OP_TAILCALL和OP_RETURN字节码的执行函数以及CallGfunction就可以。

lua最最核心的代码在opcode.go,compiler.go,vm.go,state.go四个文件中。前三个文件实现了语法树到字节码的编译器和每个字节码的执行函数,并实现了虚拟机的运行原理。state.go暴露了虚拟机对象的对外api接口。 这四个文件加起来也就5000行代码,如果去掉vm.go中大量的重复代码(主要是为了高效,用了inline的优化),最多就4000行代码,GopherLua没有c语言复杂的宏定义和眼花缭乱的垃圾回收算法,一切都是简单和直接,可以说学习GopherLua的代码是性价比最高的。

2.5.1 打印语法树和字节码

import ("fmt"lua "github.com/yuin/gopher-lua""github.com/yuin/gopher-lua/parse")func testDump() {s := `local a=5local b=5 g="hello"local add=function(x,y)return a+x+yendc=add(a,b)`l := lua.NewState()l.DoString(s)chunk, _ := parse.Parse(strings.NewReader(s), "")fmt.Println(parse.Dump(chunk)) //打印ast语法树proto, _ := lua.Compile(chunk, s)fmt.Println(proto.String()) //打印字节码}

执行结果如下: 语法树比较冗长,从中可以了解lua解析语法的过程和语法树层次结构。

所有文件或字符串脚本都会被lua解析器解析成一个个函数,这个函数在虚拟机内部的描述就叫函数原型(参考后面介绍的FunctionProto对象),FunctionProto包含常量表和使用的寄存器的个数和upvalue个数和子函数原型数组,最重要的是FunctionProto包含了字节码数组,字节码几乎都是对寄存器、全局表或常量表进行操作的。

对字节码的结构和意义有兴趣的同学请参考附录的文档。

脚本编译完成的时候,临时变量均被安排在寄存器中,位置顺序是固定不变的,因为生成的字节码中的ABC操作数对临时变量的引用就是变量在寄存器中的相对于当前函数帧基地址开始的索引(相对函数帧基地址的偏移量)。同理字节码对常量的引用,也是常量在常量表中的索引。

下面打印出来的字节码,本文做了详细注释,大家可以对照testDump中的脚本(s字符串)对照着看。

- Node$LocalAssignStmt   Names: a   Exprs:     - Node$NumberExpr: 5- Node$LocalAssignStmt   Names:  b   Exprs:     - Node$NumberExpr: 5- Node$AssignStmt   Lhs: - Node$IdentExpr: g   Rhs: - Node$StringExpr: hello- Node$LocalAssignStmt   Names: add   Exprs:       - Node$FunctionExpr         ParList:             - Node$ParList               HasVargs: false               Names:  x y         Stmts:             - Node$ReturnStmt               Exprs:                   - Node$ArithmeticOpExpr                     Operator: +                     Lhs:                         - Node$ArithmeticOpExpr                           Operator: +                           Lhs:                               - Node$IdentExpr: a                           Rhs:                               - Node$IdentExpr: x                     Rhs:                         - Node$IdentExpr: y- Node$AssignStmt   Lhs:       - Node$IdentExpr: c   Rhs:       - Node$FuncCallExpr         Func:             - Node$IdentExpr: add         Receiver:                      Method:          Args:             - Node$IdentExpr: a            - Node$IdentExpr: b         AdjustRet: false//以下为生成的字节码; function [0] definition (level 1); 0 upvalues, 0 params, 6 stacks    //整个匿名函数用到了0个upvalue,0个输入参数,6个寄存器.local a ; 0    //a, b两个临时变量,存放在寄存器0和1中.local b ; 1.local add ; 2   //临时变量add,存放在寄存器2中.const 5 ; 0   //常量 5, g,hello,c,分别按顺序存储在此函数原型的常量表中,总共4个常量.const g ; 1.const hello ; 2.const c ; 3[001] LOADK      |  0, 0; R(0) := Kst(0) (line:2)  //第0个常量赋值给寄存器0,即a=5[002] LOADK      |  1, 0; R(1) := Kst(0) (line:3)  //第0个常量赋值给寄存器1,即b=5[003] LOADK      |  2, 2; R(2) := Kst(2) (line:4)  //第2个常量赋值给寄存器2,[004] SETGLOBAL      |  2, 1; Gbl[Kst(1)] := R(2) (line:4) //设置全局变量,变量名是第1个常量(g),值是寄存器2的内容。  ; function [0] definition (level 2)  //内部子函数add  ; 1 upvalues, 2 params, 3 stacks     //使用了1个upvalue,2个参数,3个寄存器  .local x ; 0  .local y ; 1  .upvalue a ; 0  [001] GETUPVAL      |  2, 0, 0; R(2) := UpValue[0] (line:6) //把upvalue保存到R2  [002] ADD      |  2, 2, 0; R(2) := RK(2) + RK(0) (line:6) // R2=a+x, RK的意思是,操作数可能从寄存器也可能从常量表读  [003] ADD      |  2, 2, 1; R(2) := RK(2) + RK(1) (line:6) //  [004] RETURN      |  2, 2, 0; return R(2) ... R(2+2-2) (line:6) //返回1个返回值(B-1),返回值起始位置是R2,要拷贝到returnbase,returnbase一般都是R0  [005] RETURN      |  0, 1, 0; return R(0) ... R(0+1-2) (line:7) //不需要返回任何值,但是这句话是执行不到的,因为上面的RETURN指令已经切换函数帧了,lua在每个函数最后都会生成这个指令,所以用户写lua函数,可以不写return语句, 此时默认返回0个数  ; end of function[005] CLOSURE      |  2, 0; R(2) := closure(KPROTO[0] R(2) ... R(2+n)) (line:5) //把子函数0封装成一个闭包,并赋值到R2(即add变量)[006] MOVE      |  0, 0, 0; R(0) := R(0) (line:5) //准备upvalue,把变量a拷贝到寄存器0[007] MOVEN      |  3, 2, 2; R(3) := R(2); followed by 2 MOVE ops (line:8) //把add函数拷贝到寄存器3[008] MOVE      |  4, 0, 0; R(4) := R(0) (line:8)  //同时拷贝2个参数到寄存器4和5[009] MOVE      |  5, 1, 0; R(5) := R(1) (line:8)[010] CALL      |  3, 3, 2; R(3) ... R(3+2-2) := R(3)(R(3+1) ... R(3+3-1)) (line:8) //调用函数add,函数地址在寄存器3,参数2个,返回值1个[011] SETGLOBAL      |  3, 3; Gbl[Kst(3)] := R(3) (line:8)  //把返回结果赋值给全局变量c[012] RETURN      |  0, 1, 0; return R(0) ... R(0+1-2) (line:0) ; end of function

2.5.2 虚拟机运行函数mainloop

//baseframe: 最顶层的调用帧func mainLoop(L *LState, baseframe *callFrame) {var inst uint32var cf *callFrameif L.stack.IsEmpty() {return}L.currentFrame = L.stack.Last() //取出当前帧if L.currentFrame.Fn.IsG {//如果是宿主函数,执行完,就立刻返回,这里为什么要立刻返回呢?原因是:既然是mainloop进来的,应该不是由字节码执行顺序执行过来的路劲,应该是调用lua.CallByParamd的路径过来的。所以,可以直接返回。callGFunction(L, false)return}for {//顺序执行字节码cf = L.currentFrameinst = cf.Fn.Proto.Code[cf.Pc]cf.Pc++//所有的字节码在vm.go中是存储在函数数组jumpTable中的,从字节码最高6个bit可以直接索引到字节码对应的执行函数if jumpTable[int(inst>>26)](L, inst, baseframe) == 1 {//返回1表示调用帧都执行完了return}}}

2.5.3 OP_CALL

执行函数调用,寄存器R(A)持有要被调用的函数对象的引用。函数参数置于R(A)之后的寄存器中。如果 B 是 1,函数没有返回值。如果 B 是 2 或更大则 有(B-1)个参数。

如果 B 是 0,函数参数范围从 R(A+1)到栈顶。当参数表的最后一个表达式是 函数调用时用这种形式,所以实际参数的数量是不确定的。

函数调用的返回结果置于 R(A)开始的一组寄存器中。如果 C 是 1,不保存返回结果。如果 C 是 2 或更大则保存(C-1)个返回值。如果 C 是 0 则保存多个返回结果,依赖被调函数。

func(L *LState, inst uint32, baseframe *callFrame) int { //OP_CALLreg := L.reg //寄存器-栈cf := L.currentFrame //当前函数帧lbase := cf.LocalBase //当前函数帧的寄存器基地址A := int(inst>>18) & 0xff //GETARA := lbase + A   //操作数A的地址,这个地址存储的要么是函数,要么是对象B := int(inst & 0x1ff)    //GETB  //参数个数:B==0,接受其他函数全部返回来的参数,B>0,参数个数为 B-1C := int(inst>>9) & 0x1ff //GETC  函数调用结束后,原先存放函数和参数值的寄存器会被返回值占据,具体多少个返回值由操作数 C 指定,C==0,将返回值全部返回给接收者, C==1,无返回值,C>1,返回值的数量为 C-1nargs := B - 1  //计算参数个数if B == 0 {nargs = reg.Top() - (RA + 1)}lv := reg.Get(RA)  //获取函数地址nret := C - 1var callable *LFunctionvar meta boolif fn, ok := lv.assertFunction(); ok {callable = fnmeta = false} else {callable, meta = L.metaCall(lv) //如果不是函数,那么就从对象的元表中寻址是否有__call的函数定义}//定义一个新的调用栈//Base: 基地址,赋值RA,基地址存放函数对象//LocalBase: 参数的起始地址,RA+1//ReturnBase: 返回值的存放起始地址//NRet: 多少个返回值//Parent: 父协程//TailCall: 是否是尾调cfNew:=callFrame{Fn: callable, Pc: 0, Base: RA, LocalBase: RA + 1, ReturnBase: RA, NArgs: nargs, NRet: nret, Parent: cf, TailCall: 0}//如果是元表取得的函数,pushCallFrame会在给这个函数插入一个参数lv,相当于self对象。//pushCallFrame会对函数缺失的参数赋值nil,或多余参数进行也赋值nil,如果是可变参数,会调整参数顺序L.pushCallFrame(cfNew , lv, meta )//如果函数是宿主语言函数,则直接调用,否则return 0后在虚拟机的mainloop函数中会执行新的函数帧里面的指令if callable.IsG && callGFunction(L, false) {//如果返回1,表示所有调用栈都完成了,指示外面虚拟机的mainloop函数可以退出了return 1}return 0}

2.5.4 OP_TAILCALL

当 return语句只有一个函数调用作为表达式时执行尾调用,例如:return foo(bar)。尾调用实际上是个goto,并且避免了调用另一个更深的层次。只有 Lua 函数能被尾调用。

同 CALL 一样,寄存器 R(A)持有要被调用的函数的引用。 B 编码了参数数 量,方式同 CALL 一样。 TAILCALL 不用(字段)C,因为所有返回值都是有意义的。无论如何, Lua编译器总是为C生成 0以指示多返回值。

TAILCALL的跟CALL的区别在于,TAILCALL会复用当前函数帧

func(L *LState, inst uint32, baseframe *callFrame) int { //OP_TAILCALLreg := L.regcf := L.currentFramelbase := cf.LocalBaseA := int(inst>>18) & 0xff //GETA,与OP_CALL意义一致RA := lbase + AB := int(inst & 0x1ff) //GETB ,与OP_CALL意义一致nargs := B - 1if B == 0 {nargs = reg.Top() - (RA + 1)}lv := reg.Get(RA)var callable *LFunctionvar meta boolif fn, ok := lv.assertFunction(); ok {callable = fnmeta = false} else {callable, meta = L.metaCall(lv)}if callable == nil {L.RaiseError("attempt to call a non-function object")}L.closeUpvalues(lbase)if callable.IsG {luaframe := cfL.pushCallFrame(callFrame{Fn:         callable,Pc:         0,Base:       RA,LocalBase:  RA + 1,ReturnBase: cf.ReturnBase,NArgs:      nargs,NRet:       cf.NRet,Parent:     cf,TailCall:   0,}, lv, meta)if callGFunction(L, true) {   //内部会把父调用帧替换成当前帧,相当于复用当前帧return 1}if L.currentFrame == nil || L.currentFrame.Fn.IsG || luaframe == baseframe {return 1}} else {//以下要复用旧的函数帧,先保存必要的变量,然后初始化新的函数帧,最后把新的函数帧的寄存器基地址和临时变量基地址修改为和旧的函数帧一致base := cf.Base //保存旧的basecf.Fn = callable cf.Pc = 0 // 新函数帧的pc置0cf.Base = RA //新函数帧的函数对象地址cf.LocalBase = RA + 1  //新函数帧的临时变量起始地址cf.ReturnBase = cf.ReturnBase //复用旧函数帧的返回地址cf.NArgs = nargscf.NRet = cf.NRetcf.TailCall++lbase := cf.LocalBase //保存新的localBaseif meta {cf.NArgs++L.reg.Insert(lv, cf.LocalBase)}L.initCallFrame(cf) //初始化新的帧L.reg.CopyRange(base,RA,-1,reg.Top() - RA - 1) //把新的函数帧的寄存器的内容全部移到base,从RA开始到top,拷贝到base开始的地址cf.Base = base //修改新函数帧的寄存器基地址cf.LocalBase = base + (cf.LocalBase - lbase + 1) //修改新函数帧的临时变量基地址}return 0}

关于OP_TAILCALL, 这里举一个曾经让B站程序员彻夜无眠抓狂的lua代码例子,几行lua代码直接干趴B站两三个小时,代码如下:

--导致B栈崩溃的几行代码local gcd=function(a,b)  if b==0 then --如果b赋值为字符串“0”,这里不会转换成数值比较,所以条件不成立return a      end  return gcd(b,a%b) --如果b赋值为字符串“0”,a%b会把b转换成数字0,导致结果是NAN,如此递归2次,就会变成gcd(NAN,NAN)永远死循环end

就是这几行代码,当不小心执行如下代码的时候,就会触发cpu 100%,而且服务内存正常,不会oom。

    local c=gcd(24,'0') --这行代码会导致gcd函数死循环,而且不会栈溢出

他的字节码如下,其中第七行,是TAILCALL指令,TAILCALL指令复用了原调用栈,且使用的寄存器数量也没有任何增减,所以内存没有任何波动。

如果这里不是TAILCALL,会很容易导致栈溢出raise异常而不是cpu跑满。

  ; function [0] definition (level 2)  ; 1 upvalues, 2 params, 5 stacks  .local a ; 0  .local b ; 1  .upvalue gcd ; 0  .const 0 ; 0  [001] EQ      |  0, 1, 256; if ((RK(1) == RK(256)) ~= 0) then pc++ (line:3)  [002] JMP      |  0, 1; pc+=1 (line:3)  [003] RETURN      |  0, 2, 0; return R(0) ... R(0+2-2) (line:4)  [004] GETUPVAL      |  2, 0, 0; R(2) := UpValue[0] (line:6)  [005] MOVE      |  3, 1, 0; R(3) := R(1) (line:6)  [006] MOD      |  4, 0, 1; R(4) := RK(0) % RK(1) (line:6)  [007] TAILCALL      |  2, 3, 0; return R(2)(R(2+1) ... R(2+3-1)) (line:6)  [008] RETURN      |  2, 0, 0; return R(2) ... R(2+0-2) (line:6)  [009] RETURN      |  0, 1, 0; return R(0) ... R(0+1-2) (line:7)  ; end of function

2.5.5 OP_RETURN

函数返回,里面做的事情主要是,拷贝返回值到指定的栈上的位置并且弹出当前函数帧,切换下一个函数帧对象继续运行。

func(L *LState, inst uint32, baseframe *callFrame) int { //OP_RETURNreg := L.regcf := L.currentFramelbase := cf.LocalBaseA := int(inst>>18) & 0xff //GETARA := lbase + AB := int(inst & 0x1ff) //GETBL.closeUpvalues(lbase) //关闭upvalue值,只要是lbase之上的upvalue的值,都回收nret := B - 1 //返回值的个数是B-1if B == 0 {nret = reg.Top() - RA //返回值需要自己计算}n := cf.NRetif cf.NRet == MultRet {n = nret}if L.Parent != nil && L.stack.Sp() == 1 { //Parent非空,说明自己一定是子协程,如果当前调用栈已经是1了,说明是最后一帧了,要马上切换到父协程,并且本协程已经执行完了,可以标记为dead//同时要返回1,表明本协程的mainloop函数要退出了copyReturnValues(L,reg.Top(),RA,n,B)switchToParentThread(L, n, false, true)return 1}//如果当前帧是最顶层的帧,或者pop后,当前调用栈已经空了,说明是最后一个调用栈了,要指示mainloop退出islast := baseframe == L.stack.Pop() || L.stack.IsEmpty()//拷贝返回值到ReturnBasecopyReturnValues(L,cf.ReturnBase,RA,n,B)//切换新的调用帧L.currentFrame = L.stack.Last()if islast || L.currentFrame == nil || L.currentFrame.Fn.IsG {//isLast || L.currentFrame==nil  好理解//L.currentFrame.Fn.IsG==true的意思是:本字节码是在宿主函数中调用的,比如golang中调动CallByParam函数,而CallByParam函数里面执行的是lua函数,//此时,是在字节码一定是在一个独立的mainloop中运行的,都已经回到宿主函数了,所以必须要退出mainloopreturn 1}//继续后面的字节码执行return 0},

2.5.6 callGFunction

callGFunction实现了宿主函数的调用,同时顺便实现了lua协程的切换的工作(因为协程库的实现也是用宿主函数实现的)

func callGFunction(L *LState, tailcall bool) bool {frame := L.currentFrame//调用宿主函数,gfnret指示返回值的数量gfnret := frame.Fn.GFunction(L) if tailcall {//如果是尾调,那么替换父调用帧为当前调用帧(内容全覆盖),并pop掉当前调用帧,相当于复用父调用帧L.currentFrame = L.RemoveCallerFrame()}if gfnret < 0 {//返回值小于0,有且仅有协程库中的yield函数会做这种事情//里面完成子协程到父协程的切换,并把yield的参数复制到父协程的栈中,当成是父协程resume的返回值switchToParentThread(L, L.GetTop(), false, false)return true}wantret := frame.NRetif wantret == MultRet {wantret = gfnret}if tailcall && L.Parent != nil && L.stack.Sp() == 1 {//如果是尾调并且是在子协程中,并且子协程只有当前的一个调用帧了,说明当前子协程要结束了,所以切换到父协程,并kill当前子协程switchToParentThread(L, wantret, false, true)return true}//顺序拷贝返回值到当前调用帧的ReturnBase开始的位置L.reg.CopyRange(frame.ReturnBase,L.reg.Top()-gfnret,-1,wantret)//准备下一个调用帧L.stack.Pop()L.currentFrame = L.stack.Last()return false}

2.6 自带标准库

lua源码中,80%的代码都是lua自带的标准库 如table库,io库,string库,协程库,channel库等等,挑选着看就行。

下面介绍源码字符串模块是如何实现的,源码在stringlib.go中

//加载字符串模块,在lua.NewState中会自动加载func OpenString(L *LState) int {var mod *LTablemod = L.RegisterModule(StringLibName, strFuncs).(*LTable) //注册字符串模块到注册表中,并返回这个表gmatch := L.NewClosure(strGmatch, L.NewFunction(strGmatchIter)) //新建一个闭包,他有一个Upvalue,这个upValue也是一个函数,mod.RawSetString("gmatch", gmatch) //新增两个函数mod.RawSetString("gfind", gmatch)mod.RawSetString("__index", mod)//把mod作为string类型的默认元表,所谓元表,可以认为是lua实现对象继承的一种实现,元表相当于父类,当某个对象中找不到一个函数或某个key的时候,lua会从他的元表中查找L.G.builtinMts[int(LTString)] = mod L.Push(mod)return 1}var strFuncs = map[string]LGFunction{"byte":    strByte,"format":  strFormat,//略过部分函数..."reverse": strReverse,"sub":     strSub,"upper":   strUpper,}func strUpper(L *LState) int {str := L.CheckString(1) //获取参数L.Push(LString(strings.ToUpper(str))) //转化为大写字符串并返回return 1 //明确告知虚拟机,只有一个返回值}

2.7 垃圾回收

这个在c语言版本的lua中最棘手的问题在GopherLua中却不是问题,因为GopherLua把这个问题都交给了golang虚拟机,GopherLua在需要垃圾回收的地方,最多就把那个对象设置为nil,其他就交给golang处理了。如下面回收upvalue

func (ls *LState) closeUpvalues(idx int) { // +inline-startif ls.uvcache != nil {var prev *Upvaluefor uv := ls.uvcache; uv != nil; uv = uv.next {if uv.index >= idx {if prev != nil {prev.next = nil //置空,垃圾回收} else {ls.uvcache = nil //垃圾回收}uv.Close()}prev = uv}}}

三、 工程实践

3.1 项目背景

在某个项目中,需要处理各种类型的数据流(可以理解为kafka多个topic),每个数据流都是key,value类型,其中key=“tablename”指明了某种类型的数据流。现在需要在每个数据流上配置一个或多个处理任务(tasks),每个处理任务的逻辑可能都不一样,产品要求task可以任意新增,上线或下线,tasks的逻辑需要很方便修改测试。考虑到性能和灵活性两方面的因素,我们最终的方案的采用golang + GopherLua来做。

3.2 技术方案

右边的web页面,用户可以随时在某个数据流上创建N个处理任务,每个处理任务使用lua来实现处理逻辑(当然用户不写也可以,后台会生成一个默认的脚本)。服务会增量更新任务到内存,任务更新到内存的时候,会预编译好lua脚本模块到全局唯一的lua虚拟机中(gLua)。

当数据流来的时候,根据tablename寻找任务列表,依次执行任务的脚本以实现业务逻辑。

3.3 lua虚拟机池如何设计

3.3.1 传统lua虚拟机池的设计问题

在多线程环境或多协程环境(特别是golang环境下),一个全局的虚拟机对象并发执行会有问题,必须加锁( 否则调用栈互相覆盖,寄存器栈互相覆盖,全局变量写覆盖等等),如果加锁,性能肯定很低下。为了解决这问题,必须要设计虚拟机池。 在很多文章中,lua实现虚拟机池的方法都是创建N个虚拟机的池子,用的时候pop一个虚拟机对象,用完push back回池子,如果虚拟机池子用完,那就再临时new一个虚拟机出来。在我看来,上述方案有很大的问题。原因如下:

  • 虚拟机创建的代价太高,看看lua.NewState的代码,需要预加载一堆的标准库,需要创建一个全新的Global全局变量(注册表,G表等),这样的后果就是创建过程耗时且浪费内存。
  • 虚拟机对象池里面的每个虚拟机都是互相独立的,里面的全局变量都是独立的。当某个虚拟机对象创建后,除了标准库要加载外,可能还需加载一些用户定义的模块。正常情况下,这些用户定义的模块,是在golang中用init函数自动加载就的。现在你临时创建的虚拟机对象,不得不一个一个的手动加载,这样很容易漏掉某些模块。
  • 某些全局变量可能是需要所有虚拟机共享的,这些全局变量万一修改了,需要所有的虚拟机全量同步一次,很麻烦
  • 在某些情况,可能需要在线动态加载用户模块(比如模块脚本更新了),此时,虚拟机池里面的所有对象不得不遍历一次并依次加载模块
    该如何解决上面的问题呢?请看下节

3.3.2 线程池设计方案

从lua虚拟机原理可以得知,lua虚拟机在解析运行字节码的时候,其实需要的仅仅是函数的环境和一个寄存器组以及一个调用栈。正常情况下,函数的环境就是_G全局变量(可以通过setfenv修改)。GopherLua很贴心的提供了一个lua.NewThread函数(c语言也有支持的),这个函数如下:

// NewThread returns a new LState that shares with the original state all global objects.// If the original state has context.Context, the new state has a new child context of the original state and this function returns its cancel function.func (ls *LState) NewThread() (*LState, context.CancelFunc) {thread := newLState(ls.Options) //里面创建的独立的寄存器组和调用栈thread.G = ls.G  //共享了父虚拟机的全局变量和环境thread.Env = ls.Env //共享了父虚拟机的环境var f context.CancelFunc = nilif ls.ctx != nil {thread.mainLoop = mainLoopWithContextthread.ctx, f = context.WithCancel(ls.ctx)}return thread, f}

NewThread返回的也是一个虚拟机对象,但是这个虚拟机对象的环境是和父虚拟机对象共享的,lua把这种虚拟机叫lua协程。lua协程对象恰好很适合用来做虚拟机池,为了和上节讨论的虚拟机池区分开来,后面把他叫线程池。

有关lua协程的介绍,大家可以看看这个文章Lua协程(Coroutine),lua协程在openresty用的很广泛,openresty处理每个请求都会new一个协程去处理,这跟我们这边设计的线程池很相似,但是我们不会用到复杂的consume,yield等函数,我们仅仅是把lua协程当成一个个相互环境隔离的可执行的虚拟机环境,里面不存在协程dead的问题,协程永远可以循环使用。

在本方案的设计中,lua全局虚拟机有且只有一个gLua,gLua在init函数中用lua.NewState创建,gLua会预先注册所有的全局函数或全局对象,全局对象包括全局函数或全局变量或业务模块(业务模块会使用LoadModule函数注册到gLua的_G表中,见下节)

线程池也由gLua创建,这样,线程池里面的所有线程都和gLua共享环境和全局变量,线程池代码如下:

const (THREAD_POOL_IDEL_SIZE = 2000 //至少需要保留多少个thread)var lockForQueue sync.Mutex //thread池并发pop或push需要加锁var threadQueue = list.New() //thread双端丢列var threadActiveMap = make(map[*lua.LState]time.Time) //记录thread的最近使用时间//取一个lua threadfunc popThread() *lua.LState {lockForQueue.Lock()defer lockForQueue.Unlock()if threadQueue.Len() == 0 {//并发太高,thread缓存不够,临时分配l, _ := tdebuglua.GetGlobalVm().NewThread() //tdebuglua.GetGlobalVm()就是gLua,全局唯一lua虚拟机,注意,这里用NewThread而不是NewStatethreadActiveMap[l] = time.Now()return l}e := threadQueue.Front()l := e.Value.(*lua.LState)threadActiveMap[l] = time.Now()threadQueue.Remove(e)return l}//返回threadfunc pushThread(l *lua.LState) {lockForQueue.Lock()defer lockForQueue.Unlock()l.SetTop(0)threadQueue.PushFront(l)//缓存数量超出最大值,那么清理一次for threadQueue.Len() >= THREAD_POOL_IDEL_SIZE {//从最尾巴遍历,太久没有活跃的就删掉e := threadQueue.Back()thread := e.Value.(*lua.LState)if time.Since(threadActiveMap[thread]) < 60*time.Second {break}//超过60秒不活跃的才回收thread.Close()threadQueue.Remove(e)}}

执行某个lua模块中的某个函数的代码如下:

//执行某个脚本模块内的某个函数,moduleName:模块名,funcName:函数名func CallModFunc(ctx context.Context, moduleName string, funcName string, args ...interface{}) (interface{}, error) {L1 := popThread() //取一个threaddefer pushThread(L1) //用完必须要返回thread 池v := L1.GetGlobal(moduleName) //加载lua模块,模块其实已经写入到_G表且已经预编译vTable, ok := v.(*lua.LTable) //模块必须是一个table类型if !ok || vTable == nil {return nil, errors.New("module " + moduleName + " not exist")}vfun := vTable.RawGet(lua.LString(funcName)) //查找模块里面的某个函数if vfun == nil {return nil, errors.New("func " + funcName + " is not exist")}fn, ok := vfun.(*lua.LFunction) //必须是函数类型if !ok {return nil, errors.New("func " + funcName + " is not a lua function")}//函数参数,用luar封装成UserData,性能最高var luaArgs []lua.LValuefor _, arg := range args {luaArgs = append(luaArgs, luar.New(L1, arg))}if err := L1.CallByParam(lua.P{Fn:      fn,   //函数的引用NRet:    1,    // 指定返回值数量Protect: true, // 如果出现异常,是panic还是返回err}, luaArgs...); err != nil { // 传递输入参数log.ErrorContextf(ctx, "CallByParams: %s:%s,err: %v", moduleName, funcName, err.Error())}//省略代码 .....}

3.4 lua模块设计

在lua中,对象都是table,lua的table是符合类型,可以支持数组,也可以支持kv映射。

我们经常需要在处理业务逻辑的各个阶段,调用特定的lua脚本,让用户有机会写脚本去变更逻辑。 典型的项目如apisix,在apisix中,一个请求的各个阶段,可以调用lua插件的各阶段处理函数。如下图所示:

apisix各阶段lua插件介入点(图片来自网络)

所以,apisix的插件结构一般都是这样的:

local _M = {    version = 0.1,    priority = 2520,    type = 'auth',    name = plugin_name,    schema = schema,    consumer_schema = consumer_schema}function _M.check_schema(conf, schema_type) -- ...endfunction _M.header_filter(conf, ctx) -- ...endfunction _M.body_filter(conf, ctx) -- ...endfunction _M.rewrite(conf, ctx) -- ...endreturn _M 

我们的业务逻辑也类似可以分割成多个阶段,如:预处理阶段,频率限制阶段,逻辑判断阶段,单独数据读取,数据存储等,也是按照apisix的插件设计方式设计,demo如下:

local _M = {}_M.init = function(cfg)    _M.redis = redisgo.new("uo") --本模块用到的redis资源    _M.log = require("trpcLog") --加载log    _M.name = cfg["name"]    _M.tablename = cfg["tablename"]endfunction _M.getRedisKey(uoid)    local d = os.date("%Y_%m_%d")    local k = _M.tablename.."_"..uoid.."_"..d    return kend--实时流处理逻辑,ctx是一个mapfunction _M.run(ctx)    local k = _M.getRedisKey(ctx["uoid"])    local v=_M.redis:hset(k, "isactive", 1)    if v == "1" then        local h = tonumber(os.date("%H"), 10);        local exp = (24 - h) * 3600        _M.redis:expire(k, exp)    endend--供业务调用,获取数据function _M.isactive(uoid)    local k = _M.getRedisKey(uoid)    local info, ok = _M.redis:hget(k, "isactive")    if ok and info == "1" then        return 1    else        return 0    endendreturn _M

在模块设计的时候,因为所有线程共用共用一个全局虚拟机对象的所有资源,所以模块内部严禁出现全局变量的。另外因为模块本身在虚拟机中也是全局唯一的,这意味着模块内部的局部变量,也是所有线程可以同时访问的,所以模块内成员的写操作也是很危险的。所以约定,只有在模块的init函数中可以写模块成员变量,其他任何地方只能读不能写,模块内的函数禁用全局变量

加载模块,在lua中一般是用require函数,但是我们的脚本实际是存储在mysql中,而require是读取本地文件系统或调用用户提前注册的加载器实现加载模块的,实际上我们也不必要设计一个加载器, 我们可以在lua中设置一个global变量,变量名就用模块名,变量内容就是脚本返回的_M table对象,需要用到这个对象,就用lua.GetGlobal(模块名)函数即可。 加载模块的代码如下:

/*** @Description: 加载模块代码到全局虚拟机* @param moduleName:模块名称,脚本名称从mysql读取到* @param moduleScript:模块脚本,脚本内容从mysql读取到* @return error*/func LoadModule(moduleName string, moduleScript string, cfg map[string]string) error {funcStr := "local f=function()
" +moduleScript + "
" +"end
" +moduleName + "=f()
"  //moduleName是个全局变量,会写到_G表,内容就是f()返回的_M对象err := tdebuglua.GetGlobalVm().DoString(funcStr) //需要用gLua来加载模块,这样,所以线程都可见if err != nil {return err}// 调用模块的init函数,init函数可能不存在导致调用失败,不属于错误,因此只打logif _, ok := cfg["name"]; !ok {cfg["name"] = moduleName}//调用模块的init函数//只有init函数内部才能安全的给模块成员变量赋初始值,其他任何地方写模块成员变量是危险的_, err = CallModFunc(context.Background(), moduleName, "init", cfg) //CallModFunc函数实现请看上一节if err != nil {log.Errorf("Module %s has .init() failed: %s", moduleName, err.Error())} else {log.Debugf("Module %s has .init() and call succeed!", moduleName)}return nil}

四、GopherLua一般优化方法

4.1 table创建

正常情况,用lua.NewTable来创建一个table,但是当我们预先知道这个table很大(或者数组很大)的时候,最好使用lua.CreateTable来创建,lua.CreateTable支持传入2个参数acap, hcap int,可以指定数组或字典的容量。 在GopherLua中,如果table纯的是数组,内部是用[]TValue来存储的,如果是字典是用map[string]LValue来存储的。指定容量,可以让减少reshap次数,性能会提高一些。 table尽量初始化的时候给成员赋值,不要创建table后再一个个赋值。。。

4.2 尽量用局部变量

局部变量是存储在寄存器中的,而且虚拟机把代码编译成字节码的时候,操作数是局部变量的时候,局部变量的位置是编译进字节码中的。虚拟机在寄存器中获取局部变量的效率必然是高过从全局变量中获取,因为全局变量需要有一次查表的过程。 测试代码:

--代码一local beginTime = os.clock()for i = 1, 9999999 do  local x = math.sin(i)  --每次都要用GetGlobal把math.sin拷贝到寄存器中,此外math.sin需要两次查表endprint(os.clock()-beginTime )--代码二beginTime  = os.clock()local sin = math.sinfor i = 1, 9999999 do  local x = sin(i)endprint(os.clock()-beginTime )

用glua来执行: 前者大概要比后者慢20%的时间

4.3 尽量少用全局变量

上节已经介绍了,全局变量每次引用都要有多次查表的过程,性能必然会比临时变量 差,所以,能不用全局变量就不用全局变量。

另外写全局变量是一个很危险的事情,在之前介绍的线程池中,因为所有的线程池共用全局虚拟机的全局变量,那么当线程并发执行的时候,如果写全局变量了,那很可能会写map冲突,后果很严重,会导致服务挂掉。

所以,全局变量的加载必须在全局虚拟机初始化的时候加载完毕,在线程池运行的时候,是严禁写全局变量的。如果非要写全局变量,那必须要加锁。

4.3.1 全局变量踩坑例子

local test=function()return 1,2end    local a    a,_=test() --注意,这里的 _ 就是一个全局变量,不是忽略变量的意思
  -- _M是一个全局模块,当并发调用模块的run函数时,也会挂  local _M={_M.run=function(ctx)   function a()  --函数内定义了一个全局变量a,也是很危险的。正确的写法是 local a=function() ... endreturn 1   end   local b=a()end  }  return _M

以上的代码在多协程环境下并发调用,会导致map写冲突而挂掉。

4.4 参数传递优化

lua忌讳参数传递的时候,做大量的拷贝工作,当传递go对象给虚拟机的时候,尽量不要转换成lua的LTable,应该使用LUserData,建议使用luar库,详情可以看2.2.4.5 LUserData节

4.5 复杂逻辑尽量用宿主函数

lua适合做穿针引线的工作,如果逻辑太复杂,建议还是用宿主函数封装一个lua函数给虚拟机使用。这会大幅提高服务的性能。

例如下面的split函数,用go实现的splitGo函数和用lua实现的split的性能要好8倍左右。

func splitGo(l *lua.LState) int {s := l.CheckString(1)sep := l.CheckString(2)array := strings.Split(s, sep)tab := l.CreateTable(len(array), 0)for i, item := range array {tab.RawSet(lua.LNumber(i+1), lua.LString(item))}l.Push(tab)return 1}func TestSplit() {l := lua.NewState()//lua的split实现if err := l.DoString(`function split(input, delimiter)    input = tostring(input)    delimiter = tostring(delimiter)    if (delimiter == "") then return false end    local pos, arr = 0, {}    for st, sp in function() return string.find(input, delimiter, pos, true) end do        table.insert(arr, string.sub(input, pos, st - 1))        pos = sp + 1    end    table.insert(arr, string.sub(input, pos))    return arrend`); err != nil {fmt.Println(err.Error())return}l.SetGlobal("splitGo", l.NewFunction(splitGo)) //载入go实现的split函数s := `    local testStr1="1,2,a,b,c,d,e,f,g,h,i,j,k,l,m,n"    local begin = os.clock()    local fn=splitfor i=1,10000 do   fn(testStr1, ",")endprint(string.format("lua split test:total time:%.2fms
", (os.clock() - begin) * 1000))    local begin = os.clock()    fn=splitGofor i=1,10000 do    fn(testStr1, ",")endprint(string.format("go split test: total time:%.2fms
", (os.clock() - begin) * 1000))`if err := l.DoString(s); err != nil {fmt.Println(err.Error())}}

执行结果如下:

lua split test:total time:141.14msgo split test: total time:16.29ms



发表评论
留言与评论(共有 0 条评论) “”
   
验证码:

相关文章

推荐文章