Python - PEP 734 – 标准库中的多解释器
说明
本 PEP 实质上是 PEP 554 的延续。PEP 554 在长达 7 年的讨论中包含了许多辅助信息。本 PEP 重新回归最核心的内容,许多额外内容依然有效且有用,只是与当前具体提案关系不大。说明
本 PEP 已被接受,条件是名称更改为concurrent.interpreters。
摘要
本 PEP 提议新增一个 interpreters 模块,用于在当前进程中检查、创建并运行多个解释器。该模块包含代表底层解释器的 Interpreter 对象,并提供一个基础的 Queue 队列类实现解释器间通信。最后,还会基于该模块新增 concurrent.futures.InterpreterPoolExecutor。
引言
本质上,“解释器”是所有 Python 线程必须共享的运行时状态的集合。我们先看线程,再回到解释器。
线程与线程状态
一个 Python 进程可能有多个 OS 线程运行 Python 代码(或以其它方式与 C API 交互)。每个线程通过自己的线程状态(PyThreadState)与 CPython 运行时交互,线程状态记录该线程独有的所有运行时状态,也有些运行时状态是多个线程共享的。
任意 OS 线程都可切换正在使用的线程状态,只要不是被其他线程正在用的。该“当前”线程状态存于线程局部变量,可用 PyThreadState_Get() 查询。主线程和 threading.Thread 对象自动设置该状态,也可通过 C API 的 PyThreadState_Swap() 设置(及清除),或用 PyGILState_Ensure()。大多数 C API 都要求当前线程状态已设置。
OS 线程与线程状态是一对多关系。每个线程状态最多只关联一个 OS 线程,并记录其线程 ID,且不会被其他线程复用。但一个 OS 线程可有多个线程状态,只是同一时刻只用一个。
若一个线程有多个线程状态,可在该线程内用 PyThreadState_Swap() 切换,切换后原状态暂停,待切回去时恢复。
解释器状态
有些运行时状态会被多个 OS 线程共享。部分通过 sys 模块暴露,大多数仅限内部使用或通过 C API 间接暴露。
这些共享状态称为解释器状态(PyInterpreterState),简称“解释器”。当然,这一词有时也指 python 可执行文件、Python 实现或字节码解释器(如 exec()/eval())。
自 CPython 1.5(1997)起,便支持同一进程中有多个解释器(即“子解释器”)。可通过 C API 使用该特性。
解释器与线程
线程状态和解释器状态的关系类似于 OS 线程与进程(高层次看)。线程状态只属于一个解释器(并存有指针),不会切换到其它解释器。反过来,一个解释器可有 0 个或多个线程状态。当一个线程状态为当前状态时,该解释器才算在该 OS 线程上活跃。
通过 C API 的 Py_NewInterpreterFromConfig() (或 Py_NewInterpreter(),后者是前者的轻量封装)创建解释器,其流程为:
- 创建新的解释器状态
- 创建新的线程状态
- 设为当前线程状态(初始化解释器需当前 tstate)
- 用该线程状态初始化解释器
- 返回该线程状态(仍为当前状态)
注意,返回的线程状态可立即丢弃。只有在需要用到该解释器时,才必须有线程状态并激活。
要在当前 OS 线程激活某解释器,需确保该解释器有对应线程状态,然后在该线程用 PyThreadState_Swap() 切换。若原先是别的解释器的线程状态,则切出、暂停,等下次切回。
一旦解释器在当前线程激活,该线程即可调用任意 C API,如 PyEval_EvalCode()(即 exec())。这以当前线程状态为运行上下文。
“主”解释器
Python 进程启动时,会创建一个主解释器状态和主线程状态,并用其初始化运行时。
初始化后,脚本/模块/REPL 会用这对状态执行,运行于解释器的 __main__ 模块。
进程在主线程运行完 Python 代码或 REPL 后,会用主解释器完成运行时析构。
析构过程对仍在运行的 Python 线程(不论主解释器还是子解释器)影响很小,仅会等待所有非守护线程结束。
C API 可被查询,但没有机制直接通知任何 Python 线程析构已开始,除了可用 threading._register_atexit() 注册的“atexit”函数。
剩余子解释器会稍后析构,但此时不在任何线程被激活。
解释器隔离
CPython 设计上各解释器应严格隔离,互不共享对象(极个别例外,比如不灭的、不可变的内置对象)。每个解释器有独立的模块(sys.modules)、类、函数、变量。即使两个解释器定义了同名类,也各自拥有独立副本。C 层状态(包括扩展模块)也是如此。详见 CPython C API 文档 相关说明。
但有些全局状态始终共享,包括部分可变和不可变数据。共享不可变对象问题不大,甚至有利于性能;共享可变对象则需特别管理,尤其是线程安全,部分由 OS 负责。
可变的如:
- 文件描述符
- 低级环境变量
- 进程内存(分配器已隔离)
- 解释器列表
不可变的如:
- 内置类型(如
dict,bytes) - 单例(如
None) - 内置/扩展/冻结模块的底层静态数据(如函数)
已有执行组件
Python 中已有不少组件可帮助理解如何在子解释器中运行代码。
CPython 各组件底层主要围绕如下 C API 函数:
PyEval_EvalCode():用指定 code object 运行字节码解释器PyRun_String():编译 +PyEval_EvalCode()PyRun_File():读文件 + 编译 +PyEval_EvalCode()PyRun_InteractiveOneObject():编译 +PyEval_EvalCode()PyObject_Call():调用PyEval_EvalCode()
内建 exec()
内建 exec() 可用来执行 Python 代码,本质上是 PyRun_String() 和 PyEval_EvalCode() 的封装。
其特点包括:
- 在当前 OS 线程运行,暂停原线程,直到
exec()结束,其他线程不受影响。(如想避免主线程暂停,可在threading.Thread中运行) - 可启动新线程,不会干扰自身
- 执行于“globals”命名空间(及“locals”),模块级默认用当前模块的
__dict__(即globals()),不做清理 - 如有未捕获异常,会在调用
exec()的 Python 线程抛出
命令行
python CLI 提供多种运行代码方式,对应 C API:
<无参数>,-i—— 运行 REPL(PyRun_InteractiveOneObject())<文件名>—— 运行脚本(PyRun_File())-c <代码>—— 运行指定代码(PyRun_String())-m module—— 以脚本方式运行模块(PyEval_EvalCode()viarunpy._run_module_as_main())
本质都是在主解释器 __main__ 的顶层用 exec() 的变体。
threading.Thread
启动 Python 线程时,会用新的线程状态通过 PyObject_Call() 运行“target”函数。globals 来自 func.__globals__,未捕获异常会被丢弃。
动机
interpreters 模块将为多解释器功能提供高级接口。目标是让 CPython 现有的多解释器特性更便于 Python 代码访问。随着 CPython 拥有了 per-interpreter GIL(PEP 684),对此需求更为迫切。
若无标准库模块,用户仅能用 C API,大大限制了多解释器功能的探索和应用。
该模块还包含基础的解释器间通信机制。否则,多解释器特性会大打折扣。
规范
该模块将:
- 暴露现有多解释器支持
- 引入基础的解释器间通信机制
它会包装新的底层 _interpreters 模块(类似 threading),但低层 API 非公开接口,不在本提案范围。
使用解释器
模块定义如下函数:
get_current() -> Interpreter- 返回当前执行解释器的
Interpreter对象。
- 返回当前执行解释器的
list_all() -> list[Interpreter]- 返回所有现有解释器的
Interpreter对象,无论是否被激活。
- 返回所有现有解释器的
create() -> Interpreter- 创建新解释器并返回
Interpreter对象。解释器本身不会主动做任何事,也不与任何 OS 线程绑定,只有实际运行时才会绑定。内部是否预建线程状态是实现细节。
- 创建新解释器并返回
Interpreter 对象
interpreters.Interpreter 对象代表唯一 ID 的解释器(PyInterpreterState),每个解释器只对应一个对象。
如由 interpreters.create() 创建,则当所有同 ID 的 Interpreter 对象被删除时,该解释器被销毁。
Interpreter 对象也可代表非 interpreters.create() 创建的解释器(如主解释器或 C-API 创建的),但无法用如 exec() 等方法与之交互(未来可能放宽)。
主要属性及方法:
id只读,非负整数,类似进程 ID__hash__(),返回 id 的哈希is_running(),若解释器当前正执行__main__,则返回 True(不包括子线程)prepare_main(**kwargs),向解释器的__main__绑定对象。大多数对象以 pickle 复制,部分如memoryview共享底层数据,详见可共享对象exec(code, /),在该解释器的__main__中执行代码(会暂停当前 OS 线程),如有异常以ExecutionFailed传播,异常信息带原始追踪call(callable, /),在该解释器中调用无参函数,返回值丢弃,如有异常同上call_in_thread(callable, /) -> threading.Thread,在新线程调用call(),异常不传播close(),销毁解释器
解释器间通信
模块引入基于特殊队列的基础通信机制。
解释器队列为 interpreters.Queue 对象,实际数据结构在解释器外部,全局唯一。队列能安全地在解释器间传递对象数据,不破坏隔离,且线程安全。
每次 put() 加在队尾,get() 从队首弹出,保证先进先出。
可 pickle 的对象都可通过队列传递。对象本身不是直接传递,而是其底层数据(通常序列化后)被传递,详见可共享对象。
模块定义如下函数:
create_queue(maxsize=0) -> Queue- 创建新队列。maxsize≤0 表示无限制。
Queue 对象
interpreters.Queue 代表唯一 ID 的全局队列,每个队列只对应一个对象。
其方法与 queue.Queue 基本一致(不含 task_done() 和 join()),类似于 asyncio.Queue 和 multiprocessing.Queue。
主要属性及方法:
id,只读,唯一ID,类似于管道的 fdmaxsize,只读,队列最大容量,0 表示无上限__hash__(),返回 id 的哈希empty(),队列是否为空(快照)full(),是否已满(快照,maxsize=0 永不为真)qsize(),当前队列元素数量(快照)put(obj, timeout=None),入队,若已满则阻塞,timeout>0 时超时抛 interpreters.QueueFullput_nowait(obj),非阻塞入队,满则立即抛 interpreters.QueueFullget(timeout=None),阻塞出队,timeout 超时抛 interpreters.QueueEmptyget_nowait(),非阻塞出队,空则立即抛 interpreters.QueueEmpty
如队列中仍有对象时,放入该对象的解释器被销毁,则对象会立即从队列移除。
可共享对象
“可共享对象”指可在解释器间传递的对象。对象本身不会真正共享,但应像直接共享一样处理(可变对象需注意)。
所有可 pickle 对象都可共享,interpreters.Queue 亦可。
通常对象通过 pickle 传递。实现 buffer 协议(如 memoryview)的对象则直接共享底层 Py_buffer,通过新 memoryview 包装。
大部分可变对象传递时会被复制,原/副本修改互不影响。interpreters.Queue 和 buffer 协议对象除外,它们底层数据在解释器间同步。
实际共享可变数据时总有数据竞争风险,interpreters.Queue 内建跨解释器线程安全,但 buffer 协议无此保证,需用户自行同步(如通过队列传递 token 实现互斥,或分配不同子区间)。
同步
解释器间如需同步,可通过队列协作。无法直接共享 threading.Lock,但可以在队列间传递 token 管理资源。
例如:
import interpreters
from mymodule import load_big_data, check_data
numworkers = 10
control = interpreters.create_queue()
data = memoryview(load_big_data())
def worker():
interp = interpreters.create()
interp.prepare_main(control=control, data=data)
interp.exec("""if True:
from mymodule import edit_data
while True:
token = control.get()
edit_data(data)
control.put(token)
""")
threads = [threading.Thread(target=worker) for _ in range(numworkers)]
for t in threads:
t.start()
token = 'football'
control.put(token)
while True:
control.get()
if not check_data(data):
break
control.put(token)
异常
InterpreterError,解释器相关错误(Exception 子类)InterpreterNotFoundError,解释器已销毁时方法抛出(InterpreterError 子类)ExecutionFailed,Interpreter.exec/call 未捕获异常时抛出。包含原始异常类型、消息、traceback。InterpreterError 子类QueueError,队列相关错误(Exception 子类)QueueNotFoundError,队列销毁后方法抛出(QueueError 子类)QueueEmpty,get/get_nowait 取空抛出(QueueError 和 queue.Empty 子类)QueueFull,put/put_nowait 满队抛出(QueueError 和 queue.Full 子类)
InterpreterPoolExecutor
新增 concurrent.futures.InterpreterPoolExecutor,类似 ThreadPoolExecutor,每个 worker 在独立线程和子解释器中运行。
可通过 initializer 和 task 传递任意可序列化对象,worker 之间可用 interpreters.Queue 通信。
sys.implementation.supports_isolated_interpreters
并非所有 Python 实现都支持子解释器。若支持,sys.implementation.supports_isolated_interpreters 设为 True,否则为 False。不支持时导入 interpreters 会抛 ImportError。
示例
示例1:
- 每个 worker 线程一个解释器
- 任务队列分发,结果队列收集
- 结果处理在独立线程
- worker 收到 None 停止,结果处理直到所有 worker 停止
示例2:
- 多 worker 线程,每个处理数据块
- 所有解释器共享源数组的 buffer
- 结果写入第二个共享 buffer
- 任务队列调度
- 每个数据块只被一个 worker 读写,保证线程安全
原理
极简 API
由于核心开发团队缺乏多解释器实际应用经验,本提案有意保持 API 极简,为后续扩展打基础。
设计吸取了社区已有子解释器实践、标准库模块、其它语言经验,以及 CPython 测试和并发基准的教训。
create(), create_queue()
通常用户直接用类型构造实例,interpreters 模块要求用 create/create_queue 创建解释器和队列。直接构造仅返回已存在解释器/队列的包装器,因为这些资源全局唯一、不可由单解释器私有。
prepare_main() 支持多变量
prepare_main() 支持一次设置多个变量,主要为性能考虑。每次切换 OS 线程到解释器有不小开销,批量赋值可显著减少代价。
异常传播
Interpreter.exec() 的异常会以 ExecutionFailed 形式传播,而不是直接抛原异常,因为解释器间不能共享对象,包括异常。ExecutionFailed 作为 surrogate,保存原异常追踪,便于调试,但不能被 except ValueError 捕获,只能 except ExecutionFailed。
而 Interpreter.call() 传播的异常直接在当前解释器 raise,因为 call() 是高级方法,可支持更多对象。
对象 vs. ID 代理
低层模块用进程全局 ID 代理解释器和队列状态,因状态全局唯一、跨解释器,所以用 PyObject 不合适。interpreters 模块用对象弱关联 ID。
被拒绝的方案
详见 PEP 554。
版权
本文档可置于公有领域或 CC0-1.0-Universal 许可下,以更宽松者为准。
原文地址:https://peps.python.org/pep-0734/
1322

被折叠的 条评论
为什么被折叠?



