如何在 Go 中嵌入 Python
如果你看一下 新的 Datadog Agent,你可能會注意到大部分代碼庫是用 Go 編寫的,儘管我們用來收集指標的檢查仍然是用 Python 編寫的。這大概是因為 Datadog Agent 是一個 嵌入了 CPython 解釋器的普通 Go 二進位文件,可以在任何時候按需執行 Python 代碼。這個過程通過抽象層來透明化,使得你可以編寫慣用的 Go 代碼而底層運行的是 Python。
在 Go 應用程序中嵌入 Python 的原因有很多:
- 它在過渡期間很有用;可以逐步將現有 Python 項目的部分遷移到新語言,而不會在此過程中丟失任何功能。
- 你可以復用現有的 Python 軟體或庫,而無需用新語言重新實現。
- 你可以通過載入去執行常規 Python 腳本來動態擴展你軟體,甚至在運行時也可以。
理由還可以列很多,但對於 Datadog Agent 來說,最後一點至關重要:我們希望做到無需重新編譯 Agent,或者說編譯任何內容就能夠執行自定義檢查或更改現有檢查。
嵌入 CPython 非常簡單,而且文檔齊全。解釋器本身是用 C 編寫的,並且提供了一個 C API 以編程方式來執行底層操作,例如創建對象、導入模塊和調用函數。
在本文中,我們將展示一些代碼示例,我們將會在與 Python 交互的同時繼續保持 Go 代碼的慣用語,但在我們繼續之前,我們需要解決一個間隙:嵌入 API 是 C 語言,但我們的主要應用程序是 Go,這怎麼可能工作?
介紹 cgo
有 很多好的理由 說服你為什麼不要在堆棧中引入 cgo,但嵌入 CPython 是你必須這樣做的原因。cgo 不是語言,也不是編譯器。它是 外部函數介面 (FFI),一種讓我們可以在 Go 中使用來調用不同語言(特別是 C)編寫的函數和服務的機制。
當我們提起 「cgo」 時,我們實際上指的是 Go 工具鏈在底層使用的一組工具、庫、函數和類型,因此我們可以通過執行 go build
來獲取我們的 Go 二進位文件。下面是使用 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"
指令上方的注釋塊稱為「 序言 」,可以包含實際的 C 代碼。導入後,我們可以通過「C」偽包來「跳轉」到外部代碼,訪問常量 FLT_MAX
。你可以通過調用 go build
來構建,它就像普通的 Go 一樣。
如果你想查看 cgo 在這背後到底做了什麼,可以運行 go build -x
。你將看到 「cgo」 工具將被調用以生成一些 C 和 Go 模塊,然後將調用 C 和 Go 編譯器來構建目標模塊,最後鏈接器將所有內容放在一起。
你可以在 Go 博客 上閱讀更多有關 cgo 的信息,該文章包含更多的例子以及一些有用的鏈接來做進一步了解細節。
現在我們已經了解了 cgo 可以為我們做什麼,讓我們看看如何使用這種機制運行一些 Python 代碼。
嵌入 CPython:一個入門指南
從技術上講,嵌入 CPython 的 Go 程序並沒有你想像的那麼複雜。事實上,我們只需在運行 Python 代碼之前初始化解釋器,並在完成後關閉它。請注意,我們在所有示例中使用 Python 2.x,但我們只需做很少的調整就可以應用於 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
指令;這些指令被會被傳遞到工具鏈,讓你改變構建工作流程。在這種情況下,我們告訴 cgo 調用 pkg-config
來收集構建和鏈接名為 python-2.7
的庫所需的標誌,並將這些標誌傳遞給 C 編譯器。如果你的系統中安裝了 CPython 開發庫和 pkg-config,你只需要運行 go build
來編譯上面的示例。
回到代碼,我們使用 Py_Initialize()
和 Py_Finalize()
來初始化和關閉解釋器,並使用 Py_GetVersion
C 函數來獲取嵌入式解釋器版本信息的字元串。
如果你想知道,所有我們需要放在一起調用 C 語言 Python API的 cgo 代碼都是模板代碼。這就是為什麼 Datadog Agent 依賴 go-python 來完成所有的嵌入操作;該庫為 C API 提供了一個 Go 友好的輕量級包,並隱藏了 cgo 細節。這是另一個基本的嵌入式示例,這次使用 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 字元串。嵌入式看起來功能強大且對開發人員友好,是時候充分利用解釋器了:讓我們嘗試從磁碟載入 Python 模塊。
在 Python 方面我們不需要任何複雜的東西,無處不在的「hello world」 就可以達到目的:
# foo.py
def hello():
"""
Print hello world for fun and profit.
"""
print "hello, world!"
Go 代碼稍微複雜一些,但仍然可讀:
// 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())
}
構建時,我們需要將 PYTHONPATH
環境變數設置為當前工作目錄,以便導入語句能夠找到 foo.py
模塊。在 shell 中,該命令如下所示:
$ go build main.go && PYTHONPATH=. ./main
hello, world!
可怕的全局解釋器鎖
為了嵌入 Python 必須引入 cgo ,這是一種權衡:構建速度會變慢,垃圾收集器不會幫助我們管理外部系統使用的內存,交叉編譯也很難。對於一個特定的項目來說,這些問題是否是可以爭論的,但我認為有一些不容商量的問題:Go 並發模型。如果我們不能從 goroutine 中運行 Python,那麼使用 Go 就沒有意義了。
在處理並發、Python 和 cgo 之前,我們還需要知道一些事情:它就是 全局解釋器鎖 ,即 GIL。GIL 是語言解釋器(CPython 就是其中之一)中廣泛採用的一種機制,可防止多個線程同時運行。這意味著 CPython 執行的任何 Python 程序都無法在同一進程中並行運行。並發仍然是可能的,鎖是速度、安全性和實現簡易性之間的一個很好的權衡,那麼,當涉及到嵌入時,為什麼這會造成問題呢?
當一個常規的、非嵌入式的 Python 程序啟動時,不涉及 GIL 以避免鎖定操作中的無用開銷;在某些 Python 代碼首次請求生成線程時 GIL 就啟動了。對於每個線程,解釋器創建一個數據結構來存儲當前的相關狀態信息並鎖定 GIL。當線程完成時,狀態被恢復,GIL 被解鎖,準備被其他線程使用。
當我們從 Go 程序運行 Python 時,上述情況都不會自動發生。如果沒有 GIL,我們的 Go 程序可以創建多個 Python 線程,這可能會導致競爭條件,從而導致致命的運行時錯誤,並且很可能出現分段錯誤導致整個 Go 應用程序崩潰。
解決方案是在我們從 Go 運行多線程代碼時顯式調用 GIL;代碼並不複雜,因為 C API 提供了我們需要的所有工具。為了更好地暴露這個問題,我們需要寫一些受 CPU 限制的 Python 代碼。讓我們將這些函數添加到前面示例中的 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 並發列印奇數和偶數,使用兩個不同的 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()
}
在閱讀示例時,你可能會注意到一個模式,該模式將成為我們運行嵌入式 Python 代碼的習慣寫法:
- 保存狀態並鎖定 GIL。
- 執行 Python。
- 恢復狀態並解鎖 GIL。
代碼應該很簡單,但我們想指出一個微妙的細節:請注意,儘管借用了 GIL 執行,有時我們通過調用 PyEval_SaveThread()
和 PyEval_RestoreThread()
來操作 GIL,有時(查看 goroutines 裡面)我們對 PyGILState_Ensure()
和 PyGILState_Release()
來做同樣的事情。
我們說過當從 Python 操作多線程時,解釋器負責創建存儲當前狀態所需的數據結構,但是當同樣的事情發生在 C API 時,我們來負責處理。
當我們用 go-python 初始化解釋器時,我們是在 Python 上下文中操作的。因此,當調用 PyEval_InitThreads()
時,它會初始化數據結構並鎖定 GIL。我們可以使用 PyEval_SaveThread()
和 PyEval_RestoreThread()
對已經存在的狀態進行操作。
在 goroutines 中,我們從 Go 上下文操作,我們需要顯式創建狀態並在完成後將其刪除,這就是 PyGILState_Ensure()
和 PyGILState_Release()
為我們所做的。
釋放 Gopher
在這一點上,我們知道如何處理在嵌入式解釋器中執行 Python 的多線程 Go 代碼,但在 GIL 之後,另一個挑戰即將來臨:Go 調度程序。
當一個 goroutine 啟動時,它被安排在可用的 GOMAXPROCS
線程之一上執行,參見此處 可了解有關該主題的更多詳細信息。如果一個 goroutine 碰巧執行了系統調用或調用 C 代碼,當前線程會將線程隊列中等待運行的其他 goroutine 移交給另一個線程,以便它們有更好的機會運行; 當前 goroutine 被暫停,等待系統調用或 C 函數返回。當這種情況發生時,線程會嘗試恢復暫停的 goroutine,但如果這不可能,它會要求 Go 運行時找到另一個線程來完成 goroutine 並進入睡眠狀態。 goroutine 最後被安排給另一個線程,它就完成了。
考慮到這一點,讓我們看看當一個 goroutine 被移動到一個新線程時,運行一些 Python 代碼的 goroutine 會發生什麼:
- 我們的 goroutine 啟動,執行 C 調用並暫停。GIL 被鎖定。
- 當 C 調用返回時,當前線程嘗試恢復 goroutine,但失敗了。
- 當前線程告訴 Go 運行時尋找另一個線程來恢復我們的 goroutine。
- Go 調度器找到一個可用線程並恢復 goroutine。
- goroutine 快完成了,並在返回之前嘗試解鎖 GIL。
- 當前狀態中存儲的線程 ID 來自原線程,與當前線程的 ID 不同。
- 崩潰!
所幸,我們可以通過從 goroutine 中調用運行時包中的 LockOSThread
函數來強制 Go runtime 始終保持我們的 goroutine 在同一線程上運行:
go func() {
runtime.LockOSThread()
_gstate := python.PyGILState_Ensure()
odds.Call(python.PyTuple_New(0), python.PyDict_New())
python.PyGILState_Release(_gstate)
wg.Done()
}()
這會干擾調度器並可能引入一些開銷,但這是我們願意付出的代價。
結論
為了嵌入 Python,Datadog Agent 必須接受一些權衡:
- cgo 引入的開銷。
- 手動處理 GIL 的任務。
- 在執行期間將 goroutine 綁定到同一線程的限制。
為了能方便在 Go 中運行 Python 檢查,我們很樂意接受其中的每一項。但通過意識到這些權衡,我們能夠最大限度地減少它們的影響,除了為支持 Python 而引入的其他限制,我們沒有對策來控制潛在問題:
- 構建是自動化和可配置的,因此開發人員仍然需要擁有與
go build
非常相似的東西。 - Agent 的輕量級版本,可以使用 Go 構建標籤,完全剝離 Python 支持。
- 這樣的版本僅依賴於在 Agent 本身硬編碼的核心檢查(主要是系統和網路檢查),但沒有 cgo 並且可以交叉編譯。
我們將在未來重新評估我們的選擇,並決定是否仍然值得保留 cgo;我們甚至可以重新考慮整個 Python 是否仍然值得,等待 Go 插件包 成熟到足以支持我們的用例。但就目前而言,嵌入式 Python 運行良好,從舊代理過渡到新代理再簡單不過了。
你是一個喜歡混合不同編程語言的多語言者嗎?你喜歡了解語言的內部工作原理以提高你的代碼性能嗎?
via: https://www.datadoghq.com/blog/engineering/cgo-and-python/
作者:Massimiliano Pippi 譯者:Zioyi 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive