Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 734 – Stdlib 中的多解释器

作者:
Eric Snow <ericsnowcurrently at gmail.com>
讨论至:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
创建日期:
2023年11月6日
Python 版本:
3.14
发布历史:
2023年12月14日
取代:
554
决议:
2025年6月5日

目录

重要

本 PEP 是一份历史文档。最新的规范文档现在可以在 concurrent.interpreters 找到。

×

有关如何提出更改,请参阅 PEP 1

注意

本 PEP 实质上是 PEP 554 的延续。该文档在七年的讨论中积累了大量辅助信息。本 PEP 旨在将其精简回核心信息。许多额外的M信息仍然有效且有用,只是不在此处特定提案的直接上下文中。

注意

本 PEP 已被接受,但有一个条件:名称更改为 concurrent.interpreters

摘要

本 PEP 提议添加一个新模块 interpreters,以支持在当前进程中检查、创建和运行多个解释器中的代码。这包括代表底层解释器的 Interpreter 对象。该模块还将提供一个基本的 Queue 类用于解释器之间的通信。最后,我们将基于 interpreters 模块添加一个新的 concurrent.futures.InterpreterPoolExecutor

引言

从根本上说,“解释器”是(本质上)所有 Python 线程必须共享的运行时状态的集合。因此,让我们首先看一下线程。然后我们再回到解释器。

线程和线程状态

一个 Python 进程将有一个或多个 OS 线程运行 Python 代码(或以其他方式与 C API 交互)。这些线程中的每一个都使用自己的线程状态 (PyThreadState) 与 CPython 运行时交互,该线程状态保存了该线程独有的所有运行时状态。还有一些运行时状态在多个 OS 线程之间共享。

任何 OS 线程都可以切换它当前正在使用的线程状态,只要它不是另一个 OS 线程已经在使用(或曾经使用)的线程状态。这种“当前”线程状态由运行时存储在线程局部变量中,并且可以通过 PyThreadState_Get() 显式查找。它会自动为初始(“主”)OS 线程和 threading.Thread 对象设置。从 C API,它由 PyThreadState_Swap() 设置(和清除),并且可以通过 PyGILState_Ensure() 设置。大多数 C API 要求存在一个当前线程状态,无论是隐式查找还是作为参数传入。

OS 线程和线程状态之间的关系是多对一的。每个线程状态最多与一个 OS 线程关联并记录其线程 ID。一个线程状态绝不会用于多个 OS 线程。然而,另一方面,一个 OS 线程可能关联多个线程状态,尽管,同样,只有一个可以是当前的。

当一个 OS 线程有多个线程状态时,在该 OS 线程中使用 PyThreadState_Swap() 在它们之间切换,请求的线程状态成为当前状态。使用旧线程状态在该线程中运行的任何内容都会有效暂停,直到该线程状态被换回。

解释器状态

如前所述,有些运行时状态是多个 OS 线程共享的。其中一些由 sys 模块公开,但许多在内部使用,并未显式公开或仅通过 C API 公开。

这种共享状态称为解释器状态 (PyInterpreterState)。我们在这里有时会将其简称为“解释器”,尽管它有时也指 python 可执行文件、Python 实现和字节码解释器(即 exec()/eval())。

自 1.5 版(1997 年)以来,CPython 就支持在同一进程中运行多个解释器(又称“子解释器”)。该功能可通过 C API 获得。

解释器和线程

线程状态与解释器状态之间的关系与 OS 线程和进程之间的关系大致相同(在高层次上)。首先,这种关系是多对一的。一个线程状态属于一个解释器(并存储指向它的指针)。该线程状态绝不会用于不同的解释器。然而,另一方面,一个解释器可能关联零个或多个线程状态。解释器仅在其某个线程状态为当前的 OS 线程中被视为活动。

解释器通过 C API 使用 Py_NewInterpreterFromConfig()(或 Py_NewInterpreter(),它是 Py_NewInterpreterFromConfig() 的轻量级包装器)创建。该函数执行以下操作:

  1. 创建一个新的解释器状态
  2. 创建一个新的线程状态
  3. 将线程状态设置为当前状态(解释器初始化需要一个当前线程状态)
  4. 使用该线程状态初始化解释器状态
  5. 返回线程状态(仍为当前状态)

请注意,返回的线程状态可以立即丢弃。除了解释器实际要使用时,没有要求解释器拥有任何线程状态。届时,它必须在当前 OS 线程中激活。

