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 = {
    .check_multi_interp_extensions = 1,
    .gil = PyInterpreterConfig_OWN_GIL,
};
PyThreadState *tstate = NULL;
PyStatus status = Py_NewInterpreterFromConfig(&tstate, &config);
if (PyStatus_Exception(status)) {
    return -1;
}
/* 现在,新的解释器在当前线程中处于活动状态。 */

背景上下文:
自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__,任何未捕获的异常都会被丢弃。


相关参考: