《深度剖析CPython解釋器》32. Python 和 Go 聯合編程


楔子

Python 可以和 C 無縫結合,通過 C 來為 Python 編寫擴展可以極大地提升 Python 的效率,但是使用 C 來編程顯然不是很方便,於是本人想到了 Go。對比 C 和 Go 會發現兩者非常相似,沒錯,Go 語言具有強烈的 C 語言背景,其設計者以及語言的設計目標都和 C 有着千絲萬縷的聯系。因為 Go 語言的誕生就是因為 Google 中的一些開發者覺得 C++ 太復雜了,所以才決定開發一門簡單易用的語言,而 Google 的工程師大部分都有 C 的背景,因此在設計 Go 語言的時候保持了 C 語言的風格。

而在 Go 和 C 的交互方面,Go 語言也是提供了非常大的支持(CGO),可以直接通過注釋的方式將 C 源代碼嵌入在 Go 文件中,這是其它語言所無法比擬的。最初 CGO 是為了能復用 C 資源這一目的而出現的,而現在它已經變成 Go 和 C 之間進行雙向通訊的橋梁,也就是 Go 不僅能調用 C 的函數,還能將自己的函數導出給 C 調用。也正因為如此,Python 和 Go 之間才有了交互的可能。因為 Python 和 Go 本身其實是無法交互的,但是它們都可以和 C 勾搭上,所以需要通過 C 充當媒介,來為 Python 和 Go 牽線搭橋。

我們知道 Python 和 C 之間是雙向的,也就是可以互相調用,而 Go 和 C 之間也是雙向的,那么 Python 和 Go 之間自然仍是雙向的。我們可以在 Python 為主導的項目中引入 Go,也可以在 Go 為主導的項目中引入 Python,而對於我本人來說,Python 是我的主語言、或者說老本行,因此這里我只介紹如何在 Python 為主導的項目中引入 Go。

而在 Python 為主導的項目中引入 Go 有以下幾種方式:

  • 將 Go 源文件編譯成動態庫,然后直接通過 Python 的 ctypes 模塊調用
  • 將 Go 源文件編譯成動態庫或者靜態庫,再結合 Cython 生成對應的 Python 擴展模塊,然后直接 import 即可
  • 將 Go 源文件直接編譯成 Python 擴展模塊,當然這要求在使用 CGO 的時候需要遵循 Python 提供的 C API

對於第一種方式,使用哪種操作系統無關緊要,操作都是一樣的。但是對於第二種和第三種,我只在 Linux 上成功過,當然 Windows 肯定也是可以的,只不過操作方式會復雜一些(個人不是很熟悉)。因此這里我統一使用 Linux 進行演示,下面介紹一下我的相關環境:

  • Python 版本:3.6.8,系統自帶的 Python,當然 3.7、3.8、3.9 同樣是沒有問題的(個人最喜歡 3.8)
  • Go 版本:1.16.4,一個比較新的版本了,至於其它版本也同樣可以
  • gcc 版本:4.8.5,系統自帶(Windows 系統的話,需要去下載 MingGW)

下面我們來介紹一下上面這幾種方式。

Go 源文件編譯成動態庫

首先如果 Go 想要編譯成動態庫給 Python 調用,那么必須啟用 CGO 特性,並將想要被 Python 調用的函數導出。而啟用 CGO 則需要保證環境變量 CGO_ENABLE 的值設置為 1,在本地構建的時候默認是開啟的,但是交叉編譯(比如在 Windows 上編譯 Linux 動態庫)的時候,則是禁止的。

下面來看看一個最簡單的 CGO 程序是什么樣子的。

// 文件名:file.go
package main

import "C"
import "fmt"

func main() {
    fmt.Println("你好,古明地覺,我的公主大人")
}

相較於普通的 Go 只是多了一句 import "C",除此之外沒有任何和 CGO 相關的代碼,也沒有調用 CGO 的相關函數。但是由於這個 import,會使得 go build 命令在編譯和鏈接階段啟動 gcc 編譯器,所以這已經是一個完整的 CGO 程序了。

[root@satori go_py]# go run file.go
你好,古明地覺,我的公主大人

直接運行,打印輸出。當然我們也可以基於 C 標准庫函數來輸出字符串:

// 文件名:file.go
package main

//#include <stdio.h>
import "C"

func main() {
    // C.CString 表示將 Go 的字符串轉成 C 的字符串
    C.puts(C.CString("覺大人,你能猜到此刻我在想什么嗎")) 
}

可能有人好奇  import "C" 上面那段代碼是做什么的,答案是導入 C 中的標准庫。我們說 Go 里面是可以直接編寫 C 代碼的,而 C 代碼要通過注釋的形式寫在 import "C" 這行語句上方(中間不可以有空格,這是規定)。而一旦導入,就可以通過 C 這個名字空間進行調用,比如這里的 C.puts、C.CString 等等。

[root@satori go_py]# go run file.go
覺大人,你能猜到此刻我在想什么嗎

至於這里的  import "C",它不是導入一個名為 C 的包,我們可以將其理解為一個名字空間,C 語言的所有類型、函數等等都可以通過這個名字空間去調用。

最后注意里面的 C.CString,我們說這是將 Go 的字符串轉成 C 的字符串,但是當我們不用了的時候它依舊會停留在內存里,所以我們要將其釋放掉,具體做法后面會說。但是對於當前這個小程序來說,這樣是沒有問題的,因為程序退出后操作系統會回收所有的資源。

我們也可以自己定義一個函數:

// 文件名:file.go
package main

/*
#include <stdio.h> 
void SayWhat(const char *s) {
    puts(s);
}
 */
import "C"
// 上面也可以寫多行注釋

func main() {
    // 即便是我們自己定義的函數也是需要通過 C 來調用, 不然的話 go 編譯器怎么知道這個函數是 C 的函數還是 go 的函數呢
    C.SayWhat(C.CString("少女覺"))
}

同樣是可以執行成功的。

[root@satori go_py]# go run file.go
少女覺

除此之外我們還可以將 C 的代碼放到單獨的文件中,比如:

// 文件名:1.c
#include <stdio.h>

void SayWhat(const char* s) {
	puts(s);
}

然后 Go 源文件如下:

// 文件名:file.go
package main

/*
#include "1.c"
 */
import "C"

func main() {
    C.SayWhat(C.CString("古明地戀"))  // 古明地戀
}

直接執行即可打印出結果,當然我們會更願意把 C 函數的聲明寫在頭文件當中,具體實現寫在C源文件中。

// 1.h
void SayWhat(const char* s);

// 1.c
#include <stdio.h>

void SayWhat(const char* s) {
    puts(s);
}

然后在 Go 只需要導入頭文件即可使用,比如:

// 文件名:file.go
package main

/*
#include "1.h"
 */
import "C"

func main() {
    C.SayWhat(C.CString("戀,對不起,我愛的是你姐姐"))  
}

然后重點來了,這個時候如果執行 go run file.go 是會報錯的:

[root@satori go_py]# go run file.go
# command-line-arguments
/tmp/go-build24597302/b001/_x002.o:在函數‘_cgo_f2c21e79afe5_Cfunc_SayWhat’中:
/tmp/go-build/cgo-gcc-prolog:49:對‘SayWhat’未定義的引用
collect2: 錯誤:ld 返回 1

雖然文件中出現了 #include "1.h",但是和 1.h 相關的源文件 1.c 則沒有任何體現,除非你在go的注釋里面再加上 #include "1.c",但這樣頭文件就沒有意義了。因此在編譯的時候,我們不能對這個具體的 file.go 源文件進行編譯;也就是說不要執行 go build file.go,而是要在這個 Go 文件所在的目錄直接執行 go build,會對整個包進行編譯,此時就可以找到當前目錄中對應的 C 源文件了。

[root@satori go_py]# go build -o a.out
[root@satori go_py]# ./a.out 
戀,對不起,我愛的是你姐姐

但是需要注意的是:我當前目錄為 /root/go_py,里面的 Go 文件只有一個 file.go,但如果內部有多個 Go文件的話,那么對整個包進行編譯的時候,要確保只能有一個文件有 main 函數。

另外對於 go1.16 而言,需要先通過 go mod init 來初始化項目,否則編譯包的時候會失敗。

Go 導出函數給 Python 調用

上面算是簡單介紹了一下 CGO 以及 Go 如何調用 C 函數,但是 Go 調用 C 函數並不是我們的重點,我們的重點是 Go 導出函數給 Python 使用。

