当Go遇上了Lua,会发生什么

开发 后端
我们知道 Golang 是静态语言,而 Lua 是动态语言,Golang 的性能和效率各语言中表现得非常不错,但在动态能力上,肯定是无法与 Lua 相比。那么如果我们能够将二者结合起来,就能综合二者各自的长处了。

在 GitHub 玩耍时,偶然发现了 gopher-lua ,这是一个纯 Golang 实现的 Lua 虚拟机。我们知道 Golang 是静态语言,而 Lua 是动态语言,Golang 的性能和效率各语言中表现得非常不错,但在动态能力上,肯定是无法与 Lua 相比。那么如果我们能够将二者结合起来,就能综合二者各自的长处了(手动滑稽。

在项目 Wiki 中,我们可以知道 gopher-lua 的执行效率和性能仅比 C 实现的 bindings 差。因此从性能方面考虑,这应该是一款非常不错的虚拟机方案。

Hello World

这里给出了一个简单的 Hello World 程序。我们先是新建了一个虚拟机,随后对其进行了 DoString(...) 解释执行 lua 代码的操作,***将虚拟机关闭。执行程序,我们将在命令行看到 "Hello World" 的字符串。 

  1. package main  
  2. import (  
  3.     "github.com/yuin/gopher-lua"  
  4.  
  5. func main() {  
  6.     l :lua.NewState()  
  7.     defer l.Close()  
  8.     if err :l.DoString(`print("Hello World")`); err != nil {  
  9.         panic(err)  
  10.     }  
  11.  
  12. // Hello World 

提前编译

在查看上述 DoString(...) 方法的调用链后,我们发现每执行一次 DoString(...) 或 DoFile(...) ,都会各执行一次 parse 和 compile 。 

  1. func (ls *LState) DoString(source string) error {  
  2.     if fn, err :ls.LoadString(source); err != nil {  
  3.         return err  
  4.     } else {  
  5.         ls.Push(fn)  
  6.         return ls.PCall(0, MultRet, nil)  
  7.     }  
  8.  
  9. func (ls *LState) LoadString(source string) (*LFunction, error) {  
  10.     return ls.Load(strings.NewReader(source), "<string>")  
  11.  
  12. func (ls *LState) Load(reader io.Reader, name string) (*LFunction, error) {  
  13.     chunk, err :parse.Parse(reader, name)  
  14.     // ...  
  15.     proto, err :Compile(chunk, name)  
  16.     // ...  

从这一点考虑,在同份 Lua 代码将被执行多次(如在 http server 中,每次请求将执行相同 Lua 代码)的场景下,如果我们能够对代码进行提前编译,那么应该能够减少 parse 和 compile 的开销(如果这属于 hotpath 代码)。根据 Benchmark 结果,提前编译确实能够减少不必要的开销。 

  1. package glua_test  
  2. import (  
  3.     "bufio"  
  4.     "os"  
  5.     "strings"  
  6.     lua "github.com/yuin/gopher-lua"  
  7.     "github.com/yuin/gopher-lua/parse"  
  8.  
  9. // 编译 lua 代码字段  
  10. func CompileString(source string) (*lua.FunctionProto, error) {  
  11.     reader :strings.NewReader(source)  
  12.     chunk, err :parse.Parse(reader, source)  
  13.     if err != nil {  
  14.         return nil, err  
  15.     }  
  16.     proto, err :lua.Compile(chunk, source)  
  17.     if err != nil {  
  18.         return nil, err  
  19.     }  
  20.     return proto, nil  
  21.  
  22. // 编译 lua 代码文件  
  23. func CompileFile(filePath string) (*lua.FunctionProto, error) {  
  24.     file, err :os.Open(filePath)  
  25.     defer file.Close()  
  26.     if err != nil {  
  27.         return nil, err  
  28.     }  
  29.     reader :bufio.NewReader(file)  
  30.     chunk, err :parse.Parse(reader, filePath)  
  31.     if err != nil {  
  32.         return nil, err  
  33.     }  
  34.     proto, err :lua.Compile(chunk, filePath)  
  35.     if err != nil {  
  36.         return nil, err  
  37.     }  
  38.     return proto, nil  
  39.  
  40. func BenchmarkRunWithoutPreCompiling(b *testing.B) {  
  41.     l :lua.NewState()  
  42.     for i :0; i < b.N; i++ {  
  43.         _ = l.DoString(`a = 1 + 1`)  
  44.     }  
  45.     l.Close()  
  46.  
  47. func BenchmarkRunWithPreCompiling(b *testing.B) {  
  48.     l :lua.NewState()  
  49.     proto, _ :CompileString(`a = 1 + 1`)  
  50.     lfunc :l.NewFunctionFromProto(proto)  
  51.     for i :0; i < b.N; i++ {  
  52.         l.Push(lfunc) 
  53.          _ = l.PCall(0, lua.MultRet, nil)  
  54.     }  
  55.     l.Close()  
  56.  
  57. // goos: darwin  
  58. // goarch: amd64  
  59. // pkg: glua  
  60. // BenchmarkRunWithoutPreCompiling-8         100000             19392 ns/op           85626 B/op         67 allocs/op  
  61. // BenchmarkRunWithPreCompiling-8           1000000              1162 ns/op            2752 B/op          8 allocs/op  
  62. // PASS  
  63. // ok      glua    3.328s 

虚拟机实例池

在同份 Lua 代码被执行的场景下,除了可使用提前编译优化性能外,我们还可以引入虚拟机实例池。

因为新建一个 Lua 虚拟机会涉及到大量的内存分配操作,如果采用每次运行都重新创建和销毁的方式的话,将消耗大量的资源。引入虚拟机实例池,能够复用虚拟机,减少不必要的开销。 

  1. func BenchmarkRunWithoutPool(b *testing.B) {  
  2.     for i :0; i < b.N; i++ {  
  3.         l :lua.NewState()  
  4.         _ = l.DoString(`a = 1 + 1`)  
  5.         l.Close()  
  6.     }  
  7.  
  8. func BenchmarkRunWithPool(b *testing.B) {  
  9.     pool :newVMPool(nil, 100)  
  10.     for i :0; i < b.N; i++ {  
  11.         l :pool.get()  
  12.         _ = l.DoString(`a = 1 + 1`)  
  13.         pool.put(l)  
  14.     }  
  15.  
  16. // goos: darwin  
  17. // goarch: amd64  
  18. // pkg: glua  
  19. // BenchmarkRunWithoutPool-8          10000            129557 ns/op          262599 B/op        826 allocs/op  
  20. // BenchmarkRunWithPool-8            100000             19320 ns/op           85626 B/op         67 allocs/op  
  21. // PASS  
  22. // ok      glua    3.467s 

Benchmark 结果显示,虚拟机实例池的确能够减少很多内存分配操作。

下面给出了 README 提供的实例池实现,但注意到该实现在初始状态时,并未创建足够多的虚拟机实例(初始时,实例数为0),以及存在 slice 的动态扩容问题,这都是值得改进的地方。 

  1. type lStatePool struct {  
  2.     m     sync.Mutex  
  3.     saved []*lua.LState  
  4.  
  5. func (pl *lStatePool) Get() *lua.LState {  
  6.     pl.m.Lock()  
  7.     defer pl.m.Unlock()  
  8.     n :len(pl.saved)  
  9.     if n == 0 {  
  10.         return pl.New()  
  11.     }  
  12.     x :pl.saved[n-1]  
  13.     plpl.saved = pl.saved[0 : n-1]  
  14.     return x  
  15.  
  16. func (pl *lStatePool) New() *lua.LState {  
  17.     L :lua.NewState()  
  18.     // setting the L up here.  
  19.     // load scripts, set global variables, share channels, etc...  
  20.     return L  
  21.  
  22. func (pl *lStatePool) Put(L *lua.LState) {  
  23.     pl.m.Lock()  
  24.     defer pl.m.Unlock()  
  25.     pl.saved = append(pl.saved, L)  
  26.  
  27. func (pl *lStatePool) Shutdown() {  
  28.     for _, L :range pl.saved {  
  29.         L.Close()  
  30.     }  
  31.  
  32. // Global LState pool  
  33. var luaPool = &lStatePool{  
  34.     saved: make([]*lua.LState, 0, 4),  

模块调用

gopher-lua 支持 Lua 调用 Go 模块,个人觉得,这是一个非常令人振奋的功能点,因为在 Golang 程序开发中,我们可能设计出许多常用的模块,这种跨语言调用的机制,使得我们能够对代码、工具进行复用。

当然,除此之外,也存在 Go 调用 Lua 模块,但个人感觉后者是没啥必要的,所以在这里并没有涉及后者的内容。 

  1. package main  
  2. import (  
  3.     "fmt"  
  4.     lua "github.com/yuin/gopher-lua"  
  5.  
  6. const source = `  
  7. local m = require("gomodule")  
  8. m.goFunc()  
  9. print(m.name)  
  10. func main() {  
  11.     L :lua.NewState()  
  12.     defer L.Close()  
  13.     L.PreloadModule("gomodule", load)  
  14.     if err :L.DoString(source); err != nil {  
  15.         panic(err)  
  16.     }  
  17.  
  18. func load(L *lua.LState) int {  
  19.     mod :L.SetFuncs(L.NewTable(), exports)  
  20.     L.SetField(mod, "name", lua.LString("gomodule"))  
  21.     L.Push(mod)  
  22.     return 1  
  23.  
  24. var exports = map[string]lua.LGFunction{  
  25.     "goFunc": goFunc,  
  26.  
  27. func goFunc(L *lua.LState) int {  
  28.     fmt.Println("golang")  
  29.     return 0  
  30.  
  31. // golang  
  32. // gomodule 

变量污染

当我们使用实例池减少开销时,会引入另一个棘手的问题:由于同一个虚拟机可能会被多次执行同样的 Lua 代码,进而变动了其中的全局变量。如果代码逻辑依赖于全局变量,那么可能会出现难以预测的运行结果(这有点数据库隔离性中的“不可重复读”的味道)。

全局变量

如果我们需要限制 Lua 代码只能使用局部变量,那么站在这个出发点上,我们需要对全局变量做出限制。那问题来了,该如何实现呢?

我们知道,Lua 是编译成字节码,再被解释执行的。那么,我们可以在编译字节码的阶段中,对全局变量的使用作出限制。在查阅完 Lua 虚拟机指令后,发现涉及到全局变量的指令有两条:GETGLOBAL(Opcode 5)和 SETGLOBAL(Opcode 7)。

到这里,已经有了大致的思路:我们可通过判断字节码是否含有 GETGLOBAL 和 SETGLOBAL 进而限制代码的全局变量的使用。至于字节码的获取,可通过调用 CompileString(...) 和 CompileFile(...) ,得到 Lua 代码的 FunctionProto ,而其中的 Code 属性即为字节码 slice,类型为 []uint32 。

在虚拟机实现代码中,我们可以找到一个根据字节码输出对应 OpCode 的工具函数。 

  1. // 获取对应指令的 OpCode  
  2. func opGetOpCode(inst uint32) int {  
  3.     return int(inst >> 26)  

有了这个工具函数,我们即可实现对全局变量的检查。 

  1. package main  
  2. // ...  
  3. func CheckGlobal(proto *lua.FunctionProto) error {  
  4.     for _, code :range proto.Code {  
  5.         switch opGetOpCode(code) {  
  6.         case lua.OP_GETGLOBAL:  
  7.             return errors.New("not allow to access global")  
  8.         case lua.OP_SETGLOBAL:  
  9.             return errors.New("not allow to set global")  
  10.         }  
  11.     }  
  12.     // 对嵌套函数进行全局变量的检查  
  13.     for _, nestedProto :range proto.FunctionPrototypes {  
  14.         if err :CheckGlobal(nestedProto); err != nil {  
  15.             return err  
  16.         }  
  17.     }  
  18.     return nil  
  19.  
  20. func TestCheckGetGlobal(t *testing.T) {  
  21.     l :lua.NewState()  
  22.     proto, _ :CompileString(`print(_G)`)  
  23.     if err :CheckGlobal(proto); err == nil {  
  24.         t.Fail()  
  25.     }  
  26.     l.Close()  
  27.  
  28. func TestCheckSetGlobal(t *testing.T) {  
  29.     l :lua.NewState()  
  30.     proto, _ :CompileString(`_G = {}`)  
  31.     if err :CheckGlobal(proto); err == nil {  
  32.         t.Fail()  
  33.     }  
  34.     l.Close()  

模块

除变量可能被污染外,导入的 Go 模块也有可能在运行期间被篡改。因此,我们需要一种机制,确保导入到虚拟机的模块不被篡改,即导入的对象是只读的。

在查阅相关博客后,我们可以对 Table 的 __newindex 方法的修改,将模块设置为只读模式。 

  1. package main  
  2. import (  
  3.     "fmt"  
  4.     "github.com/yuin/gopher-lua"  
  5.  
  6. // 设置表为只读  
  7. func SetReadOnly(l *lua.LState, table *lua.LTable) *lua.LUserData {  
  8.     ud :l.NewUserData()  
  9.     mt :l.NewTable()  
  10.     // 设置表中域的指向为 table  
  11.     l.SetField(mt, "__index", table)  
  12.     // 限制对表的更新操作  
  13.     l.SetField(mt, "__newindex", l.NewFunction(func(state *lua.LState) int {  
  14.         state.RaiseError("not allow to modify table")  
  15.         return 0  
  16.     }))  
  17.     ud.Metatable = mt  
  18.     return ud  
  19.  
  20. func load(l *lua.LState) int {  
  21.     mod :l.SetFuncs(l.NewTable(), exports)  
  22.     l.SetField(mod, "name", lua.LString("gomodule"))  
  23.     // 设置只读  
  24.     l.Push(SetReadOnly(l, mod))  
  25.     return 1  
  26.  
  27. var exports = map[string]lua.LGFunction{  
  28.     "goFunc": goFunc,  
  29.  
  30. func goFunc(l *lua.LState) int {  
  31.     fmt.Println("golang")  
  32.     return 0  
  33.  
  34. func main() {  
  35.     l :lua.NewState()  
  36.     l.PreloadModule("gomodule", load)  
  37.     // 尝试修改导入的模块  
  38.     if err :l.DoString(`local m = require("gomodule");m.name = "hello world"`); err != nil {  
  39.         fmt.Println(err)  
  40.     }  
  41.     l.Close()  
  42.  
  43. // <string>:1: not allow to modify table 

写在***

Golang 和 Lua 的融合,开阔了我的视野:原来静态语言和动态语言还能这么融合,静态语言的运行高效率,配合动态语言的开发高效率,想想都兴奋(逃。

在网上找了很久,发现并没有关于 Go-Lua 的技术分享,只找到了一篇稍微有点联系的文章(京东三级列表页持续架构优化 — Golang + Lua (OpenResty) ***实践),且在这篇文章中, Lua 还是跑在 C 上的。由于信息的缺乏以及本人(学生党)开发经验不足的原因,并不能很好地评价该方案在实际生产中的可行性。因此,本篇文章也只能当作“闲文”了,哈哈。 

责任编辑:庞桂玉 来源: segmentfault
相关推荐

2023-06-27 16:53:50

2018-03-23 04:58:16

区块链物联网互联网

2018-06-06 00:26:20

SDN5G无线网络

2018-03-16 12:43:38

物联网智慧城市智能

2017-04-07 15:57:20

人工智能放射科诊断

2017-04-05 09:50:50

人工智能医生

2011-10-11 15:42:54

大数据数据库

2015-11-19 00:11:12

2023-04-27 07:40:08

Spring框架OpenAI

2020-03-05 16:35:06

人脸识别口罩人工智能

2012-02-03 14:06:34

Node.js

2015-08-18 09:09:46

WiFiO2O

2024-04-02 11:31:33

USBAndroid

2021-05-31 20:48:45

人工智能AI无人机

2021-12-27 08:24:08

漏洞网络安全

2021-08-19 17:27:41

IT数据中心灾难

2021-07-13 09:29:03

5G网络IaaS云计算

2018-04-12 20:19:19

无线网络人工智能机器学习

2023-08-26 07:44:13

系统内存虚拟

2024-02-29 16:51:36

GenAI运营供应链
点赞
收藏

51CTO技术栈公众号