为了使现有解释器在当前 OS 线程中激活,C API 用户首先确保该解释器具有相应的线程状态。然后像正常一样使用该线程状态调用 PyThreadState_Swap()。如果另一个解释器的线程状态已经处于当前状态,那么它将像正常一样被换出,并且该解释器在该 OS 线程中的执行将有效暂停,直到它被换回。

一旦解释器以这种方式在当前 OS 线程中处于活动状态,线程就可以调用任何 C API,例如 PyEval_EvalCode()(即 exec())。这通过使用当前线程状态作为运行时上下文来实现。

“主”解释器

当一个 Python 进程启动时,它会为当前 OS 线程创建一个单一的解释器状态(“主”解释器)和一个单一的线程状态。然后使用它们初始化 Python 运行时。

初始化后,脚本、模块或 REPL 将使用它们执行。该执行发生在解释器的 __main__ 模块中。

当进程在主 OS 线程中完成运行请求的 Python 代码或 REPL 时,Python 运行时将使用主解释器在该线程中完成。

运行时终结对仍在运行的 Python 线程(无论是在主解释器还是子解释器中)只有轻微的、间接的影响。这是因为运行时会立即无限期地等待所有非守护 Python 线程完成。

虽然可以查询 C API,但没有任何机制可以直接通知任何 Python 线程终结已开始,除了可能通过 threading._register_atexit() 注册的“atexit”函数。

任何剩余的子解释器都会稍后自行终结,但那时它们在任何 OS 线程中都不再是当前状态。

解释器隔离

CPython 的解释器旨在严格相互隔离。这意味着解释器从不共享对象(除非在非常特定的情况下,即不朽的、不可变的内置对象)。每个解释器都有自己的模块 (sys.modules)、类、函数和变量。即使两个解释器定义相同的类,每个解释器也会有自己的副本。对于 C 中的状态,包括扩展模块中的状态,也适用相同的情况。CPython C API 文档 提供了更多解释

值得注意的是,存在一些解释器将始终共享的进程全局状态,有些是可变的,有些是不可变的。共享不可变状态问题很少,同时提供一些好处(主要是性能)。然而,所有共享的可变状态都需要特殊管理,特别是对于线程安全,其中一些由操作系统为我们处理。

可变

  • 文件描述符
  • 低级环境变量
  • 进程内存(尽管分配器是隔离的)
  • 解释器列表