// 文件名:file.go
package main

import "C"
import "fmt"

//export SayWhat
func SayWhat(s *C.char) {
    // C.GoString 是將 C 的字符串轉成 Go 的字符串
    fmt.Println(C.GoString(s))
}

func main() {
    //這個main函數我們不用, 但是必須要寫
}

我們看到函數上面有一行注釋://export SayWhat,這一行注釋必須要有,即 //export 函數名。並且該注釋要和函數緊挨着,之間不能有空行,而它的作用就是將 SayWhat 函數導出,然后 Python 才可以調用,如果不導出的話,Python 會調用不到的。而且導出的時候是 C 函數的形式導出的,因為 Python 和 Go 交互需要 C 作為媒介,因此導出函數的參數和返回值都必須是 C 的類型。

導出函數的名稱不要求首字母大寫,小寫的話依舊可以導出。

最后是 main 函數,這個 main 函數也是必須要有的,盡管里面可以什么都不寫,但是必須要有,否則編譯不通過。然后我們來將這個文件編譯成動態庫:

go build -buildmode=c-shared -o 動態庫 [go源文件 go源文件 go源文件 ...]

以當前的 file.go 為例:gcc build -buildmode=c-shared -o libgo.so file.go,如果是對整個包編譯,那么不指定 go源文件即可。

[root@satori go_py]# go build -buildmode=c-shared -o libgo.so file.go

這里我們將 file.go 編譯成動態庫 libgo.so,然后 Python 來調用一下試試。

在 Linux 上,動態庫的后綴名為 .so;在 Windows 上,動態庫的后綴名為 .dll。而 Python 的擴展模塊在 Linux 上的后綴名也為 .so,在 Windows 上的的后綴名則是 .pyd(pyd 也可以看做是 dll)。因此我們發現所謂 Python 擴展模塊實際上就是對應系統上的一個動態庫,如果是遵循標准 Python/C API 的 C 源文件生成的動態庫,Python 解釋器是可以直接識別的,我們可以通過 import 導入;但如果不是,比如我們上面剛生成的 libgo.so,或者 Linux 自帶的大量動態庫,那么我們就需要通過 ctypes 的方式加載了。

from ctypes import *

libgo = CDLL("./libgo.so")

libgo.SayWhat(c_char_p("古明地覺".encode("utf-8")))
libgo.SayWhat(c_char_p("芙蘭朵露".encode("utf-8")))
libgo.SayWhat(c_char_p("霧雨魔理沙".encode("utf-8")))
"""
古明地覺
芙蘭朵露
霧雨魔理沙
"""

我們看到成功打印了,那么打印是哪里來的呢?顯然是 Go 里面的 fmt.Println。

以上就實現了 Go 導出 Python 函數給 Python 調用,但是很明顯這還不夠,我們還需要能夠傳遞參數、以及獲取返回值。而想要實現這一點,我們必須要了解一下不同語言之間類型的對應關系。

數值類型

在 Go 語言中訪問 C 語言的符號時,一般是通過虛擬的 "C" 包訪問,比如 C.int 對應 C 語言的 int 類型。但有些 C 語言的類型是由多個關鍵字組成,而通過虛擬的 "C" 包訪問 C 語言類型時名稱部分不能有空格字符,比如 unsigned int 不能直接通過 C.unsigned int 訪問,這是不合法的。因此 CGO 為 C 語言的基礎數值類型都提供了相應轉換規則,比如 C.uint 對應 C 語言的 unsigned int。

Go 語言中數值類型和 C 語言數據類型基本上是相似的,以下是它們的對應關系表。

數值類型雖然有很多,但是整型我們直接使用 long、浮點型使用 double 即可,另外我們在 Go 中定義的函數名不可以和 C 中的關鍵字沖突。

下面我們舉個栗子演示一下:

// 文件名:file.go
package main

import "C"

//export Int
func Int(val C.long) C.long {
    // C 的整型可以直接和 Go 的整型相加
    // 但前提是個常量,如果是變量,那么需要使用 C.long 轉化一下
    var a = 1
    // Go 對類型的要求很嚴格,這里需要轉化,但如果是 val + 1 是可以的,因為 1 是個常量
    return val + C.long(a) 
    // 這里函數不能起名為 int,因為 int 是 C 中的關鍵字
}

//export Double
func Double(val C.double) C.double {
    // 對於浮點型也是需要轉化,但如果是常量,也可以直接相加
    return val + 2.2 
}

//export boolean
func boolean(val C._Bool) C._Bool {
    // 接收一個 bool 類型,true 返回 false,false 返回 true
    var flag = bool(val)
    return C._Bool(!flag)
}

//export Char
func Char(val C.char) C.char {
    // 接收一個字符,進行大小寫轉化
    return val ^ 0x20
}
// main 函數必須要有
func main() {}

然后重新編譯生成動態庫,交給 Python 調用。

from ctypes import *

libgo = CDLL("./libgo.so")

"""
注意: Python 在獲取返回值的時候,默認都是按照整型解析的,如果 Go 的導出函數返回的不是整型,那么再按照整型解析的話必然會出問題
因此我們需要在調用函數之前指定返回值的類型,我們這里調用類 CDLL 返回的就是動態庫, 假設里面有一個 xxx 函數, 返回了一個 cgo 中的 C.double
那么我們就需要在調用 xxx 函數之前, 通過 go_ext.xxx.restype = c_double 提前指定返回值的類型, 這樣才能獲取正常的結果
"""

# 因為默認是按照整型解析的,所以對於返回整型的函數我們無需指定返回值類型,當然指定的話也是好的
print(libgo.Int(c_long(123)))  # 124

# Float 函數,接收一個浮點數,然后加上 2.2 返回
libgo.Double.restype = c_double
print(libgo.Double(c_double(2.5)))  # 4.7

# boolean: 接收一個布爾值, 返回相反的布爾值
libgo.boolean.restype = c_bool
print(libgo.boolean(c_bool(True)))  # False
print(libgo.boolean(c_bool(False)))  # True

# Char: 接收一個字符,然后進行大小寫轉換
libgo.Char.restype = c_char
print(libgo.Char(c_char(97)))  # b'A'
print(libgo.Char(c_char(b'v')))  # b'V'

怎么樣,是不是很簡單呢?

我們在生成 libgo.so 的同時,還會自動幫我們生成一個 libgo.h,在里面會為 Go 語言的字符串、切片、字典、接口和管道等特有的數據類型生成對應的 C 語言類型:

不過需要注意的是,其中只有字符串和切片在 CGO 中有一定的使用價值,因為二者可以直接被 C 和 Python 調用。但是 CGO 並未針對其它的類型提供相關的輔助函數,且 Go 語言特有的內存模型導致我們無法保持這些由 Go 語言管理的內存指針,所以它們在編寫動態庫給 Python 調用這一場景中並無使用價值,比如 channel,這東西在 Python 里面根本沒法用,還有 Map 也是同樣道理。

字符串

字符串可以說是用的最為頻繁了,而且使用字符串還需要考慮內存泄漏的問題,至於為什么會有內存泄漏以及如何解決它后面會說,目前先來看看如何操作字符串。

// 文件名:file.go
package main

import "C"

//export unicode
func unicode(val *C.char) *C.char {
    // 將 C 的字符串轉成 Go 的字符串, 可以使用 C.GoString
    var s = C.GoString(val)
    s += "古明地覺"
    //然后轉成 C 的字符串返回, 字符串無論是從 Go 轉 C, 還是 C 轉 Go, 都是拷貝一份
    return C.CString(s)
}
func main() {}

還是調用 go build -buildmode=c-shared -o libgo.so file.go 將其編譯成動態庫,然后 Python 進行調用。

from ctypes import *

go_ext = CDLL(r"./libgo.so")

# unicode: 接收一個 c_char_p,返回一個 c_char_p,注意 c_char_p 里面的字符串要轉成字節
go_ext.unicode.restype = c_char_p
# 調用函數返回的也是一個字節,我們需要再使用 utf-8 轉回來
print(go_ext.unicode(c_char_p("我永遠喜歡🍺".encode("utf-8"))).decode("utf-8"))  # 我永遠喜歡🍺古明地覺

 

同理我們也可以修改傳遞的字符串,當然與其說修改,倒不如說仍是重新創建一份。

