Linux中國

如何在 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 不是語言,也不是編譯器。它是 外部函數介面 Foreign Function Interface (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" 指令上方的注釋塊稱為「 序言 preamble 」,可以包含實際的 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 &apos;hello, world!&apos;")
    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&apos;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 之前,我們還需要知道一些事情:它就是 全局解釋器鎖 Global Interpreter Lock ,即 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&apos;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&apos;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 代碼的習慣寫法:

  1. 保存狀態並鎖定 GIL。
  2. 執行 Python。
  3. 恢復狀態並解鎖 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 會發生什麼:

  1. 我們的 goroutine 啟動,執行 C 調用並暫停。GIL 被鎖定。
  2. 當 C 調用返回時,當前線程嘗試恢復 goroutine,但失敗了。
  3. 當前線程告訴 Go 運行時尋找另一個線程來恢復我們的 goroutine。
  4. Go 調度器找到一個可用線程並恢復 goroutine。
  5. goroutine 快完成了,並在返回之前嘗試解鎖 GIL。
  6. 當前狀態中存儲的線程 ID 來自原線程,與當前線程的 ID 不同。
  7. 崩潰!

所幸,我們可以通過從 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

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國