不可变

  • 内置类型(例如 dictbytes
  • 单例(例如 None
  • 内置/扩展/冻结模块的底层静态模块数据(例如函数)

现有执行组件

Python 中有许多现有部分可能有助于理解如何在子解释器中运行代码。

在 CPython 中,每个组件都围绕以下 C API 函数(或变体)之一构建

  • PyEval_EvalCode():使用给定的代码对象运行字节码解释器
  • PyRun_String():编译 + PyEval_EvalCode()
  • PyRun_File():读取 + 编译 + PyEval_EvalCode()
  • PyRun_InteractiveOneObject():编译 + PyEval_EvalCode()
  • PyObject_Call():调用 PyEval_EvalCode()

builtins.exec()

内置函数 exec() 可用于执行 Python 代码。它本质上是 C API 函数 PyRun_String()PyEval_EvalCode() 的包装器。

以下是内置函数 exec() 的一些相关特性

  • 它在当前 OS 线程中运行,并暂停该线程中正在运行的任何内容,当 exec() 完成时恢复。不影响其他 OS 线程。(为了避免暂停当前 Python 线程,请在 threading.Thread 中运行 exec()。)
  • 它可以启动额外的线程,但不会中断它。
  • 它针对一个“全局”命名空间(和一个“局部”命名空间)执行。在模块级别,exec() 默认为使用当前模块的 __dict__(即 globals())。exec() 按原样使用该命名空间,不进行清除。
  • 它传播所运行代码中任何未捕获的异常。异常从最初调用 exec() 的 Python 线程中的 exec() 调用中抛出。

命令行

python CLI 提供了几种运行 Python 代码的方式。每种情况都映射到相应的 C API 调用

  • <no args>, -i - 运行 REPL (PyRun_InteractiveOneObject())
  • <filename> - 运行脚本 (PyRun_File())
  • -c <code> - 运行给定的 Python 代码 (PyRun_String())
  • -m module - 将模块作为脚本运行 (PyEval_EvalCode() 通过 runpy._run_module_as_main())

在每种情况下,它本质上都是在主解释器的 __main__ 模块的顶层运行 exec() 的变体。

threading.Thread

当一个 Python 线程启动时,它会使用新的线程状态通过 PyObject_Call() 运行“目标”函数。全局命名空间来自 func.__globals__,任何未捕获的异常都会被丢弃。

动机

interpreters 模块将提供一个高级接口来处理多解释器功能。目标是使 CPython 现有的多解释器功能更容易被 Python 代码访问。现在 CPython 具有每个解释器 GIL (PEP 684),并且人们对使用多解释器更感兴趣,这尤其相关。

如果没有标准库模块,用户只能使用 C API,这限制了他们尝试和利用多解释器的程度。

该模块将包含一个解释器之间通信的基本机制。如果没有它,多解释器将是一个用处不大的功能。

规范

该模块将

  • 公开现有的多解释器支持
  • 引入解释器之间通信的基本机制

该模块将封装一个新的低级 _interpreters 模块(与 threading 模块的方式相同)。然而,该低级 API 不打算供公众使用,因此不属于本提案的一部分。

使用解释器

该模块定义了以下函数

  • get_current() -> Interpreter
    返回当前正在执行的解释器的 Interpreter 对象。
  • list_all() -> list[Interpreter]
    返回每个现有解释器的 Interpreter 对象,无论它是否在任何 OS 线程中运行。
  • create() -> Interpreter
    创建一个新的解释器并返回其 Interpreter 对象。该解释器本身不执行任何操作,并且不与任何 OS 线程固有绑定。只有当解释器中实际运行某些内容(例如 Interpreter.exec())时,并且仅在运行时,才会发生这种情况。解释器可能准备好使用线程状态,也可能没有,但这严格是一个内部实现细节。

解释器对象

一个 interpreters.Interpreter 对象,代表具有相应唯一 ID 的解释器 (PyInterpreterState)。任何给定的解释器将只有一个对象。

如果解释器是使用 interpreters.create() 创建的,那么只要所有具有其 ID 的 Interpreter 对象(跨所有解释器)都被删除,它就会被销毁。

Interpreter 对象可以表示除 interpreters.create() 创建的解释器之外的其他解释器。示例包括主解释器(由 Python 运行时初始化创建)和通过 C-API 使用 Py_NewInterpreter() 创建的解释器。此类 Interpreter 对象将无法与其相应的解释器交互,例如通过 Interpreter.exec()(尽管我们将来可能会放宽此限制)。

属性和方法

  • id
    (只读) 一个非负 int,用于标识此 Interpreter 实例所代表的解释器。从概念上讲,这类似于进程 ID。
  • __hash__()
    返回解释器 id 的哈希值。这与 ID 整数值的哈希值相同。
  • is_running() -> bool
    如果解释器当前正在其 __main__ 模块中执行代码,则返回 True。这不包括子线程。

    它仅指是否存在 OS 线程在该解释器的 __main__ 模块中运行脚本(代码)。这基本上意味着 Interpreter.exec() 是否在某个 OS 线程中运行。在子线程中运行的代码被忽略。

  • prepare_main(**kwargs)
    将一个或多个对象绑定到解释器的 __main__ 模块中。

    关键字参数名称将用作属性名称。对于大多数对象,副本将绑定到解释器中,中间使用 pickle。对于某些对象,如 memoryview,底层数据将在解释器之间共享。请参阅 可共享对象

    prepare_main() 有助于在解释器中运行代码之前初始化其全局变量。

  • exec(code, /)
    在解释器中(在当前 OS 线程中)使用其 __main__ 模块执行给定的源代码。它不返回任何内容。

    这本质上等同于在当前 OS 线程中切换到此解释器,然后使用此解释器 __main__ 模块的 __dict__ 作为全局变量和局部变量调用内置的 exec()

    在当前 OS 线程中运行的代码(另一个解释器)将有效暂停,直到 Interpreter.exec() 完成。为了避免暂停它,创建一个新的 threading.Thread 并在其中调用 Interpreter.exec()(就像 Interpreter.call_in_thread() 所做的那样)。

    Interpreter.exec() 不会重置解释器状态或 __main__ 模块,无论是在之前还是之后,因此每次连续调用都会从上次停止的地方继续。这对于运行一些代码来初始化解释器(例如导入)然后执行一些重复任务可能很有用。

    如果存在未捕获的异常,它将以 ExecutionFailed 的形式传播到调用解释器中。原始异常的完整错误显示(相对于被调用解释器生成)将保留在传播的 ExecutionFailed 上。这包括完整的追溯,以及所有额外的信息,例如语法错误详细信息和链式异常。如果 ExecutionFailed 未被捕获,那么将显示该完整错误显示,就像传播的异常在主解释器中被抛出但未捕获一样。拥有完整的追溯在调试时特别有用。

    如果不需要异常传播,则应在传递给 Interpreter.exec() 的 *代码* 周围使用显式 try-except。同样,任何依赖于异常特定信息的错误处理都必须在给定 *代码* 周围使用显式 try-except,因为 ExecutionFailed 将不保留该信息。

  • call(callable, /)
    在解释器中调用可调用对象。返回值将被丢弃。如果可调用对象抛出异常,则该异常将以 ExecutionFailed 异常的形式传播,其方式与 Interpreter.exec() 相同。

    目前仅支持普通函数,且仅支持不带参数且没有 cell 变量的函数。自由全局变量将根据目标解释器的 __main__ 模块解析。

    将来,我们可以添加对参数、闭包和更广泛的可调用对象类型的支持,至少部分通过 pickle 实现。我们还可以考虑不丢弃返回值。最初的限制是为了让我们能够更快地向用户提供模块的基本功能。

  • call_in_thread(callable, /) -> threading.Thread
    本质上,在新线程中应用 Interpreter.call()。返回值被丢弃,异常不传播。

    call_in_thread() 大致等同于

    def task():
        interp.call(func)
    t = threading.Thread(target=task)
    t.start()
    
  • 关闭()
    销毁底层解释器。

解释器间通信

该模块通过特殊队列引入了一种基本的通信机制。

存在 interpreters.Queue 对象,但它们仅代理实际的数据结构:一个存在于任何一个解释器之外的无界 FIFO 队列。这些队列具有特殊的机制,可以在不违反解释器隔离的情况下安全地在解释器之间传递对象数据。这包括线程安全。

与 Python 中的其他队列一样,每次“put”都会将对象添加到队尾,每次“get”都会弹出队列头部的下一个对象。每个添加的对象都将按其入队顺序弹出。

任何可被 pickle 的对象都可以通过 interpreters.Queue 发送。

请注意,实际对象不会被发送,而是它们的底层数据被发送。结果对象与原始对象严格等效。对于大多数对象,底层数据会被序列化(例如,通过 pickle)。在少数情况下,例如 memoryview,底层数据会不经序列化地发送(和共享)。请参阅 可共享对象

该模块定义了以下函数

  • create_queue(maxsize=0) -> Queue
    创建一个新队列。如果 maxsize 为零或负数,则队列是无界的。

队列对象

interpreters.Queue 对象充当由 interpreters 模块公开的底层跨解释器安全队列的代理。每个 Queue 对象代表具有相应唯一 ID 的队列。任何给定队列将只有一个对象。

Queue 实现了 queue.Queue 的所有方法,除了 task_done()join(),因此它类似于 asyncio.Queuemultiprocessing.Queue

属性和方法

  • id
    (只读) 一个非负 int,用于标识相应的跨解释器队列。从概念上讲,这类似于用于管道的文件描述符。
  • 最大尺寸
    (只读) 队列中允许的项数。零表示“无限制”。
  • __hash__()
    返回队列 id 的哈希值。这与 ID 整数值的哈希值相同。
  • 空()
    如果队列为空,则返回 True,否则返回 False

    这只是调用时状态的快照。其他线程或解释器可能会导致此状态改变。

  • 满()
    如果队列中有 maxsize 个项目,则返回 True

    如果队列初始化时 maxsize=0(默认值),则 full() 永远不会返回 True

    这只是调用时状态的快照。其他线程或解释器可能会导致此状态改变。

  • qsize()
    返回队列中的项目数。

    这只是调用时状态的快照。其他线程或解释器可能会导致此状态改变。

  • put(obj, timeout=None)
    将对象添加到队列。

    如果 maxsize > 0 且队列已满,则此操作将阻塞,直到有可用插槽。如果 *timeout* 是正数,则它只阻塞至少那么秒,然后抛出 interpreters.QueueFull。否则将永远阻塞。

    几乎所有对象都可以通过队列发送。在少数情况下,例如 memoryview,底层数据实际上是共享的,而不是仅仅复制。请参阅 可共享对象

    如果一个对象仍在队列中,并且将它放入队列的解释器(即它所属的解释器)被销毁,那么该对象将立即从队列中移除。(我们以后可能会添加一个选项,将队列中移除的对象替换为哨兵,或者为相应的 get() 调用抛出异常。)

  • put_nowait(obj
    put() 类似,但实际上是即时超时。因此,如果队列已满,它会立即抛出 interpreters.QueueFull
  • get(timeout=None) -> object
    从队列中弹出下一个对象并返回。当队列为空时阻塞。如果提供了正的 *timeout* 并且在该秒内没有对象添加到队列中,则抛出 interpreters.QueueEmpty
  • get_nowait() -> object
    get() 类似,但不阻塞。如果队列不为空,则返回下一个项目。否则,抛出 interpreters.QueueEmpty

可共享对象

“可共享”对象是指可以从一个解释器传递到另一个解释器的对象。对象实际上并非由解释器直接共享。然而,共享对象应被视为直接共享,但可变性方面有注意事项。

所有可被 pickle 的对象都是可共享的。因此,几乎所有对象都是可共享的。interpreters.Queue 对象也是可共享的。

在几乎所有将对象发送到解释器的情况下,无论是通过 interp.prepare_main() 还是 queue.put(),实际对象都不会被发送。相反,发送的是对象的底层数据。对于大多数对象,对象会被 pickle,接收解释器将其 unpickle。

一个值得注意的例外是实现“缓冲区”协议的对象,例如 memoryview。它们的底层 Py_buffer 实际上在解释器之间共享。interp.prepare_main()queue.get() 将缓冲区包装在一个新的 memoryview 对象中。

对于大多数可变对象,当一个对象发送到另一个解释器时,它会被复制。因此,对原始对象或副本的任何更改都不会同步到另一个。通过 pickle 共享的可变对象属于此类别。然而,interpreters.Queue 和实现缓冲区协议的对象是值得注意的案例,它们的底层数据 *是* 在解释器之间共享的,因此对象保持同步。

当解释器真正共享可变数据时,总是存在数据竞争的风险。跨解释器安全,包括线程安全,是 interpreters.Queue 的基本特性。

然而,缓冲区协议(即 Py_buffer)没有任何针对数据竞争的本机机制。相反,用户负责管理线程安全,无论是通过队列来回传递令牌以表示安全(参见 同步),还是将子范围排他性分配给各个解释器。

大多数对象将通过队列 (interpreters.Queue) 共享,因为解释器之间相互通信信息。不那么频繁地,对象将通过 prepare_main() 共享,以在运行代码之前设置解释器。然而,prepare_main() 是队列共享的主要方式,它为另一个解释器提供了进一步通信的手段。

同步

在某些情况下,两个解释器应该同步。这可能涉及共享资源、工作者管理或保持顺序一致性。

在多线程编程中,典型的同步原语是互斥锁等类型。threading 模块公开了多个。然而,解释器不能共享对象,这意味着它们不能共享 threading.Lock 对象。

interpreters 模块不提供任何此类专用同步原语。相反,interpreters.Queue 对象提供了可能需要的一切。

例如,如果有一个需要管理访问的共享资源,那么可以使用队列来管理它,解释器之间传递一个对象来指示谁可以使用该资源

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)

异常

  • 解释器错误
    表示发生了与解释器相关的故障。

    此异常是 Exception 的子类。

  • InterpreterNotFoundError
    在底层解释器被销毁后(例如通过 C-API),从 Interpreter 方法中抛出。

    此异常是 InterpreterError 的子类。

  • ExecutionFailed
    当存在未捕获异常时,从 Interpreter.exec()Interpreter.call() 抛出。此异常的错误显示包含未捕获异常的追溯,它在正常错误显示之后显示,就像 ExceptionGroup 的情况一样。

    属性

    • type - 原始异常类的表示,具有 __name____module____qualname__ 属性。
    • msg - 原始异常的 str(exc)
    • snapshot - 原始异常的 traceback.TracebackException 对象

    此异常是 InterpreterError 的子类。

  • 队列错误
    表示发生了与队列相关的故障。

    此异常是 Exception 的子类。

  • QueueNotFoundError
    当底层队列被销毁后,从 interpreters.Queue 方法中抛出。

    此异常是 QueueError 的子类。

  • QueueEmpty
    当队列为空时,从 Queue.get() (或 get_nowait() 且未提供默认值) 中抛出。

    此异常既是 QueueError 的子类,也是标准库 queue.Empty 的子类。

  • QueueFull
    当队列已达到其最大大小时,从 Queue.put()(带超时)或 put_nowait() 抛出。

    此异常既是 QueueError 的子类,也是标准库 queue.Empty 的子类。

InterpreterPoolExecutor

除了新的 interpreters 模块外,还将有一个新的 concurrent.futures.InterpreterPoolExecutor。它将是 ThreadPoolExecutor 的一个衍生品,其中每个工作器都在自己的线程中执行,但每个工作器都有自己的子解释器。

与其他执行器一样,InterpreterPoolExecutor 将支持任务的可调用对象和初始化器。同样,像其他执行器一样,这两种情况下的参数大多不受限制。可调用对象和参数通常在发送到工作器的解释器时被序列化,例如使用 pickle,就像 ProcessPoolExecutor 的工作方式一样。这与 Interpreter.call() 形成对比,后者(至少最初)将受到更多限制。

工作器之间,或执行器(或通常其解释器)与工作器之间的通信,仍可通过 interpreters.Queue 对象进行,并通过初始化器设置。

sys.implementation.supports_isolated_interpreters

Python 实现不要求支持子解释器,尽管大多数主要实现都支持。如果一个实现确实支持子解释器,那么 sys.implementation.supports_isolated_interpreters 将被设置为 True。否则将为 False。如果不支持该功能,则导入 interpreters 模块将引发 ImportError

示例

以下示例演示了多解释器可能有用的一些实际情况。

示例 1

有一系列请求传入,将通过子线程中的工作器处理。

  • 每个工作线程都有自己的解释器
  • 有一个队列用于向工作器发送任务,另一个队列用于返回结果
  • 结果在专用线程中处理
  • 每个工作器持续运行,直到收到“停止”哨兵(None
  • 结果处理器持续运行,直到所有工作器停止
import interpreters
from mymodule import iter_requests, handle_result

tasks = interpreters.create_queue()
results = interpreters.create_queue()

numworkers = 20
threads = []

def results_handler():
    running = numworkers
    while running:
        try:
            res = results.get(timeout=0.1)
        except interpreters.QueueEmpty:
            # No workers have finished a request since last time.
            pass
        else:
            if res is None:
                # A worker has stopped.
                running -= 1
            else:
                handle_result(res)
    empty = object()
    assert results.get_nowait(empty) is empty
threads.append(threading.Thread(target=results_handler))

def worker():
    interp = interpreters.create()
    interp.prepare_main(tasks=tasks, results=results)
    interp.exec("""if True:
        from mymodule import handle_request, capture_exception

        while True:
            req = tasks.get()
            if req is None:
                # Stop!
                break
            try:
                res = handle_request(req)
            except Exception as exc:
                res = capture_exception(exc)
            results.put(res)
        # Notify the results handler.
        results.put(None)
        """)
threads.extend(threading.Thread(target=worker) for _ in range(numworkers))

for t in threads:
    t.start()

for req in iter_requests():
    tasks.put(req)
# Send the "stop" signal.
for _ in range(numworkers):
    tasks.put(None)

for t in threads:
    t.join()

示例 2

这种情况与上一种情况类似,都有大量工作器在子线程中。然而,这次代码将一个大数据数组分块,每个工作器一次处理一个块。将这些数据复制到每个解释器将非常低效,因此代码利用了直接共享 memoryview 缓冲区的功能。

  • 所有解释器共享源数组的缓冲区
  • 每个都将其结果写入第二个共享缓冲区
  • 使用队列向工作器发送任务
  • 只有一个工作器会读取源数组中的任何给定索引
  • 只有一个工作器会写入结果中的任何给定索引(这是它确保线程安全的方式)
import interpreters
import queue
from mymodule import read_large_data_set, use_results

numworkers = 3
data, chunksize = read_large_data_set()
buf = memoryview(data)
numchunks = (len(buf) + 1) / chunksize
results = memoryview(b'\0' * numchunks)

tasks = interpreters.create_queue()

def worker(id):
    interp = interpreters.create()
    interp.prepare_main(data=buf, results=results, tasks=tasks)
    interp.exec("""if True:
        from mymodule import reduce_chunk

        while True:
            req = tasks.get()
            if res is None:
                # Stop!
                break
            resindex, start, end = req
            chunk = data[start: end]
            res = reduce_chunk(chunk)
            results[resindex] = res
        """)
threads = [threading.Thread(target=worker) for _ in range(numworkers)]
for t in threads:
    t.start()

for i in range(numchunks):
    # Assume there's at least one worker running still.
    start = i * chunksize
    end = start + chunksize
    if end > len(buf):
        end = len(buf)
    tasks.put((start, end, i))
# Send the "stop" signal.
for _ in range(numworkers):
    tasks.put(None)

for t in threads:
    t.join()

use_results(results)

基本原理

一个最小化 API

由于核心开发团队对于用户如何在 Python 代码中利用多解释器没有实际经验,本提案特意将初始 API 保持尽可能精简和最小化。目标是提供一个经过深思熟虑的基础,以便将来可以适当地添加更多(更高级的)功能。

尽管如此,拟议的设计借鉴了社区现有子解释器使用经验、现有标准库模块经验以及其他编程语言经验。它还考虑了在 CPython 测试套件中使用子解释器和在 并发基准测试 中使用子解释器的经验。

create(), create_queue()

通常,用户调用类型来创建类型的实例,此时会配置对象的资源。interpreters 模块采取了不同的方法,用户必须调用 create() 来获取新的解释器,或者调用 create_queue() 来获取新的队列。直接调用 interpreters.Interpreter() 只会返回现有解释器(对于 interpreters.Queue() 也是如此)的包装器。

这是因为解释器(和队列)是特殊资源。它们在进程中全局存在,不受当前解释器的管理/拥有。因此,interpreters 模块将创建解释器(或队列)与创建 interpreters.Interpreter(或 interpreters.Queue)的实例显式区分开来。

Interpreter.prepare_main() 设置多个变量

prepare_main() 可以看作是一种设置函数。它支持一次设置多个名称,例如 interp.prepare_main(spam=1, eggs=2),而大多数设置器一次只设置一个项目。主要原因是效率。

要在解释器的 __main__.__dict__ 中设置一个值,实现必须首先将 OS 线程切换到指定的解释器,这涉及到一些不可忽略的开销。设置值后,它必须切换回来。此外,通过在解释器之间传递对象的机制存在一些额外的开销,如果一次设置多个值,可以总体减少这些开销。

因此,prepare_main() 支持一次设置多个值。

传播异常

来自子解释器的未捕获异常,通过 Interpreter.exec(),可以(有效地)被忽略,就像 threading.Thread() 所做的那样,或者被传播,就像内置的 exec() 所做的那样。由于 Interpreter.exec() 是一个同步操作,就像内置的 exec() 一样,未捕获的异常会被传播。

然而,此类异常并非直接抛出。这是因为解释器相互隔离,不得共享对象,包括异常。这可以通过抛出异常的替代品来解决,无论是摘要、副本还是包装异常的代理。这些都可以保留追溯,这对于调试很有用。ExecutionFailed 就是这样一个替代品。

还有一个问题需要考虑。如果传播的异常没有立即被捕获,它将沿着调用堆栈冒泡,直到被捕获(或未被捕获)。如果代码在其他地方可能会捕获它,那么识别异常来自子解释器(即“远程”源)而不是当前解释器会很有帮助。这就是为什么 Interpreter.exec() 抛出 ExecutionFailed,以及为什么它是一个普通的 Exception,而不是具有与原始异常匹配的类的副本或代理。例如,来自子解释器的未捕获 ValueError 永远不会在随后的 try: ... except ValueError: ... 中被捕获。相反,必须直接处理 ExecutionFailed

相反,从 Interpreter.call() 传播的异常不涉及 ExecutionFailed,而是直接抛出,就像它们起源于调用解释器一样。这是因为 Interpreter.call() 是一个更高级别的方法,它使用 pickle 来支持通常无法在解释器之间传递的对象。

对象与 ID 代理

对于解释器和队列,低级模块利用代理对象,通过其相应的进程全局 ID 公开底层状态。在这两种情况下,状态也是进程全局的,并将被多个解释器使用。因此,它们不适合实现为 PyObject,这仅适用于解释器特定数据。这就是为什么 interpreters 模块而是提供通过 ID 弱关联的对象。

被拒绝的想法

请参阅 PEP 554


来源:https://github.com/python/peps/blob/main/peps/pep-0734.rst

最后修改:2025-07-06 09:38:43 GMT