// 文件名:file.go
package main

import "C"

//export char_array
func char_array(arr *C.char) *C.char {
    // 轉成 Go 的 string 之后,我們還需要轉成 rune,不然無法修改,因為有的字符需要三字節
    r := []rune(C.GoString(arr))
    r[3] = '戀'
    return C.CString(string(r))
}
func main() {}

編譯之后給 Python 調用:

from ctypes import *

go_ext = CDLL("./libgo.so")

go_ext.char_array.restype = c_char_p
print(
    go_ext.char_array(c_char_p("古明地覺".encode("utf-8"))).decode("utf-8")
)  # 古明地戀

# 這里必須要保證至少傳遞長度為 4 的字符串, 因為在 go 中我們有一個 r[3] = '戀' 的操作

字符串操作基本上使用 C.GoString 和 C.CString 就足夠了,但是正如我們之前說的,C.CString 存在着內存泄漏問題,后面會解決它。

結構體

結構體應該算是 Go 中最重要的成員了吧,但是 Go 的結構體是不能作為導出函數的參數或返回值的,我們需要使用C中的結構體。

如果嘗試導出一個參數或返回值為 Go 的結構體的函數,那么會報錯:Go type not supported in export: struct

// 文件名:file.go
package main

/*
struct Girl{
    char *name;
    long age;
    char *gender;
    char *type;
};
*/
import "C"
// 對於結構體來說, 不要使用 typedef 的方式, 而是直接使用 struct xxx{} 的方式定義, 那么 Go 便可以通過 C.struct_xxx 的方式來訪問這個結構體
// 至於為什么要這么寫, 我也不知道, 大概這是 Go 的設計原則吧
import "fmt"

//export test_struct
func test_struct(g C.struct_Girl) *C.char {
    // 這里的結構體就可以通過C.struct_Girl來訪問
    name := C.GoString(g.name)
    age := int(g.age)
    gender := C.GoString(g.gender)
    // type 是 Go 語言中的關鍵字, 那么訪問的時候需要在前面加上一個下划線
    _type := C.GoString(g._type)

    return C.CString(fmt.Sprintf("名字: %s 年齡: %d 性別: %s 類型: %s", name, age, gender, _type))
}

func main() {
}

對於 Python 而言,我們看看如何在 Python 中創建一個結構體:

from ctypes import *

libgo = CDLL("./libgo.so")

class Girl(Structure):
    _fields_ = [
        ("name", c_char_p),
        ("age", c_long),
        ("gender", c_char_p),
        ("type", c_char_p)
    ]


libgo.test_struct.restype = c_char_p
g = Girl(c_char_p("古明地覺".encode("utf-8")),
         c_long(16),
         c_char_p("女".encode("utf-8")),
         c_char_p("高冷".encode("utf-8")))
print(libgo.test_struct(g).decode("utf-8"))  # 名字: 古明地覺 年齡: 16 性別: 女 類型: 高冷

還是比較簡單的,只不過定義變量的時候最好不要和關鍵字沖突。但是 Go 給我們提供了一個隱形的轉化方式,即便我們在 C 中定義的變量和 Go 關鍵字沖突了,也可以通過在變量前面加上一個下划線的方式訪問。那么問題來了,如果有兩個成員:一個成員以 Go 語言關鍵字命名,另一個成員以下划線加上相同的關鍵字命名,那么以關鍵字命名的成員將無法訪問(被屏蔽)。

/*
struct A {
    int   type;   // type 是 Go 語言的關鍵字
    float _type;  // 將屏蔽 CGO 對 type 成員的訪問
};
*/

 

我們看到參數只能是 C 的結構體,那么 Go 的結構體就無法使用了嗎?答案不是的,只要 Go 的結構體不作為導出函數的參數或者返回值就可以。

// 文件名:file.go
package main

import "C"
import "fmt"

//定義兩個結構體
type Girl1 struct {
    name   string
    age    int
    gender string
}

type Girl2 struct {
    name   *C.char
    age    C.long
    gender *C.char
}

//export test_struct1
func test_struct1(name *C.char, age C.long, gender *C.char) *C.char {
    // 當然,這里有點多此一舉了
    g := Girl1{C.GoString(name), int(age), C.GoString(gender)}
    return C.CString(fmt.Sprintf("你的名字: %s 你的年齡: %d 你的性別: %s", g.name, g.age, g.gender))
}

//export test_struct2
func test_struct2(name *C.char, age C.long, gender *C.char) *C.char {
    g := Girl2{name, age, gender}
    return C.CString(fmt.Sprintf("你的名字: %s 你的年齡: %d 你的性別: %s", C.GoString(g.name), C.long(g.age), C.GoString(g.gender)))
}

func main() {
}

然后交給 Python 來訪問:

from ctypes import *

libgo = CDLL("./libgo.so")

libgo.test_struct1.restype = c_char_p
libgo.test_struct2.restype = c_char_p
print(
    libgo.test_struct1(c_char_p("古明地覺".encode("utf-8")),
                        c_long(16),
                        c_char_p("女".encode("utf-8"))).decode("utf-8")
)  # 你的名字:古明地覺 年齡: 16 性別: 女

print(
    libgo.test_struct2(c_char_p("古明地覺".encode("utf-8")),
                        c_long(16),
                        c_char_p("女".encode("utf-8"))).decode("utf-8")
)  # 你的名字:古明地覺 年齡: 16 性別: 女

我們看到 Python 依舊可以正常調用,兩個結構體成員的類型可以是 Go 的類型、也可以是 C 的類型,區別就是需要類型轉化的地方不同罷了。Go 中的結構體,它沒有作為參數和返回的話是可以正常使用的,但是一旦作為參數或者返回值就不可以了,因為 Go 不允許我們這么做,所以我們只能使用 C 中的結構體。

返回一個結構體

下面來看看如何返回一個結構體:

// 文件名:file.go
package main

/*
struct Girl{
    char *name;
    long age;
    char *gender;
};
*/
import "C"

//export test_struct
func test_struct(name *C.char, age C.long, gender *C.char) C.struct_Girl {
    g := C.struct_Girl{name, age, gender}
    return g
}

func main() {
}

Python 調用的話依舊很簡單:

from ctypes import *

libgo = CDLL("./libgo.so")

class Girl(Structure):
    _fields_ = [
        ("name", c_char_p),
        ("age", c_long),
        ("gender", c_char_p)
    ]

# 指定返回值類型
libgo.test_struct.restype = Girl
g = libgo.test_struct(c_char_p("古明地覺".encode("utf-8")), c_long(16), c_char_p("女".encode("utf-8")))
print(g.name.decode("utf-8"))  # 古明地覺
print(g.age)  # 16
print(g.gender.decode("utf-8"))  # 女

傳入結構體指針

結構體指針我們也是可以傳遞的,舉個栗子:

// 文件名:file.go
package main

/*
struct Girl{
    char *name;
    long age;
    char *gender;
};
*/
import "C"

//export test_struct
func test_struct(g *C.struct_Girl){
    g.name = C.CString("古明地戀")
}

func main() {
}

在 Python 中創建一個結構體傳進去,然后值會被修改:

from ctypes import *

libgo = CDLL("./libgo.so")

class Girl(Structure):
    _fields_ = [
        ("name", c_char_p),
        ("age", c_long),
        ("gender", c_char_p)
    ]

g = Girl(c_char_p("古明地覺".encode("utf-8")),
         c_long(16),
         c_char_p("女".encode("utf-8")))

libgo.test_struct(pointer(g))
print(g.name.decode("utf-8"))  # 古明地戀

注意:傳遞一個指針可以,但是返回一個指針不行。因為 Go 語言是類型安全的,比如一個變量究竟該分配在堆上、還是分配在棧上,Go 編譯器會進行逃逸分析,是否返回指針便是決定一個變量究竟分配在什么地方的一個主要因素。而一旦返回指針給其他語言,那么 Go 就無法決定這塊內存究竟何時該被回收,所以 Go 中不允許返回指針。而且對於 Python 來講,Go 返回一個值還是指針,對於 Python 而言幾乎沒什么區別,無非是獲取的方式不一樣。所以我們不會在 Go 中返回一個指針,但是傳遞一個指針是可以的。

函數調用

函數是 C 語言編程的核心,通過 CGO 技術我們不僅僅可以在 Go 語言中調用 C 語言函數,也可以將 Go 語言函數導出為 C 語言函數。對於一個啟用 CGO 特性的程序,CGO 會構造一個虛擬的 C 包,通過這個虛擬的 C 包可以調用 C 語言函數。

