如果你看看新的Datadog Agent,你可能会注意到大多数代码库都是用Go编写的,但是我们用来收集指标的检查仍然是用Python编写的。 这可能是因为数据代理是内置了CPython解释器的常用Go二进制文件,可以随时根据需要执行Python代码。 这个过程通过抽象层变得透明,可以编写熟悉的Go代码,底层是Python。

有很多理由在go APP中嵌入Python :

在迁移过程中很有用。可以将部分现有Python项目逐步迁移到新语言,并且在此过程中不会失去功能。 可以重用现有的Python软件和库,而无需用新语言重新实现。 即使在运行时,也可以通过加载和运行常规Python脚本来动态扩展软件。 虽然有很多原因,但对于数据代理来说,最后一点非常重要。 您希望不重新编译代理,或者无论编译什么,都可以运行自定义检查或修改现有检查。

嵌入CPython非常简单,文档齐全。 解释器本身是用c编写的,提供了以编程方式执行基本操作(如创建对象、导入模块和调用函数)的C API。

本文提供了几个代码示例。 在与Python交互的同时继续保留Go代码的惯用语。 但是,在继续之前,必须解决嵌入API的是c语言,但主要的APP应用是Go这一差距。 这是如何运作的呢?

介绍cgo有很多很好的理由说服我们为什么不在堆栈中引入cgo,但嵌入CPython是我们必须这样做的理由。 cgo既不是语言,也不是编译器。 这是一个外部函数接口foreignfunctioninterface(FFI ),用于在Go中调用以不同语言(特别是c )编写的函数或服务。

“cGo”实际上是指Go工具链在基础中使用的一组工具、库、函数和类型。 因此,可以通过执行go build来获取go二进制文件。 以下是使用cgo的示例程序。

package main//#include如果包含这样的头文件,则import ‘C ‘指令上方的注释块称为“前言preamble”,可以包含实际的c代码。 要在导入后访问常量FLT_MAX,请使用” c “虚拟包“跳转”到外部代码。 可以通过调用Go build来构建。 那就像普通的go一样。

如果您想看到cgo在这背后做什么,请运行go build -x。 将调用” cGo “工具生成几个c和Go模块,然后调用c和Go编译器构建目标模块,最后链接器总结所有内容。

现在我知道了cgo能为我们做些什么。 让我们看看如何使用这个机制执行Python代码。

嵌入CPython :从技术上讲,嵌入CPython的Go程序并不像你想象的那么复杂。 实际上,您只需在执行Python代码之前初始化解释器,然后在完成后将其关闭即可。 请注意,所有示例都使用Python 2.x,但只需少量调整即可应用于Python 3.x。 请看以下示例:

package main//# CGO pkg-config:python-2.7//# includeltython.him port ‘ c ‘ import ‘ fmt ‘ func main { c.py _ initit }

可以看到在importsysprint(sys.version )序言中添加了#cgo指令; 这些命令将传递到工具链,以改变生成任务的流程。 在这种情况下,告诉cgo调用pkg-config以收集构建和链接名为python-2.7的库所需的标志,并将这些标志传递给c编译器。 如果系统中安装了CPython开发库和pkg-config,则只需运行go build来编译以上示例。

返回到代码,使用Py_Initialize和Py_Finalize初始化和关闭解释器,然后使用Py_GetVersionC函数获取嵌入式解释器的版本信息字符串。

如果你想知道的话,我们需要一起调用C语言Python API的所有cgo代码都是模板代码。 所以,Datadog Agent依赖Go-python来完成所有的嵌入操作,这个库为C API提供了go友好的轻量级软件包,隐藏了cgo的细节。 这是另一个基本的内置示例,这次使用go-python :

