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 实现
- “interpreters”模块API
- 解释器限制
- 通信API
- 文档
- 替代解决方案
- 开放问题
- 延迟的功能
- 添加便捷API
- 避免关于当前线程中运行的解释器的可能混淆
- 澄清“运行中”与“有线程”
- 用于共享的Dunder方法
- Interpreter.call()
- Interpreter.run_in_thread()
- 同步原语
- CSP库
- 语法支持
- 多进程
- C扩展的opt-in/opt-out
- 通道中毒
- 重置`__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。
提案
总结
- 添加一个新的标准库模块:“interpreters”
- 添加concurrent.futures.InterpreterPoolExecutor
- 为扩展模块维护者提供帮助
“interpreters”模块
`interpreters`模块将提供对多个解释器功能的*高层*接口,并包装一个新的*低层*`_interpreters`(就像`threading`模块一样)。有关具体的用法和用例,请参阅示例部分。
除了公开现有的(在CPython中)多个解释器支持外,该模块还将支持一种在解释器之间传递数据的基本机制。这包括在目标子解释器的`__main__`模块中设置“可共享”对象。其中一些对象,如`os.pipe()`,可用于进一步通信。该模块还将提供“通道”的最小实现,作为跨解释器通信的演示。
请注意,*对象*不会在解释器之间共享,因为它们绑定到创建它们的解释器。相反,对象的*数据*会在解释器之间传递。有关在解释器之间共享/通信的更多详细信息,请参阅共享数据和通信API部分。
interpreters模块的API摘要
以下是`interpreters`模块API的摘要。有关拟议类和函数的更深入解释,请参阅下文的“interpreters”模块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()`所接受的任何内容(例如,字符串脚本)。然而,我们也可能支持一些函数,以及最终支持一个单独的方法来序列化任务和参数,以减少摩擦(以牺牲短时运行任务的性能为代价)。
扩展模块维护者的帮助
实际上,实现多阶段初始化的扩展(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)。
子解释器的替代方案包括线程、async和多进程。线程受GIL限制,async并非适合所有问题的解决方案(也不是适合所有人的)。多进程在某些情况下也很有价值,但在并非所有情况下都是如此。直接IPC(而不是通过multiprocessing模块)提供了类似的好处,但存在相同的注意事项。
值得注意的是,子解释器并非旨在取代上述任何一种。当然,它们在某些方面有所重叠,但子解释器的优点包括隔离和(潜在的)性能。特别是,子解释器提供了一条通往替代并发模型(例如CSP)的直接途径,该模型在其他地方取得了成功,并将吸引一些Python用户。这正是`interpreters`模块将提供的核心价值。
- “标准库对多个解释器的支持增加了C扩展作者的额外负担”
在下面的解释器隔离部分,我们确定了CPython子解释器隔离不完整的方面。最值得注意的是使用C全局变量存储内部状态的扩展模块。(PEP 3121和PEP 489为该问题提供了解决方案,以及一些提高效率的额外API,例如PEP 573)。
因此,发布扩展模块的项目可能会面临更大的维护负担,因为用户开始使用子解释器,而他们的模块可能会在此中断。这种情况仅限于使用C全局变量(或使用使用C全局变量的库)存储内部状态的模块。对于numpy,报告的bug率是每6个月一次。[bug-rate]
最终,这归结为实际出现问题的频率问题:有多少项目会受到影响,其用户会受到多少次影响,项目将面临多少额外的维护负担,以及子解释器的总收益如何抵消这些成本。本PEP的立场是,实际的额外维护负担将很小,远低于子解释器值得付出的门槛。
- “创建新的并发API需要更多的思考和实验,所以新的模块不应该立即进入标准库,如果真的进入的话”
引入用于新并发模型的API,就像asyncio的情况一样,是一个非常大的项目,需要大量的仔细考虑。这不像本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(个人通信)
“interpreters”模块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
),将其设置为新`interpreters.RunFailedError`上的`__cause__`,然后抛出该异常。
直接抛出(代理)异常是有问题的,因为更难区分`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`模块的新标准库文档页面将包括以下内容:
- (顶部)一个清晰的说明,即扩展模块不需要支持多解释器
- 对子解释器是什么的一些解释
- 如何使用多个解释器(以及它们之间的通信)的简短示例
- 使用多个解释器的局限性摘要
- (对于扩展维护者)指向确保多个解释器兼容性的资源的链接
- 本PEP中的大部分API信息
关于扩展维护者资源的文件已经存在于“隔离扩展模块”操作指南页面上。任何额外的帮助将添加到那里。例如,讨论处理维护自身不兼容子解释器全局状态的链接库的策略可能会有所帮助。
请注意,文档将在减轻新的`interpreters`模块可能对扩展模块维护者产生的任何负面影响方面发挥重要作用。
此外,针对不兼容扩展模块的`ImportError`将被更新,以清楚地说明这是由于缺少多解释器兼容性,并且不要求扩展提供它。这将有助于正确设置用户期望。
替代解决方案
一种替代新模块的方法是向`concurrent.futures`添加对解释器的支持。有几个原因导致这行不通:
- 查找多个解释器支持的明显位置是“interpreters”模块,就像“threading”等一样。
- `concurrent.futures` all about executing functions,但目前我们没有一个好的方法在一个解释器中运行一个函数到另一个解释器。
类似的原因也适用于`multiprocessing`模块的支持。
开放问题
- `interp.exec()`在当前线程中运行是否会过于令人困惑?
- 我们现在应该为`interp.exec()`添加pickle回退,和/或`Interpreter.set_main_attrs()`和`Interpreter.get_main_attr()`吗?
- 我们现在应该支持(有限的)函数在`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()`在当前操作系统线程中执行,暂时阻塞当前Python线程。做一些事情来避免这种混淆可能是有益的。
对此类假设问题的可能解决方案
- 默认情况下,在新线程中运行?
- 添加`Interpreter.exec_in_thread()`?
- 添加`Interpreter.exec_in_current_thread()`?
在此PEP的早期版本中,该方法是`interp.run()`。仅将`interp.exec()`更改为`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)`
这些都不是紧急的,如果需要,任何一项都可以稍后完成。
用于共享的Dunder方法
我们可以添加一个特殊方法,比如`__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扩展的opt-in/opt-out
通过使用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__`和其他“__dunder__”属性)
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?).
多个解释器支持的基本功能不依赖于async,并且可以稍后添加。
一种可能的解决方案是为阻塞通道方法(`recv()`和`send()`)提供async实现。
或者,“就绪回调”(readiness callbacks)可用于简化在async场景中的使用。这意味着向通道的`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`。
支持继承设置(及更多?)
人们可能会发现,当创建一个新解释器时,能够指示某些内容被新解释器“继承”会很有用。该机制可以是严格复制,也可以是写时复制。动因示例是warnings模块(例如,复制过滤器)。
该功能并非至关重要,也不是广泛有用,因此可以等到有兴趣时再进行。值得注意的是,两种建议的解决方案都需要大量工作,尤其是在处理复杂对象以及最重要的是可变复杂对象的可变容器时。
使RunFailedError.__cause__惰性化
子解释器中的未捕获异常(来自`interp.exec()`)会被复制到调用解释器,并设置为`RunFailedError`上的`__cause__`,然后该异常会被抛出。复制部分涉及调用解释器中的某种反序列化,这可能很昂贵(例如,由于导入),但并非总是必需的。
因此,使用`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()
为通道提供显式的关闭方式,以防止进一步的全局使用,可能会很方便。同样,提供一种显式释放通道相对于当前解释器的末端的方式也可能有用。除其他原因外,这种机制对于在解释器之间传递整体状态很有用,而无需直接通过通道传递对象所需的额外样板代码。
挑战在于在不使其难以理解的情况下正确处理自动释放/关闭。尤其是在处理非空通道时。目前我们应该可以通过不使用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
混淆。
“enumerate”
函数 list_all()
提供所有解释器的列表。在部分启发了此 API 的 threading 模块中,该函数名为 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添加“reraise”方法
虽然将 __cause__
设置在 RunFailedError
上有助于生成更有用的回溯,但在处理原始错误时帮助不大。为了便于实现这一点,我们可以添加 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 月合并到 master 分支,作为“_xxsubinterpreters”模块(为了测试多个解释器功能)。只有异常传播的实现仍待完成,这不需要大量工作。
- 所有必需的 C-API 工作已完成
- 运行时中所有预期的工作都已完成
PEP 554 的实现工作正在作为一项旨在改进 CPython 中多核支持的更大项目的跟踪一部分进行。[multi-core-project]
参考资料
版权
本文档已置于公共领域。
来源: https://github.com/python/peps/blob/main/peps/pep-0554.rst
最后修改: 2025-02-01 08:55:40 GMT