在 GitHub 玩耍时,偶然发现了 gopher-lua ,这是一个纯 Golang 实现的 Lua 虚拟机。我们知道 Golang 是静态语言,而 Lua 是动态语言,Golang 的性能和效率各语言中表现得非常不错,但在动态能力上,肯定是无法于 Lua 相比。那么如果我们能够将二者结合起来,就能综合二者各自的长处了(手动滑稽。在项目 Wiki 中,我们可以知道 gopher-lua 的执行效率和性能仅比 C 实现的 bindings 差。因此从性能方面考虑,这应该是一款非常不错的虚拟机方案。Hello World这里给出了一个简单的 Hello World 程序。我们先是新建了一个虚拟机,随后对其进行了 DoString(…) 解释执行 lua 代码的操作,最后将虚拟机关闭。执行程序,我们将在命令行看到 “Hello World” 的字符串。package mainimport ( “github.com/yuin/gopher-lua”)func main() { l := lua.NewState() defer l.Close() if err := l.DoString(print("Hello World")); err != nil { panic(err) }}// Hello World提前编译在查看上述 DoString(…) 方法的调用链后,我们发现每执行一次 DoString(…) 或 DoFile(…) ,都会各执行一次 parse 和 compile 。func (ls *LState) DoString(source string) error { if fn, err := ls.LoadString(source); err != nil { return err } else { ls.Push(fn) return ls.PCall(0, MultRet, nil) }}func (ls *LState) LoadString(source string) (*LFunction, error) { return ls.Load(strings.NewReader(source), “<string>”)}func (ls *LState) Load(reader io.Reader, name string) (*LFunction, error) { chunk, err := parse.Parse(reader, name) // … proto, err := Compile(chunk, name) // …}从这一点考虑,在同份 Lua 代码将被执行多次(如在 http server 中,每次请求将执行相同 Lua 代码)的场景下,如果我们能够对代码进行提前编译,那么应该能够减少 parse 和 compile 的开销(如果这属于 hotpath 代码)。根据 Benchmark 结果,提前编译确实能够减少不必要的开销。package glua_testimport ( “bufio” “os” “strings” lua “github.com/yuin/gopher-lua” “github.com/yuin/gopher-lua/parse”)// 编译 lua 代码字段func CompileString(source string) (*lua.FunctionProto, error) { reader := strings.NewReader(source) chunk, err := parse.Parse(reader, source) if err != nil { return nil, err } proto, err := lua.Compile(chunk, source) if err != nil { return nil, err } return proto, nil}// 编译 lua 代码文件func CompileFile(filePath string) (*lua.FunctionProto, error) { file, err := os.Open(filePath) defer file.Close() if err != nil { return nil, err } reader := bufio.NewReader(file) chunk, err := parse.Parse(reader, filePath) if err != nil { return nil, err } proto, err := lua.Compile(chunk, filePath) if err != nil { return nil, err } return proto, nil}func BenchmarkRunWithoutPreCompiling(b *testing.B) { l := lua.NewState() for i := 0; i < b.N; i++ { _ = l.DoString(a = 1 + 1) } l.Close()}func BenchmarkRunWithPreCompiling(b *testing.B) { l := lua.NewState() proto, _ := CompileString(a = 1 + 1) lfunc := l.NewFunctionFromProto(proto) for i := 0; i < b.N; i++ { l.Push(lfunc) _ = l.PCall(0, lua.MultRet, nil) } l.Close()}// goos: darwin// goarch: amd64// pkg: glua// BenchmarkRunWithoutPreCompiling-8 100000 19392 ns/op 85626 B/op 67 allocs/op// BenchmarkRunWithPreCompiling-8 1000000 1162 ns/op 2752 B/op 8 allocs/op// PASS// ok glua 3.328s虚拟机实例池在同份 Lua 代码被执行的场景下,除了可使用提前编译优化性能外,我们还可以引入虚拟机实例池。因为新建一个 Lua 虚拟机会涉及到大量的内存分配操作,如果采用每次运行都重新创建和销毁的方式的话,将消耗大量的资源。引入虚拟机实例池,能够复用虚拟机,减少不必要的开销。func BenchmarkRunWithoutPool(b *testing.B) { for i := 0; i < b.N; i++ { l := lua.NewState() _ = l.DoString(a = 1 + 1) l.Close() }}func BenchmarkRunWithPool(b *testing.B) { pool := newVMPool(nil, 100) for i := 0; i < b.N; i++ { l := pool.get() _ = l.DoString(a = 1 + 1) pool.put(l) }}// goos: darwin// goarch: amd64// pkg: glua// BenchmarkRunWithoutPool-8 10000 129557 ns/op 262599 B/op 826 allocs/op// BenchmarkRunWithPool-8 100000 19320 ns/op 85626 B/op 67 allocs/op// PASS// ok glua 3.467sBenchmark 结果显示,虚拟机实例池的确能够减少很多内存分配操作。下面给出了 README 提供的实例池实现,但注意到该实现在初始状态时,并未创建足够多的虚拟机实例(初始时,实例数为0),以及存在 slice 的动态扩容问题,这都是值得改进的地方。type lStatePool struct { m sync.Mutex saved []*lua.LState}func (pl *lStatePool) Get() *lua.LState { pl.m.Lock() defer pl.m.Unlock() n := len(pl.saved) if n == 0 { return pl.New() } x := pl.saved[n-1] pl.saved = pl.saved[0 : n-1] return x}func (pl *lStatePool) New() *lua.LState { L := lua.NewState() // setting the L up here. // load scripts, set global variables, share channels, etc… return L}func (pl *lStatePool) Put(L *lua.LState) { pl.m.Lock() defer pl.m.Unlock() pl.saved = append(pl.saved, L)}func (pl *lStatePool) Shutdown() { for _, L := range pl.saved { L.Close() }}// Global LState poolvar luaPool = &lStatePool{ saved: make([]*lua.LState, 0, 4),}模块调用gopher-lua 支持 Lua 调用 Go 模块,个人觉得,这是一个非常令人振奋的功能点,因为在 Golang 程序开发中,我们可能设计出许多常用的模块,这种跨语言调用的机制,使得我们能够对代码、工具进行复用。当然,除此之外,也存在 Go 调用 Lua 模块,但个人感觉后者是没啥必要的,所以在这里并没有涉及后者的内容。package mainimport ( “fmt” lua “github.com/yuin/gopher-lua”)const source = local m = require("gomodule")m.goFunc()print(m.name)func main() { L := lua.NewState() defer L.Close() L.PreloadModule(“gomodule”, load) if err := L.DoString(source); err != nil { panic(err) }}func load(L *lua.LState) int { mod := L.SetFuncs(L.NewTable(), exports) L.SetField(mod, “name”, lua.LString(“gomodule”)) L.Push(mod) return 1}var exports = map[string]lua.LGFunction{ “goFunc”: goFunc,}func goFunc(L *lua.LState) int { fmt.Println(“golang”) return 0}// golang// gomodule变量污染当我们使用实例池减少开销时,会引入另一个棘手的问题:由于同一个虚拟机可能会被多次执行同样的 Lua 代码,进而变动了其中的全局变量。如果代码逻辑依赖于全局变量,那么可能会出现难以预测的运行结果(这有点数据库隔离性中的“不可重复读”的味道)。全局变量如果我们需要限制 Lua 代码只能使用局部变量,那么站在这个出发点上,我们需要对全局变量做出限制。那问题来了,该如何实现呢?我们知道,Lua 是编译成字节码,再被解释执行的。那么,我们可以在编译字节码的阶段中,对全局变量的使用作出限制。在查阅完 Lua 虚拟机指令后,发现涉及到全局变量的指令有两条:GETGLOBAL(Opcode 5)和 SETGLOBAL(Opcode 7)。到这里,已经有了大致的思路:我们可通过判断字节码是否含有 GETGLOBAL 和 SETGLOBAL 进而限制代码的全局变量的使用。至于字节码的获取,可通过调用 CompileString(…) 和 CompileFile(…) ,得到 Lua 代码的 FunctionProto ,而其中的 Code 属性即为字节码 slice,类型为 []uint32 。在虚拟机实现代码中,我们可以找到一个根据字节码输出对应 OpCode 的工具函数。// 获取对应指令的 OpCodefunc opGetOpCode(inst uint32) int { return int(inst >> 26)}有了这个工具函数,我们即可实现对全局变量的检查。package main// …func CheckGlobal(proto *lua.FunctionProto) error { for _, code := range proto.Code { switch opGetOpCode(code) { case lua.OP_GETGLOBAL: return errors.New(“not allow to access global”) case lua.OP_SETGLOBAL: return errors.New(“not allow to set global”) } } // 对嵌套函数进行全局变量的检查 for _, nestedProto := range proto.FunctionPrototypes { if err := CheckGlobal(nestedProto); err != nil { return err } } return nil}func TestCheckGetGlobal(t *testing.T) { l := lua.NewState() proto, _ := CompileString(print(_G)) if err := CheckGlobal(proto); err == nil { t.Fail() } l.Close()}func TestCheckSetGlobal(t *testing.T) { l := lua.NewState() proto, _ := CompileString(_G = {}) if err := CheckGlobal(proto); err == nil { t.Fail() } l.Close()}模块除变量可能被污染外,导入的 Go 模块也有可能在运行期间被篡改。因此,我们需要一种机制,确保导入到虚拟机的模块不被篡改,即导入的对象是只读的。在查阅相关博客后,我们可以对 Table 的 __newindex 方法的修改,将模块设置为只读模式。package mainimport ( “fmt” “github.com/yuin/gopher-lua”)// 设置表为只读func SetReadOnly(l *lua.LState, table *lua.LTable) *lua.LUserData { ud := l.NewUserData() mt := l.NewTable() // 设置表中域的指向为 table l.SetField(mt, “__index”, table) // 限制对表的更新操作 l.SetField(mt, “__newindex”, l.NewFunction(func(state *lua.LState) int { state.RaiseError(“not allow to modify table”) return 0 })) ud.Metatable = mt return ud}func load(l *lua.LState) int { mod := l.SetFuncs(l.NewTable(), exports) l.SetField(mod, “name”, lua.LString(“gomodule”)) // 设置只读 l.Push(SetReadOnly(l, mod)) return 1}var exports = map[string]lua.LGFunction{ “goFunc”: goFunc,}func goFunc(l *lua.LState) int { fmt.Println(“golang”) return 0}func main() { l := lua.NewState() l.PreloadModule(“gomodule”, load) // 尝试修改导入的模块 if err := l.DoString(local m = require("gomodule");m.name = "hello world"); err != nil { fmt.Println(err) } l.Close()}// <string>:1: not allow to modify table写在最后Golang 和 Lua 的融合,开阔了我的视野:原来静态语言和动态语言还能这么融合,静态语言的运行高效率,配合动态语言的开发高效率,想想都兴奋(逃。在网上找了很久,发现并没有关于 Go-Lua 的技术分享,只找到了一篇稍微有点联系的文章(京东三级列表页持续架构优化 — Golang + Lua (OpenResty) 最佳实践),且在这篇文章中, Lua 还是跑在 C 上的。由于信息的缺乏以及本人(学生党)开发经验不足的原因,并不能很好地评价该方案在实际生产中的可行性。因此,本篇文章也只能当作“闲文”了,哈哈。参考资料深入浅出Lua虚拟机A No-Frills Introduction to Lua 5.1 VM Instructionscocos2d-lua disable unexpected global variablelua中设置只读tableMetableEventsgithub.com/zhu327/gluaor