// 文件名:file.go
package main

/*
int add(int a, int b) {
    return a + b;
}
 */
import "C"
import "fmt"

func main() {
    fmt.Println(C.add(1, 2))  // 3
}

這一點我們之前就見過了,但是 Go 文件里面的 C 函數不僅可以讓 Go 自身調用,還可以交給 Python 調用。Go 文件里面的 C 函數和使用 export 導出的 Go 函數(導出之后就變成了 C 函數)是等價的,都是可以被 Python 調用的,我們還是對該文件進行編譯得到動態庫。

from ctypes import *

libgo = CDLL(r"./libgo.so")
print(libgo.add(1, 2))  # 3

Python 向 C 傳遞函數

Python 不能直接向 Go 的導出函數中傳遞函數,我們需要在里面定義一個 C 的函數,Python 只能向 C 的函數中傳遞函數。

// 文件名:file.go
package main

/*
int add(int a, int b, int (*f)(int *, int *)){
  return f(&a, &b);
}
*/
import "C"

func main() {
}

里面的 add 接收兩個整型和一個函數指針,這個函數指針指向的函數接收兩個 int *,我們依舊實現兩個數相加。

from ctypes import *

libgo = CDLL("./libgo.so")

# 動態鏈接庫中的函數接收的函數的參數是兩個 int *,所以我們這里的 a 和 b 也是一個 pointer
def add(a, b):
    # 調用 pointer.contents 可以得到 C 的變量, 在調用 value 屬性可以獲取對應值(Python中的)
    return a.contents.value + b.contents.value

# 此時我們把 C 中的函數用 Python 表達了, 但是這樣肯定是不可能直接傳遞的, 能傳就見鬼了
# 那我們要如何轉化呢?
# 可以通過 ctypes 里面的函數 CFUNCTYPE 轉化一下, 這個函數接收任意個參數
# 但是第一個參數是函數的返回值類型, 然后函數的參數寫在后面, 有多少寫多少。
# 比如這里的函數返回一個 int, 接收兩個 int *, 所以就是
t = CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
# 如果函數不需要返回值, 那么寫一個 None 即可
# 然后得到一個類型 t, 此時的類型 t 就等同於 C 中的 typedef int (*t)(int*, int*);
# 將我們的函數傳進去,就得到了 C 語言可以識別的函數 func
func = t(add)
# 然后調用, 別忘了定義返回值類型, 當然這里是 int(long同理)就無所謂了
libgo.add.restype = c_int
print(libgo.add(88, 97, func))
print(libgo.add(59, 55, func))
print(libgo.add(94, 105, func))
"""
184
114
199
"""

當然如果函數比較復雜的話,或者內容比較多的話,我們還可以分成多個源文件來寫。

// 1.h
typedef int (*function) (int *, int *);
int add(int, int, function);

// 1.c
#include "1.h"

int add(int a, int b, function f) {
	return f(&a, &b);
}

此時 Go 源文件的代碼就變得簡單了;

// 文件名:file.go
package main

/*
#include "1.h"
 */
import "C"

func main() {
}

然后編譯成動態庫就不要加上文件名了,直接 go build -buildmode=c-shared -o libgo.so 對整個目錄進行編譯。那么 Python 可不可以調用呢?我們試一下:

from ctypes import *

libgo = CDLL("./libgo.so")


def add(a, b):
    return a.contents.value + b.contents.value


t = CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
func = t(add)
libgo.add.restype = c_int
print(libgo.add(11, 22, func))  # 33
print(libgo.add(22, 33, func))  # 55
print(libgo.add(33, 44, func))  # 77

不僅如此,我們還可以直接使用 Go 中的導出函數作為 C 函數中的一個參數,我們的 .h 和 .c 文件都不變,只修改一下 Go 源文件:

// 文件名:file.go
package main

/*
#include "1.h"
*/
import "C"

//export f
func f(a *C.int, b *C.int) C.int {
    return *a + *b
}

func main() {
}

我們在 Go 文件中定義相應的函數,不在 Python 中定義了,然后 Python 直接調用:

from ctypes import *

libgo = CDLL("./libgo.so")
print(libgo.add(c_int(101), c_int(202), libgo.f))  # 303

數組

再來看看如何操作數組,這里操作的數組只能是 C 中的數組,因為在 Go 里面不允許導出一個參數或返回值是數組的函數。最關鍵的是,Go 數組的表達能力沒有 C 數組那么豐富。

在 Go 里面數組的長度也是類型的一部分,這一點完全限制了數組的表達能力。而 C 中的數組類型與長度無關,比如在 C 的結構體中聲明一個長度為 1 的數組,但是我們可以把它當成長度為 n 的數組來用。

// 文件名:file.go
package main

/*
#include <stdio.h>
int sum(int *arr, int size) {
    // 傳遞一個數組,里面全部是 int 類型,我們把它們加在一起
    // 由於數組在作為參數傳遞的時候會退化為指針,所以我們不知道數組有多少個元素,因此還必須要指定個數
    int i = 0, values = 0;
    for (; i < size; i++) {
        values += *(arr + i);
    }
    return values;
}
 */
import "C"

func main() {
}

然后我們來在 Python 中構建一個數組:

from ctypes import *

libgo = CDLL("./libgo.so")

# (c_int * n) 便是一個長度為 n 的 int 數組類型
# 然后通過類似於函數調用的方式,得到數組
v = (c_int * 4)(13212, 211, 22, 33)
print(libgo.sum(v, 4))  # 13478
print(13212 + 211 + 22 + 33)  # 13478

需要注意的是,數組的類型一定要正確,我們之前說對於整數而言,long 和 int 實際上沒有太大差別。如果一個函數接收的是 long,那么我們傳遞一個 int 也是可以的,反之亦然(只要都存的下,不會溢出即可)。但是對於數組而言就不行了,函數中接收的數組里面的元素是 int,我們也必須要傳遞 int,否則指針在移動的時候會出問題。

我們往 C 里面傳遞一個數組是沒有問題的,因為內存是在 Python 中申請的,C 拿到的只是一個指針罷了。但是我們不能在 C 中構建一個數組然后返回,因為如果 C 中返回了一個數組,那么它要么是靜態數組、要么是堆上申請的數組。但是問題來了,這些數組的內存最終由誰來釋放?Python 顯然是無能為力的,更何況這些 C 代碼還嵌套在 Go 里面。

盡管 C 無法返回一個數組,但是可以對我們傳遞的數組進行修改。或者說先創建一個普通數組,然后把內容再拷貝到我們傳遞的數組中,函數結束后 C 中的數組再被釋放掉。

// 文件名:file.go
package main

/*
int modify_arr(int *arr, int size) {

    int i = 0, values = 0;
    for (; i < size; i++) {
        *(arr + i) += 100;
    }
    return 0;
}
 */
import "C"


func main() {
}

我們將傳遞過來的數組里面的元素都加上 100:

from ctypes import *
import numpy as np

libgo = CDLL("./libgo.so")

v = (c_int * 6)(1, 2, 3, 4, 5, 6)
# 此時 v 內部的元素就被修改了,而且該數組是 Python 創建的,與 C 無關,因此不需要擔心內存泄露的問題
libgo.modify_arr(v, 6)
# 我們將其轉成 ndarray
# 參數一:shape
# dtype:元素類型
# buffer:緩沖區,這里的 v
# order:數組是 C 連續還是 Fortran 連續,這里顯然是 C 連續,因為是 C 的數組
print(np.ndarray((6,), dtype=c_int, buffer=v, order="C"))  # [101 102 103 104 105 106]
# 當然我們在獲取的時候也可以改變形狀
print(np.ndarray((3, 2), dtype=c_int, buffer=v, order="C"))
"""
[[101 102]
 [103 104]
 [105 106]]
"""
# 我們這里的緩沖區當中有 6 個元素,但是 shape 是 3 行 1 列,所以只拿前三個元素構建 shape 為 (3, 1) 的數組
print(np.ndarray((3, 1), dtype=c_int, buffer=v, order="C"))
"""
[[101]
 [102]
 [103]]
"""
# 但是注意:我們指定的元素個數不能超過緩沖區的大小
# 下面表示構建 3 X 3 的數組,也就是有 9 個元素,但是這里的緩沖區中只有 6 個元素
print(np.ndarray((3, 3), dtype=c_int, buffer=v, order="C")) 
"""
    print(np.ndarray((3, 3), dtype=c_int, buffer=v, order="C"))
TypeError: buffer is too small for requested array
"""