packagemainimport ( python ‘ github.com/sbin et/go-python ‘ ) func main { python.initialize python.py run _ simplestrest 您可以在访问Python API时随意使用go字符串。 在充分利用解释器的时候,嵌入式功能显得强大且对开发人员友好。 尝试从磁盘加载Python模块。

Python不需要复杂的东西。 可以通过无处不在的“hello world”达到目的:

# foo.pydefhello:’ ‘ printhelloworldforfunandprofit.’ ‘ print ‘ hello,world! ‘ Go代码有点复杂,但还能读:

//main.gopackagemainimport ‘ github.com/sbin et/go-python ‘ func main { python.initializedeferpython.finalizefon iffoomodule==nil ( panic ( errorimportingmodule ) ) hello func:=foo module.getattrstring ( hello ) )。 ifhellofunc==nil { panic ( errorimportingfunction ) }//thepythonfunctiontakesnoparamsbutwhenusingthecapi//we’rerequiredtosend(empty ) argsand ) ( kwargsanyways.hellofunc.call ) python.pytuple_new )0) python.pydiding

$ gobuildmain.gopythonpath=./main hello,world!

的可怕的全局解释器锁必须引入cgo来嵌入Python,这是一种权衡。 构建速度变慢,垃圾回收器无法帮助管理外部系统使用的内存。 交叉编译也很难。 对于特定的项目来说,这些问题是能否争论,但我认为有Go并发模式这个不能商量的问题。 如果不能从Goroutine运行Python,则使用go没有意义。

在处理并发、Python和cgo之前,您还需要了解全局解释器如何锁定全局解释器锁定(即GIL )。 GIL是语言解释器( CPython是其中之一)中广泛采用的机制,可以防止多个线程同时执行。 这意味着在CPython上运行的Python程序不能在同一进程中同时运行。 虽然并发仍然是可能的,并且锁定是速度、安全性和简易性之间的良好折衷,但是如果涉及到内置,为什么它会成为问题呢?

在启动非普通嵌入式Python程序时,不参与GIL以避免锁定操作不必要的开销; 当某些Python代码第一次请求生成线程时,GIL启动。 对于每个线程,解释器将创建一个存储当前相关状态信息并锁定GIL的数据结构。 线程完成后,状态将恢复,GIL将解锁并准备供其他线程使用。

从Go程序运行Python不会自动发生上述情况。 如果没有GIL,我们的Go程序可以创建多个Python线程。 这可能会导致竞争条件,从而导致致命的运行时错误。 它还会导致碎片错误,从而导致整个go APP应用程序崩溃。

解决方案是在从Go运行多线程代码时显式调用GIL; 代码并不复杂。 C API提供了所有必需的工具。 为了更好地暴露这个问题,需要编写受CPU限制的Python代码。 让我们将这些函数添加到上一个示例的foo.py模块中。

# foo.pyimportsysdefprint _ odds ( limit=10 ( ‘ ‘ printoddsnumbers我们试着从Go同时打印奇数和偶数。 两个不同的goroutine )。 因此,与线程有关。

packagemainimport ( sync ( github.com/sbin et/go-python ) ) func main/thefollowingwillalsocreatethegilexplicition by withoutwaiting//。 fortheinterpretertodothatpython.initializevarwgsync.waitgroupwg.add (2) foo module:=python.pyiming odds:=foo mom dule.getattrstring ) print_even )/initializehaslockedthegilbutatthispointwedon ‘ tne edit/any more.wesavethecurrented sothatgoroutinescanacquireitstate:=python.py eval _ savethreadgofunc { _ g state:=python.pygilstate _ ensure odds . w ( python.pygilstate _ release ( g state ) WG.done ) gofunc ) _gstate:=python.pygilstate ) ) python.pygilstate python.pydict _ new ( python.pygilstate _ release ) gstate ) WG.done } WG.wait//atthispointweknowwon ‘ t ned wech thefinaloperationsbeforeexiting.python.py eval _ restore thread ( )

保存状态并锁定GIL。 运行Python。 恢复状态,解除GIL的锁定。 代码应该很简单,但我想指出微妙的细节。 虽然借用了GIL的执行,但有时也会调用PyEval_SaveThread和PyEval_RestoreThread来操作GIL (在goroutines中查看)

当从Python操作多线程时,解释器负责创建保存当前状态所需的数据结构,但是当同样的事情发生在C API上时,我们负责处理。

用go-Python初始化解释器时,我们正在python的上下文中操作。 因此,调用PyEval_InitThreads将初始化数据结构并锁定GIL。 您可以使用PyEval_SaveThread和PyEval_RestoreThread操作已存在的状态。

在Goroutines中,您将从go上下文进行操作。 必须显式创建状态,并在完成后将其删除。 这就是PyGILState_Ensure和PyGILState_Release为我们所做的。

在释放Gopher方面,我知道如何处理在嵌入式解释器中运行Python的多线程Go代码,但GIL之后还有另一个挑战。 这是地理调度程序。

goroutine启动后,它将被调度为在其中一个可用的GOMAXPROCS线程上运行。 有关此主题的详细信息,请参阅此处。 如果一个goroutine偶然执行系统调用或调用c代码,则当前线程会将线程队列中等待执行的其他goroutine传递给另一个线程,以便有更好的机会执行。 目前,goroutine已暂停,等待系统调用或c函数返回。 如果出现这种情况,线程将尝试恢复暂停的Goroutine,但如果不能,则必须在go运行时找到另一个线程以完成Goroutine并进入休眠状态。 goroutine最后被放置在另一个线程上并完成了。

考虑到这一点,我们来看看当goroutine被移动到新线程时,执行一些Python代码的goroutine会发生什么:

我们的goroutine启动,执行C调用,暂停。 GIL已被锁定。 当c调用返回时,当前线程试图恢复goroutine,但失败了。 当前线程正在告诉go运行时寻找另一个线程以恢复Goroutine。 Go调度程序查找可用线程并恢复goroutine。 goroutine马上就要完成了。 在返回之前尝试解锁GIL。 存储在当前状态中的线程ID来自原始线程,与当前线程的ID不同。 崩溃! 幸运的是,通过从goroutine调用运行时包中的LockOSThread函数,可以确保Go runtime始终在同一个线程上运行。

of unc { runtime.lockosthread _ g state:=pythoon.pygilstate _ ensure odds.call ( python.pytuple _ new (0),python

结论为了嵌入Python,数据代理必须接受几个权衡:

部署cgo的开销。 手动处理GIL任务。 执行中将goroutine绑定到同一线程的限制。 为了便于在Go上执行Python检查,我们很乐意接受这些项目。 但是,通过意识到这些权衡,可以将影响降到最低。 除了为支持Python而引入的其他限制外,没有抑制潜在问题的措施。

由于内部版本是自动化和可配置的,因此开发人员仍然需要与go build非常相似。 轻量级代理版本可以使用Go内部版本标签完全剥离Python支持。 此类版本仅依赖于代理本身的硬编码核心检查(主要是系统和网络检查),但没有cgo,也可以进行交叉编译。 我们将来会重新评估我们的选择,决定是否值得保留cgo; 也可以重新考虑整个Python是否还有价值,等待Go插件包成熟到足以支持用例。 但现在,嵌入式Python运行正常,从旧代理迁移到新代理并不容易。

你是喜欢混合不同编程语言的多语者吗? 你喜欢理解语言的内部机制吗? 是提高代码的性能吗?

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

本文由LCTT原创编译,Linux中国荣誉上市