在多线程 C/C++ 应用程序中嵌入 Python
开发者经常使用高级脚本语言作为一种快速编写灵活代码的方式。长期以来,各种 shell 脚本语言一直被用于自动化 UNIX 系统上的进程。最近,软件应用程序开始提供脚本层,允许用户自动化常用任务,甚至扩展功能集。想想你使用的所有知名应用程序:GIMP、Emacs、Word、Photoshop 等等。似乎所有这些都可以通过某种方式进行脚本化。
在本文中,我将描述如何在 C 应用程序中嵌入 Python 语言。您可能有很多理由想要这样做。例如,您可能希望为更高级的用户提供更改或自定义程序的能力。或者,您可能想要利用 Python 的某种功能,而不是自己实现它。Python 是此任务的理想选择,因为它提供了一个清晰、直观的 C API。由于许多复杂的应用程序都是使用线程编写的,我还将向您展示如何为 Python 解释器创建线程安全的接口。
所有示例都假设您使用的是 Python 1.5.2 版本,该版本预装在大多数最新的 Linux 发行版中。访问 Python 解释器的 API 对于 C 和 C++ 都是相同的。没有使用特殊的 C++ 构造,所有函数都声明为 extern “C”。因此,这里描述的概念和给出的示例代码在使用 C 或 C++ 时应该同样有效。
在同一个进程中,C 代码和 Python 代码可以通过两种方式协同工作。简而言之,Python 代码可以调用 C 代码,C 代码也可以调用 Python 代码。这两种方法分别称为“扩展”和“嵌入”。当进行扩展时,您将创建一个用 C/C++ 实现的新 Python 模块。这允许您为 Python 语言提供无法在 Python 中实现的新功能。例如,一些核心 Python 模块(如“time”和“nis”)是以 C 扩展的形式实现的,而其他模块则用 Python 编写。您永远不会注意到 C 模块和 Python 模块之间的区别,因为导入和使用这些模块的行为是相同的。如果您在 /usr/lib/python1.5 目录中查找,您可能会看到一些共享库文件(扩展名为 .so)。这些是用 C 编写的 Python 模块扩展。您还会看到各种 Python 文件(扩展名为 .py),它们是用 Python 编写的模块。
通常,当您嵌入 Python 时,您将开发一个 C/C++ 应用程序,该应用程序能够加载和执行 Python 脚本。该应用程序将链接到 Python 解释器库(名为 libpython1.5.a),该库提供与评估 Python 代码相关的所有功能。不涉及 Python 可执行文件,只有供您的应用程序使用的 API。
嵌入 Python 是一个相对简单的过程。如果您的目标仅仅是从 C 程序中执行原始 Python 代码,那么实际上非常容易。“清单 1”是嵌入 Python 解释器的程序的完整源代码。这说明了您可以使用 Python 解释器编写的最简单的程序之一。
清单 1 使用了三个特定于 Python 的函数调用。Py_Initialize 启动 Python 解释器库,使其分配所需的任何内部资源。在调用 Python API 中的大多数其他函数之前,您必须调用此函数。PyEval_SimpleString 提供了一种快速、简洁的方式来执行任意 Python 代码。代码的解释是立即进行的。例如,在上面的示例中,import sys 行导致 Python 在将控制权返回给 C/C++ 程序之前导入 sys 模块。传递给 PyEval_SimpleString 的每个字符串都必须是某种完整的 Python 语句。换句话说,半语句是非法的,即使它们是通过另一次调用 PyRun_SimpleString 完成的。例如,以下代码将无法正常工作
// Python will print first error here PyRun_SimpleString("import ");<\n> // Python will print second error here PyRun_SimpleString("sys\n");<\n>
Py_Finalize 是任何嵌入 Python 的应用程序都必须调用的最后一个 Python 函数。此函数关闭解释器并释放其生命周期内分配的任何资源。当您完全完成使用 Python 库时,应调用此函数。当您调用 Py_Finalize 时,Python 将逐个卸载所有导入的模块。许多模块在卸载时必须执行自己的清理代码,以便释放它们可能已分配的任何全局资源。因此,调用 Py_Finalize 可能会产生导致相当多的其他代码运行的副作用。
PyEval_SimpleString 只是从 C 应用程序中执行 Python 代码的一种方式。实际上,存在大量类似的更高级别的函数。PyEval_SimpleFile 与 PyEval_SimpleString 非常相似,只是它从 FILE 指针而不是字符缓冲区读取输入。有关这些高级别函数的完整文档,请参阅 Python 文档 www.python.org/docs/api/veryhigh.html。
除了评估 Python 脚本外,您还可以操作 Python 对象并直接从 C 代码调用 Python 函数。虽然这比使用 PyEval_SimpleString 涉及更复杂的 C 代码,但它也允许访问更详细的信息。例如,您可以访问从 Python 函数返回的对象,或确定是否已抛出异常。
当您在应用程序中嵌入 Python 时,通常需要提供一个小型模块,该模块公开与您的应用程序相关的 API,以便在嵌入式解释器中执行的脚本能够回调到应用程序中。这是通过提供您自己的 Python 模块(用 C 编写)来完成的,这与编写普通的 Python 模块完全相同。唯一的区别是您的模块只能在嵌入式解释器中正常工作。
扩展 Python 需要了解 Python 解释器如何从 C 操作对象。所有函数参数和返回值都是指向 PyObject 结构的指针,PyObject 结构是实际 Python 对象的 C 表示形式。您可以使用各种函数调用来操作 PyObject。“清单 2”是用 C 编写的 Python 模块扩展的简单示例。这是 Python crypt 模块的源代码,该模块提供密码身份验证中使用单向哈希。
Python 可调用函数的所有 C 实现都接受两个 PyObject 类型的参数。第一个参数始终是“self”,即正在调用其方法的对象(类似于 C++ 中臭名昭著的“this”指针)。第二个对象包含函数的所有参数。PyArg_Parse 用于从包含函数参数的 PyObject 中提取值。您可以通过在包含值的 PyObject 中传递一个格式字符串(表示您期望存在的数据类型)以及一个或多个指向要用 PyObject 中的值填充的数据类型的指针来完成此操作。在清单 2 中,该函数接受两个字符串,用 “(ss)” 表示。PyArg_Parse 类似于 C 函数 sscanf,只是它在 PyObject 而不是字符缓冲区上运行。为了从函数返回字符串值,请调用 PyString_FromString。此辅助函数接受 char* 值并将其转换为 PyObject。
C 程序可以轻松创建新的执行线程。在 Linux 下,这通常使用 POSIX 线程 (pthreads) API 和函数调用 pthread_create 完成。有关如何使用 pthreads 的概述,请参阅 Felix Garcia 和 Javier Fernandez 在 https://linuxjournal.cn/lj-issues/issue70/3184.html 的 LJ 2000 年 2 月“严格在线”部分中的“POSIX 线程库”。为了支持多线程,Python 使用互斥锁来序列化对其内部数据结构的访问。我将此互斥锁称为“全局解释器锁”。在给定的线程可以使用 Python C API 之前,它必须持有全局解释器锁。这避免了可能导致解释器状态损坏的竞争条件。
锁定和释放此互斥锁的行为由 Python 函数 PyEval_AcquireLock 和 PyEval_ReleaseLock 抽象化。在调用 PyEval_AcquireLock 之后,您可以安全地假设您的线程持有该锁;所有其他协作线程要么被阻止,要么正在执行与 Python 解释器内部无关的代码,现在您可以调用任意 Python 函数。但是,一旦获得锁,您必须确保稍后通过调用 PyEval_ReleaseLock 来释放它。否则将导致线程死锁并冻结所有其他 Python 线程。
更复杂的是,每个运行 Python 的线程都维护着自己的状态信息。此线程特定数据存储在名为 PyThreadState 的对象中。当从多线程应用程序中的 C 调用 Python API 函数时,您必须维护自己的 PyThreadState 对象,以便安全地执行并发 Python 代码。
如果您在开发线程应用程序方面经验丰富,您可能会发现全局解释器锁的想法相当令人不快。嗯,它并没有最初看起来那么糟糕。当 Python 解释脚本时,它会定期通过交换当前的 PyThreadState 对象并释放全局解释器锁来将控制权让给其他线程。先前在尝试锁定全局解释器锁时被阻止的线程现在将能够运行。在某些时候,原始线程将重新获得对全局解释器锁的控制权并重新交换自身。
这意味着当您调用 PyEval_SimpleString 时,您将面临不可避免的副作用,即其他线程将有机会执行,即使您持有全局解释器锁。此外,调用用 C 编写的 Python 模块(包括许多内置模块)会打开将控制权让给其他线程的可能性。因此,两个执行计算密集型 Python 脚本的 C 线程确实会显得共享 CPU 时间并同时运行。缺点是,由于全局解释器锁的存在,Python 无法完全利用多处理器机器上的 CPU。
在您的线程 C 程序能够使用 Python API 之前,它必须调用一些初始化例程。如果解释器库在编译时启用了线程支持(通常是这种情况),您可以选择在运行时启用线程或不启用线程。除非您计划使用线程,否则不要启用运行时线程支持。如果未启用运行时支持,Python 将能够避免与互斥锁锁定其内部数据结构相关的开销。如果您使用 Python 来扩展线程应用程序,则需要在初始化解释器时启用线程支持。我建议从主执行线程中初始化 Python,最好在应用程序启动期间,使用以下两行代码
// initialize Python Py_Initialize(); // initialize thread support PyEval_InitThreads();
这两个函数都返回 void,因此没有错误代码要检查。您现在可以假设 Python 解释器已准备好执行 Python 代码。Py_Initialize 分配解释器库使用的全局资源。调用 PyEval_InitThreads 启用运行时线程支持。这导致 Python 启用其内部互斥锁机制,用于序列化对解释器内代码关键部分的访问。此函数还具有锁定全局解释器锁的副作用。函数完成后,您有责任释放锁。但是,在释放锁之前,您应该获取指向当前 PyThreadState 对象的指针。稍后您将需要它来创建新的 Python 线程,并在完成使用 Python 后正确关闭解释器。使用以下代码段来执行此操作
PyThreadState * mainThreadState = NULL; // save a pointer to the main PyThreadState object mainThreadState = PyThreadState_Get(); // release the lock PyEval_ReleaseLock();
Python 要求每个执行 Python 代码的线程都有一个 PyThreadState 对象。解释器使用此对象为每个线程管理单独的解释器数据空间。从理论上讲,这意味着在一个线程中采取的行动不应干扰另一个线程的状态。例如,如果您在一个线程中抛出异常,则其他 Python 代码段将继续运行,就像什么也没发生一样。您必须帮助 Python 管理每个线程的数据。为此,请为每个将执行 Python 代码的 C 线程手动创建一个 PyThreadState 对象。为了创建一个新的 PyThreadState 对象,您需要一个预先存在的 PyInterpreterState 对象。PyInterpreterState 对象保存跨所有协作线程共享的信息。当您初始化 Python 时,它创建了一个 PyInterpreterState 对象并将其附加到主 PyThreadState 对象。您可以使用此解释器对象为自己的 C 线程创建一个新的 PyThreadState。以下是一些执行此操作的示例代码(忽略换行符)
// get the global lock PyEval_AcquireLock(); // get a reference to the PyInterpreterState PyInterpreterState * mainInterpreterState = mainThreadState->interp<\n>; // create a thread state object for this thread PyThreadState * myThreadState = PyThreadState_New(mainInterpreterState); // free the lock PyEval_ReleaseLock();
现在您已经创建了一个 PyThreadState 对象,您的 C 线程可以开始使用 Python API 来执行 Python 脚本。当从 C 线程执行 Python 代码时,您必须遵守一些简单的规则。首先,在执行任何更改当前线程状态的操作之前,您必须持有全局解释器锁。其次,在执行任何 Python 代码之前,您必须将线程特定的 PyThreadState 对象加载到解释器中。一旦您满足了这些约束,您就可以使用 PyEval_SimpleString 等函数执行任意 Python 代码。完成操作后,请记住交换出您的 PyThreadState 对象并释放全局解释器锁。请注意代码中“锁定、交换、执行、交换、解锁”的对称性(忽略换行符)
// grab the global interpreter lock PyEval_AcquireLock(); // swap in my thread state PyThreadState_Swap(myThreadState); // execute some python code PyEval_SimpleString("import sys\n"); PyEval_SimpleString("sys.stdout.write('Hello from a C thread!\n')\n"); // clear the thread state PyThreadState_Swap(NULL); // release our hold on the global interpreter PyEval_ReleaseLock();
一旦您的 C 线程不再使用 Python 解释器,您必须处置其资源。为此,请删除您的 PyThreadState 对象。这可以通过以下代码完成
// grab the lock PyEval_AcquireLock(); // swap my thread state out of the interpreter PyThreadState_Swap(NULL); // clear out any cruft from thread state object PyThreadState_Clear(myThreadState); // delete my thread state object PyThreadState_Delete(myThreadState); // release the lock PyEval_ReleaseLock();
此线程现在实际上已完成使用 Python API。此时您可以安全地调用 pthread_exit 来停止线程的执行。
一旦您的应用程序完成使用 Python 解释器,您可以使用以下代码关闭 Python 支持
// shut down the interpreter PyEval_AcquireLock(); Py_Finalize();
请注意,没有理由释放锁,因为 Python 已关闭。在调用 Py_Finalize 之前,请务必使用 PyThreadState_Clear 和 PyThreadState_Delete 删除所有线程状态对象。
Python 是用作嵌入式语言的理想选择。解释器提供对嵌入和扩展的支持,这允许 C 应用程序代码和嵌入式 Python 脚本之间的双向通信。此外,线程支持有助于与多线程应用程序集成,而不会影响性能。
您可以在 ftp.linuxjournal.com/pub/lj/listings/issue73/3641.tgz 下载示例源代码。这包括一个带有嵌入式 Python 解释器的多线程 HTTP 服务器的示例实现。为了了解更多关于实现细节的信息,我建议阅读 https://pythonlang.cn/docs/api/ 上的 Python C API 文档。此外,我发现 Python 解释器代码本身也是一个宝贵的参考。
电子邮件:ivan@torpid.com
Ivan Pulleyn 可以通过电子邮件 ivan@torpid.com 联系。