以上就是 Python 向 C 傳遞數組,例子比較簡單。

但是問題來了,此時貌似壓根就沒有 Go 什么事情,因為里面根本就沒有涉及到 Go。原因就是 Go 無法導出一個參數或者返回值為 Go 數組的函數(由於數組的長度也是類型的一部分,導致靈活性也大大降低),並且我們也不能像 C 一樣聲明一個 arr *C.int、然后把 arr 當成數組使用,這是不允許的,在 Go 里面該 arr 只能是一個指向整型的指針。於是可能有人想到了切片,在 Go 里面切片可以作為導出函數的參數或返回值,但是 Go 里面的切片比較特殊、它本質上是一個結構體:

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 指向底層數組的指針
    len   int // 長度
    cap   int // 容量
}

Python 傳遞一個數組過來的話,我們在操作的時候可能會出問題。我們先舉個栗子看看 Go 自身訪問是什么情況:

// 文件名:file.go
package main

import "C"
import (
    "fmt"
    "reflect"
    "unsafe"
)

func test1(s []int) {
    header := (*reflect.SliceHeader)(unsafe.Pointer(&s))
    data := header.Data
    // 相當於訪問第 3 個元素
    fmt.Println(*(*int)(unsafe.Pointer(data + 2*unsafe.Sizeof(0))))
}

func test2(s []C.int) {
    header := (*reflect.SliceHeader)(unsafe.Pointer(&s))
    data := header.Data
    // 相當於訪問第 3 個元素
    fmt.Println(*(*int)(unsafe.Pointer(data + 2*unsafe.Sizeof(C.int(0)))))
}

func main() {
    s1 := []int{1, 2, 3, 4}
    s2 := []C.int{C.int(1), C.int(2), C.int(3), C.int(4)}

    test1(s1)  // 3
    test2(s2)  // 17179869187
}

我們看到對於 Go 的 int 而言,結果是正常的,但是對於 C.int 卻得到了一個亂七八糟的臟數據。Go 自身訪問會得到錯誤數據,如果是作為導出函數讓 Python 訪問,那么首先會報錯,並且解釋器還會異常退出。

那么我們能不能直接通過下標的方式來訪問呢?答案是:在 Go 里面是可以的,因為 Go 的導出函數接收的是一個切片,只要我們也傳遞切片即可。

// 文件名:file.go
package main

import "C"
import "fmt"

//export sum_slice
func sum_slice(s []C.int) C.int {
    sum := C.int(0)
    for i := 0; i < len(s); i++{
        sum += s[i]
    }
    return sum
}

func main() {
    fmt.Println(sum_slice([]C.int{C.int(11), C.int(22), C.int(33), C.int(44)}))  // 110
}

但如果這個函數給 Python 調用的話,會產生如下后果:

我們看到會出現段錯誤,此時解釋器會直接異常退出,不是使用異常捕獲能解決的了的問題。原因就在於操作了一個無效的內存地址,Go 不會出問題是因為它接收的是切片、傳遞的也是切片,而 Python 傳遞的是一個數組,對於 Go 而言切片和數組是不同的。

結論:我們可以傳遞一個數組,但只能向 C 的函數傳遞,因為 Go 的導出函數的參數或返回值不能是 Go 數組。也不要試圖使用切片,很容易造成段錯誤。

內存模型

我們目前的做法是將 Go 的函數導出給 Python 使用,因此就會受到很多限制,比如不能返回指針等等。原因就是我們之前說的,Go 是一個類型安全的語言,一旦返回指針之后給 Python 使用,那么 Go 編譯器就無法把控該指針指向的變量的聲明周期了。舉個栗子:

// 文件名:file.go
package main

import "C"
import "fmt"

//export return_pointer
func return_pointer() *C.int {
    var a C.int = 123
    return &a
}

func main() {
    fmt.Println(return_pointer())  // 0xc00001c084
    fmt.Println(*return_pointer()) // 123
}

我們看到 return_pointer 返回了一個指針,但是在 Go 里面使用是沒有任何問題的,原因就是 Go 編譯器會進行逃逸分析,或者說此時對函數的調用仍然是發生在 Go 里面。只要是在 Go 里面,那么編譯器就能牢牢地把控,可一旦交給 Python 使用,就意味着它要獨立於 Go 了。

from ctypes import *

libgo = CDLL("./libgo.so")
# 指定返回值的類型為整型指針
libgo.return_pointer.restype = POINTER(c_int)
libgo.return_pointer()

Python 在調用導出函數的時候直接就異常了,告訴我們 Go 的導出函數中返回了一個指針,所以 Python 在和 Go 交互的時候是會受到很多限制的。但是 C 和 Go 交互的時候是沒有限制的,不僅可以返回指針,而且還可以通過 C 來為 Go 創建一個超過 2GB 的切片。因為 Go 的切片是有大小限制的,不能超過 2 GB,但是我們可以通過 C 的 malloc 申請超過 2 GB 的內存,然后再轉成 Go 的切片。

Go 和 C 之間的訪問是很自由的,主要是 Go 編譯器能夠把握全局,然而一旦導出函數給別的語言使用,Go 編譯器就鞭長莫及了。所以 Python 在訪問 Go 的時候才會有這么多限制,畢竟兩門語言的內存模型不同,當同一段內存被跨語言操作時肯定會非常危險,因此對於 Go 這種類型安全的語言壓根就不允許訪問一個返回指針的導出函數。

但我們之前返回一個 *C.char 為什么可以呢?原因就是我們調用了 C.CString,此時返回的字符串是在 C 中申請的,所以它可以返回。而像  var a C.int 這種,此時 a 的內存是在 Go 里面被申請的,因此我們不能返回 &a。舉個栗子,如果我們返回字符串不是調用 C.CString 的話,看看會有什么后果:

// 文件名:file.go
package main

import "C"
import (
    "fmt"
    "unsafe"
)

//export return_string
func return_string() *C.char {
    var s = []byte("古明地覺")
    return (*C.char)(unsafe.Pointer(&s[0]))
}

func main() {
    fmt.Println(C.GoString(return_string()))  // 古明地覺
}

假設我們有一個切片,那么我們可以直接將底層數組的地址返回轉成 * C.char 返回,注意:此時 C 字符串和 Go 的底層數組之間是共享內存的,因此省去了開銷。

但是這個 return_string 不可以給 Python 調用,因為我們將切片對應的底層數組的地址返回了。換句話說內存依舊是在 Go 里面申請的,而我們返回了指向該內存的指針,所以 Python 調用的話依舊會出現 panic: runtime error: cgo result has Go pointer。

不要試圖返回一個指向 Go 申請的內存的指針給 Python。

問題來了,我們之前就說 C.CString 存在一個巨大的缺陷, 那就是返回的字符串是 C 在堆區申請的,那么這個字符串最后要由誰來釋放?

// 文件名:file.go
package main

import "C"

//export return_string
func return_string(s *C.char) *C.char {
    s1 := C.GoString(s)
    s1 += "你好呀"
    return C.CString(s1)
}

func main() {
}

然后我們給 Python 來調用:

from ctypes import *

libgo = CDLL("./libgo.so")
libgo.return_string.restype = c_char_p
print(
    libgo.return_string(c_char_p("古明地覺".encode("utf-8"))).decode("utf-8")
)  # 古明地覺你好呀

這種做法看似沒有問題,雖然結果也是正確的,但是卻有一個重大的隱患。因為在返回 C 的字符串之后,Python 會拷貝得到一份 bytes 對象,但問題是這個 C 字符串它是不會主動釋放的。假設我們的字符串比較長,而且是在一個不間斷的服務中調用 Go 編寫的動態庫,那么后果是很嚴重的。我們將 Python 的代碼改一下:

from ctypes import *

libgo = CDLL("./libgo.so")
libgo.return_string.restype = c_char_p
while True:
    libgo.return_string(c_char_p(("古明地覺" * 100).encode("utf-8"))).decode("utf-8")

你會發現,內存沒一會就被占滿了,執行的時候可以通過 top 命令看到內存使用率蹭蹭的網上長。

