當 Go 遇上了 Lua

Jiahonzheng發表於2019-03-12

在 GitHub 玩耍時,偶然發現了 gopher-lua ,這是一個純 Golang 實現的 Lua 虛擬機器。我們知道 Golang 是靜態語言,而 Lua 是動態語言,Golang 的效能和效率各語言中表現得非常不錯,但在動態能力上,肯定是無法與 Lua 相比。那麼如果我們能夠將二者結合起來,就能綜合二者各自的長處了(手動滑稽。

在專案 Wiki 中,我們可以知道 gopher-lua 的執行效率和效能僅比 C 實現的 bindings 差。因此從效能方面考慮,這應該是一款非常不錯的虛擬機器方案。

Hello World

這裡給出了一個簡單的 Hello World 程式。我們先是新建了一個虛擬機器,隨後對其進行了 DoString(...) 解釋執行 lua 程式碼的操作,最後將虛擬機器關閉。執行程式,我們將在命令列看到 "Hello World" 的字串。

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 結果,提前編譯確實能夠減少不必要的開銷。

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 虛擬機器會涉及到大量的記憶體分配操作,如果採用每次執行都重新建立和銷燬的方式的話,將消耗大量的資源。引入虛擬機器例項池,能夠複用虛擬機器,減少不必要的開銷。

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 上的。由於資訊的缺乏以及本人(學生黨)開發經驗不足的原因,並不能很好地評價該方案在實際生產中的可行性。因此,本篇文章也只能當作“閒文”了,哈哈。

參考資料

相關文章