如何在 Go 中嵌入 Python
如果你看一下 新的 Datadog Agent,你可能會注意到大部分代碼庫是用 Go 編寫的,盡管我們用來收集指標(biāo)的檢查仍然是用 Python 編寫的。這大概是因?yàn)?Datadog Agent 是一個 嵌入了 CPython 解釋器的普通 Go 二進(jìn)制文件,可以在任何時候按需執(zhí)行 Python 代碼。這個過程通過抽象層來透明化,使得你可以編寫慣用的 Go 代碼而底層運(yùn)行的是 Python。
在 Go 應(yīng)用程序中嵌入 Python 的原因有很多:
- 它在過渡期間很有用;可以逐步將現(xiàn)有 Python 項(xiàng)目的部分遷移到新語言,而不會在此過程中丟失任何功能。
- 你可以復(fù)用現(xiàn)有的 Python 軟件或庫,而無需用新語言重新實(shí)現(xiàn)。
- 你可以通過加載去執(zhí)行常規(guī) Python 腳本來動態(tài)擴(kuò)展你軟件,甚至在運(yùn)行時也可以。
理由還可以列很多,但對于 Datadog Agent 來說,最后一點(diǎn)至關(guān)重要:我們希望做到無需重新編譯 Agent,或者說編譯任何內(nèi)容就能夠執(zhí)行自定義檢查或更改現(xiàn)有檢查。
嵌入 CPython 非常簡單,而且文檔齊全。解釋器本身是用 C 編寫的,并且提供了一個 C API 以編程方式來執(zhí)行底層操作,例如創(chuàng)建對象、導(dǎo)入模塊和調(diào)用函數(shù)。
在本文中,我們將展示一些代碼示例,我們將會在與 Python 交互的同時繼續(xù)保持 Go 代碼的慣用語,但在我們繼續(xù)之前,我們需要解決一個間隙:嵌入 API 是 C 語言,但我們的主要應(yīng)用程序是 Go,這怎么可能工作?
介紹 cgo
有 很多好的理由 說服你為什么不要在堆棧中引入 cgo,但嵌入 CPython 是你必須這樣做的原因。cgo 不是語言,也不是編譯器。它是 外部函數(shù)接口(FFI),一種讓我們可以在 Go 中使用來調(diào)用不同語言(特別是 C)編寫的函數(shù)和服務(wù)的機(jī)制。
當(dāng)我們提起 “cgo” 時,我們實(shí)際上指的是 Go 工具鏈在底層使用的一組工具、庫、函數(shù)和類型,因此我們可以通過執(zhí)行 go build
來獲取我們的 Go 二進(jìn)制文件。下面是使用 cgo 的示例程序:
package main
// #include <float.h>
import "C"
import "fmt"
func main() {
fmt.Println("Max float value of float is", C.FLT_MAX)
}
在這種包含頭文件情況下,import "C"
指令上方的注釋塊稱為“序言”,可以包含實(shí)際的 C 代碼。導(dǎo)入后,我們可以通過“C”偽包來“跳轉(zhuǎn)”到外部代碼,訪問常量 FLT_MAX
。你可以通過調(diào)用 go build
來構(gòu)建,它就像普通的 Go 一樣。
如果你想查看 cgo 在這背后到底做了什么,可以運(yùn)行 go build -x
。你將看到 “cgo” 工具將被調(diào)用以生成一些 C 和 Go 模塊,然后將調(diào)用 C 和 Go 編譯器來構(gòu)建目標(biāo)模塊,最后鏈接器將所有內(nèi)容放在一起。
你可以在 Go 博客 上閱讀更多有關(guān) cgo 的信息,該文章包含更多的例子以及一些有用的鏈接來做進(jìn)一步了解細(xì)節(jié)。
現(xiàn)在我們已經(jīng)了解了 cgo 可以為我們做什么,讓我們看看如何使用這種機(jī)制運(yùn)行一些 Python 代碼。
嵌入 CPython:一個入門指南
從技術(shù)上講,嵌入 CPython 的 Go 程序并沒有你想象的那么復(fù)雜。事實(shí)上,我們只需在運(yùn)行 Python 代碼之前初始化解釋器,并在完成后關(guān)閉它。請注意,我們在所有示例中使用 Python 2.x,但我們只需做很少的調(diào)整就可以應(yīng)用于 Python 3.x。讓我們看一個例子:
package main
// #cgo pkg-config: python-2.7
// #include <Python.h>
import "C"
import "fmt"
func main() {
C.Py_Initialize()
fmt.Println(C.GoString(C.Py_GetVersion()))
C.Py_Finalize()
}
上面的例子做的正是下面 Python 代碼要做的事:
import sys
print(sys.version)
你可以看到我們在序言加入了一個 #cgo
指令;這些指令被會被傳遞到工具鏈,讓你改變構(gòu)建工作流程。在這種情況下,我們告訴 cgo 調(diào)用 pkg-config
來收集構(gòu)建和鏈接名為 python-2.7
的庫所需的標(biāo)志,并將這些標(biāo)志傳遞給 C 編譯器。如果你的系統(tǒng)中安裝了 CPython 開發(fā)庫和 pkg-config,你只需要運(yùn)行 go build
來編譯上面的示例。
回到代碼,我們使用 Py_Initialize()
和 Py_Finalize()
來初始化和關(guān)閉解釋器,并使用 Py_GetVersion
C 函數(shù)來獲取嵌入式解釋器版本信息的字符串。
如果你想知道,所有我們需要放在一起調(diào)用 C 語言 Python API的 cgo 代碼都是模板代碼。這就是為什么 Datadog Agent 依賴 go-python 來完成所有的嵌入操作;該庫為 C API 提供了一個 Go 友好的輕量級包,并隱藏了 cgo 細(xì)節(jié)。這是另一個基本的嵌入式示例,這次使用 go-python:
package main
import (
python "github.com/sbinet/go-python"
)
func main() {
python.Initialize()
python.PyRun_SimpleString("print 'hello, world!'")
python.Finalize()
}
這看起來更接近普通 Go 代碼,不再暴露 cgo,我們可以在訪問 Python API 時來回使用 Go 字符串。嵌入式看起來功能強(qiáng)大且對開發(fā)人員友好,是時候充分利用解釋器了:讓我們嘗試從磁盤加載 Python 模塊。
在 Python 方面我們不需要任何復(fù)雜的東西,無處不在的“hello world” 就可以達(dá)到目的:
# foo.py
def hello():
"""
Print hello world for fun and profit.
"""
print "hello, world!"
Go 代碼稍微復(fù)雜一些,但仍然可讀:
// main.go
package main
import "github.com/sbinet/go-python"
func main() {
python.Initialize()
defer python.Finalize()
fooModule := python.PyImport_ImportModule("foo")
if fooModule == nil {
panic("Error importing module")
}
helloFunc := fooModule.GetAttrString("hello")
if helloFunc == nil {
panic("Error importing function")
}
// The Python function takes no params but when using the C api
// we're required to send (empty) *args and **kwargs anyways.
helloFunc.Call(python.PyTuple_New(0), python.PyDict_New())
}
構(gòu)建時,我們需要將 PYTHONPATH
環(huán)境變量設(shè)置為當(dāng)前工作目錄,以便導(dǎo)入語句能夠找到 foo.py
模塊。在 shell 中,該命令如下所示:
$ go build main.go && PYTHONPATH=. ./main
hello, world!
可怕的全局解釋器鎖
為了嵌入 Python 必須引入 cgo ,這是一種權(quán)衡:構(gòu)建速度會變慢,垃圾收集器不會幫助我們管理外部系統(tǒng)使用的內(nèi)存,交叉編譯也很難。對于一個特定的項(xiàng)目來說,這些問題是否是可以爭論的,但我認(rèn)為有一些不容商量的問題:Go 并發(fā)模型。如果我們不能從 goroutine 中運(yùn)行 Python,那么使用 Go 就沒有意義了。
在處理并發(fā)、Python 和 cgo 之前,我們還需要知道一些事情:它就是全局解釋器鎖,即 GIL。GIL 是語言解釋器(CPython 就是其中之一)中廣泛采用的一種機(jī)制,可防止多個線程同時運(yùn)行。這意味著 CPython 執(zhí)行的任何 Python 程序都無法在同一進(jìn)程中并行運(yùn)行。并發(fā)仍然是可能的,鎖是速度、安全性和實(shí)現(xiàn)簡易性之間的一個很好的權(quán)衡,那么,當(dāng)涉及到嵌入時,為什么這會造成問題呢?
當(dāng)一個常規(guī)的、非嵌入式的 Python 程序啟動時,不涉及 GIL 以避免鎖定操作中的無用開銷;在某些 Python 代碼首次請求生成線程時 GIL 就啟動了。對于每個線程,解釋器創(chuàng)建一個數(shù)據(jù)結(jié)構(gòu)來存儲當(dāng)前的相關(guān)狀態(tài)信息并鎖定 GIL。當(dāng)線程完成時,狀態(tài)被恢復(fù),GIL 被解鎖,準(zhǔn)備被其他線程使用。
當(dāng)我們從 Go 程序運(yùn)行 Python 時,上述情況都不會自動發(fā)生。如果沒有 GIL,我們的 Go 程序可以創(chuàng)建多個 Python 線程,這可能會導(dǎo)致競爭條件,從而導(dǎo)致致命的運(yùn)行時錯誤,并且很可能出現(xiàn)分段錯誤導(dǎo)致整個 Go 應(yīng)用程序崩潰。
解決方案是在我們從 Go 運(yùn)行多線程代碼時顯式調(diào)用 GIL;代碼并不復(fù)雜,因?yàn)?C API 提供了我們需要的所有工具。為了更好地暴露這個問題,我們需要寫一些受 CPU 限制的 Python 代碼。讓我們將這些函數(shù)添加到前面示例中的 foo.py
模塊中:
# foo.py
import sys
def print_odds(limit=10):
"""
Print odds numbers < limit
"""
for i in range(limit):
if i%2:
sys.stderr.write("{}\n".format(i))
def print_even(limit=10):
"""
Print even numbers < limit
"""
for i in range(limit):
if i%2 == 0:
sys.stderr.write("{}\n".format(i))
我們將嘗試從 Go 并發(fā)打印奇數(shù)和偶數(shù),使用兩個不同的 goroutine(因此涉及線程):
package main
import (
"sync"
"github.com/sbinet/go-python"
)
func main() {
// The following will also create the GIL explicitly
// by calling PyEval_InitThreads(), without waiting
// for the interpreter to do that
python.Initialize()
var wg sync.WaitGroup
wg.Add(2)
fooModule := python.PyImport_ImportModule("foo")
odds := fooModule.GetAttrString("print_odds")
even := fooModule.GetAttrString("print_even")
// Initialize() has locked the the GIL but at this point we don't need it
// anymore. We save the current state and release the lock
// so that goroutines can acquire it
state := python.PyEval_SaveThread()
go func() {
_gstate := python.PyGILState_Ensure()
odds.Call(python.PyTuple_New(0), python.PyDict_New())
python.PyGILState_Release(_gstate)
wg.Done()
}()
go func() {
_gstate := python.PyGILState_Ensure()
even.Call(python.PyTuple_New(0), python.PyDict_New())
python.PyGILState_Release(_gstate)
wg.Done()
}()
wg.Wait()
// At this point we know we won't need Python anymore in this
// program, we can restore the state and lock the GIL to perform
// the final operations before exiting.
python.PyEval_RestoreThread(state)
python.Finalize()
}
在閱讀示例時,你可能會注意到一個模式,該模式將成為我們運(yùn)行嵌入式 Python 代碼的習(xí)慣寫法:
- 保存狀態(tài)并鎖定 GIL。
- 執(zhí)行 Python。
- 恢復(fù)狀態(tài)并解鎖 GIL。
代碼應(yīng)該很簡單,但我們想指出一個微妙的細(xì)節(jié):請注意,盡管借用了 GIL 執(zhí)行,有時我們通過調(diào)用 PyEval_SaveThread()
和 PyEval_RestoreThread()
來操作 GIL,有時(查看 goroutines 里面)我們對 PyGILState_Ensure()
和 PyGILState_Release()
來做同樣的事情。
我們說過當(dāng)從 Python 操作多線程時,解釋器負(fù)責(zé)創(chuàng)建存儲當(dāng)前狀態(tài)所需的數(shù)據(jù)結(jié)構(gòu),但是當(dāng)同樣的事情發(fā)生在 C API 時,我們來負(fù)責(zé)處理。
當(dāng)我們用 go-python 初始化解釋器時,我們是在 Python 上下文中操作的。因此,當(dāng)調(diào)用 PyEval_InitThreads()
時,它會初始化數(shù)據(jù)結(jié)構(gòu)并鎖定 GIL。我們可以使用 PyEval_SaveThread()
和 PyEval_RestoreThread()
對已經(jīng)存在的狀態(tài)進(jìn)行操作。
在 goroutines 中,我們從 Go 上下文操作,我們需要顯式創(chuàng)建狀態(tài)并在完成后將其刪除,這就是 PyGILState_Ensure()
和 PyGILState_Release()
為我們所做的。
釋放 Gopher
在這一點(diǎn)上,我們知道如何處理在嵌入式解釋器中執(zhí)行 Python 的多線程 Go 代碼,但在 GIL 之后,另一個挑戰(zhàn)即將來臨:Go 調(diào)度程序。
當(dāng)一個 goroutine 啟動時,它被安排在可用的 GOMAXPROCS
線程之一上執(zhí)行,參見此處 可了解有關(guān)該主題的更多詳細(xì)信息。如果一個 goroutine 碰巧執(zhí)行了系統(tǒng)調(diào)用或調(diào)用 C 代碼,當(dāng)前線程會將線程隊(duì)列中等待運(yùn)行的其他 goroutine 移交給另一個線程,以便它們有更好的機(jī)會運(yùn)行; 當(dāng)前 goroutine 被暫停,等待系統(tǒng)調(diào)用或 C 函數(shù)返回。當(dāng)這種情況發(fā)生時,線程會嘗試恢復(fù)暫停的 goroutine,但如果這不可能,它會要求 Go 運(yùn)行時找到另一個線程來完成 goroutine 并進(jìn)入睡眠狀態(tài)。 goroutine 最后被安排給另一個線程,它就完成了。
考慮到這一點(diǎn),讓我們看看當(dāng)一個 goroutine 被移動到一個新線程時,運(yùn)行一些 Python 代碼的 goroutine 會發(fā)生什么:
- 我們的 goroutine 啟動,執(zhí)行 C 調(diào)用并暫停。GIL 被鎖定。
- 當(dāng) C 調(diào)用返回時,當(dāng)前線程嘗試恢復(fù) goroutine,但失敗了。
- 當(dāng)前線程告訴 Go 運(yùn)行時尋找另一個線程來恢復(fù)我們的 goroutine。
- Go 調(diào)度器找到一個可用線程并恢復(fù) goroutine。
- goroutine 快完成了,并在返回之前嘗試解鎖 GIL。
- 當(dāng)前狀態(tài)中存儲的線程 ID 來自原線程,與當(dāng)前線程的 ID 不同。
- 崩潰!
所幸,我們可以通過從 goroutine 中調(diào)用運(yùn)行時包中的 LockOSThread
函數(shù)來強(qiáng)制 Go runtime 始終保持我們的 goroutine 在同一線程上運(yùn)行:
go func() {
runtime.LockOSThread()
_gstate := python.PyGILState_Ensure()
odds.Call(python.PyTuple_New(0), python.PyDict_New())
python.PyGILState_Release(_gstate)
wg.Done()
}()
這會干擾調(diào)度器并可能引入一些開銷,但這是我們愿意付出的代價。
結(jié)論
為了嵌入 Python,Datadog Agent 必須接受一些權(quán)衡:
- cgo 引入的開銷。
- 手動處理 GIL 的任務(wù)。
- 在執(zhí)行期間將 goroutine 綁定到同一線程的限制。
為了能方便在 Go 中運(yùn)行 Python 檢查,我們很樂意接受其中的每一項(xiàng)。但通過意識到這些權(quán)衡,我們能夠最大限度地減少它們的影響,除了為支持 Python 而引入的其他限制,我們沒有對策來控制潛在問題:
- 構(gòu)建是自動化和可配置的,因此開發(fā)人員仍然需要擁有與
go build
非常相似的東西。 - Agent 的輕量級版本,可以使用 Go 構(gòu)建標(biāo)簽,完全剝離 Python 支持。
- 這樣的版本僅依賴于在 Agent 本身硬編碼的核心檢查(主要是系統(tǒng)和網(wǎng)絡(luò)檢查),但沒有 cgo 并且可以交叉編譯。
我們將在未來重新評估我們的選擇,并決定是否仍然值得保留 cgo;我們甚至可以重新考慮整個 Python 是否仍然值得,等待 Go 插件包 成熟到足以支持我們的用例。但就目前而言,嵌入式 Python 運(yùn)行良好,從舊代理過渡到新代理再簡單不過了。
你是一個喜歡混合不同編程語言的多語言者嗎?你喜歡了解語言的內(nèi)部工作原理以提高你的代碼性能嗎?