而導致這一點的原因就是返回的 C 字符串沒有被釋放,每一次執行都會創建這么一個字符串。因此我們一定要將其釋放掉,釋放的方式是使用 free,但問題是這個 free 要如何使用?下面這種做法可以嗎?

// 文件名:file.go
package main

//#include <stdlib.h>
import "C"
import "unsafe"

//export return_string
func return_string(s *C.char) *C.char {
    s1 := C.GoString(s)
    s1 += "你好呀"
    // 先使用變量保存
    res := C.CString(s1)
    // 然后通過 C.free 釋放, 但是需要導入 stdlib 這個庫(完全就像寫 C 語言一樣)
    // 但是 C.free 接收一個 void *,我們需要調用一下 unsafe.Pointer
    C.free(unsafe.Pointer(res))
    return res
}

func main() {
}

如果你編譯成動態庫之后讓 Python 調用的話,你會發現解釋器得不到正確結果,而且有可能會異常退出,原因就是我們在將字符串返回給 Python 之前,就已經將其回收了,那么 Python 拿到的就是一塊非法的內存。

因此正確的做法是:先正常返回,Python 在獲取到值之后 Go 再將其釋放掉,不過這樣就又產生了一個問題:那就是地址要如何保存。因為必須要確保 Python 能夠獲取字符串(意味着 Go 中導出的執行函數的 return 語句結束,顯然此時該函數也已經結束),然后再將 C 字符串銷毀,所以我們肯定還需要一個函數,這個函數接收一個地址、然后專門用來對 C 字符串進行釋放。

那么又回到了開始的問題,地址怎么辦?由誰來保存,思考一下不難發現應該由 Go 負責保存。因為 Python 獲取結果的時候,實際上也是將 C 的字符串拷貝一份得到 Python 的 bytes 對象,因此在 Python 中你是拿不到相應的地址的,使用 id 查看得到也是 Python 對象的地址。所以解決辦法是我們可以在 Go 中使用一個全局變量專門負責保存地址,舉個栗子:

// 文件名:file.go
package main

//#include <stdlib.h>
import "C"
import (
    "unsafe"
)

var address unsafe.Pointer = nil

//export return_string
func return_string(s *C.char) *C.char {
    s1 := C.GoString(s)
    s1 += "你好呀"
    res := C.CString(s1)
    // 將地址使用全局變量進行保存, 注意這里是 res、不是 &res, 因為 res 本身就是個 C 中的char *, 因此不能再取 &, 否則反而會出問題
    address = unsafe.Pointer(res)
    return res
   
}

//export release_memory
func release_memory(){
    // 釋放 C 字符串所占內存
    if address != nil {
        C.free(address)
        address = nil
    }
}

func main() {
}

然后我們使用 Python 來進行測試,看看是否有效:

from ctypes import *

libgo = CDLL("./libgo.so")
# 指定返回值的類型為整型指針
libgo.return_string.restype = c_char_p
print(
    libgo.return_string(c_char_p(b"komeiji satori")).decode("utf-8")
)  # komeiji satori你好呀
while True:
    libgo.return_string(c_char_p(("古明地覺" * 10000).encode("utf-8"))).decode("utf-8")
    libgo.release_memory()

此時不管持續多長時間,內存都不會有太大變化,證明該方法是有效的。

如果我們將 libgo.release_memory() 給注釋掉的話,那么會發現內存使用率再度蹭蹭往上漲。所以對於那些需要回收的數據,我們就可以通過這種方式來釋放,每調用一次就釋放一次即可。對於數值類型我們無需擔心,我們只需要關注字符串即可,至於結構體,如果里面包含 char *,那么同樣需要考慮字符串的釋放問題,但是不建議返回這種復雜的數據結構。

因此我們更關心字符串,因為它非常容易造成內存泄漏,那么什么時候應該進行回收呢?答案是:如果是使用 C.CString 返回的字符串,我們是一定要進行回收的;如果看一下上面的 Go 代碼的話,你會發現參數是一個 char * 類型的變量 s,那么這個變量 s 不需要回收嗎?其實是不需要的,還是那句話我們只需要對 C.CString 返回的字符串進行回收即可。

如果 C.CString 返回的字符串作為了返回值,那么顯然不能在執行函數的過程中刪除,使用 defer 也不可以,因為要確保 Python 能夠拿到返回值,就不能在函數執行過程中回收;而解決辦法就是我們上面說的定義一個專門用來釋放的函數,但是程序中未必只有一個 C.CString 啊。是的,如果不止一個,那么就把不被 Python 接收的C字符串在函數執行過程中釋放掉。比如:

//export return_string
func return_string(s *C.char) *C.char {
    s1 := C.GoString(s)
    s1 += "你好呀"
    res1 := C.CString(s1)
    res2 := C.CString(s1)
    res3 := C.CString(s1)
    C.free(unsafe.Pointer(res1))
    C.free(unsafe.Pointer(res2))
    // 將地址使用全局變量進行保存
    address = unsafe.Pointer(res3)
    return res3 
}

我們 return 了 res3,那么 res1 和 res2 在用完之后就直接釋放掉即可,而 res3 是需要被 Python 接收的,所以它需要使用另一個函數單獨釋放。還是那句話:只需要釋放 C.CString 返回的字符串,如果把上面代碼改一下:

//export return_string
func return_string(s *C.char) *C.char {
    s1 := C.GoString(s)
    s1 += "你好呀"
    res1 := C.CString(s1)
    res2 := res1
    res3 := res2
    C.free(unsafe.Pointer(res1))
    C.free(unsafe.Pointer(res2))
    // 將地址使用全局變量進行保存
    address = unsafe.Pointer(res3)
    return res3
}

那么你會發現 Python 解釋器在調用的時候直接就異常退出了,原因是上面 res1、res2、res3 都是指向同一個字符串,而 C.free 對其釋放了兩次。所以當調用這個執行函數的時候就直接崩潰了,解決辦法就是將那兩個 C.free 注釋掉即可,因為 res3 被返回了,所以它應該由專門的函數進行一次釋放即可。當兩個 C.free 被注釋掉之后,會發現 Python 又調用正常了。

總結:

1. 關於字符串,我們需要對其進行釋放,否則會一直停留在堆區,如果字符串比較大、或者是長時間運行的服務,很容易造成內存溢出;

2. 一旦字符串作為返回值返回,那么不可以在執行函數內部釋放它,而是保存它的地址,然后由專門的函數去釋放;

3. 我們只需要對 C.CString 返回的字符串進行釋放,所以應該使用變量進行接收,如果一旦使用完畢就直接釋放掉;

4. 因為 C 字符串是由一個指針指向,所以如果是變量之間的傳遞的話,那么不管有多少個變量,字符串在內存中只有一份;因此最直觀的做法就是:有多少個 C.CString 就釋放多少次,所以使用變量作為左值,然后只對那些出現 C.CString 的賦值語句中的左值進行 free 即可。

這里多提一句,關於 Go 給 Python 提供動態庫,需要遵循一個原則:Go 中導出的執行函數的內部邏輯可以很復雜,但是參數和返回值一定要簡單。因為這兩者之間是需要通過 C 來作為媒介,參數和返回值必須能用 C 准確表達,所以建議只選擇整型、浮點型、字符串這三種。

最關鍵的是內存方面,對於 Go 中的數據結構我們完全不需要關心,因為 Go 的垃圾回收機制會解決它,我們只需要關注 C 中的字符串即可,而原則就是我們上面說的那樣。另外在 Go 中不允許返回指針,原因我們也說過了,因為 Go 中的指針是類型安全的,只要在 Go 里面,那么 Go 的編譯器便可以牢牢地把控它們。但是一旦將指針返回了,不好意思,即使你能編譯成功,當 Python 調用時也會報錯,會提示你 panic: runtime error: cgo result has Go pointer。所以只要返回值帶有"取址符",Python 在調用時都是不允許的,當然返回切片也是不允許的,甚至(*C.char)(unsafe.Pointer(&s))也不允許,因為它也是一個指針,C 的字符串只能通過 C.CString 的方式。

因此我們只建議返回整型、浮點型或者字符串,像數組、map 我們可以轉成 json,然后再讓 Python 對其進行解析即可。

我們舉個栗子:

// 文件名:file.go
package main

import "C"
import (
    "encoding/json"
    "fmt"
    "sync"
    "time"
)

