當 Go 趕上了 Lua

在 GitHub 玩耍時,偶然發現了 gopher-lua ,這是一個純 Golang 實現的 Lua 虛擬機。咱們知道 Golang 是靜態語言,而 Lua 是動態語言,Golang 的性能和效率各語言中表現得很是不錯,但在動態能力上,確定是沒法與 Lua 相比。那麼若是咱們可以將兩者結合起來,就能綜合兩者各自的長處了(手動滑稽。git

在項目 Wiki 中,咱們能夠知道 gopher-lua 的執行效率和性能僅比 C 實現的 bindings 差。所以從性能方面考慮,這應該是一款很是不錯的虛擬機方案。github

Hello World

這裏給出了一個簡單的 Hello World 程序。咱們先是新建了一個虛擬機,隨後對其進行了 DoString(...) 解釋執行 lua 代碼的操做,最後將虛擬機關閉。執行程序,咱們將在命令行看到 "Hello World" 的字符串。golang

package main

import (
    "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 結果,提早編譯確實可以減小沒必要要的開銷。segmentfault

package glua_test

import (
    "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 虛擬機會涉及到大量的內存分配操做,若是採用每次運行都從新建立和銷燬的方式的話,將消耗大量的資源。引入虛擬機實例池,可以複用虛擬機,減小沒必要要的開銷。app

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.467s

Benchmark 結果顯示,虛擬機實例池的確可以減小不少內存分配操做。函數

下面給出了 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 pool
var luaPool = &lStatePool{
    saved: make([]*lua.LState, 0, 4),
}

模塊調用

gopher-lua 支持 Lua 調用 Go 模塊,我的以爲,這是一個很是使人振奮的功能點,由於在 Golang 程序開發中,咱們可能設計出許多經常使用的模塊,這種跨語言調用的機制,使得咱們可以對代碼、工具進行復用。性能

固然,除此以外,也存在 Go 調用 Lua 模塊,但我的感受後者是沒啥必要的,因此在這裏並無涉及後者的內容。

package main

import (
    "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 的工具函數。

// 獲取對應指令的 OpCode
func 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 main

import (
    "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 上的。因爲信息的缺少以及本人(學生黨)開發經驗不足的緣由,並不能很好地評價該方案在實際生產中的可行性。所以,本篇文章也只能看成「閒文」了,哈哈。

參考資料

相關文章
相關標籤/搜索