Chapter 75: Subinterpreters, Isolation, and Runtime Parallelism
多解释器要解决的问题,是在同一个进程内把多份 Python 运行时状态分隔开,再让宿主用明确的通信边界组织并行执行。读完本章后,读者应能定位一个任务运行在哪个 interpreter 中,判断对象能否跨 interpreter 传递,区分线程、进程和 subinterpreter 的隔离强度,并为插件系统或嵌入式运行时设计可检查的并行边界。
本章以 Python 3.14 标准库中的 concurrent.interpreters 作为观察入口,以 PEP 684 提出的 per-interpreter GIL 作为 CPython 3.12+ 的实现边界。concurrent.interpreters 提供的是用户可见的管理接口;PyInterpreterState、PyThreadState、GIL、module state 和 C extension 兼容性才是理解它的 runtime 结构。
本章贯穿一个插件宿主场景:主程序加载多个租户插件,每个插件运行一段 Python 任务,插件之间共享进程资源,但 Python 模块、全局变量和大部分对象状态需要相互隔离。这个场景足够小,可以用一段代码表达;它也足够接近工程现实,因为插件系统、嵌入式脚本引擎、批处理 worker 和服务内隔离都会遇到同一组问题。
from concurrent import interpreters
def run_plugin(config):
# 示例只展示可序列化输入和可序列化返回值。
name = config["name"]
payload = config["payload"]
return {"name": name, "size": len(payload)}
plugin_interp = interpreters.create()
result = plugin_interp.call(
run_plugin,
{"name": "tenant-a", "payload": b"abc"},
)
print(result)
这段代码的第一层含义很直接:主程序创建一个 interpreter,并把函数调用放入这个 interpreter 的执行上下文中。第二层含义更适合本章分析:run_plugin 的执行依赖目标 interpreter 的 __main__、builtins、导入状态和异常状态;输入和返回值需要经过跨解释器传递边界;并行收益来自 OS thread 与不同 interpreter 的组合,而单独创建 interpreter 本身只建立隔离上下文。
75.1 Interpreter State and Isolation Boundary
Interpreter state 是 Python 代码执行时需要共享的一组运行时状态。放到 CPython 语境中,它主要对应 PyInterpreterState:一个 interpreter 拥有自己的导入状态、builtins、sys.modules 视图、异常相关状态、GC 相关状态和执行所需的内部表。线程执行 Python 代码时会持有当前 PyThreadState,而 PyThreadState 指向它所属的 interpreter。
用插件宿主场景看,主程序创建 plugin_interp 后,新的 interpreter 拥有一套独立的 Python 层运行环境。插件在这个环境中导入模块、写全局变量、创建类和函数,都会先落到这个 interpreter 的状态内。主 interpreter 中已经导入的模块对象,通常不会自动变成插件 interpreter 中的同一个模块对象;插件 interpreter 需要按自己的导入状态重新建立模块对象或使用传入的对象副本。
下面的图只描述单个进程内的执行边界。它的核心是:OS process 承载资源,OS thread 负责执行,thread state 把当前执行绑定到某个 interpreter,interpreter state 保存 Python 层运行时状态。
图中的 process resources 包括地址空间、文件描述符、环境变量、当前工作目录、动态库和操作系统句柄。这些资源属于进程层级。Interpreter isolation 主要约束 Python runtime state,进程层资源仍然共享。插件代码如果打开同一个文件、修改进程环境变量、调用同一个 C 库全局状态,隔离边界会进入 Python runtime 之外的层级。
PyInterpreterState 的隔离也需要扩展模块配合。纯 Python 模块天然通过模块对象、全局变量和 sys.modules 进入当前 interpreter。C extension 如果把状态放在 C static 变量中,这个状态具有进程级作用域,多个 interpreter 会看到同一份 C 状态。Python 官方的 Isolating Extension Modules 文档要求扩展模块把状态放入 module object 的 per-module state,并使用 multi-phase initialization 管理模块生命周期。
理解 isolation boundary 时,可以按三层判断。第一层是 Python object 和 module state,它们应归属某个 interpreter 或某个 module object。第二层是 CPython runtime state,目标是把可变 runtime 数据放到 PyInterpreterState 或明确受锁保护的位置。第三层是 process state,它继续由进程共享,subinterpreter 只提供约束访问方式的机会。
回到贯穿代码,plugin_interp.call(run_plugin, config) 并未创建新的进程。它创建的是同一进程中的另一套 Python 执行状态。config 能传进去,是因为它只包含 str 和 bytes 等可跨边界处理的数据。插件内部如果写入 globals()["cache"],这个名字属于插件 interpreter 的 __main__ namespace;主 interpreter 的全局变量不会因此被改写。
75.2 Per-Interpreter GIL and Object Ownership
Per-interpreter GIL 把 GIL 的保护范围从进程级运行时收缩到 interpreter 级运行时。GIL 是 CPython 用来保护多数运行时可变状态的互斥锁。历史上多个 interpreter 共享一个进程级 GIL,多个线程即使运行在不同 interpreter 中,也会在执行 Python bytecode 时被同一个 GIL 串行化。CPython 3.12 起具备 per-interpreter GIL 的基础,前提是 interpreter 拥有足够隔离的 runtime state。
这一步改变了并行能力的来源。单独的 interpreter 表示隔离状态;实际并发来自 OS thread;多核并行来自多个 OS thread 分别运行不同 interpreter,并且这些 interpreter 使用各自的 GIL。Python 3.14 文档把这个模型描述为“threads with opt-in sharing”:线程提供调度和 CPU 执行,interpreter 提供默认隔离,显式通信决定哪些数据跨边界流动。
这张图的判断点是锁的归属。两个线程如果进入同一个 interpreter,就仍然受同一把 interpreter GIL 约束。两个线程进入不同 interpreter,才有机会在 Python bytecode 层面并行推进。并行收益还取决于任务是否真正占用 CPU、数据传递成本是否可控、扩展模块是否支持多 interpreter,以及共享进程资源是否引入新的锁竞争。
Object ownership 是 per-interpreter GIL 的配套边界。大多数 Python 对象拥有可变实现细节,最典型的是引用计数。对象如果被多个 interpreter 同时持有,就需要跨 interpreter 的线程安全引用计数、生命周期管理和对象内部状态保护。CPython 的路线是把大多数对象限定在所属 interpreter 内,用复制、序列化、特殊共享对象或不可变对象优化来处理跨边界数据。
在插件宿主中,{"name": "tenant-a", "payload": b"abc"} 作为输入很合适,因为它可以被复制或高效处理。一个打开的数据库连接、一个锁对象、一个 generator、一个带内部可变缓存的普通实例,通常就不是理想的跨 interpreter 参数。它们的 Python identity、底层资源、线程安全假设和析构时机都依赖原 interpreter 的状态。
这里需要把“对象值”和“对象身份”分开。跨 interpreter 传入一个 dict,调用方关心的是字段值。目标 interpreter 得到的通常是另一份可用数据表示。主 interpreter 中原 dict 的 identity、引用关系和可变状态不会自动进入插件 interpreter。插件改写输入副本,也不会把主 interpreter 中的原对象同步更新。
Per-interpreter GIL 还会影响 C API 判断。扩展模块若假设“进程内任意 Python 对象都受同一把 GIL 保护”,在 per-interpreter GIL 下会失去前提。扩展模块需要把状态挂到 module object,使用 heap type 连接 module state,或对明确的 process-global state 使用自己的锁。此时对象所有权的检查顺序是:对象属于哪个 interpreter,扩展模块状态属于哪个 module instance,跨线程访问由哪把锁保护,销毁路径会在哪个 interpreter 中发生。
75.3 Channel, Serialization, and Data Transfer
跨解释器通信的主问题是数据如何离开一个 interpreter,又如何在另一个 interpreter 中成为可用对象。Python 3.14 的 concurrent.interpreters 提供 cross-interpreter queue,PEP 734 把标准库 API 的方向收束到 interpreter 对象、queue 对象和可共享对象规则。通信模型的核心是 message passing:发送端提交数据,运行时按支持情况共享、复制或序列化,接收端获得自己能安全使用的对象。
下面的示例把插件任务改成队列通信。它展示的重点是数据边界,而非完整 worker 框架。
from concurrent import interpreters
request_queue = interpreters.create_queue()
response_queue = interpreters.create_queue()
plugin_interp = interpreters.create()
plugin_interp.prepare_main(
request_queue=request_queue,
response_queue=response_queue,
)
request_queue.put({"name": "tenant-a", "payload": b"abc"})
plugin_interp.exec("""
item = request_queue.get()
response_queue.put({"name": item["name"], "size": len(item["payload"])})
""")
answer = response_queue.get()
这段代码把两个 queue 作为跨 interpreter 通信对象。prepare_main() 把对象绑定到目标 interpreter 的 __main__ namespace,exec() 在目标 interpreter 中执行源码。真实工程中通常会把 exec() 内的代码替换成已导入模块中的函数调用,示例保留字符串源码,只为了展示 __main__ namespace、queue 和数据传递边界之间的关系。
数据传递可以按三类判断。第一类是直接共享或高效处理的不可变内置对象,例如 None、bool、int、float、str、bytes,以及元素同样受支持的 tuple。第二类是通过 pickle 等机制复制的普通对象,接收端拿到的是数据重建结果。第三类是受运行时专门支持的跨 interpreter 可变共享对象,例如 cross-interpreter queue 或部分 buffer 视图,它们带有额外同步和生命周期约束。
Serialization 的成本来自对象图遍历、字节表示构造、目标端反序列化和失败处理。这个成本会改变并行设计:CPU 密集任务如果每次只传少量配置和大块 bytes,subinterpreter 更容易产生收益;如果每个任务都要传大型 Python 对象图、复杂实例和大量回调,通信成本会抵消并行执行收益。判断并行收益时,应把任务计算成本和传输成本放在同一张账里。
跨解释器通信还会改变异常观察方式。目标 interpreter 中未捕获的异常会被包装成调用方可见的 interpreter 相关异常,调用方通常得到异常快照、类型名、消息和部分上下文,而非直接持有目标 interpreter 中的原异常对象。这个设计延续了对象所有权原则:异常也是对象,跨 interpreter 返回时需要经过表示转换。
数据传递的稳定设计是使用显式协议。插件输入使用 dict[str, str | bytes | int | float | None] 这类简单结构,输出也保持同样形状。资源句柄由宿主持有,插件通过消息请求宿主执行受控操作。这样做可以把对象所有权、错误传播和审计日志集中到通信层,减少插件直接共享进程资源带来的状态耦合。
贯穿示例中的 config 正好是这种协议对象。它表达“插件任务需要什么数据”,而没有把宿主的数据库连接、日志对象或缓存对象直接传给插件。主程序获得 result 后,也只读取返回的数据表示。这个边界让每个 interpreter 都能独立管理自己的 module state 和对象生命周期。
75.4 Embedding and Plugin Runtime Design
Subinterpreter 对嵌入式运行时和插件系统的价值,在于它把“同进程效率”和“Python runtime 隔离”组合到一个设计点。进程模型隔离更强,但启动、通信和内存成本更高;线程模型通信成本低,但默认共享同一份 interpreter state;subinterpreter 处在二者之间,适合宿主需要加载多个 Python 逻辑组件,同时保留统一进程部署和受控消息边界的场景。
插件宿主可以采用一条清晰的生命周期路径:创建 interpreter,准备最小 __main__ namespace,导入插件入口模块,传入协议数据,收集返回结果,处理异常,最后关闭 interpreter 或放回池中复用。每一步都应对应可审计的 runtime 对象。创建阶段产生 PyInterpreterState;准备阶段绑定跨边界对象;执行阶段建立 frame 和异常状态;关闭阶段触发模块清理、对象析构和 interpreter finalization。
这个序列图强调宿主仍然是所有权边界的管理者。插件 interpreter 可以运行 Python 代码,但生命周期、输入协议、输出协议和资源访问策略由宿主定义。宿主如果把可变全局对象直接注入插件,就把隔离收益交给对象内部实现和扩展模块线程安全来承担。更稳定的做法是把共享能力包装成消息、队列、不可变数据或宿主代理操作。
扩展模块是插件设计的主要风险点。官方 C API 文档要求 extension module 支持多个 module instance,或明确声明对多 interpreter 的限制。支持多实例通常意味着使用 multi-phase initialization、正的 PyModuleDef.m_size、per-module state、heap type,以及正确的 GC hooks。一个依赖 C static 全局缓存的扩展,在单 interpreter 测试中可能表现正常,但在多个 interpreter 中会把插件状态混到同一份进程级变量里。
因此,插件系统的依赖白名单应包含 multi-interpreter 兼容性维度。评估一个插件依赖时,先判断它是纯 Python 包、稳定支持 per-module state 的 C extension,还是依赖进程级全局状态的扩展。再判断它是否使用线程、信号、文件描述符、外部连接池或 native library 全局回调。最后用目标 Python 版本的文档和小规模压力测试确认导入、并行执行、关闭和重复创建路径。
Subinterpreter 也适合嵌入式应用。C/C++ 宿主可以为不同脚本租户创建 interpreter,把宿主 API 以受控模块或 capsule 暴露给 Python 代码。这里的关键设计是“宿主拥有资源,脚本拥有请求”。脚本通过 Python 对象表达要执行的动作,宿主在 C/C++ 层完成权限检查、资源调用和错误翻译。这样可以把文件、网络、设备句柄和 native 回调留在宿主控制层。
需要单独说明安全边界。多个 interpreter 位于同一进程、同一地址空间内。Python runtime 会尽力分隔 interpreter state,但 native 扩展、内存错误、未受控的进程资源访问和宿主注入对象都可能穿透隔离。Subinterpreter 适合可靠性隔离、状态隔离和并行执行边界;高强度安全隔离应使用进程、容器、沙箱或操作系统权限模型。
75.5 Parallel Runtime Checklist
分析 subinterpreter 并行运行时,应先确定任务边界,再判断 interpreter state、对象所有权、通信方式和失败边界。这个顺序能防止把“能创建 interpreter”误判为“已经具备可扩展并行架构”。创建 interpreter 只是入口;可维护的运行时设计来自一组可验证约束。
第一步,定位执行上下文。确认代码运行在 main interpreter 还是 plugin interpreter,确认当前 OS thread 对应哪个 PyThreadState,确认同一 interpreter 是否被多个线程复用。若多个 CPU 密集任务需要并行,应让不同 OS thread 进入不同 interpreter,并确认版本和扩展依赖支持 per-interpreter GIL 路径。
第二步,检查状态归属。Python 层状态应归属 module object、function object、class object 或 interpreter state。C 层状态应归属 per-module state、明确的 per-process resource,或受锁保护的共享结构。发现 C static 变量、全局单例、native library 全局回调和进程级缓存时,应把它们记录为共享状态,并为它们设计锁、隔离或加载限制。
第三步,检查对象传递。输入和输出应尽量使用简单、可序列化、结构稳定的数据。跨 interpreter 传递普通可变对象时,应按复制语义理解;传递 queue、buffer 等特殊对象时,应按共享语义和同步约束理解。任何依赖 object identity、可变别名同步或析构副作用的设计,都需要重新回到对象所有权层检查。
第四步,检查通信协议。协议应定义消息类型、字段类型、错误表示、超时策略和背压行为。队列容量、任务取消、插件异常、宿主关闭和 interpreter close 都应有明确状态转移。通信层还应承担日志与审计,让宿主能复盘哪个 interpreter 接收了哪条消息,返回了什么结果,在哪个阶段失败。
第五步,检查失败边界。Python 异常、序列化失败、扩展导入失败、worker 线程崩溃、interpreter 关闭失败和进程级资源泄漏属于不同层级。Python 异常可以作为任务失败返回;扩展崩溃可能直接终止进程;进程级资源泄漏会影响所有 interpreter。设计恢复策略时,应根据层级选择重试任务、重建 interpreter、重建进程或隔离依赖。
最后,把这些检查映射回贯穿代码。interpreters.create() 回答执行上下文问题;plugin_interp.call() 回答调度入口问题;config 和 result 回答对象传递问题;插件依赖回答扩展兼容性问题;异常包装和 close 回答失败边界问题。只要这五类问题都有明确答案,subinterpreter 才从“运行一个函数”升级为可复用的并行 runtime 组件。
最小自检任务
阅读下面的代码,判断它能说明哪些隔离边界,哪些边界仍然需要宿主额外设计。
from concurrent import interpreters
counter = {"value": 0}
def plugin_job(data):
data["value"] += 1
return data["value"]
plugin_interp = interpreters.create()
answer = plugin_interp.call(plugin_job, counter)
print(counter["value"], answer)
要求:从 interpreter state、对象所有权、数据传递和并行边界四个角度作答。
答案要点
plugin_interp 创建了新的 interpreter state,plugin_job 在目标 interpreter 的执行上下文中运行。目标 interpreter 有自己的运行时状态,函数调用期间使用目标 interpreter 的 __main__、builtins 和异常状态。创建 interpreter 本身不产生新的进程,也不自动启动新的 OS thread;并行执行还需要把不同 interpreter 放到不同线程中调度。
counter 是普通可变对象。跨 interpreter 传递时,接收端应按复制后的数据表示来理解。plugin_job 中的 data["value"] += 1 改写的是目标 interpreter 可使用的那份对象表示,主 interpreter 中原 counter 的 identity 和可变状态不应被当作同步共享状态。打印结果应按“主侧原对象值”和“插件返回值”分开判断。
这个示例能说明对象所有权边界:跨 interpreter 任务适合传入简单数据,结果通过返回值或 queue 传回。它还说明并行边界需要单独设计:若宿主希望多个插件并行执行,应为每个执行线程绑定目标 interpreter,并设计消息协议、异常表示、关闭策略和扩展模块兼容性检查。
本章知识点总结
- Interpreter state:
PyInterpreterState承载一个 interpreter 执行 Python 代码所需的运行时状态,包括导入状态、builtins、模块视图和内部执行状态。 - Thread state:
PyThreadState把当前 OS thread 的 Python 执行绑定到某个 interpreter,线程切换 interpreter 时需要切换对应 thread state。 - 隔离层级:Subinterpreter 主要隔离 Python runtime state,进程地址空间、文件描述符、环境变量和 native 全局状态仍然属于进程层资源。
- 模块归属:纯 Python 模块通常在每个 interpreter 中拥有自己的 module object 和全局 namespace,主 interpreter 的模块对象不会自动成为插件 interpreter 的同一对象。
- 扩展状态:C extension 应使用 multi-phase initialization 和 per-module state 管理状态,进程级 C
static变量会削弱多 interpreter 隔离。 - Per-interpreter GIL:CPython 3.12+ 的 per-interpreter GIL 让不同线程运行不同 interpreter 时具备多核并行基础,前提是 runtime state 和扩展模块满足隔离要求。
- 对象所有权:大多数 Python 对象应归属于单个 interpreter,跨 interpreter 传递应按复制、序列化、特殊共享对象或不可变对象优化理解。
- 数据协议:插件输入和输出应使用简单、可序列化、结构稳定的数据,把资源访问留给宿主受控接口。
- 通信边界:Cross-interpreter queue 提供消息传递入口,队列协议应定义字段类型、错误表示、超时、背压和关闭路径。
- 异常传播:目标 interpreter 中的异常会经过跨边界表示转换,调用方应按异常快照和任务失败语义处理。
- 插件生命周期:宿主应管理创建、准备 namespace、执行、返回、异常处理、关闭或复用,把每一步映射到明确 runtime 对象。
- 安全边界:Subinterpreter 适合状态隔离、可靠性隔离和并行执行,高强度安全隔离应交给进程、容器、沙箱或操作系统权限模型。
- 检查顺序:分析并行 runtime 时,先定位执行上下文,再检查状态归属、对象传递、通信协议和失败边界。