//export return_json
func return_json() *C.char {
    var m = make(map[string][]int)
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(i int) {
            // 這里就不加鎖了,因為多個 goroutine 不會操作同一個 key
            m[fmt.Sprintf("satori_%d", i)] = []int{i + 100, i + 101}
            time.Sleep(2 * time.Second)
            wg.Done()
        }(i)
    }
    wg.Wait()
    data, _ := json.Marshal(m)
    return C.CString(string(data))
}

func main() {
}

將 json 變成 C 字符串,然后返回,這里為了簡便就不寫釋放邏輯了:

from ctypes import *
import time
from pprint import pprint
import orjson

libgo = CDLL("./libgo.so")
libgo.return_json.restype = c_char_p
start = time.perf_counter()
res = libgo.return_json() 
print(f"耗時:{time.perf_counter() - start}")  # 耗時:2.0029728785157204
pprint(orjson.loads(res)) 
"""
{'satori_0': [100, 101],
 'satori_1': [101, 102],
 'satori_2': [102, 103],
 'satori_3': [103, 104],
 'satori_4': [104, 105]}
"""
pprint(orjson.loads(res)["satori_1"])  # [101, 102]

我們看到導出函數中可以編寫更加復雜的邏輯,可以做很多的操作,但是參數和返回值一定要簡單。因為內部邏輯再復雜,那也是在 Go 的內部,不需要 Python 關心。但參數和返回值就不一樣了,它們是需要 Python 和 Go 同時理解的,因此我們要秉承着最保守的原則,使用那些 Python 和 Go 都能准確理解、並且不會產生歧義的數據結構。

Go 源文件編譯成靜態庫、動態庫並結合 Cython

對於使用 ctypes 調用而言,Go 的動態庫叫什么名字其實無關緊要,但是在 Linux 中靜態庫和動態庫的命名是有規范的,我們在使用 gcc 進行鏈接的時候需要遵循這種規范。首先靜態庫以 .a 為后綴、動態庫以 .so 為后綴,並且它們的名字都必須以 lib 開頭。比如我們上面指定的 libgo.so,然后在鏈接的時候把開頭的 lib 和結尾的 .so 去掉、也就是只需要指定 "go" 即可,會自動尋找 libgo.so 這個動態庫,如果沒有 libgo.so,那么會去尋找靜態庫 libgo.a。

我們還是編寫 Go 源文件:

// 文件名:go_fib.go
package main

import "C"
import "fmt"

//export go_fib
func go_fib(n C.int) C.double {
    var i C.int = 0
    var a, b C.double = 0.0, 1.0
    for ; i < n; i++ {
        a, b = a + b, a
    }
    fmt.Println("斐波那契計算完畢,我是 Go 語言")
    return a
}

func main() {}

然后我們來使用 go build 根據 go 源文件生成靜態庫:

go build -buildmode=c-archive -o 靜態庫文件 [go源文件1, go源文件2, go源文件3, ...]

[root@satori go_py]# go build -buildmode=c-archive -o libfib.a go_fib.go 
[root@satori go_py]# 

然后我們還需要一個頭文件,這里定義為 go_fib.h:

double go_fib(int);

里面只需要放入一個函數聲明即可,具體實現在 libfib.a 中,然后編寫 Cython 源文件:

# 文件名:fib.pyx
cdef extern from "go_fib.h":
    double go_fib(int)


def fib_with_go(n):
    """調用 Go 編寫的斐波那契數列,以靜態庫形式存在"""
    return go_fib(n)

然后我們來進行編譯:

# 文件名:setup.py
from distutils.core import setup, Extension
from Cython.Build import cythonize

# 這里我們不能在 sources 里面寫上 ["fib.pyx", "libfib.a"],這是不合法的,因為 sources 里面需要放入源文件
# 靜態庫和動態庫需要通過 library_dirs 和 libraries 指定
ext = Extension(name="wrapper_gofib",
                sources=["fib.pyx"],
                # 相當於 gcc 的 -L 參數,路徑可以指定多個
                library_dirs=["."],
                # 相當於 gcc 的 -l 參數,鏈接的庫可以指定多個
                # 注意:不能寫 libfib.a,直接寫 fib 就行,所以靜態命名需要遵循規范,要以 lib 開頭、.a 結尾
                # 動態庫同理,lib 開頭、.so 結尾
                libraries=["fib"]
                # 如果還需要頭文件的話,那么通過 include_dirs 指定
                # 只不過由於頭文件就在當前目錄中,所以我們不需要指定
                )

setup(ext_modules=cythonize(ext, language_level=3))

然后我們執行 python3 setup.py build,執行成功之后,會生成一個 build 目錄,我們將里面的擴展模塊移動到當前目錄,然后進入交互式 Python 中導入它,看看會有什么結果。

除了靜態庫之外,Cython 還可以包裝動態庫,我們只需要生成 libfib.so 即可,其它不需要有任何改動。因為 gcc 在鏈接的時候,如果指定的是 fib、那么優先鏈接 libfib.so,當 libfib.so 不存在的時候才會去鏈接 libfib.a。只不過在生成擴展模塊之后,對應的動態庫不可以丟,它是在運行的時候被動態加載的,不僅不能丟、還要將所在路徑配置到 /etc/ld.so.conf 中,否則找不到;而對於靜態庫而言,在鏈接的時候會把靜態庫的內容都包含進去,所以編譯之后是可以獨立於相應的靜態庫的。

因此這就是 Cython 的強大之處,它將 C 的性能引入了 Python 中,Cython 同時理解 C 和 Python,可以直接包裝 C、C++ 源文件、靜態庫、動態庫。關於 Cython,它是一門單獨的技術,值得去學習。

關於 Cython,可以看 https://www.cnblogs.com/traditional/tag/Cython/ 。

由 Cython 釋放內存

本來這一部分之前是沒有的,然而在 B 站上有一個小伙伴問了我一個問題:

這個問題很簡單,我們看一下怎么做。首先編寫 Go 源文件:

// 文件名:return_string.go
package main

import "C"

//export get_name
func get_name() *C.char {
    return C.CString("古明地覺")
}

func main() {}

編譯成靜態庫:

go build -buildmode=c-archive -o libreturn_string.a return_string.go

然后編寫頭文件:

// 文件名 return_string.h
// 對函數進行聲明,函數的返回值、參數要和 Go 的導出函數保持一致
char *get_name();

注意:Go 編譯器在生成 libreturn_string.a 的同時,也會自動生成一個 libreturn_string.h,我們直接用自動生成的頭文件也是可以的。

最后是 Cython 源文件:

# 文件名:return_string.pyx
cdef extern from "return_string.h":
    # 我們說對於 Cython 而言,想使用哪些函數都必須要在 cdef extern from 塊里面聲明好
    # 因此我們在得到庫之后,還需要定義一個頭文件
    char *get_name()

def get_name_py():
    return get_name()

以上就完事了,然后編譯成 Python 擴展模塊:

from distutils.core import setup, Extension
from Cython.Build import cythonize

ext = Extension(name="return_string",
                sources=["return_string.pyx"],
                library_dirs=["."],
                libraries=["return_string"])

setup(ext_modules=cythonize(ext, language_level=3))

然后我們來測試一下:

結果上是沒有問題的,上面的小伙伴調用之后得到的整型,估計是函數聲明的時候返回值類型寫錯了。當然我這里之所以單獨拿出來說一下,並不是為了這個,而是為了引出內存釋放這一話題。我們在 Go 里面返回了字符串,這個字符串是 C 在堆區創建的,Python 在調用之后,這個字符串依舊會停在堆區,不會被釋放。

import return_string

while True:
    return_string.get_name_py()

調用這個死循環,會發現內存占用瞬間飆升,原因就是每調用一次就會在堆區創建一個字符串,並且字符串還不會被回收。所以問題來了,我們要如何將 C 在堆區申請的字符串給釋放掉呢?

在 Python 使用 ctypes 調用動態庫的時候,我們說過,在 Go 里面需要有一個全局變量來保存字符串的指針,然后再定義一個函數,在里面調用 C.free 進行釋放。但是在 Cython 中我們完全不需要這么做,因為 Cython 同時理解 C 和 Python,我們完全可以在 Cython 里面去釋放這個堆區的字符串。

from libc.stdlib cimport free

cdef extern from "return_string.h":
    char *get_name()

