Go 怎么调用 Python?或者说 Python 怎么嵌入到 Go?—— 我要研究明白。
- 方案 1:go -- RESTFul -- python
- 需要用户起一个 python 进程的 http server 进程,并且定义 RESTFul API。
- 方案 2:go -- rpc -- python
- 可用 grpc 或其他 rpc,也是需要单独启动一个 python 进程并开启 rpc server,go 中代码作为 rpc client。
- 方案 3:go -- cgo/内存共享 -- python(C-API)
- python 的解释器绝大多数人都是使用官方的 CPython,内核和解释器是用 C 实现的,可以用 C 代码方便的调用。
- go 又和 C 是天然搭档,通过标准库中的 cgo 组件可以无缝调用 C。
- 此时 python core 作为动态库链接到 go 上。
RESTFul 和 RPC 都是网络调用方式,更适合高度解耦的场景,cgo 或直接内存共享则是效率优先。 —— 我打算先摸索一下方案 3。一通搜索,找到了一些好文章,再加上 10 天左右的实战摸索,总结以下要点:
下面依次展开描述图中 1、2、3:
- Python C-API
- Python 对外提供
Python.h
,include 此文件即可访问 CPython 提供的 C API。 - 通过 Python C API,调用 Python C 库,包括 Python 解析器、Python Core、Python 标准库……,由此可以实现 2 类需求:
- 为 Python 写扩展:可以用 C 语言或其他语言(如 go),通过 C-API 写个扩展,编译后放在 Python 库中,供任意 py 脚本调用。—— 官方文档中称之为 Extending Python With C
- 将 Python 嵌入到其他语言中:—— 官方文档称之为 Embedding Python into C,我们这里要进一步 Embedding Python into Go。
- Python 对外提供
- cgo
- cgo 是 go 官方实现的一套 toolchain 工具 + 转换库函数,能够将 go 的数据转为 c(如:
C.CString()
)或反之(如:C.GoString()
),其工具能够自动调用 gcc、glibc、能够使用 pkg-config 等工具发现编译参数……。 - cgo 调用 C 标准库仅需 1 行
import "C"
,然后在 go 代码中即可C.print("foobar");
- 基于 cgo 的方式,有热心网友做了更高层次的封装,如:
- 封装 Python2 C-API 的 sbinet/go-python、spikeekips/embedding-python-in-golang
- 封装 Python3.7 C-API 的 DataDog/go-python3(已停止)及其 fork go-python/cpy3,但都仅限于 python3.7,并且已经多年不维护。
- cgo 是 go 官方实现的一套 toolchain 工具 + 转换库函数,能够将 go 的数据转为 c(如:
- go
- go 中除了直接调用
C.xxx
变量和函数之外,就是要特别考虑:- 协程并发:go 中的协程如何与 C 中的函数保持可重入性和幂等性,如果 C 函数不可重入,要封装加锁,或 go 代码中加锁。
- 对 C 对象生命周期的管理:C 中本来没有对象,它也不是面向对象语言,但 Python 封装带 PyObject 数据结构,具有高级语言对象的完整特征,但又不能自动垃圾回收,所以使用 PyObject 但手工维护指针计数,以放置内存泄露——几乎可以说搞定 Python C-API 的第一个必过考点、难点。
- go 中除了直接调用
好 吧,开始启程,章节安排依然是上图中的 1、2、3。
Python ~ C
嵌入与扩展
Python 官方文档首页中的这个主题:Extending and Embedding,从 python2 至今 3.10,一直都在这里,其中内容分 2 块:
- Extending(扩展):使用 C 编写 python 扩展模块,作为 Python 自身源码中大量扩展模块(有 C 也有 Python 实现的)的扩充。
- Embedding(嵌入):将 Python 解释器、Core 嵌入到另一个应用程序中,这样另一名语言可以调用 Python 的 Core 和标准库、第 3 方库……。
画了个可能不是很准确的图,表达一下我对扩展和嵌入的理解。
- 括号中的部分(
(Python/*.c)
、(Lib/*.py)
)表示这些模块在 CPython 源码中的文件夹,及其实现语言,官方网站可以下载任一版本的源码。 - 大圆圈中的 Python Core 与 Parser、Objects、Moudules 等编译后生成 python 解释器(一般位于
/usr/bin/python
)和 python 库(一般位于/usr/lib/x86_64_linux-gnu/libpythonx.y.a
),都是二进制文件。其中我把 C-API 放在了正中间,表示几乎所有其他模块的源码,都会 include 其中的头文件。 - 标准库的 python 部分源码在
Lib/*.py
,安装时不编译,以源码形式安装(一般位于/usr/lib/pythonx.y/
)
官方文档中有一段对比的解释:
扩展 Python 和嵌入 Python 的过程相当类似:
从 Python 到 C 的扩展代码到底做了什么: —— 对应上图中红色线条及 1、2、3。
- 将 Python 的数据转换为 C 格式,
- 用转换后的数据执行 C 程序的函数调用,
- 将调用返回的数据从 C 转换为 Python 格式。
嵌入 Python 时,接口代码会这样做: —— 对应上图中蓝色线条及 1、2、3。
- 将 C 数据转换为 Python 格式,
- 用转换后的数据执行对 Python 接口的函数调用,
- 将调用返回的数据从 Python 转换为 C 格式。
可能一般我们认识 python 都是从 python 的命令行开始的,比如我们查看 python3.8 可执行文件(解释器):
$ file /usr/bin/python3.8
/usr/bin/python3.8: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1f3df9df2b5e575fdee41890fe17f6de614f93f6, for GNU/Linux 3.2.0, stripped
从这个命令行可以启动一个 py 文件:python foobar.py
,但同时 python 还包含一个二进制的库,根据安装或源码编译的参数不同,可以是静态库或动态库:
$ file /usr/lib/x86_64-linux-gnu/libpython3.8.a
./libpython3.8.a: symbolic link to ../python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a
原来是个软连接
$ file /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a
/usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a: current ar archive
还是个压缩包
$ readelf -h /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a |grep "^File:*"
File: /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a(getbuildinfo.o)
File: /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a(acceler.o)
File: /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a(grammar1.o)
File: /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a(listnode.o)
File: /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a(node.o)
File: /usr/lib/python3.8/config-3.8-x86_64-linux-gnu/libpython3.8.a(parser.o)
......
170+ .o 合并而成,对应上图中的大圆圈中相应的 .c 文件。
我们来总结一下:
- 扩展:指的是 C 语言实现的与标准库 C 部分类似的,可以通过 C-API 调用 Core、Object、Moudules,从而实现的功能扩展。
- 相比 python 在上层基于标准库实现的扩展或模块,C 扩展可以实现更底层的功能,比如:实现新的内置对象类型、调用 C 的库函数和系统调用 —— 这都是 python 调用标准库而实现的扩展模块做不到的。
- C 写的扩展也需要编译为二进制、连接后才能使用,不能像 python 一样由词法器、解释器运行期处理。
- 可以用 disutils 编译 C 扩展,如:
python setup.py build
,会自动调用 gcc 生成 .so 或 .o。 - 然后扩展也可以与其他 python 编写的扩展一样,发布到 pypi
- 嵌入:指的是将 python 解释器嵌入到其他语言或 APP 中,不再是主进程,而是被其他语言(C、go 等)主进程调用的派成进程。
- 扩展和嵌入都是调用 C-API
python C 的编译
下面我们来写一些简短的 C 来实际调用一下 Python C API。但是写代码之前,非常有必要认识一下 pkg-config 这个工具。
pkg-config
pkg-config 是用来收集系统上已安装库的元数据的小工具,Linux、macOS、Windows 上都可以使用,可以用在向 gcc、make 等提供数据的场景,是 gcc 的好帮手。
对于库的开发人员来说,随库版本一起发布和安装 pkg-config 文件(*.pc
)可以简化用户(gcc)寻找信息的方法和时间。
安装:
如果想从源码安装,可以到 官网 上下载源码,或从 这里找到 git 库地址 git clone https://gitlab.freedesktop.org/pkg-config/pkg-config.git
。然后 configuration、make...
但还是简单点,不折腾,安装二进制版本吧:
- Linux: 各大发行版都默认安装了,直接使用。
- macOS:
brew -v install pkg-config
- windows: 待研究,好像要用到 MinGW,那干脆直接用微软在 windows 内嵌的原生 Linux 算了。
工作原理:
pkg-config 搜索 *.pc
文件,路径可以用下面命令查看:
$ pkg-config --variable pc_path pkg-config
/usr/local/lib/x86_64-linux-gnu/pkgconfig:/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
/usr/local/lib/x86_64-linux-gnu/pkgconfig
/usr/local/lib/pkgconfig
/usr/local/share/pkgconfig
/usr/lib/x86_64-linux-gnu/pkgconfig
—— 我的电脑这里有 280+ 个/usr/lib/pkgconfig
/usr/share/pkgconfig
—— 40+ 个
另外,环境变量 PKG_CONFIG_PATH
也影响 pkg-config 搜索 pc 文件的路径。
用下面的命令可以查看已经搜索到 pc 文件,即表示能够使用的库,比如:
$ pkg-config --list-all | grep glib-2
glib-2.0 GLib - C Utility Library
说明本机已经安装了 glib-2.0 库,可以使用了:
$ pkg-config --cflags glib-2.0
-I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include
$ pkg-config --libs glib-2.0
-lglib-2.0
--cflags
和 --libs
是最常用的 pkg-config 参数,获取指定库自己暴露的头文件和库文件位置,并封装为相应的 gcc 参数(即添加 -I
、-L
前缀)。在我的电脑里:
$ pkg-config --cflags --libs glib-2.0
-I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include -lglib-2.0
这样聚合到 gcc 或 make 中就可以这样:
$ gcc `pkg-config --cflags --libs glib-2.0` main.c -o main
等效于
$ gcc -I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include -lglib-2.0 main.c -o main
python's pkg-config
我本机安装了多个版本的 python,pkg-config 都可以找到:
$ pkg-config --list-all | grep python
python2 Python - Python library
python-2.7 Python - Python library
python3 Python - Build a C extension for Python
python3-embed Python - Embed Python into an application
python-3.8 Python - Build a C extension for Python
python-3.8-embed Python - Embed Python into an application
还可以通过 PKG_CONFIG_PATH
环境变量增加搜索 *.pc
文件的路径,如新增我用 pyenv 安装的更多 python:
export PKG_CONFIG_PATH=~/.pyenv/versions/3.9.2/lib/pkgconfig
然后就可以找到 3.9 的 python 了:
$ pkg-config --list-all | grep python
python2 Python - Python library
python-2.7 Python - Python library
python3 Python - Build a C extension for Python
python3-embed Python - Embed Python into an application
python-3.8 Python - Build a C extension for Python
python-3.8-embed Python - Embed Python into an application
python-3.9 Python - Build a C extension for Python
python-3.9-embed Python - Embed Python into an application
你应该注意到了,每个 python 都有 2 个 pc 文件,根据其注释,明确表明了 2 个 pc 的用途:
- Build a C extension for Python —— 扩展
- Embed Python into an application —— 嵌入
所以当我们开发扩展 or 嵌入的时候,一定要使用相应的 pc 文件。
对比一下:
$ cat /usr/lib/x86_64-linux-gnu/pkgconfig/python3.pc
# See: man pkg-config
prefix=/usr
exec_prefix=${prefix}
includedir=${prefix}/include
Name: Python
Description: Build a C extension for Python
Requires:
Version: 3.8
Libs.private: -lcrypt -lpthread -ldl -lutil -lm
Libs:
Cflags: -I${includedir}/python3.8 -I${includedir}/x86_64-linux-gnu/python3.8
$ cat /usr/lib/x86_64-linux-gnu/pkgconfig/python3-embed.pc
# See: man pkg-config
prefix=/usr
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include
Name: Python
Description: Embed Python into an application
Requires:
Version: 3.8
Libs.private: -lcrypt -lpthread -ldl -lutil -lm
Libs: -L${libdir} -lpython3.8
Cflags: -I${includedir}/python3.8
所以相同命令得到不同结果:
$ pkg-config --libs python3
$ pkg-config --libs python3-embed
-lpython3.8
扩展不需要 Libs 配置参数,嵌入需要 —— 想想为什么?
python 共享库
如果你使用 pyenv,通过编译 python 源码生成的 python 可执行文件,通常会连带生成其静态库,如:
$ ls /home/me/.pyenv/versions/3.9.2/lib/
libpython3.9.a pkgconfig python3.9
但 python 还可以编译成共享库(动态库):
$ env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.9.2
...
$ ls /home/me/.pyenv/versions/3.9.2/lib/
libpython3.9.a libpython3.9.so libpython3.9.so.1.0 libpython3.so pkgconfig python3.9
多出来的 libpython3.9.so
即共享库,可用于嵌入 python 的相关开发,前面 -lpython3.x
也就是指的它了。
文档参考pyenv 的 wiki。
python 的 flag 还能这样获取
python 官方还提供了另外一个工具,更专业的提供 gcc 编译参数:
输入 python3. 然后 tab 键,自动弹出的工具里有一类: pythonx.x-config
$ python3.
python3.5 python3.5m python3.6-config python3.6m-config python3.7-gdb.py python3.8 python3.9
python3.5-config python3.5m-config python3.6-gdb.py python3.7 python3.7m python3.8-config python3.9-config
python3.5-gdb.py python3.6 python3.6m python3.7-config python3.7m-config python3.8-gdb.py python3.9-gdb.py
用法示例:
$ python3.9-config --cflags
-I/home/me/.pyenv/versions/3.9.2/include/python3.9 -I/home/me/.pyenv/versions/3.9.2/include/python3.9 -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall
$ python3.8-config --ldflags
-L/usr/lib/python3.8/config-3.8-x86_64-linux-gnu -L/usr/lib -lcrypt -lpthread -ldl -lutil -lm -lm
如果要使用嵌入特性,则使用 --embed
参数,如:
$ python3.8-config --cflags --ldflags --embed
-I/usr/include/python3.8 -I/usr/include/python3.8
-Wno-unused-result -Wsign-compare -g
-fdebug-prefix-map=/build/python3.8-4wuY7n/python3.8-3.8.10=.
-specs=/usr/share/dpkg/no-pie-compile.specs -fstack-protector
-Wformat -Werror=format-security -DNDEBUG -g -fwrapv -O3 -Wall
-L/usr/lib/python3.8/config-3.8-x86_64-linux-gnu
-L/usr/lib
-lpython3.8
-lcrypt -lpthread -ldl -lutil -lm -lm
比暴露给 pkg-config 的更完整,对故障打印格式等信息也做了优化,所以我们就可以这样:
$ gcc `python3-config --cflags --ldflags --embed` main.c
来代替
$ gcc `pkg-config --cflags --libs python3-embed` main.c
或者干脆写一个二合一的 shell 脚本:
gcc `pkg-config --cflags --libs python3-embed` main.c
if [ $? != 0 ];then
gcc `python3-config --cflags --ldflags --embed` main.c
fi
Hello World
现在,我们必须来实现一个上面提到的、简单的 main.c 了。
// main.c
#include <Python.h>
int main()
{
printf("hello python c api\n");
Py_Initialize();
printf("%s", Py_GetVersion());
Py_Finalize();
return 0;
}
Py_Initialize
初始化 Python CorePy_Finalize
停止 Python CorePy_GetVersion
中间用 c 语言的 print,打印了 Python 的版本。
3 个函数都是 <Python.h>
中提供的。执行结果:
$ gcc `pkg-config --cflags --libs python3-embed` main.c -o demo
$ ./demo
hello python c api
3.9.13 (main, May 24 2022, 21:28:12)
[Clang 12.0.0 (clang-1200.0.32.29)]
情况 1: 编译失败
也许你不是很顺利,编译报错,比如:
$ gcc `pkg-config --cflags --libs python3-embed` main.c -o demo
/usr/bin/ld: /tmp/ccteb8Kn.o: in function `main':
main.c:(.text+0x15): undefined reference to `Py_Initialize'
/usr/bin/ld: main.c:(.text+0x1a): undefined reference to `Py_GetVersion'
/usr/bin/ld: main.c:(.text+0x33): undefined reference to `Py_Finalize'
collect2: error: ld returned 1 exit status
找不到需要的动态库?
确认一下 libpython3...
$ pkg-config --cflags --libs python3-embed
-I/home/me/.pyenv/versions/3.8.13/include/python3.8 -L/home/me/.pyenv/versions/3.8.13/lib -lpython3.8
$ ls /home/me/.pyenv/versions/3.8.13/lib
libpython3.8.so libpython3.8.so.1.0 libpython3.so pkgconfig python3.8
libpython3.8.so 就在指定的位置,那为啥找不到?
把动态库放在本地试试
$ ln -s /home/me/.pyenv/versions/3.8.13/lib/libpython3.8.so.1.0 ./
$ gcc `pkg-config --cflags --libs python3-embed` main.c -o demo ./libpython3.8.so.1.0
$ ./demo
hello python c api
3.8.13 (default, Jun 15 2022, 09:07:53)
[GCC 9.4.0]
成功!难道 -L
参数不工作了?
试试编译和链接分开:
$ gcc `pkg-config --cflags python3-embed` main.c -c
$ file main.o
main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ gcc main.o /home/me/.pyenv/versions/3.8.13/lib/libpython3.8.so -o demo
$ ./demo
hello python c api
3.8.10 (default, Mar 15 2022, 12:22:08)
[GCC 9.4.0]
咦?链接的 3.8.13,运行是 3.8.10?
$ ldd demo
libpython3.8.so.1.0 => /lib/x86_64-linux-gnu/libpython3.8.so.1.0 (0x00007f51bc49d000)
......
哦,这应该是那个 linker and loader 的常见问题,重新来:
$ LD_LIBRARY_PATH=/home/me/.pyenv/versions/3.8.13/lib/
$ ./demo
hello python c api
3.8.13 (default, Jun 15 2022, 09:07:53)
[GCC 9.4.0]
偶然间发现:
$ gcc /home/me/.pyenv/versions/3.8.13/lib/libpython3.8.so main.o -o demo
/usr/bin/ld: /tmp/ccZv3FZH.o: in function `_Py_DECREF':
main.c:(.text+0x39): undefined reference to `_Py_Dealloc'
/usr/bin/ld: /tmp/ccZv3FZH.o: in function `main':
main.c:(.text+0x59): undefined reference to `Py_Initialize'
/usr/bin/ld: main.c:(.text+0x5e): undefined reference to `Py_GetVersion'
这就诡异了:
$ gcc main.o /home/me/.pyenv/versions/3.8.13/lib/libpython3.8.so -o demo # 成功
$ gcc /home/me/.pyenv/versions/3.8.13/lib/libpython3.8.so main.o -o demo # 失败
$ gcc main.o `pkg-config --libs python3-embed` -o demo # 成功
$ gcc `pkg-config --libs python3-embed` main.o -o demo # 失败
那不分开编译,也更换一下顺序呢?
$ gcc main.c `pkg-config --cflags --libs python3-embed` -o demo # 成功
$ gcc `pkg-config --cflags --libs python3-embed` main.c -o demo # 失败
难道是我健忘症犯了?忘记了 gcc 的哪个细节?我怎么完全没印象以前遇到过这个问题啊?
又找了几个电脑的 gcc 测试,发现有些版本的都成功,有些和我一样,macOS 的则全部都成功 —— 不会是某个 gcc 版本的 bug 吧!
最后我又测 试了这样:
$ gcc `pkg-config --cflags python3-embed` main.c `pkg-config --libs python3-embed` -o demo
也是成功的,难道 linker 必须放在 compiler 后面,gcc 哪个版本开始做这个强制要求了?
高层 API
可以使用 PyRun_SimpleString
直接执行 python 语句:
int main()
{
printf("hello python c api\n");
Py_Initialize();
PyRun_SimpleString("import datetime");
PyRun_SimpleString("print(datetime.datetime.now())");
Py_Finalize();
return 0;
}
$ gcc `pkg-config --cflags --libs python3-embed` main.c -o demo
$ ./demo
hello python c api
2022-06-13 20:41:05.210003
底层 API
PyRun_SimpleString
可以说是最高层、也是最简单的 Python 提供的 C-API 了,就是个简单的套壳,相当于从 C 切入,运行了 py 代码,再转回 c 代码。从效率上讲不是个好方案,还好 python 只提供了这 1 个高层函数,其他大量、巨量的都是底层函数,比如:
mod = PyImport_ImportModule("foo.bar")
导入模块,相当于import foo.bar
,mod 是个 C 的 structstruct PyObject
。attr = PyObject_GetAttrString(mod, "foobar")
获取 module 的某个属性,比如函数、class、变量……返回的 attr 也是 PyObject —— 一切都是 PyObject。PyTuple_New
、PyTuple_New
创建并赋值元组PyLong_FromLong
、PyLong_AsLong
在 c 语言的 long 和 PyObject 之间互转。PyObject_CallObject
调用函数
来看代码吧:
int main()
{
// 底层 API
PyObject *mod, *func, *args, *val, *rlt;
mod = PyImport_ImportModule("random");
if (mod != NULL)
{
func = PyObject_GetAttrString(mod, "randint");
if (func && PyCallable_Check(func))
{
args = PyTuple_New(2);
// 添加第 0 个参数
val = PyLong_FromLong(1);
PyTuple_SetItem(args, 0, val);
Py_DECREF(val);
// 添加第 1 个参数
val = PyLong_FromLong(100);
PyTuple_SetItem(args, 1, val);
Py_DECREF(val);
// 调用函数
rlt = PyObject_CallObject(func, args);
printf("Result of call: %ld\n", PyLong_AsLong(rlt));
Py_DECREF(args);
Py_DECREF(rlt);
}
}
Py_Finalize();
return 0;
}
$ gcc `pkg-config --cflags --libs python3-embed` main.c -o demo
$ ./demo
Result of call: 54
得到了一个随机数 54。上面近 30 行的 C 代码如果用 python 来实现的话可能只需这样:
import random
print("Result of call:", random.randint(1,100))
没错,就是 2 行 —— 为啥用了 python 就再也回不去 C 了,因为这是血淋淋的降维打击。
最后还需特别说明一下 Py_DECREF
,因为 C 没有对象,Python 的 PyObject 完全是用 C 实现了面向对象,但又不能用 Python 自己的对象指针自动维护、垃圾回收。创建一个 PyObject 对象,对象的指针会 +1,但 -1 的任务就落在了使用者身上,如果不及时 -1,PyObject 对象实例就会永远驻留在内存中,造成内存泄露。Py_DECREF
就是操作 PyObject 指针指向的 PyObject 对象内的指针引用计数 -1。
OK,Hello World 差不多了,如果在团队中不负责这一块,了解到这里就差不多了,可以跳过下面的几个小结,直奔 go-c 章节了。
python C-API 知识要点
几乎一切都可以在 Python 的官方文档中找到答案。
都有中文翻译,但大概只有 50% 左右翻译了。下面根据我实战中经验记录一些陷阱和提示吧。
PyObject、引用计数、垃圾回收
没有人可以拥有一个对象,对象都在内存堆上,你只能拥有一个对象的指针(引用)。
几乎所有 Python 对象存放在堆中:你不能声明一个类型为 PyObject 的自动或静态的变量,只能声明类型为 PyObject*
的指针。
- 对象指针的拥有:生成某个对象指针变量的函数称为其拥有者,有责任及时调用
Py_DECREF()
。 - 对象指针的借用:
- 拥有者将指针 copy 给另一方,借用出去,即接收方不新建指针(临时)变量,也不应该调用
Py_DECREF()
。 - 因为借入者(接收方)只是借用,你的 Py_DECREF 会导致拥有者逻辑失效,当拥有者 Py_DECREF 的 时候出现异常。
- 借入者一旦
Py_INCREF()
,则变为拥有者,就要及时Py_DECREF()
。
- 拥有者将指针 copy 给另一方,借用出去,即接收方不新建指针(临时)变量,也不应该调用
我这里做个比喻
- 管杀不管埋(传递),即:负责创建 PyObject 对象,然后
Py_INCREF()
,或不是新建对象,但都是获取到了一个新的对象指针(变量),但传递出去后自己不再负责Py_DECREF()
(即把拥有权传递出去),由接收方埋, 此时接受者变成拥有者,要负责及时调用Py_DECREF()
。这样的函数有:PyLong_FromLong()
Py_BuildValue()
PyObject_Str()
PyUnicode_AsUTF8()
PyObject_GetAttrString()
- 管杀也管埋(借用),即:负责创建 PyObject 对象,然后
Py_INCREF()
,但同时也负责必要时调用Py_DECREF()
消亡该对象,不需要接收者Py_DECREF()
,对象指针在它们与接收者之间是借用关系。接收者要注意借用时长不要超过借出方的给定时长,如果会超出,先Py_INCREF()
一下。这样的函数有:PyTuple_GetItem()
PyList_GetItem()
PyDict_GetItem()
PyDict_GetItemString()
PyImport_AddModule()
所以,我们自己用库函数或写自己的函数时,都要首先弄清楚、想明白这个函数是情况 1、还是 2,然后决策自己是否需要 Py_INCREF 和 Py_DECREF。
没有必要为每个包含指向对象的指针的局部变量增加对象的引用计数。理论上,当变量指向对象时,对象的引用计数增加 1 ,当变量超出范围时,对象的引用计数减少 1 。但是,这两者相互抵消,所以最后引用计数没有改变。—— 所以函数内临时变量就可以省略 Py_INCREF 和 Py_DECREF。
使用引用计数的唯一真正原因是防止对象被释放。—— 即把根留住,自己要用。类似锁机制。
比如:
void
bug(PyObject *list)
{
PyObject *item = PyList_GetItem(list, 0);
PyList_SetItem(list, 1, PyLong_FromLong(0L));
PyObject_Print(item, stdout, 0); /* BUG! */
}
首先借用一个引用 list[0] ,然后替换 list[1] 为值 0 ,最后打印借用的 list[0] 的引用。
如果借用方代码写的有问题,当替换 list[1] 的时候,原来的 list[1] 会被释放,自动调用 __del__()
,把 list[0] 也释放了,则打印那句就 emo 了。
所以改进为:
void
no_bug(PyObject *list)
{
PyObject *item = PyList_GetItem(list, 0);
Py_INCREF(item);
PyList_SetItem(list, 1, PyLong_FromLong(0L));
PyObject_Print(item, stdout, 0);
Py_DECREF(item);
}
如果知道至少有一个对该对象的其他引用存活时间至少和我们的变量一样长,则没必要临时增加引用计数。一个典型的情形是,对象 作为参数从 Python 中传递给扩展模块中的 C 函数时,调用机制会保证在调用期间持有对所有参数的引用 —— 所以扩展中的 C 函数内不必 Py_INCREF 和 Py_DECREF。
Load and Import
PyObject *PyImport_ImportModule(const char *name)
PyObject *PyImport_Import(PyObject *name)
PyObject *PyImport_AddModule(const char *name)
实例化类和方法
创建 class
参考文档
- pkg-config
- Python C-API
go ~ C
下面聊聊图中的 2:神奇的 cgo ……
Hello World
新建一个 go demo 项目
$ mkdir hello-world
$ cd hello-world
$ go mod init github.com/wkevin/demo
$ vi main.go
在 main.go 文件中通过 cgo 调用 C 标准库函数获取浮点数的最大值:
// #include <float.h>
import "C"
import "fmt"
func main() {
fmt.Println("Max float value of float is", C.FLT_MAX)
}
import "C"
是启动 cgo 的标志,cgo 还会自动解析紧挨这的上面的注释行,这些内容称为序文,cgo 自动解析这些注释行,减低用户的心智负担。
可以通过 go run
直接运行:
$ go run main.go
Max float value of float is 3.4028234663852886e+38
也可见使用 go build
编译后得到目标文件:
$ go build
$ ./demo
Max float value of float is 3.4028234663852886e+38
cgo 编译过程
完整的编译过程在这里有详细的描述,我来画点重点。
如果 build 时添加 -x
参数,还能看到 cgo 完整的编译过程:
$ go build -x
WORK=/tmp/go-build4264576471
cd /data/kevin/workspace/klearn/blog
git status --porcelain
cd /data/kevin/workspace/klearn/blog
git -c log.showsignature=false show -s --format=%H:%ct
......
虽然代码很短,但打印很长,完整显示了 go build 的过程,包括:
- 创建一个临时的工作目录,用于 cgo 转换文件
WORK=/tmp/go-build4264576471
临时路径
- gcc 编译用户源码,放在一个临时路径
packagefile github.com/wkevin/demo=/home/me/.cache/go-build/d9/d98fd26140760ead7452e4142299b280f7786db18cea8b9f078f488491323ebc-d
-- 我的 demo,放在了~/.cache/...
下面
- 创建一个 importcfg.link 文件记录所依赖的 go 包的路径
packagefile xxx=yyy
- link 链接
mkdir -p $WORK/b001/exe/
/data/software/go/go.root/go1.18.2/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out ...
- 目标拷贝到用户目录
cp $WORK/b001/exe/a.out demo
- 删除临时路径
rm -r $WORK/b001/
cgo 知识要点
cgo 有 2 块内容:
- cmd/go
go tool cgo [cgo options] [-- compiler options] gofiles...
- runtime/go: 用于在 Go 和 C 之间安全地传递 Go 值
命令行
$ go tool cgo --help
序文
cgo 可以识别源码中 import "C"
紧挨着的上面的注释行,并分析出所需的参数,cgo 给这些注释行起了个名字:序文 —— 这点总是让从其他语言转移而来的新同学大开眼界。
前面 hello world 中的
// #include <float.h>
import "C"
也可以写成
/*
#include <float.h>
*/
import "C"
import "C"
与注释之间不能有空行,注释可以多行:
/*
#include <stdio.h>
#include <float.h>
*/
import "C"
序文中还支持 #cgo
开头的指令(directive):
CGO_CFLAGS
,CGO_CPPFLAGS
,CGO_CXXFLAGS
,CGO_FFLAGS
,CGO_LDFLAGS
CFLAGS
,CPPFLAGS
,CXXFLAGS
,FFLAGS
,CPPFFLAGS
,LDFLAGS
- 交叉编译时还支持
CXX*FOR_TARGET
,CXX_FOR*${GOOS}_${GOARCH}
举例
// #cgo CFLAGS: -DPNG_DEBUG=1
// #cgo amd64 386 CFLAGS: -DX86=1
// #cgo LDFLAGS: -lpng
// #include <png.h>
import "C"
数据类型转换
cgo 是 go 官方标准库中的一个模块,go doc 中
C 语言类型 | CGO 类型 | Go 语言类型 |
---|---|---|
char | C.char | byte |
singed char | C.schar | int8 |
unsigned char | C.uchar | uint8 |
short | C.short | int16 |
unsigned short | C.ushort | uint16 |
int | C.int | int32 |
unsigned int | C.uint | uint32 |
long | C.long | int32 |
unsigned long | C.ulong | uint32 |
long long int | C.longlong | int64 |
unsigned long long int | C.ulonglong | uint64 |
float | C.float | float32 |
double | C.double | float64 |
size_t | C.size_t | uint |
参考文档
- 官方文档:cmd/cgo、runtime/cgo
- 《Go 语言高级编程(Advanced Go Programming)》-- 第 2 章 CGO 编程
- 内容完整,篇幅较长,并且中文,书籍的两位作者都是 Go 语言最早期国内推广者。
- 2021.02 - Go 与 C 的桥梁:cgo 入门,剖析与实践 @ 腾讯 IEG 后台开发工程师
- 完整描述了 cgo 所需的各方面知识:cgo 的 N 种语法、数据类型转换、内部机制
- 后来发现本文大部分内容有抄袭上面书籍的嫌疑。
- 2020.6 - CGO 和 CGO 性能之谜
- 先报了 cgo 的黑料(不能交叉编译、性能……),然后用个小例子说明了 cgo 的原理,然后弄了个大例子说明性能损耗在真实情况下可以忽略不计,最后推荐了一些使用 cgo 的开源项目。—— 很棒的一篇文章!
- 13.10 cgo
- 《Go 语言原本》- 2018中的一个章节,本书是 Go 源码解读系列,作者是 B 站 Go 夜读的发起人和网友们。
- 但此章节非常简短,只是贬低了一下 cgo,并且由于撰写时已年代久远,应该已经不足为据。
- 2011.3 - C? Go? Cgo!
- go 官方网站上的一篇博客文章,2011 年,OMG,11 年前的文章,但百度上的很多水文都 copy 了这篇,一直 copy 到 2022 年。
go ~ C ~ python
下面聊聊图中的 3:贯穿、打通 go(cgo)--python(C-API)。
Hello World
同前一个 hello world 一样,新建一个 go demo 项目
$ mkdir hello-world
$ cd hello-world
$ go mod init github.com/wkevin/demo
$ vi main.go
在 main.go 文件中通过 cgo 调用 python C-API:
package main
/*
#cgo pkg-config: python-3.8-embed
#include <Python.h>
*/
import "C"
import (
"fmt"
)
func main() {
C.Py_Initialize()
defer C.Py_Finalize()
fmt.Println(C.GoString(C.Py_GetVersion()))
}
运行或编译:
$ go run main.go
3.8.10 (default, Mar 15 2022, 12:22:08)
[GCC 9.4.0]
但您可能不会那么顺利,如果 python 的版本没有指定好,会遇到一些令人沮丧的提示,比如:
情况 1: 跑不起来
在我本机上虽然已经安装好了 python3.10,并且已经成功找到了其 pc 文件
$ pkg-config --list-all | grep python-3.10
python-3.10-embed Python - Embed Python into an application
python-3.10 Python - Build a C extension for Python
但当我修改为
#cgo pkg-config: python-3.10-embed
时报错:
$ go run main.go
/tmp/go-build3500527914/b001/exe/main: error while loading shared libraries: libpython3.10.so.1.0: cannot open shared object file: No such file or directory
诊断:
首先查看编译出的 demo 是否依赖对了:
$ objdump -x demo |grep NEEDED
DED
NEEDED libpython3.10.so.1.0
NEEDED libpthread.so.0
NEEDED libc.so.6
或
$ readelf -d demo |grep "Shared"
0x0000000000000001 (NEEDED) Shared library: [libpython3.10.so.1.0]
0x0000000000000001 (NEEDED) Shared library: [libpthread.so.0]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
或
$ ldd demo
linux-vdso.so.1 (0x00007fff497b1000)
libpython3.10.so.1.0 => not found
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f8d5394d000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8d5375b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8d539a9000)
3 种方法都表明依赖对了,就是找不到 libpython3.10.so.1.0。
那么其实原理是这样的:
Linux 中 编译运行大体分 3 步:
- compile:编译
- link:链接
- load:运行
我们使用 gcc -I... -L... -l... main.c
只执行上 1、2 两步,其中 link 阶段涉及搜索库的问题,但第 3 步 load 也存在到哪里搜索依赖库文件的问题。
- 首先要明确,gcc 编译过程中,gcc 只是前端,后端还有很多工具支持,如:linker、ar...
- linker 链接时使用
LIBRARY_PATH
环境变量和送入的-Lxxx
参数 - loader 运行时使用
LD_LIBRARY_PATH
环境变量和/etc/ld.so.conf
运行二进制文件时,ld 更具默认查找 lib 的路径是从 /etc/ld.so.conf
文件 和环境变量 LD_LIBRARY_PATH
获取的。
ldconfig -p
可以查看当前 ld 命令能够看到的动态库,比如:
$ ldconfig -p |grep libpython
libpython3.8.so.1.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libpython3.8.so.1.0
libpython3.8.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libpython3.8.so
libpython2.7.so.1.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libpython2.7.so.1.0
libpython2.7.so (libc6,x86-64) => /lib/x86_64-linux-gnu/libpython2.7.so
平常使用 gcc 编译、ld 链接的时候,添加临时目录可以使用 LD_LIBRARY_PATH
,添加常用目录可以修改 /etc/ld.so.conf
下的文件。
$ cat /etc/ld.so.conf
include /etc/ld.so.conf.d/*.conf
$ ls /etc/ld.so.conf.d/*.conf | xargs cat
/usr/lib/x86_64-linux-gnu/libfakeroot
# Multiarch support
/usr/local/lib/i386-linux-gnu
/lib/i386-linux-gnu
/usr/lib/i386-linux-gnu
/usr/local/lib/i686-linux-gnu
/lib/i686-linux-gnu
/usr/lib/i686-linux-gnu
# libc default configuration
/usr/local/lib
# Multiarch support
/usr/local/lib/x86_64-linux-gnu
/lib/x86_64-linux-gnu
/usr/lib/x86_64-linux-gnu
也可以继续查看一下具体哪个路径下有多少文件:
$ ls /etc/ld.so.conf.d/*.conf | xargs cat | sed '/^#/d' | xargs -I{} sh -c 'du -sh {} 2>/dev/null'
116K /usr/lib/x86_64-linux-gnu/libfakeroot
368M /lib/i386-linux-gnu
368M /usr/lib/i386-linux-gnu
548K /usr/local/lib
3.6G /lib/x86_64-linux-gnu
3.6G /usr/lib/x86_64-linux-gnu
由于我 ubuntu 根目录下的 lib*
都是软连接:
$ ll /lib*
lrwxrwxrwx 1 root root 7 4月 2 2020 /lib -> usr/lib
lrwxrwxrwx 1 root root 9 4月 2 2020 /lib32 -> usr/lib32
lrwxrwxrwx 1 root root 9 4月 2 2020 /lib64 -> usr/lib64
lrwxrwxrwx 1 root root 10 4月 2 2020 /libx32 -> usr/libx32
所以可见,ubuntu 主要将 lib 放在 /usr/lib/i386-linux-gnu
和 /usr/lib/x86_64-linux-gnu
中。
所以解决方案也就得到了:
- 临时方案
$ echo $LD_LIBRARY_PATH
/usr/local/lib:
$ LD_LIBRARY_PATH=/home/me/.pyenv/versions/3.10.5/lib:$LD_LIBRARY_PATH
$ ./demo
3.10.5 (main, Jun 13 2022, 18:16:59) [GCC 9.4.0]
- 长期方案
$ sudo ln -s ~/.pyenv/versions/3.10.5/lib/libpython3.10.so.1.0 /usr/local/lib/
其实 gcc 对此也是有更佳解决方案的:gcc -Wl,-rpath-link,<dir>
可以向 loader 传递寻找依赖库的路径,但一是 cgo 我暂时没找到如何传 -Wl
参数的方式,二来这也不是个好方案,一旦用户挪走了依赖库,则造成更难定位的故障。
我尝试了
#cgo LDFLAGS: -Wl,-rpath-link,/home/me/.pyenv/versions/3.10.5/lib
但没有成功。
情况 2: 更新不了
我将 python3-embed.pc 做成了指向 python-3.10-pc 的软连接
$ sudo rm python3-embed.pc
$ sudo ln -s python-3.10-embed.pc python3-embed.pc
$ pkg-config --libs python3-embed
-L/home/me/.pyenv/versions/3.10.5/lib -lpython3.10
但执行这样的 main.go
#cgo pkg-config: python3-embed
时仍然找到的是 python3.8
$ go run main.go
3.8.10 (default, Mar 15 2022, 12:22:08)
[GCC 9.4.0]
有点诡异,不科学,甚至删掉 //usr/lib/x86_64-linux-gnu/pkgconfig/python3-embed.pc
都仍然能基于 python3.8 运行 —— 盲猜可能是被缓存了,一时不能使用最新的。
过了一会再试,果然就好了。
后来搜到一篇文章,提到 go clean -cache
—— 有用!再遇到就不用等了。
cgo 不识别宏定义
<Python.h>
中的宏定义在 cgo 中看不到
比如在 python C-API 的源码中可以找到 PyNumber_Check
和 PyType_FastSubclass
的定义:
PyAPI_FUNC(int) PyNumber_Check(PyObject *o);
...
#define PyDict_Check(op) PyType_FastSubclass(Py_TYPE(op), Py_TPFLAGS_DICT_SUBCLASS)
- PyNumber_Check: cgo 能够使用
- PyDict_Check: cgo 不能够使用
可以做个封装给 cgo
/*
#cgo pkg-config: python3-embed
#include <Python.h>
int Cgo_PyDict_Check(PyObject *o){
return PyDict_Check(o);
}
*/
import "C"
func PyDict_Check(o *PyObject) bool {
return C.Cgo_PyDict_Check(toc(o)) != 0
}
Python C-API 的 go 封装
上面的 PyDict_Check
就是对 C.Cgo_PyDict_Check
的封装就是示例,直接使用 C.xxx
的最大问题是 VSCode 无法做智能提示,如果封装成 go,能够 .
出智能提示,可以减少一些工作量,同时,go 能够做强类型检查,减少编辑错误的产生。
更多一些:
type PyObject C.PyObject # go 类型直接对标 c 类型
func togo(cobject *C.PyObject) *PyObject {
return (*PyObject)(cobject)
}
func toc(object *PyObject) *C.PyObject {
return (*C.PyObject)(object)
}
func PyCallable_Check(o *PyObject) bool {
return C.PyCallable_Check(toc(o)) == 1
}
func PyNumber_Check(o *PyObject) bool {
return C.PyNumber_Check(toc(o)) != 0
}
func PyLong_Check(o *PyObject) bool {
return C.PyLong_AsDouble(toc(o)) != 0
}
func PyDict_Check(o *PyObject) bool {
return C.Cgo_PyDict_Check(toc(o)) != 0
}
这样我的 go 代码中可以直接使用了各种 Check 函数了。
PyObject* goobj = ...
if PyDict_Check(goobj) {
// do something...
// goobj.XXX -- go 的智能提示可以使用
}
否则需要
C.PyObject* cobj = ...
if C.Cgo_PyDict_Check(cobj) != 0 {
// do something...
// cobj 没有智能提示
}
这里推荐 2 个开源项目:
- DataDog/go-python3: 针对 python3 的 C-API 封装
- sbinet/go-python: 针对 python2 的 C-API 封装
很可惜的是,2 个项目都已经停止开发了,不过大家仍然可以参考其源码写出自己的封装,毕竟我们一般需要封装的函数只是 C-API 全集中很少的一部分,常用的 30 个封装一下就够用了,人家维护这个项目要封装全部确实工作量很大,并且价值不高。同时该项目的目项目 DataDog,也是个不错的项目, 数据收集、分析、挖掘的网络后端,下载其源码,go 写的,内部也使用了很多 python 封装,值得参考、学习。
CPython 的 GIL
CPython 与其他语言(Java、C#等)实现的 python 解释器一个很大的不同点:GIL —— 它是 python 的 global interpreter lock,全局解释器锁,CPython 解释器所采用的一种机制,它确保同一时刻每个解释器进程中只有一个线程在执行 Python bytecode。单线程程序可以无视 GIL,多线程则必须先申请,拿到锁后再执行,执行完毕就释放。 —— 与二进制信号量原理一致。
PyGILState_Ensure()
: 保存状态并锁定 GIL。PyGILState_Release()
: 恢复状态并解锁 GIL。
由于 GIL 的存在,使得 CPython 本质上进程中只能单线程(thread),有不少网文都在试图证明多线程是个假象,如果要真正的并发,只能多进程(process)。那我有 3 个建议:
- 换其他 Python 解释器吧 : )
- 其实从嵌入式程序员角度看,这就类似于多线程(或多进程、多任务)跑在单核 CPU/MCU 上,仔细打磨照样能提升效率。比如在 GPU 计算任务、I/O 等操作时及时释放 GIL,让其他线程及时获取 GIL,整个程序的性能也能提高。——作用还是有的,仔细斟酌就是。并且线程比进程成本小,多搞几个也没啥大的副作用,不容易把程序搞死。再加上线程池的应用,就更能减少工作量了。
- 如果没有信心这样细化操作,也可以使用 multiprocessing 模块的多进程,能够把 CPU 多核用起来了。python 的多进程是指多个解释器进程(与我们平常理解的 C 运行态的多进程还不一样),每启动一个 进程本质上是创建一个解释器进程,python 代码都是喂给解释器的食物,解释器一行一行执行,就像一口一口吃,这也是 python 代码叫脚本的缘故。每个解释器进程中都维护一个 GIL,多进程可以跑在多 CPU 多核上,进程间多出来要考虑的就是进程间通信了。
Python 的进线协
标准库中的下面几个模块有必要仔细阅读一下:
进程(Process)
- subprocess --- 子进程管理(启进程执行 shell 命令)
run()
调用子进程,如:subprocess.run(["ls", "-l"])
, 替代以前的os.system()
、os.popen()
、os.spawn*
CompletedProcess
:run()
的返回值Popen
: ProcessOpen,run()
的底层实现
- multiprocessing --- 基于进程的并行
Process
: 进程类,start()
启动、join()
阻塞调用者,即同步Pool
:进程池- 进程间通信:
Connection=Pipe()
、Queue
线程(Threading)
- threading --- 基于线程的并行
Thread
: 线程类,start()
启动、join()
阻塞调用者,即同步- GIL 的 72 变:、
Lock
-原始锁、RLock
-递归锁/重入锁、Semaphore
-信号量、Event
-事件、Timer
-定时器、Barrier
-栅栏
进程 + 线程
- concurrent 包
- concurrent.futures --- 启动并行任务
- ThreadPoolExecutor/ProcessPoolExecutor: 创建线程池/进程池
.submit(fn, *args, **kwargs)
: 将 fn 函数提交给线程池, 返回一个 Future 对象.- Future 可以:
cancel()
、running()
、done()
、result()
、exception()
、add_done_callback()
- Future 可以:
map(func, *iterables, timeout=None, chunksize=1)
: 异步方式立即对 iterables 执行 mapshutdown(wait=True)
: 关闭线程池
- ThreadPoolExecutor/ProcessPoolExecutor: 创建线程池/进程池
- concurrent.futures --- 启动并行任务
协程(Coroutines)+ 进程
- asyncio --- 异步 I/O 包含 14 个主题,全面阐述协程的使用
- 高层 API
- 协程
async def foobar()
定义一个协程(异步)函数await foobar()
等待一个可等待对象(协程, 任务 和 Future)asyncio.create_task()
并发运行作为 asyncio 任务 的多个协程asyncio.sleep()
to_thread()
在不同的 OS 线程中异步地运行一个函数
- 进程
asyncio.create_subprocess_shell()
创建子进程asyncio.subprocess.PIPE/STDOUT/DEVNULL
- 网络 IO(略)
- 操作
asyncio.run(foobar())
运行协程、进程
- 协程
- 底层 API(略)
- 高层 API
协程函数不同于普通函数,调用普通函数会得到返回值,而调用异步函数会得到一个协程对象。我们需要将协程对象放到一个事件循环中才能达到与其他协程对象协作的效果,因为事件循环会负责处理子程 序切换的操作。
python 将协程与异步的概念进行了强捆绑,我觉得不妥,虽然协程肯定会异步执行,但异步的并不一定就是协程,新城、进程都可以异步。相比 go func()
就创建一个 go 协程,就不强捆绑,舒服很多。python 背后的原因我猜想会不会也是考虑到整合网络 IO,学习 js、ts 的 async+await,但个人觉得强整不好,有害健康。
go 的进线协
首先,python 和 go 中的进程、线程就是操作系统的进程和线程,go 和 python 以及所有语言都不会在进程(Process)、线程(Thread)上做新的定义,统一遵守操作系统的定义:
- 进程:资源的最小单位
- 线程:调度的最小单位
但协程则没有统一的定义和规范,各家自由发挥,大概都是朝着“托管在线程中,比线程更轻量级,操作系统感知不到”这个方向努力!但是,go 和 python 的协程在创建、调度、API 方面差异还是挺大的——是几乎看不到相同点好吧!。要说 go 和 python 谁先有的协程,我还真不知道,python 好像很晚才引入的协程,但 go 才一开始就有,说不定 go 更早些。gp 还将专业术语 coroutine 演进为 gorutine,企图标榜其 G 字头的地位之特殊。
进程(Process)
- os
Process
func FindProcess(pid int) (*Process, error)
func StartProcess()
func (p *Process) Kill() error
func (p *Process) Release() error
func (p *Process) Signal(sig Signal) error
func (p *Process) Wait() (*ProcessState, error)
- os/exec
Cmd
func Command(name string, arg ...string) *Cmd
: 创建 Cmd(shell 命令)func (cmd *Cmd)Run()/Start()/Wait()/...
: 执行/开始/等待/...
协程(Gorutine)与线程(Threading)
go func()
就创建并启动了 go 协程,极其方便,协程的生成后会被放入队列(有当前线程的本地队列,也有全局队列),然后协程调度器会自动安排,调度器可以将多个协程调度到一个线程中,一个协程也可能切换到多个线程中执行。由于线程之间也是资源共享的,所以协程之间也是资源共享的,没有“线程或协程见通信的概念和需求”,也就没有了切换开销,也不需要多线程的锁机制(没错,说的就是上面的 GIL 同学),所以效率比多线程高很多。—— 有时间的话可以看看Golang 的 协程调度机制 与 GOMAXPROCS 性能调优。
至于线程,go 编程人员几乎用不到,go 认为有协程了还用啥线程,协程比线程好用,线程的 API 就不提供了,你就认为协程就是线程算了。只留了线程锁(类似 python GIL)的 2 个接口:LockOSThread()
,UnlockOSThread()
,以便我们开发线程安全的可重入函数。
go 协程嵌 py 进程
典型代码示例:
func Foobar(){
runtime.LockOSThread() // 别被其他 go 线程抢占了
defer runtime.UnlockOSThread()
if C.Py_IsInitialized() == 0 {
py.Py_Initialize()
}
_gil := C.PyGILState_Ensure() // 别被其他 python 线程抢占了
defer C.PyGILState_Release(_gil)
// do something...
}
试想:
- go 程序(进程)启动后会自动创建线程池,线程池与协程之间由调度器自己安排。python 解释器进程就是 go 进程,从
ps -ef
里只会看到一个进程。 - 某个调用了 C-API 的 go 函数每次可能运行在不同的协程上,也十分可能运行在不同的线程上。
- goroutine 执行 C 后,自身暂停。cgo 是用新线程运行 C 还是没有,我不确定,但从下面代码中看似乎 py 和 go 的 threading id 相同。
- 如果 goroutine 函数被抢占了,安装文章 2021.7 - 如何在 Go 中嵌入 Python 的说法会奔溃,但我觉得 C 和 go 已经合为一体,会延续在一个线程这执行,没有异步的情况发生,即使中间被调度到其他 CPU/核 上执行,应该也不会出错。
- 上面示例代码我注释掉前 2 行,在 go 1.16、1.17、1.18 上测试都没有崩溃,也可能我用的框架 限制了线程数,先搁置一下,以后遇到崩溃再回头来看。
参考文档
- Python Embedding into Go -- go 中嵌 python
- google:
embedding python in go
- Python 官方文档:在其它应用程序嵌入 Python
- 2020.6 - Python and Go : Part I - gRPC
- 这是有 4 篇的系列文章,分别讲述了 1.go-grpc-python、2.python-c-go、3.python 打包、4.go-内存共享-python —— 尤其是 1、4 都非常有创意,我受益匪浅。
- 2021.7 - 如何在 Go 中嵌入 Python
- 翻译自:Cgo and Python -- DataDog 的 Blog,应该是 DataDog 产品的开发者缩写,DataDog 大量使用了 Go 调 Python。
- 特别介绍了前面书和文章中没有的 GIL(全局解释器锁)
- 2020.10 - Embedding Python in Go
- google:
- Python Extending with Go -- python 中嵌 go
- 2017.12 - Extending Python 3 in Go
- 文章名字与内容不符,应该是 Extending Python3 with go,但内容写的还是不错的
- 2017.6 - Embedding Go and groupcache in Python
- 2017.12 - Extending Python 3 in Go
划重点
- python 编译、链接
env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install x.y.z
编译 python 的动态库pkg-config --variable pc_path pkg-config
pc 文件搜索路径pkg-config --list-all | grep python
可用的 python pc 文件pkg-config --cflags --libs python3-embed
&python3.8-config --cflags --ldflags --embed
获取 gcc 编译、链接参数- gcc
pkg-config --cflags --libs python3-embed
main.c -o demo 可能失败 - gcc main.c
pkg-config --cflags --libs python3-embed
-o demo 都会成功
- python C-API
- 高层 API:
PyRun_SimpleString
- 底层 API: 其他都是
- PyObject:都在内存堆上,只能拥有指针,并做好引用计数。
- 需要用户维护引用计数的:
PyObject_GetAttrString()
PyLong_FromLong()
- 不需要
PyImport_AddModule()
PyList_GetItem()
、PyTuple_GetItem()
、PyDict_GetItem()
- 需要用户维护引用计数的:
- 高层 API:
- cgo
import "C"
独立一行,前置序文#cgo
指令go run main.go
orgo build -x
go tool cgo --help
- cgo 提供的
C.CString()
,C.GoString()
go clean -cache
刷新 cgo 指令
- other
ldd demo
、objdump -x demo|grep NEEDED
、readelf -d demo|grep "Shared"
查看依赖的动态库- linker use
LIBRARY_PATH
- loader use
LD_LIBRARY_PATH
and/etc/ld.so.conf
源码下载
最后,附上本文包含,以及不包含的更深入的 demo 源码下载。