PEP 554 – 标准库中的多个解释器
- 作者:
- Eric Snow <ericsnowcurrently at gmail.com>
- 讨论至:
- Discourse 主题
- 状态:
- 已取代
- 类型:
- 标准跟踪
- 创建:
- 2017 年 9 月 5 日
- Python 版本:
- 3.13
- 历史记录:
- 2017 年 9 月 7 日, 2017 年 9 月 8 日, 2017 年 9 月 13 日, 2017 年 12 月 5 日, 2020 年 5 月 4 日, 2023 年 3 月 14 日, 2023 年 11 月 1 日
- 取代:
- 734
目录
- 摘要
- 提议
- 示例
- 基本原理
- 关于子解释器
- 替代 Python 实现
- “解释器”模块 API
- 解释器限制
- 通信 API
- 文档
- 替代解决方案
- 悬而未决的问题
- 延迟的功能
- 添加便利 API
- 避免对解释器在当前线程中运行的可能混淆
- 阐明“运行”与“拥有线程”
- 用于共享的双下划线方法
- Interpreter.call()
- Interpreter.run_in_thread()
- 同步原语
- CSP 库
- 语法支持
- 多进程
- C 扩展选择加入/选择退出
- 中毒通道
- 重置 __main__
- 重置解释器的状态
- 复制现有解释器的状态
- 可共享文件描述符和套接字
- 与 async 集成
- 支持迭代
- 通道上下文管理器
- 管道和队列
- 从 send() 返回锁
- 支持通道中的优先级
- 支持继承设置(以及更多?)
- 使异常可共享
- 通过序列化使所有内容都可共享
- 使 RunFailedError.__cause__ 延迟
- 从
interp.exec()
返回值 - 添加一个可共享的同步原语
- 以不同方式传播 SystemExit 和 KeyboardInterrupt
- 向通道端类添加显式 release() 和 close()
- 添加 SendChannel.send_buffer()
- 在线程中自动运行
- 被拒绝的想法
- 实现
- 参考文献
- 版权
摘要
CPython 自 1.5 版(1997 年)起就支持同一进程中的多个解释器(也称为“子解释器”)。该功能可通过 C-API 使用。 [c-api] 多个解释器在 彼此相对隔离 的环境中运行,这有助于实现针对 并发 的新颖替代方法。
本提议引入了标准库 interpreters
模块。它公开 C-API 已提供的多个解释器的基本功能,以及在解释器之间进行通信的基本支持。由于 PEP 684 在 Python 3.12 中引入了每个解释器 GIL,因此该模块尤为重要。
提议
摘要
- 添加一个新的标准库模块:“解释器”
- 添加 concurrent.futures.InterpreterPoolExecutor
- 扩展模块维护者的帮助
“解释器”模块
interpreters
模块将提供一个对多个解释器功能的高级接口,并包装一个新的低级 _interpreters
(与 threading
模块相同)。有关具体用法和用例,请参阅 示例 部分。
除了公开现有的(在 CPython 中)多个解释器支持外,该模块还将支持在解释器之间传递数据的基本机制。这涉及在目标子解释器的 __main__
模块中设置“可共享”对象。一些此类对象,例如 os.pipe()
,可用于进一步通信。该模块还将提供“通道”的最小实现,作为跨解释器通信的演示。
请注意,对象不会在解释器之间共享,因为它们与创建它们的解释器绑定。相反,对象的数据会在解释器之间传递。有关在解释器之间共享/通信的更多详细信息,请参阅下面的 共享数据 和 通信 API 部分。
解释器模块的 API 摘要
以下是 interpreters
模块的 API 摘要。有关建议的类和函数的更深入解释,请参阅下面的 “解释器”模块 API 部分。
用于创建和使用解释器
签名 | 描述 |
---|---|
list_all() -> [Interpreter] |
获取所有现有的解释器。 |
get_current() -> Interpreter |
获取当前正在运行的解释器。 |
get_main() -> Interpreter |
获取主解释器。 |
create() -> Interpreter |
初始化一个新的(空闲)Python 解释器。 |
签名 | 描述 |
---|---|
class Interpreter |
单个解释器。 |
.id |
解释器的 ID(只读)。 |
.is_running() -> bool |
解释器当前是否正在执行代码? |
.close() |
完成并销毁解释器。 |
.set_main_attrs(**kwargs) |
在 __main__ 中绑定“可共享”对象。 |
.get_main_attr(name) |
从 __main__ 获取“可共享”对象。 |
.exec(src_str, /) |
在解释器中运行给定的源代码
(在当前线程中)。
|
用于解释器之间进行通信
签名 | 描述 |
---|---|
is_shareable(obj) -> Bool |
对象的数据是否可以传递
到不同的解释器?
|
create_channel() -> (RecvChannel, SendChannel) |
创建一个新的通道,用于传递
解释器之间的數據。
|
concurrent.futures.InterpreterPoolExecutor
将添加一个执行器,它扩展 ThreadPoolExecutor
以在子解释器中运行每个线程的任务。最初,唯一支持的任务将是 Interpreter.exec()
接受的内容(例如 str
脚本)。但是,我们也可能会支持一些函数,并最终支持一种单独的方法来对任务和参数进行序列化,以减少摩擦(以短任务的性能为代价)。
扩展模块维护者的帮助
在实践中,实现多阶段初始化的扩展(PEP 489)被认为是隔离的,因此与多个解释器兼容。否则,它就是“不兼容”的。
许多扩展模块仍然不兼容。当这些扩展模块更新以支持多个解释器时,维护者和用户都将受益。与此同时,用户可能会因在使用多个解释器时遇到的失败而感到困惑,这可能会对扩展维护者造成负面影响。请参阅下面的 担忧 部分。
为了减轻这种影响并加快兼容性,我们将执行以下操作
- 明确说明扩展模块不需要支持在多个解释器中使用
- 当在子解释器中导入不兼容的模块时,会引发
ImportError
- 提供资源(例如文档)来帮助维护者实现兼容性
- 联系 Cython 和 PyPI 上使用最广泛的扩展模块的维护者,以获得反馈并可能提供帮助
示例
在当前操作系统线程中运行隔离代码
interp = interpreters.create()
print('before')
interp.exec('print("during")')
print('after')
在不同的线程中运行
interp = interpreters.create()
def run():
interp.exec('print("during")')
t = threading.Thread(target=run)
print('before')
t.start()
t.join()
print('after')
预先填充解释器
interp = interpreters.create()
interp.exec(tw.dedent("""
import some_lib
import an_expensive_module
some_lib.set_up()
"""))
wait_for_request()
interp.exec(tw.dedent("""
some_lib.handle_request()
"""))
处理异常
interp = interpreters.create()
try:
interp.exec(tw.dedent("""
raise KeyError
"""))
except interpreters.RunFailedError as exc:
print(f"got the error from the subinterpreter: {exc}")
重新抛出异常
interp = interpreters.create()
try:
try:
interp.exec(tw.dedent("""
raise KeyError
"""))
except interpreters.RunFailedError as exc:
raise exc.__cause__
except KeyError:
print("got a KeyError from the subinterpreter")
请注意,这种模式可以作为未来改进的候选者。
与 __main__ 命名空间交互
interp = interpreters.create()
interp.set_main_attrs(a=1, b=2)
interp.exec(tw.dedent("""
res = do_something(a, b)
"""))
res = interp.get_main_attr('res')
使用操作系统管道进行同步
interp = interpreters.create()
r1, s1 = os.pipe()
r2, s2 = os.pipe()
def task():
interp.exec(tw.dedent(f"""
import os
os.read({r1}, 1)
print('during B')
os.write({s2}, '')
"""))
t = threading.thread(target=task)
t.start()
print('before')
os.write(s1, '')
print('during A')
os.read(r2, 1)
print('after')
t.join()
通过 pickle 传递对象
interp = interpreters.create()
r, s = os.pipe()
interp.exec(tw.dedent(f"""
import os
import pickle
reader = {r}
"""))
interp.exec(tw.dedent("""
data = b''
c = os.read(reader, 1)
while c != b'\x00':
while c != b'\x00':
data += c
c = os.read(reader, 1)
obj = pickle.loads(data)
do_something(obj)
c = os.read(reader, 1)
"""))
for obj in input:
data = pickle.dumps(obj)
os.write(s, data)
os.write(s, b'\x00')
os.write(s, b'\x00')
捕获解释器的 stdout
interp = interpreters.create()
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
interp.exec(tw.dedent("""
print('spam!')
"""))
assert(stdout.getvalue() == 'spam!')
# alternately:
interp.exec(tw.dedent("""
import contextlib, io
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
print('spam!')
captured = stdout.getvalue()
"""))
captured = interp.get_main_attr('captured')
assert(captured == 'spam!')
管道(os.pipe()
)可以以类似的方式使用。
运行模块
interp = interpreters.create()
main_module = mod_name
interp.exec(f'import runpy; runpy.run_module({main_module!r})')
作为脚本运行(包括 zip 存档和目录)
interp = interpreters.create()
main_script = path_name
interp.exec(f"import runpy; runpy.run_path({main_script!r})")
使用通道进行通信
tasks_recv, tasks = interpreters.create_channel()
results, results_send = interpreters.create_channel()
def worker():
interp = interpreters.create()
interp.set_main_attrs(tasks=tasks_recv, results=results_send)
interp.exec(tw.dedent("""
def handle_request(req):
...
def capture_exception(exc):
...
while True:
try:
req = tasks.recv()
except Exception:
# channel closed
break
try:
res = handle_request(req)
except Exception as exc:
res = capture_exception(exc)
results.send_nowait(res)
"""))
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads:
t.start()
requests = ...
for req in requests:
tasks.send(req)
tasks.close()
for t in threads:
t.join()
基本原理
在多个解释器中运行代码可以在同一进程中提供有用的隔离级别。这可以以多种方式利用。此外,子解释器提供了一个明确定义的框架,在此框架中,这种隔离可以扩展。(请参阅 PEP 684。)
Alyssa(Nick)Coghlan 通过与多进程的比较解释了一些好处 [benefits]
[I] expect that communicating between subinterpreters is going
to end up looking an awful lot like communicating between
subprocesses via shared memory.
The trade-off between the two models will then be that one still
just looks like a single process from the point of view of the
outside world, and hence doesn't place any extra demands on the
underlying OS beyond those required to run CPython with a single
interpreter, while the other gives much stricter isolation
(including isolating C globals in extension modules), but also
demands much more from the OS when it comes to its IPC
capabilities.
The security risk profiles of the two approaches will also be quite
different, since using subinterpreters won't require deliberately
poking holes in the process isolation that operating systems give
you by default.
CPython 从 1.5 版本开始就支持多个解释器,并且支持程度不断提高。虽然这项功能有潜力成为一个强大的工具,但由于多个解释器功能无法直接从 Python 中获得,因此一直被忽视。在标准库中公开现有的功能将有助于扭转这种情况。
本提案的重点是实现同一个 Python 进程中多个相互隔离的解释器的基本功能。这是 Python 的一个新领域,因此对于最佳工具选择(作为解释器伴侣)存在相对的不确定性。因此,我们尽可能减少提案中添加的功能。
担忧
- “子解释器不值得费事”
有些人认为子解释器带来的好处不足以证明将其作为 Python 的正式组成部分。向语言(或标准库)添加功能会增加语言大小的成本。因此,添加的内容必须物有所值。
在本例中,多个解释器支持提供了一种新颖的并发模型,专注于隔离的执行线程。此外,它们还为 CPython 的更改提供了机会,这些更改将允许同时使用多个 CPU 内核(目前被 GIL 阻止——参见 PEP 684)。
子解释器的替代方案包括线程、异步和多进程。线程受 GIL 的限制,异步并非所有问题(或并非所有人的选择)的最佳解决方案。多进程在某些情况下同样有价值,但在另一些情况下则不然。直接 IPC(而不是通过 multiprocessing 模块)提供了类似的好处,但存在同样的警告。
值得注意的是,子解释器并非旨在替代上述任何一种方案。它们当然在某些方面存在重叠,但子解释器的优势包括隔离和(潜在的)性能。特别是,子解释器为替代并发模型(例如 CSP)提供了一条直接路径,这种模型已在其他地方取得成功,并将吸引一些 Python 用户。这就是 interpreters
模块将提供的核心价值。
- “标准库对多个解释器的支持增加了 C 扩展作者的额外负担”
在下面的 解释器隔离 部分,我们确定了 CPython 子解释器中隔离不完整的方式。最显着的是使用 C 全局变量存储内部状态的扩展模块。(PEP 3121 和 PEP 489 为该问题提供了解决方案,之后是一些提高效率的额外 API,例如 PEP 573)。
因此,发布扩展模块的项目可能会面临维护负担增加的问题,因为他们的用户开始使用子解释器,而他们的模块可能会出现故障。这种情况仅限于使用 C 全局变量(或使用使用 C 全局变量的库)来存储内部状态的模块。对于 numpy,报告的错误率是每 6 个月一次。 [bug-rate]
归根结底,这取决于一个实际问题:有多少项目会受到影响,用户的受影响频率,项目需要承担多少额外的维护负担,以及子解释器的整体优势能否抵消这些成本。本 PEP 的立场是,实际的额外维护负担将很小,远低于子解释器值得的阈值。
- “创建新的并发 API 需要更多思考和实验,因此新模块不应该马上进入标准库,如果有的话”
引入像 asyncio 那样用于新并发模型的 API 是一个非常大的项目,需要仔细考虑。这并非像本 PEP 提案那样简单地完成,并且可能需要在 PyPI 上投入大量时间才能成熟。(参见 Nathaniel 在 python-dev 上的帖子。)
但是,本 PEP 没有提出任何新的并发 API。它最多只公开最少的工具(例如子解释器、通道),这些工具可用于编写遵循与 Python 中(相对)较新的 并发模型 相关的模式的代码。这些工具也可以用作此类并发模型的 API 的基础。再说一次,本 PEP 没有提出任何此类 API。
- “如果子解释器仍然共享 GIL,就没有公开它们的意义”
- “使 GIL 成为每个解释器的努力具有破坏性和风险”
一个常见的误解是,本 PEP 还包括一个承诺,即解释器将不再共享 GIL。当这一点被澄清后,下一个问题是“有什么意义呢?”。本 PEP 已经对这个问题进行了详细解答。为了明确起见,其价值在于
* increase exposure of the existing feature, which helps improve
the code health of the entire CPython runtime
* expose the (mostly) isolated execution of interpreters
* preparation for per-interpreter GIL
* encourage experimentation
- “数据共享可能会对多核场景中的缓存性能产生负面影响”
(参见 [cache-line-ping-pong]。)
目前这应该不成问题,因为我们目前没有立即计划实际在解释器之间共享数据,而是专注于复制。
关于子解释器
并发性
并发是软件开发中的一个挑战领域。数十年的研究和实践导致了各种并发模型,每个模型都有不同的目标。大多数都集中在正确性和可用性上。
一类并发模型专注于隔离的执行线程,这些线程通过某种消息传递方案进行交互。一个值得注意的例子是 Communicating Sequential Processes [CSP](Go 的并发模型大致基于此)。CPython 解释器固有的隔离性使其非常适合这种方法。
解释器隔离
CPython 的解释器旨在严格地相互隔离。每个解释器都有其所有模块、类、函数和变量的副本。C 中的状态也是如此,包括扩展模块中的状态。CPython C-API 文档对此进行了更详细的解释。 [caveats]
但是,解释器在某些方面共享一些状态。首先,一些进程级全局状态保持共享
- 文件描述符
- 低级环境变量
- 进程内存(尽管分配器是隔离的)
- 内置类型(例如 dict、bytes)
- 单例(例如 None)
- 内置/扩展/冻结模块的底层静态模块数据(例如函数)
目前没有计划对此进行更改。
其次,由于错误或未考虑子解释器的实现,导致一些隔离出现故障。这包括依赖于 C 全局变量的扩展模块。 [cryptography] 在这些情况下,应该打开错误报告(有些错误报告已经存在)
- readline 模块的钩子函数 (http://bugs.python.org/issue4202)
- 重新初始化时的内存泄漏 (http://bugs.python.org/issue21387)
最后,由于 CPython 的当前设计,一些潜在的隔离缺失。目前正在进行改进以解决这一领域中的差距
- 使用
PyGILState_*
API 的扩展在一定程度上是不兼容的 [gilstate]
现有用法
多个解释器支持并非一个广泛使用的功能。实际上,只有少数几个有据可查的案例表明其被广泛使用,包括 mod_wsgi、OpenStack Ceph 和 JEP。一方面,这些案例表明现有的多个解释器支持相对稳定。另一方面,没有多少样本量来判断该功能的实用性。
替代 Python 实现
我已经向多个 Python 实现者征求了有关子解释器支持的反馈。每位实现者都表示,他们能够在同一个进程中支持多个解释器(如果他们选择这样做),而且不会遇到太多麻烦。以下是与我联系的项目
- jython ([jython])
- ironpython(个人通信)
- pypy(个人通信)
- micropython(个人通信)
“解释器”模块 API
该模块提供以下函数
list_all() -> [Interpreter]
Return a list of all existing interpreters.
get_current() => Interpreter
Return the currently running interpreter.
get_main() => Interpreter
Return the main interpreter. If the Python implementation
has no concept of a main interpreter then return None.
create() -> Interpreter
Initialize a new Python interpreter and return it.
It will remain idle until something is run in it and always
run in its own thread.
is_shareable(obj) -> bool:
Return True if the object may be "shared" between interpreters.
This does not necessarily mean that the actual objects will be
shared. Instead, it means that the objects' underlying data will
be shared in a cross-interpreter way, whether via a proxy, a
copy, or some other means.
该模块还提供以下类
class Interpreter(id):
id -> int:
The interpreter's ID. (read-only)
is_running() -> bool:
Return whether or not the interpreter's "exec()" is currently
executing code. Code running in subthreads is ignored.
Calling this on the current interpreter will always return True.
close():
Finalize and destroy the interpreter.
This may not be called on an already running interpreter.
Doing so results in a RuntimeError.
set_main_attrs(iterable_or_mapping, /):
set_main_attrs(**kwargs):
Set attributes in the interpreter's __main__ module
corresponding to the given name-value pairs. Each value
must be a "shareable" object and will be converted to a new
object (e.g. copy, proxy) in whatever way that object's type
defines. If an attribute with the same name is already set,
it will be overwritten.
This method is helpful for setting up an interpreter before
calling exec().
get_main_attr(name, default=None, /):
Return the value of the corresponding attribute of the
interpreter's __main__ module. If the attribute isn't set
then the default is returned. If it is set, but the value
isn't "shareable" then a ValueError is raised.
This may be used to introspect the __main__ module, as well
as a very basic mechanism for "returning" one or more results
from Interpreter.exec().
exec(source_str, /):
Run the provided Python source code in the interpreter,
in its __main__ module.
This may not be called on an already running interpreter.
Doing so results in a RuntimeError.
An "interp.exec()" call is similar to a builtin exec() call
(or to calling a function that returns None). Once
"interp.exec()" completes, the code that called "exec()"
continues executing (in the original interpreter). Likewise,
if there is any uncaught exception then it effectively
(see below) propagates into the code where ``interp.exec()``
was called. Like exec() (and threads), but unlike function
calls, there is no return value. If any "return" value from
the code is needed, send the data out via a pipe (os.pipe())
or channel or other cross-interpreter communication mechanism.
The big difference from exec() or functions is that
"interp.exec()" executes the code in an entirely different
interpreter, with entirely separate state. The interpreters
are completely isolated from each other, so the state of the
original interpreter (including the code it was executing in
the current OS thread) does not affect the state of the target
interpreter (the one that will execute the code). Likewise,
the target does not affect the original, nor any of its other
threads.
Instead, the state of the original interpreter (for this thread)
is frozen, and the code it's executing code completely blocks.
At that point, the target interpreter is given control of the
OS thread. Then, when it finishes executing, the original
interpreter gets control back and continues executing.
So calling "interp.exec()" will effectively cause the current
Python thread to completely pause. Sometimes you won't want
that pause, in which case you should make the "exec()" call in
another thread. To do so, add a function that calls
"interp.exec()" and then run that function in a normal
"threading.Thread".
Note that the interpreter's state is never reset, neither
before "interp.exec()" executes the code nor after. Thus the
interpreter state is preserved between calls to
"interp.exec()". This includes "sys.modules", the "builtins"
module, and the internal state of C extension modules.
Also note that "interp.exec()" executes in the namespace of the
"__main__" module, just like scripts, the REPL, "-m", and
"-c". Just as the interpreter's state is not ever reset, the
"__main__" module is never reset. You can imagine
concatenating the code from each "interp.exec()" call into one
long script. This is the same as how the REPL operates.
Supported code: source text.
除了 Interpreter.set_main_attrs()
的功能外,该模块还提供了一种相关的方式在解释器之间传递数据:通道。参见下面 通道。
未捕获异常
关于在 Interpreter.exec()
中未捕获的异常,我们注意到它们被“有效地”传播到调用 interp.exec()
的代码中。为了防止异常(和跟踪回溯)在解释器之间泄漏,我们创建了异常及其跟踪回溯的代理(参见 traceback.TracebackException
),将其设置为 __cause__
,并将其提升为新的 interpreters.RunFailedError
。
直接提升(异常的代理)是有问题的,因为它难以区分 interp.exec()
调用中的错误和子解释器中的未捕获异常。
解释器限制
现在,由 interpreters.create()
创建的每个新解释器对它运行的任何代码都有特定的限制。这包括以下内容:
- 导入扩展模块失败,如果它没有实现多阶段初始化。
- 不允许创建守护线程。
os.fork()
不允许(因此不允许使用multiprocessing
)。os.exec*()
不允许(但“fork+exec”,类似于subprocess
,是可以的)。
请注意,使用现有 C-API 创建的解释器没有这些限制。对于“主”解释器也是如此,因此现有 Python 使用不会改变。
我们可能会选择稍后放宽上述某些限制,或提供一种方法来单独启用/禁用粒度限制。无论如何,从扩展模块要求多阶段初始化将始终是默认限制。
通信 API
如上所述 共享数据,如果没有在解释器之间共享数据(通信)的机制,多解释器支持就不那么有用。但是,在解释器之间共享实际的 Python 对象有足够多的潜在问题,因此我们在此提案中避免了对此的支持。正如前面提到的,我们也没有添加任何超出基本通信机制的内容。
该机制是 Interpreter.set_main_attrs()
方法。它可以用于在调用 Interpreter.exec()
之前设置全局变量。传递给 set_main_attrs()
的名称-值对绑定为解释器的 __main__
模块的属性。这些值必须是“可共享的”。参见下面的 可共享类型。
通过 Interpreter.set_main_attrs()
启用了其他通信和共享对象的途径。可以实现一个可共享的对象,其工作方式类似于队列,但具有跨解释器安全性。事实上,此 PEP 包含此类方法的示例:通道。
通道
interpreters
模块将包含一个专门的解决方案,用于在解释器之间传递对象数据:通道。它们包含在模块中,部分原因是为了提供比使用 os.pipe()
更简单的机制,部分原因是为了演示库如何利用 Interpreter.set_main_attrs()
及其使用的协议。
通道是一个单工 FIFO。它是一种基本的、选择性的数据共享机制,其灵感来自管道、队列和 CSP 的通道。 [fifo] 与管道的主要区别在于,通道可以与任一端的零个或多个解释器相关联。与也是多对多的队列一样,通道是缓冲的(尽管它们也提供具有非缓冲语义的方法)。
通道有两个操作:发送和接收。这些操作的一个关键特征是通道传输来自 Python 对象而不是对象本身的数据。当对象被发送时,其数据被提取。当“对象”在另一个解释器中被接收时,数据被转换回该解释器拥有的对象。
为了使此工作,可变共享状态将由 Python 运行时管理,而不是由任何解释器管理。最初,我们将仅支持一种类型的共享状态对象:由 interpreters.create_channel()
提供的通道。反过来,通道将仔细管理在解释器之间传递对象。
这种方法,包括保持 API 最小化,有助于我们避免向 Python 用户进一步暴露任何底层复杂性。
interpreters
模块提供以下与通道相关的函数:
create_channel() -> (RecvChannel, SendChannel):
Create a new channel and return (recv, send), the RecvChannel
and SendChannel corresponding to the ends of the channel.
Both ends of the channel are supported "shared" objects (i.e.
may be safely shared by different interpreters. Thus they
may be set using "Interpreter.set_main_attrs()".
该模块还提供以下与通道相关的类:
class RecvChannel(id):
The receiving end of a channel. An interpreter may use this to
receive objects from another interpreter. Any type supported by
Interpreter.set_main_attrs() will be supported here, though at
first only a few of the simple, immutable builtin types
will be supported.
id -> int:
The channel's unique ID. The "send" end has the same one.
recv(*, timeout=None):
Return the next object from the channel. If none have been
sent then wait until the next send (or until the timeout is hit).
At the least, the object will be equivalent to the sent object.
That will almost always mean the same type with the same data,
though it could also be a compatible proxy. Regardless, it may
use a copy of that data or actually share the data. That's up
to the object's type.
recv_nowait(default=None):
Return the next object from the channel. If none have been
sent then return the default. Otherwise, this is the same
as the "recv()" method.
class SendChannel(id):
The sending end of a channel. An interpreter may use this to
send objects to another interpreter. Any type supported by
Interpreter.set_main_attrs() will be supported here, though
at first only a few of the simple, immutable builtin types
will be supported.
id -> int:
The channel's unique ID. The "recv" end has the same one.
send(obj, *, timeout=None):
Send the object (i.e. its data) to the "recv" end of the
channel. Wait until the object is received. If the object
is not shareable then ValueError is raised.
The builtin memoryview is supported, so sending a buffer
across involves first wrapping the object in a memoryview
and then sending that.
send_nowait(obj):
Send the object to the "recv" end of the channel. This
behaves the same as "send()", except for the waiting part.
If no interpreter is currently receiving (waiting on the
other end) then queue the object and return False. Otherwise
return True.
文档
interpreters
模块的新的 stdlib 文档页面将包含以下内容:
- (在顶部)一个明确的说明,即扩展模块不需要支持多个解释器。
- 对子解释器是什么的一些解释。
- 如何使用多个解释器(以及它们之间通信)的简要示例。
- 使用多个解释器的限制的摘要。
- (针对扩展维护者)一个链接,用于确保多个解释器兼容性。
- 此 PEP 中的大部分 API 信息。
针对扩展维护者的文档已存在于 隔离扩展模块 操作指南页面上。任何额外的帮助都将添加在那里。例如,讨论处理链接库的策略可能会有所帮助,这些库保留其自身与子解释器不兼容的全局状态。
请注意,文档将在减轻新的 interpreters
模块可能对扩展模块维护者产生的任何负面影响方面发挥重要作用。
此外,针对不兼容扩展模块的 ImportError
将更新为明确指出这是由于缺少多个解释器兼容性,并且扩展不需要提供此功能。这将有助于正确设定用户预期。
替代解决方案
一个可能的替代新模块的方法是将对解释器的支持添加到 concurrent.futures
中。有几个原因导致这行不通:
- 寻找多个解释器支持的明显位置是一个“解释器”模块,就像“线程”等一样。
concurrent.futures
的全部内容是执行函数,但目前我们没有一个好的方法可以在一个解释器中运行另一个解释器中的函数。
类似的推理适用于 multiprocessing
模块中的支持。
悬而未决的问题
- 如果
interp.exec()
在当前线程中运行,会让人感到困惑吗? - 我们现在应该为
interp.exec()
以及Interpreter.set_main_attrs()
和Interpreter.get_main_attr()
添加 pickle 回退吗? - 我们现在应该支持
interp.exec()
中的(有限的)函数吗? - 将
Interpreter.close()
重命名为Interpreter.destroy()
吗? - 由于我们有通道,因此应该删除
Interpreter.get_main_attr()
吗? - 通道应该有自己的 PEP 吗?
延迟的功能
为了使此提案保持最小化,以下功能已留待将来考虑。请注意,这不是对任何功能的评判,而是推迟。也就是说,每个功能可以说都是有效的。
添加便利 API
我可以想象有很多事情可以使新模块的假设边缘更加平滑。
- 添加类似于
Interpreter.run()
或Interpreter.call()
的东西,它调用interp.exec()
并回退到 pickle。 - 在
Interpreter.set_main_attrs()
和Interpreter.get_main_attr()
中回退到 pickle。
如果事实证明这是一个痛点,这些将很容易做到。
避免对解释器在当前线程中运行的可能混淆
一个经常出现的困惑是 Interpreter.exec()
在当前 OS 线程中执行,暂时阻塞当前 Python 线程。也许值得做一些事情来避免这种困惑。
针对这个假设问题,一些可能的解决方案
- 默认情况下,在新的线程中运行?
- 添加
Interpreter.exec_in_thread()
? - 添加
Interpreter.exec_in_current_thread()
?
在早期版本的 PEP 中,这个方法是 interp.run()
。仅仅将它改为 interp.exec()
可能会在结合文档中的用户教育之后,足以减少混淆。如果它确实成为一个真正的问题,我们可以在那时寻求其中一个替代方案。
阐明“运行”与“拥有线程”
Interpreter.is_running()
特指 Interpreter.exec()
(或类似方法)是否正在运行。它并没有说明解释器是否有任何子线程在运行。这个信息可能会有帮助。
我们可以做一些事情
- 将
Interpreter.is_running()
重命名为Interpreter.is_running_main()
- 添加
Interpreter.has_threads()
,作为Interpreter.is_running()
的补充 - 扩展为
Interpreter.is_running(main=True, threads=False)
这些都不紧急,如果需要,任何一项都可以以后完成。
用于共享的双下划线方法
我们可以添加一个特殊的方法,比如 __xid__
,对应于 tp_xid
。至少,它将允许 Python 类型将它们的实例转换为实现 tp_xid
的其他类型。
问题是,将这种能力暴露给 Python 代码会带来一定程度的复杂性,而这种复杂性还没有被探索过,也没有令人信服的理由去调查这种复杂性。
Interpreter.call()
直接在子解释器中运行现有函数会很方便。 Interpreter.exec()
可以被调整来支持这个功能,或者可以添加一个 call()
方法。
Interpreter.call(f, *args, **kwargs)
这与通过队列在解释器之间共享对象的问题相同。 最小的解决方案(运行源字符串)对于我们来说足够了,可以将该功能发布出去,以便对其进行探索。
Interpreter.run_in_thread()
这个方法将在一个线程中为你执行 interp.exec()
调用。 只使用 threading.Thread
和 interp.exec()
来实现这一点是比较简单的,所以我们把它省略了。
同步原语
threading
模块为协调并发操作提供了一些同步原语。 由于线程的共享状态特性,这一点尤其必要。 相反,解释器不共享状态。 数据共享仅限于运行时的可共享对象功能,这消除了对显式同步的需求。 如果将来将任何类型的选择性共享状态支持添加到 CPython 的解释器中,那么相同的努力可以引入同步原语来满足这种需求。
CSP 库
一个 csp
模块与 PEP 提供的功能相差不大。 但是,添加这样的模块超出了这个提案的极简主义目标。
语法支持
Go
语言提供了一个基于 CSP 的并发模型,因此它与多个解释器支持的并发模型类似。 但是,Go
还提供语法支持,以及几个内置的并发原语,使并发成为一等公民。 可以设想,可以使用解释器为 Python 添加类似的语法(和内置)支持。 但是,这完全超出了这个 PEP 的范围!
多进程
multiprocessing
模块可以以与支持线程和进程相同的方式支持解释器。 事实上,该模块的维护者 Davin Potts 指出这是一个合理的功能请求。 但是,它超出了这个 PEP 的狭窄范围。
C 扩展选择加入/选择退出
通过使用 PEP 489 引入的 PyModuleDef_Slot
,我们可以轻松地添加一个机制,允许 C 扩展模块选择退出多解释器支持。 然后,导入机制在子解释器中运行时,需要检查模块是否支持。 如果不支持,则会引发 ImportError。
或者,我们可以支持选择加入多解释器支持。 但是,这可能会比选择退出方法排除更多(不必要的)模块。 另外,请注意 PEP 489 规定,扩展使用 PEP 的机制意味着支持多个解释器。
添加 ModuleDef 插槽和修复导入机制的范围并不简单,但可能值得。 这完全取决于有多少扩展模块在子解释器下出现故障。 鉴于我们通过 mod_wsgi 知道的案例相对较少,我们可以把这件事留到以后。
中毒通道
CSP 有一个通道中毒的概念。 一旦一个通道被中毒,任何对它的 send()
或 recv()
调用都会引发一个特殊异常,有效地终止尝试使用中毒通道的解释器中的执行。
这可以通过在通道的两端添加 poison()
方法来实现。 close()
方法可以以这种方式使用(大部分),但这些语义相对专门,可以等待。
重置 __main__
根据提议,每次调用 Interpreter.exec()
都会在解释器现有 __main__
模块的命名空间中执行。 这意味着数据在 interp.exec()
调用之间持续存在。 有时候这不是我们想要的,你想要在一个新的 __main__
中执行。 另外,你不一定想把不再使用的对象泄露到那里。
请注意,以下方法无法正常工作,因为它会清除太多内容(例如 __name__
和其他“__双下划线__”属性)
interp.exec('globals().clear()')
可能的解决方案包括
- 一个
create()
参数,用于指示在每次interp.exec()
调用后重置__main__
- 一个
Interpreter.reset_main
标志,用于支持事后选择加入或退出 - 一个
Interpreter.reset_main()
方法,用于在需要时选择加入 importlib.util.reset_globals()
[reset_globals]
还要注意,重置 __main__
对存储在其他模块中的状态没有任何作用。 因此,任何解决方案都必须清楚地说明正在重置的范围。 可以设想,我们可以发明一种机制,通过这种机制,可以重置任何(或每个)模块,这与 reload()
不同,后者不会在加载到模块中之前清除模块。
无论如何,由于 __main__
是解释器的执行命名空间,重置它与解释器及其动态状态的关联比重置其他模块更直接。 因此,更通用的模块重置机制可能被证明是多余的。
最初,这不是一个关键功能。 如果需要,它可以等待以后。
重置解释器的状态
重新使用现有子解释器而不是启动一个新的解释器可能很有用。 由于解释器包含比 __main__
模块更多的状态,因此将解释器恢复到原始/新鲜状态并不容易。 事实上,可能有一些状态部分无法从 Python 代码中重置。
一个可能的解决方案是添加一个 Interpreter.reset()
方法。 这会将解释器恢复到新创建时的状态。 如果在正在运行的解释器上调用它,它将失败(因此主解释器永远不会被重置)。 这可能比创建一个新的解释器效率更高,尽管这取决于以后对解释器创建会进行哪些优化。
虽然这可能会提供从 Python 代码中无法获得的其他功能,但它不是基本功能。 因此,本着极简主义的精神,这可以等待。 无论如何,我怀疑在 PEP 发布后添加它会引起争议。
复制现有解释器的状态
相关地,支持基于现有解释器创建新的解释器可能很有用,例如 Interpreter.copy()
。 这与解释器内存可以进行快照的想法相一致,这将使 CPython 启动或创建新的解释器总体上更快。 相同的机制可以用于假设的 Interpreter.reset()
,如前所述。
与 async 集成
根据 Antoine Pitrou [async]
Has any thought been given to how FIFOs could integrate with async
code driven by an event loop (e.g. asyncio)? I think the model of
executing several asyncio (or Tornado) applications each in their
own subinterpreter may prove quite interesting to reconcile multi-
core concurrency with ease of programming. That would require the
FIFOs to be able to synchronize on something an event loop can wait
on (probably a file descriptor?).
多解释器支持的基本功能不依赖于异步,可以在以后添加。
一个可能的解决方案是为阻塞通道方法(recv()
和 send()
)提供异步实现。
或者,可以使用“准备就绪回调”来简化异步场景中的使用。 这意味着在 recv_nowait()
和 send_nowait()
通道方法中添加一个可选的 callback
(仅限关键字)参数。 回调将在对象被发送或接收后(分别)被调用。
(请注意,使通道缓冲将使准备就绪回调变得不那么重要。)
支持迭代
支持对 RecvChannel
(通过 __iter__()
或 _next__()
)进行迭代可能很有用。 一个简单的实现将使用 recv()
方法,类似于文件如何进行迭代。 由于这不是一项基本功能,并且有一个简单的类似方法,因此添加迭代支持可以等待以后。
通道上下文管理器
在 RecvChannel
和 SendChannel
上支持上下文管理器可能会有帮助。实现很简单,只需像文件一样包装对 close()
(或可能是 release()
)的调用。与迭代一样,这可以稍后再做。
管道和队列
通过提出的“os.pipe()”对象传递机制,其他类似的基本类型不是严格要求的,才能实现多个解释器最小的实用功能。此类类型包括管道(类似于无缓冲通道,但是一对一)和队列(类似于通道,但更通用)。有关更多信息,请参见下面的 被拒绝的想法。
即使这些类型不包含在本提案中,它们在并发环境中仍然可能有用。稍后添加它们是完全合理的。它们可以作为通道的包装器来实现。或者,它们可以与通道在相同的低级实现以提高效率。
从 send() 返回锁
当通过通道发送对象时,您无法知道对象何时在另一端被接收。解决此问题的一种方法是从 SendChannel.send()
返回一个锁定的 threading.Lock
,该锁在对象被接收后解锁。
或者,提出的 SendChannel.send()
(阻塞)和 SendChannel.send_nowait()
提供了一个明确的区分,不太可能让用户感到困惑。
请注意,对于缓冲通道(即队列),返回锁很重要。对于无缓冲通道,这无关紧要。
支持通道中的优先级
一个简单的例子是标准库中的 queue.PriorityQueue
。
支持继承设置(以及更多?)
在创建新的解释器时,人们可能会发现,能够指示他们希望某些内容被“继承”到新的解释器中很有用。该机制可以是严格的复制,也可以是写时复制。激励的例子是使用警告模块(例如复制过滤器)。
此功能并不关键,也不是广泛适用,因此可以等到有兴趣再做。值得注意的是,两种建议的解决方案都需要大量的努力,尤其是在处理复杂对象时,尤其是对于可变复杂对象的容器。
使 RunFailedError.__cause__ 延迟
子解释器中(来自 interp.exec()
)未捕获的异常会被复制到调用解释器,并被设置为 __cause__
,然后抛出一个 RunFailedError
。复制部分涉及在调用解释器中进行某种反序列化,这可能很昂贵(例如由于导入),但并不总是必要的。
因此,使用 ExceptionProxy
类型包装序列化异常,并在需要时才反序列化它可能会有用。这可以通过 ExceptionProxy__getattribute__()
或者通过 RunFailedError.resolve()
来实现(这会抛出反序列化的异常,并将 RunFailedError.__cause__
设置为异常)。
将 RunFailedError.__cause__
设置为描述符,在 RunFailedError
实例上进行延迟反序列化(并设置 __cause__
)也可能是有意义的。
从 interp.exec()
返回值
目前 interp.exec()
始终返回 None。一个想法是返回子解释器运行的任何内容的返回值。然而,现在没有意义。人们唯一可以运行的是代码字符串(即脚本)。这等效于 PyRun_StringFlags()
、exec()
或模块体。这些都没有“返回”任何东西。一旦 interp.exec()
支持函数等,我们可以重新考虑这个问题。
以不同方式传播 SystemExit 和 KeyboardInterrupt
继承自 BaseException
(除 Exception
之外)的异常类型通常会受到特殊对待。这些类型是:KeyboardInterrupt
、SystemExit
和 GeneratorExit
。当从 interp.exec()
中传播时,可能需要对它们进行特殊处理。以下是一些选项
* propagate like normal via RunFailedError
* do not propagate (handle them somehow in the subinterpreter)
* propagate them directly (avoid RunFailedError)
* propagate them directly (set RunFailedError as __cause__)
我们不会担心以不同方式处理它们。线程已经忽略了 SystemExit
,所以现在我们将遵循这种模式。
向通道端类添加显式 release() 和 close()
能够明确地关闭一个通道以防止进一步的全局使用会很方便。同样,能够相对于当前解释器明确地释放一个通道端也会很有用。除其他原因外,这种机制可用于在解释器之间传递整体状态,而无需通过通道直接传递对象所需的额外样板。
挑战在于在不使其难以理解的情况下正确实现自动释放/关闭。在处理非空通道时尤其如此。我们现在应该能够在没有释放/关闭的情况下解决问题。
添加 SendChannel.send_buffer()
如果该方法支持 PEP 3118 缓冲协议(例如 memoryview),则此方法允许通过通道无复制发送对象。
对该方法的支持对于通道来说不是根本性的,可以稍后添加,不会造成太大干扰。
在线程中自动运行
PEP 建议在子解释器和线程之间进行严格的分隔:如果您想在线程中运行,您必须自己创建线程并在其中调用 interp.exec()
。然而,如果 interp.exec()
可以为您完成此操作,则可能很方便,这意味着将减少样板代码。
此外,我们预计用户会比平时更频繁地想要在线程中运行。因此,将此设置为默认行为是有意义的。我们将向 interp.exec()
添加一个关键字参数“threaded”(默认值为 True
),以允许在当前线程中运行操作。
被拒绝的想法
显式通道关联
在 recv()
和 send()
调用时,解释器会隐式地与通道关联。它们会通过 release()
调用与通道取消关联。另一种选择是使用显式方法。这将是 Interpreter
对象上的 add_channel()
和 remove_channel()
方法,或通道对象上的类似方法。
在实践中,用户不需要这种级别的管理。因此,添加更多显式支持只会增加 API 的混乱程度。
添加基于管道的 API
管道将是两个解释器之间的单向 FIFO。对于大多数用例,这将足够了。它还可能简化实现。但是,通过通道支持多对多单向 FIFO 并不是一个很大的步骤。此外,使用管道,API 最终会变得稍微复杂一些,需要对管道进行命名。
添加基于队列的 API
队列和缓冲通道几乎是一样的。主要区别在于通道与上下文(即关联的解释器)具有更强的关系。
使用“Channel”而不是“Queue”是为了避免与标准库 queue.Queue
混淆。
“枚举”
函数 list_all()
提供所有解释器的列表。在 threading 模块中,它部分启发了提出的 API,该函数称为 enumerate()
。这里的名字不同是为了避免混淆那些不熟悉 threading API 的 Python 用户。对他们来说,“enumerate”相当不清楚,而“list_all”则很清楚。
防止异常泄漏到不同解释器的其他解决方案
在函数调用中,未捕获的异常会传播到调用帧。同样的方法也可以用于 interp.exec()
。但是,这意味着异常对象将泄漏到解释器之间的边界。同样,跟踪中的帧也可能会泄漏。
虽然这目前可能不是问题,但一旦解释器在内存管理方面获得更好的隔离(这对于停止在解释器之间共享 GIL 是必要的),它就会成为问题。我们已经通过抛出 RunFailedError
来解决异常传播的语义,其中 __cause__
包装了原始异常和跟踪的安全代理。
拒绝的可能的解决方案
- 在原始解释器中重现异常和跟踪,并抛出该异常。
- 抛出一个 RunFailedError 的子类,它代理原始异常和跟踪。
- 抛出 RuntimeError 而不是 RunFailedError
- 在边界处转换(类似于
subprocess.CalledProcessError
)(需要跨解释器表示) - 支持通过
Interpreter.excepthook
进行自定义(需要跨解释器表示) - 在边界处包装在代理中(包括支持类似于
err.raise()
的东西以传播跟踪)。 - 从
interp.exec()
返回异常(或其代理)而不是抛出它 - 返回一个结果对象(如
subprocess
所做的那样)[result-object](不必要的复杂性?) - 丢弃异常,并期望用户在他们传递给
interp.exec()
的脚本中明确地处理未处理的异常(他们可以通过通道传递错误信息);对于线程,您必须执行类似的操作
始终将每个新解释器与其自己的线程关联
在 C-API 中的实现中,解释器本身并不绑定到任何线程。此外,它将在任何现有的线程中运行,无论该线程是由 Python 创建的还是其他方式创建的。您只需要先在该线程中激活其线程状态之一 (PyThreadState
) 即可。这意味着同一个线程可以运行多个解释器(虽然显然不能同时运行)。
提议的模块保留了这种行为。解释器不绑定到线程。只有对 Interpreter.exec()
的调用才绑定。然而,这个 PEP 的主要目标之一是提供一个更以人为本的并发模型。考虑到这一点,从概念的角度来看,如果每个解释器都与它自己的线程相关联,这个模块可能会更容易理解。
这意味着 interpreters.create()
将创建一个新线程,而 Interpreter.exec()
只能在该线程中执行(并且不会执行其他任何操作)。这样做的好处是,用户无需将 Interpreter.exec()
调用包装在一个新的 threading.Thread
中。他们也不会意外地暂停当前线程中的当前解释器,而他们的解释器正在执行。
这个想法被拒绝了,因为它的好处很小,而成本很高。与 C-API 中功能的差异可能会造成混淆。隐式创建线程是魔法般的。过早创建线程可能会造成浪费。无法在现有线程中运行任意解释器将阻止一些有效的用例,从而让用户感到沮丧。将解释器绑定到线程将需要额外的运行时修改。它也会使模块的实现过于复杂。最后,它甚至可能不会让模块更容易理解。
仅在使用时将解释器关联
仅在调用 recv()
、send()
等时将解释器与通道端关联。
这样做可能会造成混淆,并且还会导致意外的竞争情况,其中通道在可以在原始(创建)解释器中使用之前被自动关闭。
允许对 Interpreter.exec() 进行多个同时调用
如果 Interpreter.exec()
为您管理新的线程(我们已经拒绝了),这将很有意义。本质上,每个调用都将独立运行,从狭窄的技术角度来看,这基本上是可行的,因为每个解释器都可以拥有多个线程。
问题是,解释器只有一个 __main__
模块,并且同时 Interpreter.exec()
调用必须解决共享 __main__
的问题,或者我们必须发明一个新的机制。这两种方法都不够简单,不值得这样做。
向 RunFailedError 添加“重新抛出”方法
虽然在 RunFailedError
上设置 __cause__
有助于生成更有用的回溯信息,但在处理原始错误时,它不太有用。为了帮助促进这一点,我们可以添加 RunFailedError.reraise()
。此方法将启用以下模式
try:
try:
interp.exec(script)
except RunFailedError as exc:
exc.reraise()
except MyException:
...
如果存在 __reraise__
协议,这将变得更加简单。
综上所述,这完全没有必要。使用 __cause__
已经足够了。
try:
try:
interp.exec(script)
except RunFailedError as exc:
raise exc.__cause__
except MyException:
...
请注意,在极端情况下,它可能需要一些额外的样板代码。
try:
try:
interp.exec(script)
except RunFailedError as exc:
if exc.__cause__ is not None:
raise exc.__cause__
raise # re-raise
except MyException:
...
实现
PEP 的实现分为 4 部分
- 本 PEP 中描述的高级模块(主要是对底层 C 扩展的轻量级包装器
- 底层 C 扩展模块
- 底层模块所需的内部 C-API 的添加
- CPython 运行时中的辅助修复/更改,以促进底层模块(以及其他好处)
这些处于不同的完成阶段,越往下,完成的越多。
- 高级模块最多已经粗略地实现了。但是,完全实现它将非常简单。
- 底层模块已经基本完成。大部分实现已于 2018 年 12 月合并到主分支中,名为“_xxsubinterpreters”模块(为了测试多个解释器功能)。只有异常传播实现尚待完成,这将不需要大量工作。
- 所有必要的 C-API 工作已完成。
- 运行时中所有预期的工作已完成。
PEP 554 的实施工作正在作为一项更大的项目的一部分进行跟踪,该项目旨在改进 CPython 中的多核支持。 [multi-core-project]
参考文献
版权
本文件已进入公有领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0554.rst
最后修改时间:2023-11-28 02:32:35 GMT