def get_name_py():
    # 此時 s 也指向了這個堆區字符串的首元素
    cdef char *s = get_name()
    # 用 Python 變量接收,此時會將堆區字符串拷貝一份得到 bytes 對象
    name = s
    # 然后將堆區字符串釋放掉,因為這里的 char *s 指向的字符串和 Go 里面 C.CString 申請的字符串是同一個字符串
    # 因此在 Go 里面調用 C.free 釋放,和這里直接使用 free 釋放是等價的
    free(<void *>s)
    return name  # 返回

此時重新編譯,然后再調用的話,會發現不管調用多少次,內存占用都不會往上漲,因為堆區字符串會被回收。而之前內存占用上漲的原因是我們直接 return get_name(),那么在將堆區的字符串拷貝一份得到 bytes 對象之后就直接返回了,但堆區的字符串並沒有被回收。

顯然此時就方便多了,我們不需要再通過回調的方式在 Go 里面釋放了,因為在 Go 里面也是要通過 C 來釋放的(調用 C.free)。而我們說 Cython 同時理解 C 和 Python,所以在 Cython 里面釋放完全等價。並且此時對導出函數的返回值也沒有任何要求,返回數組、結構體、指針統統都是沒有問題的,Cython 都是支持的。

這也算是一個比較重要的地方吧,值得說明一下。

Go 源文件直接編譯成 Python 擴展模塊

直接編寫擴展是一件難度比較大的事情,因為這要求你嚴格遵循 Python/C API,所以才有了 Cython。那么如何用 Go 來給 Python 寫擴展呢,首先還是那句話,Python 和 Go 之間是通過 C 進行交互的,所以用 Go 寫擴展實際上還是相當於用 C 寫擴展。但其實 Go 寫擴展並沒有 C 寫擴展方便,因為 CPython 提供的一些宏在 Go 里面沒辦法通過 C 這個名字空間進行引用,而且還不能調用具有可變參數的 C 函數。比如 CPython 解析函數參數時會使用一個函數:

int PyArg_ParseTuple(PyObject *args, const char *format, ...);

這個函數你在 Go 里面沒法直接用,因為它包含可變參數 ...,如果我們調用 C.PyArg_ParseTuple,Go 編譯器會報錯。解決辦法是你要在 import "C" 上面的 C 代碼中單獨定義一個包裝器,所以還是比較麻煩的。那么下面我們來簡單實現一下 Python 的 binascii 模塊里面的兩個函數,看看 Go 是如何編寫 Python 擴展的。

import binascii


data = b"satori"
print(binascii.hexlify(data))  # b'7361746f7269'
print(binascii.unhexlify(b"7361746f7269"))  # satori

在 binascii 里面有這兩個函數,我們下面就來用 Go 實現它們,先來介紹一下這兩個函數吧。binascii.hexlify 是將數據用 16 進制表示,binascii.unhexlify 則是前者的逆運算。

import binascii


data = b"satori"
# 說白了就是將每一個字節都變成 16 進制
print(binascii.hexlify(data))  # b'7361746f7269'
print([hex(b) for b in data])  # ['0x73', '0x61', '0x74', '0x6f', '0x72', '0x69']
print([hex(b)[2:] for b in data])  # ['73', '61', '74', '6f', '72', '69']
print("".join([hex(b)[2:] for b in data]))  # 7361746f7269

# unhexlify 則是逆運算
data = "古明地覺".encode("utf-8")
hex_data = "".join([hex(b)[2:] for b in data])  # 將數據手動轉成 16 進制
# unhexlify 可以接收字節串、也可以接收字符串,但是 hexlify 只接收字節串
print(binascii.unhexlify(hex_data).decode("utf-8"))  # 古明地覺

# 當然我們仍然可以手動 unhexlify
unhex_data = bytes([int(hex_data[i: i + 2], 16) for i in range(0, len(hex_data), 2)])
print(unhex_data.decode("utf-8"))  # 古明地覺

了解完函數原理之后,我們接下來就用 Go 來寫擴展實現它們。

package main

/*
#cgo linux pkg-config: python3

#include "Python.h"

extern PyObject *PyInit_binascii();
extern PyObject *hexlify(PyObject *, PyObject *);
extern PyObject *unhexlify(PyObject *, PyObject *);

static PyObject *__PyInit_binascii(void){
    static PyMethodDef methods[] = {
        {"hexlify", (PyCFunction) hexlify, METH_O, ""},
        {"unhexlify", (PyCFunction) unhexlify, METH_O, ""},
        {NULL, NULL, 0, NULL}
    };

    static PyModuleDef module = {
        PyModuleDef_HEAD_INIT,
        "binascii",
        "this is a module named binascii",
        -1,
        methods,
        NULL, NULL, NULL, NULL
    };
    return PyModule_Create(&module);
}
*/
import "C"
import (
    "strconv"
    "strings"
    "unsafe"
)

//export hexlify
func hexlify(self, arg *C.PyObject) *C.PyObject {
    // arg 必須是一個 bytes 對象,這里我們就不做參數檢測了

    buf := strings.Builder{}
    // 字符串轉成 C 的字符串、再轉成 Go 字符串
    go_string := C.GoString(C.PyBytes_AsString(arg))
    // 遍歷字符串,將整型轉成 16 進制
    for _, char := range []byte(go_string) {
        buf.WriteString(strconv.FormatInt(int64(char), 16))
    }
    // 轉成 C 字符串
    c_string := C.CString(buf.String())
    // 根據 C 字符串創建 Python 的 bytes 對象
    res := C.PyBytes_FromString(c_string)
    // 記得將堆區申請的 C 字符串給刪除
    C.free(unsafe.Pointer(c_string))
    return res
}

//export unhexlify
func unhexlify(self, arg *C.PyObject) *C.PyObject {

    buf := make([]byte, 0)
    // 得到 Go bytes
    go_bytes := []byte(C.GoString(C.PyBytes_AsString(arg)))
    for i := 0; i < len(go_bytes); i += 2 {
        n, _ := strconv.ParseInt(string(go_bytes[i:i+2]), 16, 0)
        buf = append(buf, byte(n))
    }
    c_string := C.CString(string(buf))
    res := C.PyBytes_FromString(c_string)
    C.free(unsafe.Pointer(c_string))
    return res
}

//export PyInit_binascii
func PyInit_binascii() *C.PyObject {
    return C.__PyInit_binascii()
}

func main() {}

里面涉及到的一些細節就不詳細說了,使用 Go 寫擴展首先需要了解如何使用 C 寫擴展,而且正如之前所說,用 Go 寫擴展反而會沒有 C 方便。原因就是 CPython 解釋器內置了大量的宏,這些宏在 Go 里面沒法直接通過 C 這個名字來進行引用,還有上面說的具有可變參數的 C 函數,不能直接調用,必須定義一個包裝器才可以(個人覺得這算是最大的硬傷);以及 Python 底層的數據結構、C 的數據結構、Go 的數據結構三者要經常來回轉化,還有引用計數的增加、減少,堆區上 C 字符串的釋放等等,個人覺得這些東西處理起來不是一件簡單的事情。個人覺得最好的做法還是前兩種,如果熟悉 Cython 則更推薦第二種,至於這里的第三種:用 Go 直接給 Python 寫擴展,個人不是很推薦。

而上面的代碼則是簡單實現了 hexlify、unhexlify 兩個函數,我們來測試一下吧。

從結果上來看是沒有任何問題的,但這是參數類型傳遞正確的前提下。因為我們這里沒有對參數進行檢測,假設我們傳遞了一個整型過去,那么在執行 C.PyBytes_AsString 的時候很明顯是會報錯的。

當然這里關於擴展的更多細節,這里就不討論了,個人覺得不管啥語言,直接寫擴展都不是一件簡單的事情。所以本人特別喜歡 Cython,因為它同時理解 C 和 Python,將 C 的高性能和 Python 的動態特性結合在了一起。

小結

以上就是在 Python 中引入 Go 的幾種方式,當然 Go 里面也可以引入 Python,只不過個人是以 Python 作為主語言,所以只關注前者。而 Python 引入 Go 也有三種方式:

  • 1. Go 直接編寫動態庫給 Python,然后 Python 解釋器通過 ctypes 調用
  • 2. Go 編寫靜態庫或者動態庫,然后再由 Cython 包裝成 Python 擴展,Python解釋器直接 import
  • 3. Go 直接為 Python 提供擴展

第三種個人不推薦,因為受到的限制太多了,可以嘗試前兩種。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM