Python 3.12将开启并发和并行编程模型
PEP 684 引入了Per-Interpreter的 GIL,因此现在可以为每个解释器创建具有唯一 GIL 的子解释器。这样,Python 程序就能充分利用多个 CPU 内核。目前只能通过 C-API 使用,不过预计 3.13 版将推出 Python API。
摘要
- Python 3.12 中提供了带有 C-API 的每解释器 GIL
- 预计 Python API 将在 Python 3.13 中推出
PEP 684: A Per-Interpreter GIL
使用新的 Py_NewInterpreterFromConfig() 函数创建一个具有自己 GIL 的解释器:
PyInterpreterConfig config = { |
背景上下文:
自Python 1.5(1997)以来,CPython 用户可以在同一进程中运行多个解释器。然而,同一进程中的解释器总是共享大量的全局状态。这是错误的来源,随着越来越多的人使用该功能,其影响也越来越大。此外,足够的隔离将促进真正的多核并行,其中解释器不再共享 GIL。
PEP 734 – Stdlib 中的多个解释器:
本质上是PEP 554的延续。经过 7 年的讨论,该文件增加了大量辅助信息。此 PEP 是对基本信息的缩减。许多额外信息仍然有效且有用,只是不在此处具体提案的直接上下文中。
本 PEP 建议添加一个新模块--解释器,以支持在当前进程的多个解释器中检查、创建和运行代码。这包括代表底层解释器的解释器对象。该模块还将提供一个基本的队列(Queue)类,用于解释器之间的通信。最后,我们将在解释器模块的基础上添加一个新的 concurrent.futures.InterpreterPoolExecutor。
从根本上说,"解释器 "是 Python 线程必须共享的(基本上)所有运行时状态的集合。因此,让我们先看看线程。然后我们再回到解释器。
线程与进程
Python 进程将有一个或多个运行 Python 代码(或以其他方式与 C API 交互)的操作系统线程。每个线程都使用自己的线程状态 ( PyThreadState) 与 CPython 运行时交互,该状态保存该线程特有的所有运行时状态。还有一些运行时状态在多个操作系统线程之间共享。
任何操作系统线程都可以切换它当前正在使用的线程状态,只要它不是另一个操作系统线程已经使用(或一直在使用)的状态。
- 当前 线程状态由运行时保存在一个线程本地变量中,并可通过 PyThreadState_Get() 明确查询。对于初始("主")操作系统线程和 threading.Thread 对象,它会被自动设置。
- 在 C API 中,它由 PyThreadState_Swap() 设置(和清除),也可以由 PyGILState_Ensure() 设置。
- 大多数 C API 都要求有一个当前线程状态,它可以是隐式查找的,也可以是作为参数传递的。
操作系统线程与线程状态之间是一对多的关系。每个线程状态最多与一个操作系统线程相关联,并记录其线程 ID。一个线程状态绝不会用于多个操作系统线程。但在另一个方向上,一个操作系统线程可能有不止一个线程状态与之关联,但同样只有一个线程状态是当前的。
当一个操作系统线程有多个线程状态时,PyThreadState_Swap() 会在该操作系统线程中使用,在它们之间切换,请求的线程状态会成为当前状态。在使用旧线程状态的线程中运行的任何程序都会被暂停,直到该线程状态被换回。
解释器状态
如前所述,多个操作系统线程共享一些运行时状态。其中一些状态由 sys 模块公开,但大部分状态在内部使用,不会明确公开或仅通过 C API 公开。
这种共享状态被称为解释器状态 (PyInterpreterState)。在这里,我们有时会把它称为 "解释器",不过有时也用来指 python 可执行文件、Python 实现和字节码解释器(即 exec()/eval())。
从 1.5 版(1997 年)开始,CPython 就支持在同一进程中使用多个解释器(又称 "子解释器")。该功能通过 C API 提供。
解释器和线程
线程状态与解释器状态之间的关系,与操作系统线程和进程之间的关系(在高层次上)大致相同。
首先,这种关系是一对多的。线程状态只属于一个解释器(并存储指向解释器的指针)。该线程状态永远不会用于其他解释器。然而,从另一个方向看,一个解释器可能有零个或多个与之相关的线程状态。只有在操作系统线程中,解释器的一个线程状态为当前状态时,解释器才被视为处于活动状态。
解释器是通过 C API 使用 Py_NewInterpreterFromConfig() 创建的(或 Py_NewInterpreter(),它是 Py_NewInterpreterFromConfig() 的轻量级封装)。该函数的功能如下:
- 创建一个新的解释器状态
- 创建一个新的线程状态
- 将线程状态设置为当前状态(解释器初始化需要当前的 tstate)
- 使用该线程状态初始化解释器状态
- 返回线程状态(仍为当前状态)
请注意,返回的线程状态可能会被立即丢弃。并不要求解释器具有任何线程状态,除非解释器将被实际使用。此时,它必须在当前操作系统线程中处于活动状态。
要使现有的解释器在当前操作系统线程中处于活动状态,C API 用户首先要确保解释器具有相应的线程状态。然后,像正常一样,使用该线程状态调用 PyThreadState_Swap()。如果另一个解释器的线程状态已经是当前的,那么它就会像正常情况一样被交换出去,操作系统线程中解释器的执行就会暂停,直到它被交换回来。
一旦解释器在当前操作系统线程中处于活动状态,该线程就可以调用任何 C API,如 PyEval_EvalCode()(即 exec())。这是通过使用当前线程状态作为运行时上下文来实现的。
主 解释器
Python 进程启动时,会为当前操作系统线程创建一个解释器状态("主 "解释器)和一个线程状态。然后使用它们初始化 Python 运行时。
初始化后,脚本、模块或 REPL 将使用它们执行。执行在解释器的 __main__ 模块中进行。
当进程在操作系统主线程中运行完请求的 Python 代码或 REPL 时,Python 运行时将在该线程中使用主解释器最终完成。
无论是在主解释器中还是在子解释器中,运行时终结对仍在运行的 Python 线程只有轻微的间接影响。这是因为它会立即无限期地等待所有非守护进程的 Python 线程结束。
虽然可以查询 C API,但除了使用 threading._register_atexit() 注册的 "atexit "函数外,没有其他机制可以直接提醒任何 Python 线程最终结束已经开始。
任何剩余的子解释器都会在稍后定稿,但此时它们在任何操作系统线程中都不是当前的。
解释器隔离
CPython 的解释器之间是严格隔离的。这意味着解释器从不共享对象(除非在非常特殊的情况下使用不朽的、不可变的内置对象)。每个解释器都有自己的模块(sys.modules)、类、函数和变量。即使两个解释器定义了相同的类,每个解释器也有自己的副本。这同样适用于 C 语言中的状态,包括扩展模块中的状态。CPython C API 文档对此有更多解释。
值得注意的是,解释器总是会共享一些进程全局状态,其中有些是可变的,有些是不可变的。共享不可变状态不会带来什么问题,同时还能带来一些好处(主要是性能)。不过,所有共享的可变状态都需要特殊管理,尤其是线程安全方面的管理,操作系统会为我们处理其中的一些问题。
可变:
- 文件描述符
- 低级环境变量
- 进程内存(尽管分配器是孤立的)
- 解释器列表
不可变:
- 内置类型(如 dict、字节)
- 单子(如 None)
- 内置模块/扩展模块/冻结模块的底层静态模块数据(如函数
现有的执行组件
Python 中现有的一些组件可能有助于理解代码如何在子解释器中运行。
在 CPython 中,每个组件都是围绕下列 C API 函数(或变体)之一构建的:
- PyEval_EvalCode():使用给定的代码对象运行字节码解释器
- PyRun_String():编译 + PyEval_EvalCode()
- PyRun_File(): 读 + 编译 + PyEval_EvalCode()
- PyRun_InteractiveOneObject(): 编译 + PyEval_EvalCode()
- PyObject_Call(): 调用 PyEval_EvalCode()
buildins.exec()
内置函数 exec() 可用于执行 Python 代码。它本质上是 C API 函数 PyRun_String() 和 PyEval_EvalCode() 的封装。
以下是内置 exec() 的一些相关特性:
- 它在当前操作系统线程中运行,并暂停在该线程中运行的任何程序,当 exec() 程序运行结束后,该线程会重新开始运行。其他操作系统线程不受影响。(要避免暂停当前 Python 线程,请在 threading.Thread 中运行 exec())。
- 它可能会启动其他线程,但这些线程不会中断它。
- 它针对一个 "globals "命名空间(和一个 "locals "命名空间)执行。在模块级,exec() 默认使用当前模块的__dict__(即 globals())。 exec() 按原样使用该命名空间,不会在执行前后清除命名空间。
- 它会传播它运行的代码中任何未捕获的异常。异常会从最初调用 exec() 的 Python 线程中的 exec() 调用引发。
当 Python 线程启动时,它会使用新的线程状态通过 PyObject_Call()运行 "目标 "函数。globals 命名空间来自 func.__globals__,任何未捕获的异常都会被丢弃。
相关参考:
- A Per-Interpreter GIL section in What’s New In Python 3.12
- PEP 686: A Per-Interpreter GIL
- PEP 734: Multiple Interpreters in the Stdlib