這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。如果你研究過 [新近的 Datadog Agent](https://github.com/DataDog/datadog-agent/) ,你可能會注意到大部分程式碼程式庫是用 Go 語言編寫,而我們用來收集指標的檢查工具仍然是用的 Python 。這是有可能的,因為 Datadog Agent(一個標準的 Go 二進位程式),內嵌了一個 CPython 解譯器,在需要運行 Python 代碼的時候就會調用這個解譯器。通過使用一個抽象層可以讓整個過程是透明的,由此,你盡可以編寫慣用的 Go 代碼,即使底層同時運行著 Python 代碼。 想要在 Go 應用中嵌入 Python 的原因有很多種:- 對介面有益處;逐步把現有 Python 項目的一部分遷移到新的語言,在整個過程中不丟失功能。- 你可以重用現有的 Python 軟體或庫,不必用新的語言重新實現它們。- 通過載入和運行標準的 Python 指令碼你可以動態擴充你的軟體,即使是在運行時。這個清單還可以繼續下去,但對 Datadog Agent 來說最後一點最為關鍵: 我們希望你能夠執行自訂的檢查和更改而不必去重新編譯 Agent ,或者更廣泛點兒,編譯任何一處。內嵌的 CPython 非常簡單而且文檔也很完善。解譯器本身使用 C 編寫,並且提供了一個 C API 使得可以用編程方式執行一些比較底層的操作,例如建立對象,匯入模組,以及調用函數。在本文中我們會介紹一些簡單的程式碼範例,並且在與 Python 互動的時候,我們會著力於保證 Go 代碼仍符合語言習慣。但在開始之前我們需要先標記一處小障礙:內嵌的 API 是 C 但我們的主應用卻是 Go ,這又是怎麼能完成的呢?![Introducing cgo](https://raw.githubusercontent.com/studygolang/gctt-images/master/cgo-python/cgo_python_divider_1.png)## Cgo 簡介可能會有 [很多原因](https://dave.cheney.net/2016/01/18/cgo-is-not-go) 讓你不希望引入 Cgo 到你的工作棧中,但內嵌的 CPython 卻是一個能讓你同意的關鍵砝碼。Cgo 既不是語言也不是編譯器。它是 [外部函數介面](https://en.wikipedia.org/wiki/Foreign_function_interface)(FFI),一種能讓我們在 Go 中調用其他語言編寫的函數和服務,尤其是 C 。當我們提到 “Cgo” 時,我們實際指的是底層 Go 工具鏈使用的一系列工具,庫,函數,以及類型,所以我們依然可以用 `go build` 擷取 Go 的二進位程式。一個使用 Cgo 的極簡程式碼範例如下所示: ```gopackage 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` 來 build 該樣本,就跟普通的 Go 代碼一樣。如果你想要瞭解一下底層 Cgo 所做的全部工作,就運行 `go build -x ` 。你將會看到 “Cgo” 工具被調用去產生一些 C 和 Go 的模組,然後 C 和 Go 編譯器會被調用以建立目標模組,最終連結器會把一切都安排好。你可以在 [Go 部落格](https://blog.golang.org/c-go-cgo) 中閱讀更多 Cgo 的內容。這篇文章包含更多樣本,以及一些深入細節的有用連結。既然我們已經知道了 Cgo 可以協助我們做哪些工作,接下來就來看看我們如何藉助該機制來運行 Python 代碼。![Embedding CPython](https://raw.githubusercontent.com/studygolang/gctt-images/master/cgo-python/cgo_python_divider_2.png)## 內嵌 CPython : 入門一個 Go 程式,嚴格來說,內嵌 CPython 並不像你預計的那樣複雜。事實上,在最低限度上,我們要做的所有工作就是在運行 Python 代碼之前初始化解譯器,以及在運行結束後回收資源。請注意,我們將會在所有的樣本中都使用 Python 2.x ,但是這些都能夠適用於 Python 3.x,僅僅需要很少的修改。讓我們先看一個例子:```gopackage 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 代碼等價:```pythonimport sysprint(sys.version)```你可以看到我們放了一個 `#cgo` 在前面;這些符號將會被傳遞給工具鏈,從而改編 build 的工作流程。在這個例子,我們讓 Cgo 去調用 “pkg-config” 來擷取 build 需要的標誌,並且連結到一個叫 “python-2.7” 的庫,以及傳遞這些標誌給 C 編譯器。如果你的系統上安裝了 CPython 開發庫並用 pkg-config 串連,這將使得你可以繼續使用普通的 `go build` 編譯上面的例子。重新再看代碼,我們使用 `Py_Initialize()` 和`Py_Finalize()` 來開啟和關閉解譯器,以及 C 函數 `Py_GetVersion` 來擷取包含內嵌解譯器版本資訊的字串。如果你有疑問,所有我們需要整合來調用 C Python API 的 Cgo 部分都是樣板代碼。這也是 Datadog Agent 依賴 [go-python](https://github.com/sbinet/go-python) 來執行所有內嵌操作的原因所在; go-python 庫提供了 Go 風格的對 C API 的簡單封裝,並且隱藏了 Cgo 的細節。這是另一個簡單的嵌入樣本,這次使用 go-python:```gopackage 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” 就可以滿足需求:```python# foo.pydef hello(): """ Print hello world for fun and profit. """print "hello, world!"```Go 代碼稍微複雜點,但依然容易閱讀:```go// main.go package mainimport "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` 環境變數到當前工作目錄,由此 import 語句就能夠找到 `foo.py` 模組。在 shell 中,命令類似下面:```shell$ go build main.go && PYTHONPATH=. ./main hello, world!```![The dreadful Global Interpreter Lock](https://raw.githubusercontent.com/studygolang/gctt-images/master/cgo-python/cgo_python_divider_3.png)## 糟糕的全域解譯器鎖( GIL )為了內嵌 Python 引入 Cgo 是一個妥協:build 過程會變慢,記憶體回收行程不會幫我們管理外部系統使用的記憶體,並且交叉編譯也會有難度。是否為一個特定項目引入可能會引發爭論,但有一點我認為是無需討論的: Go 並行存取模型。如果我們不能在一個 goroutine 裡面運行 Python,這一切就毫無意義。在用 Python 和 cgo 實現並發之前 ,有一點我們需要瞭解:就是全域解譯器鎖,簡稱 GIL 。GIL 是一個被語言解譯器( CPython 只是一種)廣泛採用的機制,目的是防止同一時刻有超過一個以上的線程運行。這意味著被 CPython 執行的 Python 程式不可能在同一個進程中並行。並發倒是仍然有可能,鎖是在速度,安全性和實現難度上的一個較好權衡。那麼它為什麼會在內嵌的時候引出問題?當一個標準的,非內嵌 Python 程式啟動時,不會有 GIL 卷進來,從而避免了鎖操作帶來的無用的間接損耗;GIL 第一次啟動時一些 Python 代碼會請求開啟一個線程。對任何一個線程來說,解譯器建立一個資料結構來儲存目前狀態資訊和鎖住 GIL 。當線程結束後,狀態會被恢複而且 GIL 也會解鎖,從而可以被其它線程使用。我們在 Go 程式中運行 Python 的時候,以上這些都不會自動發生。沒有 GIL,我們的 Go 程式可能會建立多個 Python 線程。這將有可能引起競爭條件而導致致命的執行階段錯誤,而且一個模組的錯誤極有可能摧毀整個 Go 應用。解決方案就是在任何時候運行 Go 中的多線程代碼都要顯式調用 GIL;代碼不會太複雜,因為 C API 提供了我們需要的所有工具。為了更好的暴漏問題,我們需要在Python中做一些CPU綁定的事情。讓我們把這些函數添加到前面樣本中的 foo.py 中:```pythonimport sysdef 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 (由此引入線程):```gopackage main import ( "sync""github.com/sbinet/go-python" ) func main() { // 下面代碼會通過調用PyEval_InitThreads()顯式調用 GIL ,// 無需等待解譯器去執行 python.Initialize() var wg sync.WaitGroupwg.Add(2) fooModule := python.PyImport_ImportModule("foo") odds := fooModule.GetAttrString("print_odds") even := fooModule.GetAttrString("print_even")// Initialize() 已經鎖定 GIL ,但這時我們並不需要它。// 我們儲存目前狀態和釋放鎖,從而讓 goroutine 能擷取它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() // 在這裡我們知道程式不會再需要運行 Python 代碼了,// 我們可以恢複狀態和 GIL 鎖,執行退出前的最後操作。python.PyEval_RestoreThread(state)python.Finalize()}```在閱讀樣本的時候你可能注意到了一個模式,這個模式將會是我們運行內嵌 Python 的準則1. 儲存狀態並鎖住 GIL。2. 執行 Python 。3. 恢複狀態,解鎖 GIL。代碼可以說得上簡潔明了,但仍有一處細節需要指出:注意,即使是遵循 GIL 模式,在一個例子裡面我們運行 GIL 時是通過調用`PyEval_SaveThread()` 和 `PyEval_RestoreThread()` ,在另一個例子裡(請看 goroutines 裡面的代碼)我們是通過調用 `PyGILState_Ensure()` 和 `PyGILState_Release()` 。我們說過,當 Python 裡面運行多線程時,解譯器會負責建立儲存目前狀態的資料結構,但如果是在 C API 裡面的話,需要我們親自動手實現。當我們通過 go-python 初始化解譯器的時候,我們是運行在 Python 上下文環境。所以當調用 `PyEval_InitThreads()` 時解譯器會初始化資料結構並鎖住 GIL 。我們可以使用`PyEval_SaveThread()` 和`PyEval_RestoreThread()` 對已經存在的狀態進行操作。在 goroutine 中,我們則是在一個 Go 上下文環境中運行,並且我們不需要顯式的建立和刪除狀態, `PyGILState_Ensure()` 和 `PyGILState_Release()` 負責完成這些工作。![Unleash the Gopher](https://raw.githubusercontent.com/studygolang/gctt-images/master/cgo-python/cgo_python_divider_4.png)## 解放 Go 愛好者現在我們已經知道怎麼處理多線程 Go 代碼在一個內嵌解譯器中執行 Python 的過程了,但是在 GIL 之後,我們又面臨著一個新的挑戰:Go 調度器。當一個 goroutine 啟動時,它會被調度運行在 `GOMAXPROCS ` 個可用線程中的其中一個線程——[點擊這裡](https://morsmachine.dk/go-scheduler) 可以瞭解更多細節。當一個 goroutine 執行系統調用或者調用 C 代碼時,當前線程會把等待運行線程隊列中的其它 goroutine 移交給另一個線程,從而讓這些 goroutine 有更多機會執行;當前 goroutine 被掛起,直到系統調用或是 C 函數返回。如果有返回傳生,線程就會試圖喚醒被終止的 goroutine ,但如果沒有返回的可能性,那該線程就會請求 Go 運行時去尋找另一個線程來完成該 goroutine ,並且進入睡眠狀態。 goroutine 最終被調度給另一個線程,然後結束。考慮到這些,讓我們來看看當一個正在運行 Python 代碼的 goroutine 被移動到一個新的線程時, goroutine 都會發生什麼:1. 我們的 goroutine 啟動後,執行一個 C 函數調用,然後掛起。GIL 被鎖住。2. 當 C 函數調用返回,當前線程試圖喚醒該 goroutine,但它失敗了。3. 當前線程告訴 Go 運行時去尋找另一個線程來喚醒我們的 goroutine。4. Go 調度器找到一個可用的線程,並且 goroutine 也被喚醒。5. goroutine 基本完成,並且嘗試在返回前解鎖 GIL。6. 目前狀態儲存的線程 ID 是初始線程的ID,和當前線程的 ID 不一致。7. Panic !幸運的是,我們可用強制要求 Go 運行時保證我們的 goroutine 一直運行在同一個線程上,只要通過 goroutine 調用 runtime 包裡的 LockOSThread 函數就行。```gogo func() { runtime.LockOSThread()_gstate := python.PyGILState_Ensure()odds.Call(python.PyTuple_New(0), python.PyDict_New()) python.PyGILState_Release(_gstate) wg.Done()}()```這將會干擾調度器並可能帶來一些間接損耗,但為了避免隨機的 panic 我們願意付出這種代價。## 結論顧及到內嵌 Python , Datadog Agent 做出了以下取捨:- cgo 引入的間接損耗。- 手動操作 GIL。- 運行期間綁定 goroutine 到同一個線程的限制。考慮到在 Go 中運行 Python 檢查的便利,我們很樂意接受這一切。但既然意識到了這些取捨,我們就能夠最小化它們帶來的影響。對於為支援 Python 而帶來的其它限制,我們很難有對策處理可能的問題:- build 依然是自動化的並且可配置的,因此開發人員照樣會類似於 `go build` 。- 一個輕量級版本的 agent 可以被建立,而且剝離 Python 支援後也完全可以支援簡單使用 Go build 標記。- 這樣的版本只依賴於 agent 本身的寫入程式碼核心檢查(絕大部分是系統和網路檢查),但不受 cgo 限制而且可以被交叉編譯。我們會重新評估將來的選擇,並判斷是否值得繼續使用 cgo ;我們甚至可能會考慮把 Python 作為一個整體是否值得,等到 [Go 外掛程式包](https://golang.org/pkg/plugin/) 足夠成熟,可以支援我們的用例。但至少現在內嵌 Python 工作的很好,而且從舊 Agent 遷移到新的上面也很簡單。你是一個掌握並且喜歡混合不同語言編程的愛好者嗎?你熱愛學習語言的內部工作機制來保證你的代碼更加健壯嗎? [請加入 Datadog](https://www.datadoghq.com/careers/ ) !
via: https://www.datadoghq.com/blog/engineering/cgo-and-python/
作者:Massimiliano Pippi 譯者:sunzhaohao 校對:rxcai
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
527 次點擊