Chapter 58: Embedding and Interop
把 Python 嵌入到宿主程序以后,Python 不再拥有进程入口,宿主程序开始决定解释器何时初始化、哪些线程能进入 Python、错误如何回到宿主系统、对象和内存由谁释放。本章要建立的判断能力是:看到一个 C/C++ 程序调用 Python 代码时,能够定位解释器生命周期、线程状态、对象引用、异常路径、buffer 生命周期和边界成本分别落在哪一层。
贯穿本章的材料是一个插件式图像处理宿主。宿主程序接收一块图像内存,把它作为 memoryview 传给 Python 插件函数 transform(data),插件返回处理结果或抛出异常。这个例子覆盖 embedding、native interop、buffer sharing、GIL、异常回传、stdout/stderr、finalize 和扩展边界。
Python 官方文档把 embedding 描述为 C/C++ 应用把 Python 解释器作为能力嵌入自身,进程主程序由宿主提供,宿主至少要初始化解释器,然后才能执行 Python 代码或构造 Python 对象。具体 API 在 Python 3.14 文档的 Embedding Python in Another Application、Interpreter initialization and finalization、Thread states and the global interpreter lock、Buffer Protocol 和 MemoryView objects 中有对应边界。本章不把这些文档当作调用清单,而是把它们整理成一条稳定的 runtime 路径:宿主配置解释器,解释器生成 runtime state,线程附着到解释器,C 边界把 native 值包装成 Python object,Python 调用完成后把结果、异常和资源责任交回宿主。
下面的简化 C 片段只展示这条边界顺序。真实工程还要处理平台链接参数、模块路径、日志系统、错误对象序列化和宿主资源回收。
#define PY_SSIZE_T_CLEAN
#include <Python.h>
static int call_transform(const char *module_name,
const unsigned char *data,
Py_ssize_t size) {
PyObject *module = NULL;
PyObject *func = NULL;
PyObject *view = NULL;
PyObject *args = NULL;
PyObject *result = NULL;
int ok = -1;
module = PyImport_ImportModule(module_name);
if (module == NULL) {
goto error;
}
func = PyObject_GetAttrString(module, "transform");
if (func == NULL || !PyCallable_Check(func)) {
goto error;
}
view = PyMemoryView_FromMemory((char *)data, size, PyBUF_READ);
if (view == NULL) {
goto error;
}
args = PyTuple_Pack(1, view);
if (args == NULL) {
goto error;
}
result = PyObject_CallObject(func, args);
if (result == NULL) {
goto error;
}
ok = 0;
error:
if (ok < 0 && PyErr_Occurred()) {
PyErr_Print();
}
Py_XDECREF(result);
Py_XDECREF(args);
Py_XDECREF(view);
Py_XDECREF(func);
Py_XDECREF(module);
return ok;
}
这段代码里,data 是宿主拥有的内存,view 是 Python 层可见的对象包装,args 是调用约定要求的参数容器,result 是 Python 函数返回的对象引用。PyObject_CallObject 成功时返回一个新引用,失败时返回 NULL 并设置 Python exception indicator。这个边界把 Python 语言现象拆成几个 runtime 判断点:哪个线程正在进入解释器,哪个对象持有引用,哪块内存仍属于宿主,异常状态是否已经被转换,调用结束后是否还有 Python 对象引用到宿主内存。
58.1 embedding Python
embedding Python 的工作定义是:宿主进程主动创建并驱动 CPython runtime,让 Python 代码作为宿主能力的一部分运行。这里的主语是宿主程序。宿主决定进程入口、初始化时机、路径配置、线程进入方式、脚本加载方式、错误展示方式和退出顺序。
在独立运行的 Python 程序里,python script.py 由解释器主程序完成配置、初始化、导入 __main__、执行代码和退出清理。嵌入式场景把这些动作拆开交给宿主。宿主可以只执行一段字符串,也可以导入一个插件模块并调用函数,还可以把自己的 C 函数注册成内置模块供 Python 回调。上一段的 call_transform 处在中间形态:宿主已经初始化解释器,然后导入插件模块,把内存包装成 Python 对象,再调用插件函数。
最小 embedding 路径可以分成四步。第一步是配置解释器,典型对象是 PyConfig;Python 3.14 还加入了新的 PyInitConfig C API,用 opaque 配置对象表达初始化选项。第二步是初始化解释器,例如调用 Py_InitializeFromConfig,让 CPython 建立 sys.modules、builtins、__main__、sys 和 sys.path 这类基础状态。第三步是进入 Python 执行路径,可以用高层的 PyRun_SimpleString,也可以像本章例子一样导入模块、取属性、检查 callable、构造参数并调用。第四步是清理引用和结束解释器,例如调用 Py_FinalizeEx,并检查 finalization 期间可能出现的 flush 错误。
int main(int argc, char **argv) {
PyStatus status;
PyConfig config;
PyConfig_InitPythonConfig(&config);
status = PyConfig_SetBytesString(&config, &config.program_name, argv[0]);
if (PyStatus_Exception(status)) {
goto init_error;
}
status = Py_InitializeFromConfig(&config);
if (PyStatus_Exception(status)) {
goto init_error;
}
PyConfig_Clear(&config);
call_transform("plugin_filter", image_bytes, image_size);
if (Py_FinalizeEx() < 0) {
return 120;
}
return 0;
init_error:
PyConfig_Clear(&config);
Py_ExitStatusException(status);
}
这段初始化代码应把核心对象定位到 PyConfig。program_name 会参与运行时库路径推导;module_search_paths、home、isolated、use_environment、site_import 等配置会改变 Python 在宿主进程中看到的环境。工程上,embedding 失败经常表现为插件模块找不到、标准库找不到、加载到了系统 Python 的包、stdout/stderr 混入宿主日志、signal 被解释器接管。这些现象都应先回到初始化配置和路径配置检查。
embedding 和普通扩展模块的方向相反。扩展模块中,Python 进程先存在,Python 代码调用 C 函数;embedding 中,C/C++ 进程先存在,native 代码在某个时刻进入 Python。二者共用大量 Python/C API 规则,例如引用计数、异常 indicator、参数转换和类型检查。区别落在所有权顺序:扩展模块服从 Python 主程序的生命周期;embedding 要把 CPython 生命周期嵌入宿主生命周期。
本节的判断顺序是:先确认谁拥有进程入口,再确认解释器是否已经初始化,然后确认 sys.path 和内置模块表是否按宿主预期配置,接着确认进入 Python 的 API 层级,最后确认退出路径是否释放引用并调用了合适的 finalization。只要这条顺序不清楚,后续的 GIL、buffer 和异常处理都会缺少上层边界。
58.2 runtime embedding
runtime embedding 关注的对象是已经进入宿主进程的 CPython runtime state。它回答的问题是:哪个解释器、哪个线程、哪个异常状态、哪个退出阶段正在参与这次调用。
CPython 在进程内有 runtime state,runtime state 下有 interpreter state,interpreter state 下有 thread state。线程要调用 Python/C API,通常需要附着到某个 thread state。带 GIL 的构建中,附着 thread state 与持有对应解释器的 GIL高度相关;free-threaded 构建中,GIL 的含义变化,thread state 仍然是解释器识别当前线程访问 Python 对象的必要状态。这个版本边界在 Python 3.13+ free-threaded 讨论中尤其重要:把“进入 Python”简化成“拿到 GIL”会丢掉 thread state 这个对象层。
宿主主线程在 Py_InitializeFromConfig 之后通常已经具备进入 Python 的上下文。由宿主线程池、GUI 回调、音视频引擎或第三方 C 库创建的新线程没有自动附着到 CPython。它们要调用 call_transform 这类函数时,需要先进入解释器线程状态。简单单解释器场景可以用 PyGILState_Ensure 和 PyGILState_Release 成对包围调用;存在多解释器时,官方文档明确说明 GIL-state API 使用线程本地存储,并且与 sub-interpreter 组合不可靠。更稳妥的模型是保存目标 PyInterpreterState *,在 native 线程中创建并交换 PyThreadState。
PyGILState_STATE state = PyGILState_Ensure();
int rc = call_transform("plugin_filter", data, size);
PyGILState_Release(state);
这段代码适合表达单解释器、短时回调、宿主线程临时进入 Python 的情况。它的判断边界有两条。第一,Ensure 和 Release 必须在同一线程成对出现。第二,解释器 finalizing 阶段再进入这条路径会带来挂起或崩溃风险,Python 3.14 文档已经把部分 finalization 行为描述为挂起当前线程直到程序退出。
多解释器场景需要把线程入口写成显式 interpreter 绑定。下面的片段仍然是简化代码,它展示的是对象关系:native 线程拿到目标 interp,创建 tstate,交换为当前 thread state,完成 Python 调用后清理并删除当前 thread state。
PyInterpreterState *interp = thread_data->interp;
PyThreadState *tstate = PyThreadState_New(interp);
PyThreadState_Swap(tstate);
int rc = call_transform("plugin_filter", data, size);
PyThreadState_Clear(tstate);
PyThreadState_DeleteCurrent();
runtime embedding 的异常回传也属于线程状态问题。Python exception indicator 保存在当前线程相关状态中;PyObject_CallObject 返回 NULL 时,宿主应立即读取、格式化、记录或转换这个异常。把异常 indicator 留到后续 C API 调用之后再处理,会让错误来源变得不稳定。宿主工程通常会把 Python 异常转换成自己的错误对象,例如错误码、日志事件、UI diagnostic 或任务失败状态。
本节的复用判断顺序是:先确认当前线程来源,再确认它附着到哪个 interpreter,再确认进入 Python 的 API 是否与多解释器模型兼容,接着在失败点读取 exception indicator,最后确认 finalization 阶段没有新的线程入口。这个顺序能解释嵌入式 Python 中最常见的“偶发死锁、退出卡住、回调崩溃、错误丢失”。
58.3 native interop
native interop 的工作定义是:在 Python object 模型和 C/C++ 数据模型之间建立可调用、可释放、可报错的转换层。它包含数据转换、调用约定、引用所有权、异常转换和 ABI 边界。本章例子中,const unsigned char *data 是 native 指针,PyMemoryView_FromMemory 生成 Python 可见对象,PyTuple_Pack 生成调用参数,PyObject_CallObject 执行 Python callable。
互操作的第一层是数据表示。C 里的 int、double、结构体、指针和数组没有 Python 对象头;Python 层的 int、bytes、list、memoryview 都是 PyObject *。跨边界传值时,宿主要么复制并转换成 Python 对象,例如 PyLong_FromLong、PyUnicode_FromString、PyBytes_FromStringAndSize;要么共享底层内存并提供对象包装,例如 memoryview 或自定义 buffer exporter。两种方式的成本和风险不同:复制转换让生命周期清晰,共享内存降低复制开销,同时引入写权限、释放顺序和并发访问边界。
互操作的第二层是调用约定。Python callable 接收 PyObject * 参数,C API 常见形式是 tuple 参数、dict 关键字参数或 vectorcall 相关路径。PyObject_CallObject(func, args) 里,func 必须是 callable,args 通常是 tuple 或 NULL。参数容器本身也是 Python 对象,需要引用计数管理。调用成功后返回一个新引用;调用失败后返回 NULL 并设置异常 indicator。这个约定把“函数调用失败”从 C 的错误码系统转成了 Python 的异常状态系统。
互操作的第三层是所有权。下面的表只列本章例子中最小的所有权关系。
| 对象 | 创建来源 | 所有权判断 | 释放动作 |
|---|---|---|---|
module | PyImport_ImportModule | 成功返回新引用 | Py_DECREF 或 Py_XDECREF |
func | PyObject_GetAttrString | 成功返回新引用 | Py_DECREF 或 Py_XDECREF |
view | PyMemoryView_FromMemory | 成功返回新引用,底层内存仍属宿主 | Py_DECREF,并保证宿主内存有效期覆盖使用期 |
args | PyTuple_Pack | 成功返回新引用 | Py_DECREF |
result | PyObject_CallObject | 成功返回新引用 | 宿主读取后 Py_DECREF |
| exception indicator | 当前线程状态 | 失败路径由 Python 设置 | PyErr_Fetch、PyErr_Print 或转换后清理 |
这个表的关键结论是:PyObject * 引用和 native 指针所有权分属两套系统。Py_DECREF(view) 释放的是 Python 包装对象的引用;它不会自动释放 data 指向的宿主内存。相反,如果宿主提前释放 data,Python 层仍持有 memoryview,就会形成悬空内存视图。互操作代码必须同时审查 Python 引用图和 native 资源图。
互操作的错误路径要以“第一个失败点”为中心组织。PyImport_ImportModule 失败说明模块导入、路径或模块初始化出错;PyObject_GetAttrString 失败说明名称查找或属性访问出错;callable 检查失败说明插件 API 契约错误;PyObject_CallObject 失败说明 Python 函数执行中抛出异常。把这些失败都压缩成一个 -1 会损失排查信息。工程上应把错误阶段、模块名、函数名、Python traceback 和宿主任务上下文一起记录。
C++ 场景还要处理 RAII 与 Python 引用计数的组合。一个常见做法是把 PyObject * 包在小型 RAII holder 里,让析构函数执行 Py_XDECREF。这个封装只管理 Python 引用;native 内存、文件句柄、GPU buffer、线程任务仍然需要各自的生命周期策略。把所有资源都塞进一个“Python 对象 wrapper”会让释放顺序失去可见性。
本节的判断顺序是:先确定数据跨边界时采用复制还是共享,再确定参数和返回值的 Python 对象形态,接着检查每个 C API 返回值的引用所有权,然后在失败点读取异常 indicator,最后把 Python 对象释放和 native 资源释放分开审查。
58.4 buffer sharing
buffer sharing 的工作定义是:让 Python 对象和 native 代码围绕同一段二进制内存建立视图关系,减少复制,同时通过 Py_buffer、memoryview、flags、shape、strides、readonly 和 release 规则表达生命周期。它解决的是“大块数据跨边界传递”的成本问题,代价是内存有效期和访问权限必须精确。
本章例子使用 PyMemoryView_FromMemory((char *)data, size, PyBUF_READ)。它把宿主内存包装成 Python memoryview,Python 插件可以读取这块内存。这里没有复制图像数据,memoryview 只是 Python 层对象视图。结论是:零复制只说明数据字节没有复制,不说明生命周期已经自动安全。宿主必须保证 data 指向的内存在 Python 读取完成前保持有效。
更通用的路径是 buffer protocol。消费者可以调用 PyObject_GetBuffer(exporter, &view, flags) 向导出对象请求 buffer;成功后 Py_buffer 中包含 buf、obj、len、itemsize、readonly、ndim、format、shape、strides 等字段。view.obj 通常是导出对象的新引用,消费者完成访问后必须调用 PyBuffer_Release(&view)。这个规则相当于把“借用一段内存视图”写成明确的 acquire/release 协议。
Py_buffer view;
if (PyObject_GetBuffer(exporter, &view, PyBUF_SIMPLE) < 0) {
return -1;
}
process_bytes(view.buf, view.len);
PyBuffer_Release(&view);
这段代码展示的是 consumer 视角。PyObject_GetBuffer 成功后,consumer 可以读取 view.buf 和 view.len;PyBuffer_Release 后,view 不再代表可访问的内存视图。错误路径上,如果 PyObject_GetBuffer 返回 -1,通常会设置 BufferError 或其它异常,调用方应按 Python exception indicator 处理。
写权限是 buffer sharing 中最容易被低估的边界。PyBUF_READ 表示只读视图,PyBUF_WRITE 或 PyBUF_WRITABLE 表示请求可写视图。导出者可以拒绝不满足权限要求的请求。对图像插件来说,只读输入适合滤镜分析、识别、编码;可写输入适合原地修改,但它会让 Python 代码直接改变宿主内存。只要允许写入,就要同时规定并发访问、锁、脏数据标记和失败回滚策略。
连续性也是 buffer sharing 的边界。PyBUF_SIMPLE 适合一维连续字节;多维数组可能有 shape、strides、suboffsets。一个 NumPy-like 图像对象可能把 height × width × channels 暴露成多维视图,某个切片可能不连续。需要连续内存的 native 算法应检查 PyBuffer_IsContiguous,或显式调用复制到连续布局的 API。这里的判断是:共享内存能降低复制成本,但算法仍要确认自己能处理视图的实际布局。
memoryview 是 buffer interface 的 Python 对象化形式。PyMemoryView_FromObject(obj) 从支持 buffer 的对象创建 memoryview;PyMemoryView_FromBuffer(&view) 包装已有 Py_buffer;PyMemoryView_GetContiguous 在输入连续时指向原内存,在输入不连续时可能创建新的 bytes 对象。这个差异会改变成本模型:代码写着 memoryview,不代表每条路径都保持零复制。
本节的判断顺序是:先确认谁是 exporter、谁是 consumer,再确认请求的 flags 和读写权限,接着检查内存连续性、shape 和 strides,然后确认 release 位置,最后确认宿主内存有效期覆盖 Python 视图使用期。只要 buffer 生命周期无法画清楚,就应优先使用复制转换换取确定性。
58.5 C boundary cost
C boundary cost 指 Python 与 native 代码每次跨边界时产生的运行时成本。它不只来自 C 函数执行时间,还来自对象转换、引用计数、参数容器创建、动态查找、GIL 或 thread state 切换、异常转换、buffer 协议协商、内存复制和调试观测成本。
在本章插件例子里,call_transform 的边界成本至少有六段。第一段是模块查找和属性查找,PyImport_ImportModule 和 PyObject_GetAttrString 会触发 import system 或 attribute lookup。第二段是包装输入,PyMemoryView_FromMemory 创建 Python 对象。第三段是构造参数,PyTuple_Pack 创建 tuple 并增加元素引用。第四段是调用分发,PyObject_CallObject 进入 Python callable 协议和 frame 执行。第五段是错误和结果转换,成功要读取 result,失败要读取 exception indicator。第六段是清理引用,每个对象都要走引用计数更新,部分对象归零时还会触发析构。
边界成本的可见表现通常是高频小调用把固定开销放大。例如宿主每处理一个像素都调用一次 Python 函数,成本会被函数查找、参数打包、GIL 切换和 frame 创建吞没。把整个图像块作为一个 memoryview 传给一次 Python 函数,成本变成一次跨边界调用加一次大块处理。这个判断是 Python native interop 的核心性能原则:跨边界次数越少,单次传递的语义越完整,固定成本越容易被有效工作摊薄。
def transform(data: memoryview) -> bytes:
# data 表示宿主传来的整块图像内存视图
# 插件在一次调用内完成解析、处理和返回
return bytes(data[:16])
这个 Python 片段展示了更合理的边界形状:插件函数处理一块完整数据,宿主在最内层循环之外完成一次 Python 入口调用。bytes(data[:16]) 仍然会复制切片结果;如果插件返回新的二进制结果,这个复制可能是业务需要。如果目标是原地修改,则应使用可写 buffer,并把写权限、锁和失败回滚写入契约。
异常路径也有成本。Python 异常携带类型、值和 traceback,转换成宿主错误对象时通常要格式化 traceback、收集上下文、写日志或跨线程传回 UI。把异常当成常规分支会让错误系统成为热路径。工程上应把可预期状态用返回对象表达,把真正失败交给异常路径,例如插件 API 可以返回 {status, payload} 这类结构,只有契约破坏、导入失败、不可恢复资源错误才抛出异常。
内存共享降低复制成本,却会增加同步成本。memoryview 让 Python 读取宿主图像内存,宿主就需要知道这块内存什么时候还被 Python 引用、哪个线程可能读取、是否允许 Python 保存视图到全局变量。如果插件把 memoryview 存起来并在调用返回后继续使用,宿主的“调用结束即可释放输入缓冲区”假设就失效。性能优化必须和生命周期契约一起设计。
本节的判断顺序是:先数跨边界调用次数,再看每次调用是否构造 Python 对象和参数容器,接着检查是否发生复制或 buffer 协商,然后检查 GIL/thread state 切换,最后评估异常格式化和引用释放是否进入热路径。这个顺序能把“C 扩展或 embedding 一定快”的模糊判断改成可定位的成本分析。
58.6 interpreter lifecycle
interpreter lifecycle 描述 CPython 在宿主进程中的阶段顺序:preinit、config、initialize、import bootstrap、thread state、执行 Python、finalize、资源析构。它是 embedding 稳定性的主轴,因为路径、内存分配器、signal、标准流、线程和模块清理都依赖解释器所处阶段。
下面的状态图展示宿主视角的生命周期。图的边界是单个 CPython runtime 被宿主初始化并关闭;它不展开每个模块的内部初始化,也不把扩展模块的 ABI 兼容问题纳入图中。
Preinit 阶段适合处理最早的 runtime 前置配置,例如 allocator、locale、UTF-8 mode 这类会影响后续初始化的选项。Config 阶段把宿主意图写入 PyConfig 或 Python 3.14 的 PyInitConfig。Initialize 阶段创建解释器基础状态。ImportBootstrap 阶段让 import system、sys.modules、内置模块和路径配置进入可用状态。Ready 阶段才是宿主反复调用 Python 插件的稳定区间。
PythonCalls 阶段是一组进入和退出 Python 的动作。每次进入都要有当前 thread state;每次返回都要处理成功结果或异常 indicator;每次创建对象都要在合适位置释放引用。对宿主来说,Ready → PythonCalls → Ready 是高频循环,Config → Initialize 和 Ready → Finalizing 应是低频生命周期操作。
Finalizing 阶段需要格外谨慎。Py_FinalizeEx 会尝试撤销初始化、销毁未销毁的 sub-interpreter、flush buffered data,并清理模块和对象。官方文档提醒,模块和对象销毁顺序可能让依赖其它模块的 finalizer 失败,动态加载的扩展模块也可能不会卸载。这个阶段应停止新任务进入 Python,等待已进入 Python 的线程退出,释放宿主保存的 Python 对象引用,然后由持有主解释器上下文的线程执行 finalization。
多次 initialize/finalize 是嵌入式程序容易误判的地方。Py_FinalizeEx 的存在不等于所有扩展和全局状态都能安全重启。某些扩展模块会保留进程级 native state,某些第三方库初始化后缺少完整反初始化路径。长期运行的宿主通常更适合把 CPython runtime 作为进程级服务初始化一次,插件热更新通过模块加载策略、隔离进程或可控 sub-interpreter 模型设计。
本节的判断顺序是:先标出当前阶段,再确认这个阶段允许哪些 Python/C API,然后检查路径、标准流、signal 和 allocator 是否已经固定,接着确认线程入口是否只发生在 Ready 阶段,最后在 finalization 前收口任务、引用和日志输出。生命周期阶段一旦混乱,错误常表现为退出时崩溃、二次初始化行为不一致、模块清理时访问空对象、后台线程卡住。
58.7 embedded runtime
embedded runtime 要把 Python 的进程级行为映射回宿主系统。独立 Python 程序可以直接使用 stdout/stderr、signal handler、memory allocator、sys.excepthook、warnings、logging、atexit 和环境变量。嵌入式程序中,这些行为都要和宿主已有系统协调。
错误映射是第一项工作。PyErr_Print 会把异常打印到 sys.stderr,适合最小调试,却不适合生产宿主。更稳妥的方式是用 PyErr_Fetch 取出异常类型、异常值和 traceback,转换成宿主错误对象,再交给日志、UI、RPC 或任务系统。宿主应记录模块名、函数名、输入任务 ID、Python traceback 和 native 调用阶段。这样才能区分导入失败、插件契约错误、运行时异常和宿主资源错误。
stdout/stderr 映射是第二项工作。Python 插件里的 print、warnings 和部分库日志会写到 sys.stdout 或 sys.stderr。桌面宿主、游戏引擎、服务进程和移动应用通常没有可见终端,直接输出会丢失上下文。工程做法通常是在初始化后替换 sys.stdout 和 sys.stderr 为宿主提供的 Python 对象,或者把 logging 配置到宿主日志 sink。这个对象需要实现 write 和 flush,并把字符串加上插件名、任务 ID 和线程信息。
class HostLogStream:
def __init__(self, level):
self.level = level
def write(self, message):
if message.strip():
host_log(self.level, message)
def flush(self):
pass
这个 Python 片段展示的是 stdout/stderr 重定向对象的形状。host_log 代表宿主暴露给 Python 的日志函数,可以通过内置扩展模块、capsule 或绑定库提供。它的关键点是把 Python 字符串输出转换成宿主日志事件,而输出对象本身仍然遵守 Python 文件类接口的一小部分。
memory allocator 映射是第三项工作。CPython 有 raw、mem、object allocator 层级,宿主可能已有内存统计、调试分配器、崩溃转储和泄漏检查。需要在初始化前设置 allocator 的场景,应放在 preinit 或初始化配置阶段完成。初始化后替换分配器会牵涉已有对象和调试 hook,通常要按官方 API 约束处理。对插件平台来说,至少应区分 Python 对象内存、宿主资源内存和共享 buffer 内存,日志里应分别标注这几类内存来源。
signal 映射是第四项工作。独立 Python 可以安装自己的 signal handler;嵌入式宿主可能已经用 signal 管理崩溃、终止、热重载、子进程或服务生命周期。Py_InitializeEx(0) 或配置项可以让宿主保留 signal handler 控制权。服务端和 GUI 宿主通常应由宿主统一处理 signal,再把取消、关闭或 reload 事件转换成 Python 可见的任务取消或插件卸载事件。
环境和路径映射是第五项工作。嵌入式 Python 读取 PYTHONPATH、用户 site-packages、当前工作目录和系统环境变量,会让插件加载结果依赖机器状态。isolated、use_environment、site_import、module_search_paths 等配置可以把插件运行环境固定到宿主分发目录。稳定的插件平台应把 Python home、stdlib 位置、第三方包目录和插件目录写入宿主配置,把用户 shell 环境排除在核心加载契约之外。
本节的判断顺序是:先列出 Python 进程级行为,再逐项决定由 CPython 默认处理还是映射到宿主;然后检查 stdout/stderr、异常、logging、allocator、signal、environment 和 path 是否有明确策略;最后把这些策略写入初始化阶段和插件 API 契约。embedded runtime 的目标是让 Python 成为宿主的一部分,而宿主仍然保持自己的可观测性和生命周期控制。
58.8 extension boundary
extension boundary 定义 native code 与 Python runtime 的安全边界:何时持有 thread state 或 GIL,谁拥有引用,谁拥有 native 资源,如何返回错误,如何处理崩溃风险。embedding 和 extension 在这个边界上共享同一套纪律,只是调用方向不同。
第一条边界是线程状态。任何访问 Python object、调用 Python/C API、读取或设置 exception indicator 的 native 代码,都应处在附着 thread state 的区域。长时间阻塞的 I/O 或纯 native 计算如果不访问 Python 对象,可以用 Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS 释放并恢复 thread state,让其它 Python 线程推进。进入这类区域后,代码只能操作不依赖 Python runtime 的 native 数据,返回前再恢复 thread state。
Py_BEGIN_ALLOW_THREADS
native_blocking_io(fd, buffer, length);
Py_END_ALLOW_THREADS
这段代码的含义是:阻塞 I/O 期间释放解释器相关执行权,I/O 完成后恢复当前线程状态。它适合文件、网络、压缩、哈希、图像编码这类可以在 native 层独立推进的操作。它的边界是 I/O 期间只操作独立 native 数据,Python 对象访问、Python 异常设置和 Python callback 调用都要放到恢复 thread state 之后。
第二条边界是引用所有权。C API 返回新引用、借用引用、强引用、偷取引用的规则必须在函数局部就能读清。PyTuple_SetItem 这类偷取引用的 API 与 PyTuple_Pack 这类增加引用的 API不同,混用时很容易出现泄漏或过早释放。嵌入式宿主应把 Python 引用释放集中在单一 cleanup 区域,并用 Py_XDECREF 处理可能为 NULL 的对象。
第三条边界是错误返回。Python/C API 通常用 NULL 或 -1 表示失败,同时设置 exception indicator。返回给 Python 的扩展函数如果失败,应返回 NULL 并保证异常已经设置;embedding 宿主调用 Python 失败时,应读取异常并转换成宿主错误。两侧的错误系统要在边界处完成转换,错误 indicator 留在当前 thread state 里会污染后续 API 行为。
第四条边界是崩溃风险。Python 异常可以被捕获;native 崩溃通常直接终止进程。悬空指针、越界写、重复释放、错误的 Py_DECREF、不匹配的 thread state、在 finalization 阶段进入 Python,都可能让宿主进程崩溃。插件平台如果允许第三方 native 扩展与 embedded Python 同进程运行,就要接受进程级崩溃边界。更强隔离通常要靠子进程、沙箱或独立 worker 进程完成。
第五条边界是 ABI 和版本。CPython C API 与具体 Python 版本、构建选项和平台 ABI 有关系。embedding 链接时要使用匹配版本的 pythonX.Y-config --ldflags --embed 或等价构建配置;扩展模块分发时要考虑 Python tag、ABI tag 和平台 tag。Stable ABI 与 Limited API 能降低一部分版本耦合,但不会消除所有 runtime 行为差异,特别是初始化、GIL、buffer、sub-interpreter 和内部结构访问。
本章最后给出一条 extension boundary 检查顺序:先确认当前代码是否访问 Python runtime;如果访问,确认 thread state;然后逐个标注 PyObject * 引用所有权;接着把每个失败点映射到 Python exception 或宿主错误;再检查 native 资源和 Python 对象释放顺序;最后评估崩溃是否需要进程隔离。这个顺序可以用于 embedding、扩展模块、C++ binding、插件系统和高性能数据通道。
最小自检任务
阅读下面的宿主调用场景,并判断这个设计中需要补齐哪些 runtime 边界。
一个 C++ 图像宿主在启动时调用一次 Py_InitializeFromConfig。宿主线程池中每个 worker 收到图像任务后,直接调用 PyObject_CallObject(plugin_func, args)。args 里包含 PyMemoryView_FromMemory 生成的只读视图,底层内存来自宿主任务对象。调用返回后,worker 立即释放任务对象。插件函数有时会把传入的 memoryview 保存到模块全局变量,稍后由另一个 Python 函数读取。宿主退出时直接调用 Py_FinalizeEx,没有等待 worker 完成。
答案要点
这个设计需要先补齐线程进入边界。worker 是宿主线程池创建的 native 线程,调用 Python/C API 前必须附着到目标 interpreter 的 thread state;单解释器短回调可以用 PyGILState_Ensure 成对包围,多解释器设计应保存 PyInterpreterState * 并创建对应 PyThreadState。
第二个问题是 buffer 生命周期。memoryview 指向宿主任务对象中的内存,插件把它保存到模块全局变量后,调用返回不再代表 Python 已经完成读取。worker 立即释放任务对象会让全局 memoryview 指向失效内存。修正方式是把输入复制成 Python 拥有的对象,或让宿主任务内存的生命周期覆盖所有 Python 视图使用期,并提供明确的释放协议。
第三个问题是 finalization 顺序。宿主退出时应先停止提交新任务,等待已经进入 Python 的 worker 离开,释放宿主持有的 Python 引用,再由合适线程执行 Py_FinalizeEx。finalizing 阶段再让 worker 进入 Python 会造成挂起、崩溃或错误状态丢失。
第四个问题是异常和日志映射。每次 PyObject_CallObject 返回 NULL 后,worker 应立即读取 Python exception indicator,转换成宿主任务错误并记录 traceback。stdout/stderr 和插件日志也应映射到宿主日志系统,这样导入失败、插件契约错误和运行时异常可以被区分。
第五个问题是边界成本。每个任务跨一次 Python 边界处理整块图像是可接受形状;如果宿主在像素级循环中反复调用 Python,参数构造、thread state 切换、引用计数和异常检查会成为热路径。这里的主要风险集中在线程状态、buffer 生命周期和 finalization 顺序没有收束。
本章知识点总结
- Embedding:embedding 把 CPython runtime 放入宿主进程,宿主负责解释器配置、初始化、调用入口和退出顺序。
- 进程入口:独立 Python 由解释器主程序驱动,嵌入式 Python 由 C/C++ 宿主驱动。
- PyConfig:
PyConfig承载程序名、路径、环境、site 和隔离等初始化选项,直接影响模块加载和标准库定位。 - 线程状态:native 线程进入 Python/C API 前要附着到目标 interpreter 的 thread state。
- GIL API:
PyGILState_Ensure适合单解释器短回调,多解释器模型应显式管理PyThreadState。 - 异常回传:C API 失败后要立即读取 Python exception indicator,并转换成宿主错误或日志事件。
- 对象互操作:native 值跨边界时要转换或包装成
PyObject *,返回值和参数容器都受引用计数约束。 - 内存共享:
memoryview和Py_buffer可以减少复制,但宿主必须保证底层内存有效期覆盖视图使用期。 - Buffer release:
PyObject_GetBuffer成功后必须用PyBuffer_Release成对释放 buffer view。 - 写权限:可写 buffer 会让 Python 代码直接修改宿主内存,需要并发、回滚和所有权契约。
- 边界成本:跨 C 边界的成本来自调用次数、对象构造、引用计数、thread state 切换、复制和异常格式化。
- 生命周期:解释器生命周期按 preinit、config、initialize、import bootstrap、ready、finalizing、destroyed 推进。
- 宿主映射:stdout/stderr、logging、signal、allocator、environment 和 path 都要映射到宿主系统策略。
- 释放顺序:finalization 前应停止新任务、等待线程退出、释放 Python 引用,再关闭解释器。
- 崩溃边界:native 崩溃会影响整个宿主进程,第三方 native 插件需要考虑进程隔离。
- 检查顺序:先看进程入口和生命周期,再看线程状态、对象引用、buffer 生命周期、异常转换和边界成本。