『GCTT 出品』Cgo和Python

如果你研究过 新近的 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 ,这又是怎么能完成的呢?

Cgo 简介

可能会有 很多原因 让你不希望引入 Cgo 到你的工作栈中,但内嵌的 CPython 却是一个能让你同意的关键砝码。Cgo 既不是语言也不是编译器。它是 外部函数接口(FFI),一种能让我们在 Go 中调用其他语言编写的函数和服务,尤其是 C 。

当我们提到 “Cgo” 时,我们实际指的是底层 Go 工具链使用的一系列工具,库,函数,以及类型,所以我们依然可以用 go build 获取 Go 的二进制程序。一个使用 Cgo 的极简代码示例如下所示:

在 import "C" 上方的注释块可以预先调用,并且能包含实际的 C 代码,在这个例子里包含了一个头文件。一旦被导入,“C” 伪库就会让程序跳转到外部代码,访问 FLT_MAX 宏。你可以通过调用 go build 来 build 该示例,就跟普通的 Go 代码一样。

如果你想要了解一下底层 Cgo 所做的全部工作,就运行 go build -x 。你将会看到 “Cgo” 工具被调用去生成一些 C 和 Go 的模块,然后 C 和 Go 编译器会被调用以建立目标模块,最终链接器会把一切都安排好。

你可以在 Go 博客 中阅读更多 Cgo 的内容。这篇文章包含更多示例,以及一些深入细节的有用链接。

既然我们已经知道了 Cgo 可以帮助我们做哪些工作,接下来就来看看我们如何借助该机制来运行 Python 代码。

内嵌 CPython : 入门

一个 Go 程序,严格来说,内嵌 CPython 并不像你预计的那样复杂。事实上,在最低限度上,我们要做的所有工作就是在运行 Python 代码之前初始化解释器,以及在运行结束后回收资源。请注意,我们将会在所有的示例中都使用 Python 2.x ,但是这些都能够适用于 Python 3.x,仅仅需要很少的修改。让我们先看一个例子:

上面的例子和下面的 Python 代码等价:

你可以看到我们放了一个 #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 来执行所有内嵌操作的原因所在; go-python 库提供了 Go 风格的对 C API 的简单封装,并且隐藏了 Cgo 的细节。这是另一个简单的嵌入示例,这次使用 go-python:

这个例子更接近标准 Go 代码,没有暴漏出更多的 Cgo , 而且我们可以在访问 Python API 的时候来回使用 Go 字符串。内嵌看起来功能强大并且对开发者友好。是时候好好使用解释器了:让我们尝试加载一个磁盘上的 Python 模块。

我们没必要使用复杂的 Python 模块,一个最简单的 “hello world” 就可以满足需求:

Go 代码稍微复杂点,但依然容易阅读:

完成之后,我们需要设置 PYTHONPATH 环境变量到当前工作目录,由此 import 语句就能够找到

foo.py 模块。在 shell 中,命令类似下面:

糟糕的全局解释器锁( 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 中:

我们会在 Go 中尝试并发的打印奇数和事件编号,使用两个不同的 goroutine (由此引入线程):

在阅读示例的时候你可能注意到了一个模式,这个模式将会是我们运行内嵌 Python 的准则

保存状态并锁住 GIL。

执行 Python 。

恢复状态,解锁 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() 负责完成这些工作。

解放 Go 爱好者

现在我们已经知道怎么处理多线程 Go 代码在一个内嵌解释器中执行 Python 的过程了,但是在 GIL 之后,我们又面临着一个新的挑战:Go 调度器。

当一个 goroutine 启动时,它会被调度运行在 GOMAXPROCS 个可用线程中的其中一个线程——点击这里 可以了解更多细节。当一个 goroutine 执行系统调用或者调用 C 代码时,当前线程会把等待运行线程队列中的其它 goroutine 移交给另一个线程,从而让这些 goroutine 有更多机会执行;当前 goroutine 被挂起,直到系统调用或是 C 函数返回。如果有返回发生,线程就会试图唤醒被终止的 goroutine ,但如果没有返回的可能性,那该线程就会请求 Go 运行时去查找另一个线程来完成该 goroutine ,并且进入睡眠状态。 goroutine 最终被调度给另一个线程,然后结束。

考虑到这些,让我们来看看当一个正在运行 Python 代码的 goroutine 被移动到一个新的线程时, goroutine 都会发生什么:

我们的 goroutine 启动后,执行一个 C 函数调用,然后挂起。GIL 被锁住。

当 C 函数调用返回,当前线程试图唤醒该 goroutine,但它失败了。

当前线程告诉 Go 运行时去查找另一个线程来唤醒我们的 goroutine。

Go 调度器找到一个可用的线程,并且 goroutine 也被唤醒。

goroutine 基本完成,并且尝试在返回前解锁 GIL。

当前状态存储的线程 ID 是初始线程的ID,和当前线程的 ID 不一致。

Panic !

幸运的是,我们可用强制要求 Go 运行时保证我们的 goroutine 一直运行在同一个线程上,只要通过 goroutine 调用 runtime 包里的 LockOSThread 函数就行。

这将会干扰调度器并可能带来一些间接损耗,但为了避免随机的 panic 我们愿意付出这种代价。

结论

顾及到内嵌 Python , Datadog Agent 做出了以下取舍:

cgo 引入的间接损耗。

手动操作 GIL。

运行期间绑定 goroutine 到同一个线程的限制。

考虑到在 Go 中运行 Python 检查的便利,我们很乐意接受这一切。但既然意识到了这些取舍,我们就能够最小化它们带来的影响。对于为支持 Python 而带来的其它限制,我们很难有对策处理可能的问题:

build 依然是自动化的并且可配置的,因此开发者照样会类似于 go build 。

一个轻量级版本的 agent 可以被创建,而且剥离 Python 支持后也完全可以支持简单使用 Go build 标记。

这样的版本只依赖于 agent 本身的硬编码核心检查(绝大部分是系统和网络检查),但不受 cgo 限制而且可以被交叉编译。

我们会重新评估将来的选择,并判断是否值得继续使用 cgo ;我们甚至可能会考虑把 Python 作为一个整体是否值得,等到 Go 插件包 足够成熟,可以支持我们的用例。但至少现在内嵌 Python 工作的很好,而且从旧 Agent 迁移到新的上面也很简单。

你是一个掌握并且喜欢混合不同语言编程的爱好者吗?你热爱学习语言的内部工作机制来保证你的代码更加健壮吗? 请加入 Datadog !

via: https://www.datadoghq.com/blog/engineering/cgo-and-python/

作者:Massimiliano Pippi

译者:sunzhaohao

校对:rxcai

本文由 GCTT 原创编译,Go语言中文网 荣誉推出

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20181218B0H7FJ00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券