首页 > 技术文章 > 《深度剖析CPython解释器》32. Python 和 Go 联合编程

traditional 2021-05-18 17:39 原文

楔子

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("我永远喜欢

推荐阅读