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

Python 增强提案

PEP 703 – 使 CPython 中的全局解释器锁成为可选

作者:
Sam Gross <colesbury at gmail.com>
发起人:
Łukasz Langa <lukasz at python.org>
讨论至:
Discourse 帖子
状态:
已接受
类型:
标准跟踪
创建日期:
2023年1月9日
Python 版本:
3.13
发布历史:
2023年1月9日, 2023年5月4日
决议:
2023年10月24日

目录

注意

指导委员会接受 PEP 703,但明确规定:推行应循序渐进,尽量减少破坏,并且我们可以回滚任何被证明破坏性过大的更改——这包括在必要时可能完全回滚 PEP 703 的所有更改(尽管我们认为这种情况不太可能发生或不受欢迎)。

摘要

CPython 的全局解释器锁(“GIL”)阻止多个线程同时执行 Python 代码。GIL 阻碍了 Python 有效利用多核 CPU。本 PEP 建议在 CPython 中添加一个构建配置(--disable-gil),使其能够在没有全局解释器锁的情况下运行 Python 代码,并进行必要的更改以使解释器线程安全。

动机

GIL 是并发的主要障碍。对于科学计算任务,这种缺乏并发性通常比执行 Python 代码的速度更是一个大问题,因为大多数处理器周期都花在优化的 CPU 或 GPU 内核中。GIL 引入了一个全局瓶颈,如果其他线程调用任何 Python 代码,可能会阻止它们取得进展。目前 CPython 中存在启用并行性的现有方法,但这些技术存在显著限制(参见替代方案)。

本节重点介绍 GIL 对科学计算,特别是 AI/ML 工作负载的影响,因为这是作者经验最丰富的领域,但 GIL 也影响 Python 的其他用户。

GIL 使得许多类型的并行难以表达

基于神经网络的 AI 模型暴露了多种并行机会。例如,单个操作可以在内部并行化(“操作内”),多个操作可以同时执行(“操作间”),请求(跨多个操作)也可以并行化。高效执行需要利用多种类型的并行性[1]

GIL 使得在 Python 中高效表达操作间并行性以及某些形式的请求并行性变得困难。在其他编程语言中,系统可能会使用线程在不同的 CPU 核心上运行神经网络的不同部分,但这在 Python 中由于 GIL 而效率低下。类似地,对延迟敏感的推理工作负载经常使用线程跨请求进行并行化,但在 Python 中面临相同的扩展瓶颈。

GIL 给 Python 中利用并行性带来的挑战经常出现在强化学习中。NetHack Learning Environment 的作者、Inflection AI 的技术人员 Heinrich Kuttler 写道:

强化学习的最新突破,例如在 Dota 2StarCraftNetHack 上,都依赖于使用异步 Actor-Critic 方法并行运行多个环境(模拟游戏)。Python 中简单的多线程实现由于 GIL 竞争而无法扩展到几个并行环境之外。多进程,通过共享内存或 UNIX 套接字进行通信,增加了很大的复杂性,实际上排除了从不同工作进程与 CUDA 交互的可能性,严重限制了设计空间。

DeepMind 强化学习团队的软件工程师 Manuel Kroiss 描述了 GIL 造成的瓶颈如何导致将 Python 代码库重写为 C++,从而使代码更难访问:

在 DeepMind,我们经常与 Python GIL 的问题作斗争。在我们的许多应用程序中,我们希望每个进程运行约 50-100 个线程。然而,我们经常看到即使线程数少于 10 个,GIL 也会成为瓶颈。为了解决这个问题,我们有时会使用子进程,但在许多情况下,进程间通信的开销过大。为了处理 GIL,我们通常最终将我们 Python 代码库的大部分转换为 C++。这是不希望的,因为它使研究人员更难访问代码。

涉及与多个硬件设备接口的项目面临类似的挑战:高效通信需要使用多个 CPU 核心。Dose-3D 项目旨在通过精确剂量计划改进癌症放射治疗。它使用医用模型(人体组织的替代品)以及自定义硬件和用 Python 编写的服务器应用程序。Dose-3D 项目数据采集系统的首席软件架构师 Paweł Jurgielewicz 描述了 GIL 造成的扩展挑战,以及使用没有 GIL 的 Python 分支如何简化项目:

在 Dose-3D 项目中,主要挑战是维护与硬件单元稳定、非平凡的并发通信链路,同时最大限度地利用 1 Gbit/s UDP/IP 连接。自然地,我们从多进程包开始,但在某个时候,很明显大部分 CPU 时间都消耗在数据处理阶段之间的数据传输上,而不是数据处理本身。基于 GIL 的 CPython 多线程实现也是死路一条。当我们发现 Python 的“nogil”分支时,一个人只用了不到半个工作日就调整了代码库以使用该分支,结果令人惊讶。现在我们可以专注于数据采集系统开发,而不是微调数据交换算法。

CellProfiler 的作者、Prescient Design 和 Genentech 的高级工程师 Allen Goodman 描述了 GIL 如何使 Python 中的生物学方法研究变得更加困难:

Python 全局解释器锁的问题是生物学方法研究中经常遇到的挫折来源。

我想更好地了解当前的多线程情况,所以我重新实现了 HMMER 的一部分,HMMER 是多序列比对的标准方法。我选择这种方法是因为它既强调单线程性能(评分)又强调多线程性能(搜索序列数据库)。当使用八个线程时,GIL 成为瓶颈。这是一种目前流行的实现依赖于每个进程 64 甚至 128 个线程的方法。我尝试转向子进程,但被高昂的 IPC 成本所阻碍。HMMER 是一种相对初级的生物信息学方法,而更新的方法对多线程的需求要大得多。

方法研究人员恳求使用 Python(包括我自己),因为它易于使用、拥有 Python 生态系统,并且因为“这是人们所熟知的”。许多生物学家只懂一点点编程(而且几乎总是 Python)。在 Python 的多线程问题得到解决之前,C 和 C++ 仍将是生物学方法研究社区的通用语言。

GIL 影响 Python 库的可用性

GIL 是 CPython 的一个实现细节,它限制了多线程并行性,因此将其视为可用性问题可能看起来有些反直觉。然而,库作者通常非常关心性能,并会设计支持绕过 GIL 的 API。这些权宜之计通常会导致 API 更难使用。因此,这些 API 的用户可能会将 GIL 视为一个**可用性**问题,而不仅仅是性能问题。

例如,PyTorch 暴露了一个基于多进程的 API,名为 DataLoader,用于构建数据输入管道。它在 Linux 上使用 fork(),因为它通常比 spawn() 更快且内存使用更少,但这给用户带来了额外的挑战:在访问 GPU 后创建 DataLoader 可能会导致令人困惑的 CUDA 错误。在 DataLoader 工作进程中访问 GPU 很快会导致内存不足错误,因为进程不共享 CUDA 上下文(与进程内的线程不同)。

scikit-learn 开发者、Inria 软件工程师 Olivier Grisel 描述了在 scikit-learn 相关库中不得不绕过 GIL 如何导致更复杂和令人困惑的用户体验:

多年来,scikit-learn 开发者维护了诸如 joblibloky 等辅助库,试图解决多进程的一些限制:部分通过大型数据缓冲区的半自动化内存映射缓解了额外的内存使用;通过透明地重用长期运行的工作进程池来加快工作进程启动速度;通过从不使用仅 fork 启动方法来解决第三方原生运行时库(如 GNU OpenMP)的 fork 安全问题;通过 cloudpickle 以跨平台方式在笔记本和 REPL 中并行调用交互式定义函数的能力。尽管我们做出了努力,但这种基于多进程的解决方案仍然脆弱、难以维护,并且对于系统级约束理解有限的数据科学家来说令人困惑。此外,仍然存在不可避免的限制,例如进程间通信所需的基于 pickle 的序列化/反序列化步骤造成的开销。如果我们可以在多核主机(有时具有 64 个或更多物理核心)上使用无竞争的线程来运行在 Python 级操作和调用原生库之间交替的数据科学管道,那么所有这些额外的工作和复杂性将不再需要。

Quansight Labs 联合主任、NumPy 和 SciPy 维护者 Ralf Gommers 描述了 GIL 如何影响 NumPy 和数值 Python 库的用户体验:

NumPy 及其围绕构建的包堆栈中的一个关键问题是 NumPy 仍然(大部分)是单线程的——这已经塑造了用户体验和围绕它构建的项目的大部分。NumPy 在其内部循环中确实会释放 GIL(它们执行繁重的工作),但这远远不够。NumPy 不提供一个很好的解决方案来利用一台机器的所有 CPU 核心,而是将其留给 Dask 和其他多进程解决方案。这些效率不高,也更笨拙。这种笨拙主要体现在用户在使用例如包装 numpy.ndarraydask.array 时需要关注的额外抽象和层。它也体现在用户必须明确意识到并管理的环境变量或第三方包 threadpoolctl 的过度订阅问题。主要原因是 NumPy 调用 BLAS 进行线性代数——它无法控制这些调用,它们默认通过 pthreads 或 OpenMP 使用所有核心。

协调 API 和设计决策以控制并行性仍然是一项大量工作,也是 PyData 生态系统中最艰难的挑战之一。如果没有 GIL,它会大不相同(更好,更容易)。

GPU 密集型工作负载需要多核处理

许多高性能计算(HPC)和人工智能工作负载大量使用 GPU。这些应用程序通常需要高效的多核 CPU 执行,即使大部分计算都在 GPU 上运行。

PyTorch 核心开发者、FAIR (Meta AI) 研究员 Zachary DeVito 描述了即使大部分计算在 Python 之外执行,GIL 如何使多线程扩展效率低下:

在 PyTorch 中,Python 通常用于协调约 8 个 GPU 和约 64 个 CPU 线程,对于大型模型,可扩展到 4k 个 GPU 和 32k 个 CPU 线程。虽然繁重的工作是在 Python 之外完成的,但 GPU 的速度使得即使是 Python 中的协调也无法扩展。由于 GIL,我们最终经常使用 72 个进程而不是一个。在这种情况下,日志记录、调试和性能调优的难度增加了几个数量级,不断导致开发人员生产力下降。

使用许多进程(而不是线程)使常见任务更加困难。Zachary DeVito 继续说:

在过去几个月里,我有三次不同的经历(减少数据加载器中的冗余计算,异步写入模型检查点,以及并行化编译器优化),我花在解决 GIL 限制上的时间比实际解决特定问题的时间多了一个数量级。

即使是 GPU 密集型工作负载也经常包含 CPU 密集型组件。例如,计算机视觉任务通常在数据输入管道中需要多个“预处理”步骤,如图像解码、裁剪和调整大小。这些任务通常在 CPU 上执行,并可能使用 PillowPillow-SIMD 等 Python 库。有必要在多个 CPU 核心上运行数据输入管道,以确保 GPU “喂饱”数据。

GPU 性能相对于单个 CPU 核心的提升使得多核性能变得更加重要。要使 GPU 保持完全占用越来越困难。为此,需要高效利用多个 CPU 核心,尤其是在多 GPU 系统上。例如,NVIDIA 的 DGX-A100 拥有 8 个 GPU 和两个 64 核 CPU,以确保 GPU “喂饱”数据。

GIL 使得部署 Python AI 模型变得困难

Python 被广泛用于开发基于神经网络的 AI 模型。在 PyTorch 中,模型通常作为多线程、主要由 C++ 构成的环境的一部分进行部署。Python 经常受到质疑,因为 GIL 可能成为一个全局瓶颈,即使绝大多数计算都在 GIL 释放的“Python 之外”发生,它也会阻止高效的扩展。[2] torchdeploy 论文展示了多种模型架构中这些扩展瓶颈的实验证据。

PyTorch 提供了许多部署 Python AI 模型的机制,这些机制可以避免或绕过 GIL,但它们都存在实质性限制。例如,TorchScript 捕获了模型的一种表示形式,可以在 C++ 中执行,无需任何 Python 依赖,但它只支持 Python 的有限子集,并且通常需要重写部分模型代码。torch::deploy API 允许在同一个进程中存在多个 Python 解释器,每个解释器都有自己的 GIL(类似于 PEP 684)。然而,torch::deploy 对使用 C-API 扩展的 Python 模块支持有限。

动机总结

Python 的全局解释器锁使得许多科学和数值计算应用程序难以有效利用现代多核 CPU。Heinrich Kuttler、Manuel Kroiss 和 Paweł Jurgielewicz 发现,Python 中的多线程实现在他们的任务中无法很好地扩展,并且使用多个进程并非合适的替代方案。

扩展瓶颈并非仅限于核心数值任务。Zachary DeVito 和 Paweł Jurgielewicz 都描述了 Python 中协调和通信的挑战。

Olivier Grisel、Ralf Gommers 和 Zachary DeVito 描述了当前 GIL 的权宜之计如何“难以维护”并导致“开发人员生产力下降”。GIL 使得开发和维护科学和数值计算库更加困难,也导致了更难使用的库设计。

规范

构建配置更改

全局解释器锁将仍然是 CPython 构建和 python.org 下载的默认设置。一个新的构建配置标志 --disable-gil 将添加到配置脚本中,它将构建支持在没有全局解释器锁的情况下运行 CPython 的版本。

当使用 --disable-gil 构建时,CPython 将在 Python/patchlevel.h 中定义 Py_GIL_DISABLED 宏。ABI 标签将包含字母“t”(表示“threading”)。

禁用 GIL 的 CPython 构建仍将支持在运行时选择性地启用 GIL 运行(参见PYTHONGIL 环境变量Py_mod_gil 槽)。

CPython 更改概述

移除全局解释器锁需要对 CPython 内部进行实质性更改,但对公共 Python 和 C API 的更改相对较少。本节描述了对 CPython 实现所需的更改,然后是拟议的 API 更改。

实施变更可分为以下四类:

  • 引用计数
  • 内存管理
  • 容器线程安全
  • 锁定和原子 API

引用计数

移除 GIL 需要更改 CPython 的引用计数实现以使其线程安全。此外,它需要较低的执行开销并允许高效的多线程扩展。本 PEP 提出了三种技术的组合来解决这些约束。第一种是从普通的非原子引用计数切换到偏置引用计数,这是一种线程安全的引用计数技术,其执行开销低于普通的原子引用计数。另外两种技术是不朽化和有限形式的延迟引用计数;它们通过避免一些引用计数修改来解决引用计数的一些多线程可扩展性问题。

偏置引用计数 (BRC) 是一种由 Jiho Choi、Thomas Shull 和 Josep Torrellas 于 2018 年首次描述的技术[3]。它基于这样的观察:即使在多线程程序中,大多数对象也只被单个线程访问。每个对象都与一个拥有线程(创建它的线程)关联。拥有线程的引用计数操作使用非原子指令来修改“本地”引用计数。其他线程使用原子指令来修改“共享”引用计数。这种设计避免了当代处理器上昂贵的许多原子读-修改-写操作。

本 PEP 中提出的 BRC 实现与偏置引用计数的原始描述基本一致,但在引用计数字段大小和这些字段中的特殊位等细节上有所不同。BRC 要求在每个对象的头部存储三部分信息:“本地”引用计数、“共享”引用计数以及拥有线程的标识符。BRC 论文将这三部分信息打包到一个 64 位字段中。本 PEP 建议在每个对象的头部使用三个单独的字段,以避免引用计数溢出可能导致的问题。此外,本 PEP 支持一种更快的解除分配路径,可以在常见情况下避免原子操作。

拟议的 PyObject 结构(也称为 struct _object)如下所示

struct _object {
  _PyObject_HEAD_EXTRA
  uintptr_t ob_tid;         // owning thread id (4-8 bytes)
  uint16_t __padding;       // reserved for future use (2 bytes)
  PyMutex ob_mutex;         // per-object mutex (1 byte)
  uint8_t ob_gc_bits;       // GC fields (1 byte)
  uint32_t ob_ref_local;    // local reference count (4 bytes)
  Py_ssize_t ob_ref_shared; // shared reference count and state bits (4-8 bytes)
  PyTypeObject *ob_type;
};

偏置引用计数实现使用 ob_tidob_ref_localob_ref_sharedob_gc_bits 字段用于存储以前存储在 PyGC_Head 中的垃圾收集标志(参见垃圾收集(循环收集))。 ob_mutex 字段提供了一个单字节的每对象锁。

不朽化

某些对象,例如内部化字符串、小整数、静态分配的 PyTypeObjects 以及 TrueFalseNone 对象,在程序生命周期内保持存活。这些对象通过将本地引用计数字段 (ob_ref_local) 设置为 UINT32_MAX 来标记为不朽。

Py_INCREFPy_DECREF 宏对于不朽对象来说是空操作。这避免了多个线程并发访问这些对象时引用计数字段上的争用。

此提出的不朽化方案与 Python 3.12 中采用的 PEP 683 非常相似,但在不朽对象的引用计数字段中具有略微不同的位表示,以便与偏置引用计数和延迟引用计数配合使用。另请参阅为什么不使用 PEP 683 不朽化?

偏置引用计数

偏置引用计数对当前线程“拥有”的对象具有快速路径,对其他对象具有慢速路径。所有权由 ob_tid 字段指示。确定线程 ID 需要平台特定的代码[5]ob_tid 中的值为 0 表示对象不被任何线程拥有。

ob_ref_local 字段存储本地引用计数和两个标志。两个最高有效位用于指示对象是不朽的或使用延迟引用计数(参见延迟引用计数)。

ob_ref_shared 字段存储共享引用计数。两个**最不**显著的位用于存储引用计数状态。因此,共享引用计数左移两位。ob_ref_shared 字段使用最不显著的位是因为共享引用计数可能会暂时为负;线程之间的 increfs 和 decrefs 可能不平衡。

可能的引用计数状态如下所列:

  • 0b00 - 默认
  • 0b01 - 弱引用
  • 0b10 - 排队
  • 0b11 - 合并

这些状态形成了一个进程:在其生命周期中,对象可以转换到任何数值上更高的状态。对象只能从“默认”和“合并”状态中解除分配。其他状态必须在解除分配之前转换到“合并”状态。转换状态需要在 ob_ref_shared 字段上进行原子比较并交换。

默认 (0b00)

对象最初是在默认状态下创建的。这是唯一允许快速解除分配代码路径的状态。否则,线程必须合并本地和共享引用计数字段,这需要原子比较和交换。

这种快速解除分配代码路径与并发解引用弱引用不是线程安全的,因此第一次创建弱引用时,如果对象当前处于“默认”状态,它将转换为“弱引用”状态。

同样,快速解除分配代码路径与无锁列表和字典访问(参见乐观地避免锁定)不是线程安全的,因此非拥有线程第一次尝试检索“默认”状态下的对象时,它会回退到较慢的锁定代码路径,并将对象转换为“弱引用”状态。

弱引用 (0b01)

处于弱引用和更高状态的对象支持解引用弱引用以及非拥有线程的无锁列表和字典访问。它们在解除分配之前需要转换为合并状态,这比“默认”状态支持的快速解除分配代码路径更昂贵。

排队 (0b10)

排队状态表明非拥有线程已请求合并引用计数字段。当共享引用计数变为负值时(由于线程间递增和递减操作不平衡),可能会发生这种情况。该对象被插入到拥有线程的待合并对象队列中。拥有线程通过 eval_breaker 机制收到通知。实际上,此操作很少发生。大多数对象仅由单个线程访问,而那些由多个线程访问的对象很少具有负共享引用计数。

如果拥有线程已终止,则执行线程立即合并本地和共享引用计数字段并转换为合并状态。

合并 (0b11)

合并状态表示该对象不被任何线程拥有。在这种状态下,ob_tid 字段为零,ob_ref_local 未使用。一旦共享引用计数达到零,就可以从合并状态解除分配对象。

引用计数伪代码

提议的 Py_INCREFPy_DECREF 操作应按以下方式执行(使用 C 语言风格的伪代码):

// low two bits of "ob_ref_shared" are used for flags
#define _Py_SHARED_SHIFT 2

void Py_INCREF(PyObject *op)
{
  uint32_t new_local = op->ob_ref_local + 1;
  if (new_local == 0)
    return;  // object is immortal
  if (op->ob_tid == _Py_ThreadId())
    op->ob_ref_local = new_local;
  else
    atomic_add(&op->ob_ref_shared, 1 << _Py_SHARED_SHIFT);
}

void Py_DECREF(PyObject *op)
{
  if (op->ob_ref_local == _Py_IMMORTAL_REFCNT) {
    return;  // object is immortal
  }
  if (op->ob_tid == _Py_ThreadId()) {
    op->ob_ref_local -= 1;
    if (op->ob_ref_local == 0) {
      _Py_MergeZeroRefcount(); // merge refcount
    }
  }
  else {
    _Py_DecRefShared(); // slow path
  }
}

void _Py_MergeZeroRefcount(PyObject *op)
{
  if (op->ob_ref_shared == 0) {
    // quick deallocation code path (common case)
    op->ob_tid = 0;
    _Py_Dealloc(op);
  }
  else {
    // slower merging path not shown
  }
}

参考实现[17]包含了 _Py_MergeZeroRefcount_Py_DecRefShared 的实现。

请注意,以上是伪代码:在实践中,实现应使用“宽松原子操作”访问 ob_tidob_ref_local,以避免 C 和 C++ 中的未定义行为。

延迟引用计数

一些类型的对象,例如顶级函数、代码对象、模块和方法,倾向于被许多线程并发频繁访问。这些对象不一定在程序的生命周期内存在,因此不朽化不适用。本 PEP 提出了一种有限形式的延迟引用计数,以避免在多线程程序中这些对象的引用计数字段上的争用。

通常,解释器在对象被推入和弹出解释器堆栈时修改对象的引用计数。对于使用延迟引用计数的对象,解释器会跳过这些引用计数操作。支持延迟引用计数的对象通过将本地引用计数字段的两个最高有效位设置为一来进行标记。

由于跳过了一些引用计数操作,引用计数字段不再反映这些对象的真实引用数。真实的引用计数是引用计数字段的总和加上每个线程解释器栈中所有跳过的引用。只有在循环垃圾收集期间所有线程都暂停时,才能安全地计算真实的引用计数。因此,使用延迟引用计数(deferred reference counting)的对象只能在垃圾收集周期中解除分配。

请注意,即使没有延迟引用计数,使用延迟引用计数(deferred reference counting)的对象在 CPython 中也自然形成引用循环,因此它们通常会被垃圾收集器解除分配。例如,顶级函数和模块形成一个引用循环,方法和类型对象也是如此。

垃圾收集器针对延迟引用计数的修改

跟踪垃圾收集器查找并解除分配未引用的对象。目前,跟踪垃圾收集器只查找作为引用循环一部分的未引用对象。通过延迟引用计数,跟踪垃圾收集器还将查找并收集一些可能不属于任何引用循环,但其收集已被延迟的未引用对象。这要求所有支持延迟引用计数的对象也具有支持跟踪垃圾收集的相应类型对象(通过 Py_TPFLAGS_HAVE_GC 标志)。此外,垃圾收集器需要遍历每个线程的堆栈,以便在每次收集开始时向 GC 引用计数添加引用。

引用计数类型对象

类型对象 (PyTypeObject) 混合使用了引用计数技术。静态分配的类型对象是永生的,因为这些对象在程序生命周期中始终存在。堆类型对象结合使用了延迟引用计数和每线程引用计数。延迟引用计数不足以解决堆类型的多线程扩展瓶颈,因为对堆类型的大多数引用来自对象实例,而不是解释器堆栈上的引用。

为了解决这个问题,堆类型引用计数部分以分布式方式存储在每线程数组中。每个线程为每个堆类型对象存储一个本地引用计数数组。堆类型对象被分配一个唯一编号,该编号决定其在本地引用计数数组中的位置。堆类型的真实引用计数是其在每线程数组中的条目总和,加上 PyTypeObject 上的引用计数,再加上解释器堆栈中的任何延迟引用。

线程在递增或递减类型对象的局部引用计数时,可以根据需要增长自己的类型引用计数数组。

每线程引用计数数组的使用仅限于少数几个地方:

  • PyType_GenericAlloc(PyTypeObject *type, Py_ssize_t nitems):如果 type 是堆类型,则递增当前线程对 type 的局部引用计数。
  • subtype_dealloc(PyObject *self):如果类型是堆类型,则递减当前线程对 self->ob_type 的局部引用计数。
  • gcmodule.c:将每个线程的局部引用计数添加到相应堆类型对象的 gc_refs 计数中。

此外,当线程终止时,它会将任何非零的本地引用计数添加到每个类型对象自身的引用计数字段中。

内存管理

CPython 目前使用内部分配器 pymalloc,它针对小对象分配进行了优化。pymalloc 实现没有 GIL 就不是线程安全的。本 PEP 建议用 mimalloc 替换 pymalloc,mimalloc 是一种通用线程安全分配器,性能良好,包括小对象分配。

使用 mimalloc 经过一些修改,也解决了与移除 GIL 相关的另外两个问题。首先,遍历内部 mimalloc 结构允许垃圾收集器找到所有 Python 对象,而无需维护链表。这将在垃圾收集部分详细描述。其次,基于大小类的 mimalloc 堆和分配使得像字典这样的集合通常可以在只读操作期间避免获取锁。这将在集合线程安全部分详细描述。

CPython 已经要求支持垃圾收集的对象使用 GC 分配器 API(通常间接通过调用 PyType_GenericAlloc)。本 PEP 将对 Python 分配器 API 的使用添加额外要求。首先,Python 对象必须通过对象分配 API 进行分配,例如 PyType_GenericAllocPyObject_Malloc 或其他封装这些调用的 Python API。Python 对象不应通过其他 API 进行分配,例如直接调用 C 的 malloc 或 C++ 的 new 运算符。此外,PyObject_Malloc 只能用于分配 Python 对象;它不应用于分配缓冲区、存储或其他非 PyObject 的数据结构。

本 PEP 还对可插拔分配器 API (PyMem_SetAllocator) 施加了限制。在不编译 GIL 的情况下,使用此 API 设置的分配器最终必须将分配委托给相应的底层分配器(例如 PyObject_Malloc)进行 Python 对象分配。这允许“包装”底层分配器(例如 Python 的 tracemalloc 和调试分配器)的分配器,但不允许完全替换分配器。

CPython 自由列表

CPython 利用自由列表来加速小而频繁分配的对象(如元组和数字)的分配。这些自由列表从每个解释器状态转移到 PyThreadState

垃圾收集(循环收集)

CPython 垃圾收集器需要以下更改才能与此提议配合使用:

  • 使用“暂停一切”来提供以前由 GIL 提供的线程安全保证。
  • 取消分代垃圾回收,转而使用非分代收集器。
  • 与延迟引用计数和偏置引用计数集成。

此外,上述更改使得可以从 GC 对象中删除 _gc_prev_gc_next 字段。存储跟踪、最终化和不可达状态的 GC 位被移动到 PyObject 头部的 ob_gc_bits 字段中。

暂停一切

CPython 循环垃圾收集器目前依赖全局解释器锁来防止其他线程在收集器查找循环时访问 Python 对象。在循环查找例程期间,GIL 从不释放,因此收集器可以依赖稳定(即不变)的引用计数和引用。然而,在循环检测之后,GIL 可能会在调用对象的终结器和清除 (tp_clear) 函数时暂时释放,允许其他线程交错运行。

在没有 GIL 的情况下运行,实现需要一种方法来确保在循环检测期间引用计数保持稳定。运行 Python 代码的线程必须暂停以确保引用和引用计数保持稳定。一旦识别出循环,其他线程将恢复。

当前的 CPython 循环垃圾收集器在每个垃圾收集循环中涉及两次循环检测遍历。因此,在没有 GIL 的情况下运行垃圾收集器时,这需要两次暂停所有线程。第一次循环检测遍历识别循环垃圾。第二次遍历在终结器之后运行,以识别哪些对象仍然不可达。请注意,在调用终结器和 tp_clear 函数之前,其他线程会恢复,以避免引入当前 CPython 行为中不存在的潜在死锁。

线程状态

为了支持暂停线程进行垃圾收集,PyThreadState 获得了一个新的“status”字段。与 PyThreadState 中的其他字段一样,status 字段不属于公共 CPython API。status 字段可以处于以下三种状态之一:

  • ATTACHED (已连接)
  • DETACHED (已分离)
  • GC (垃圾收集)

ATTACHEDDETACHED 状态与获取和释放全局解释器锁密切相关。在不编译 GIL 的情况下,以前获取 GIL 的函数现在将线程状态转换为 ATTACHED,而以前释放 GIL 的函数现在将线程状态转换为 DETACHED。就像以前线程在访问或修改 Python 对象之前需要获取 GIL 一样,现在它们必须处于 ATTACHED 状态才能访问或修改 Python 对象。由于相同的公共 C-API 函数“附加”线程(例如 PyEval_RestoreThread)与以前获取 GIL 的函数相同,因此扩展中线程初始化的要求保持不变。实质性区别在于多个线程可以同时处于附加状态,而以前一次只有一个线程可以获取 GIL。

在“暂停所有世界”暂停期间,执行垃圾收集的线程需要确保没有其他线程正在访问或修改 Python 对象。所有其他线程必须处于“GC”状态。垃圾收集线程可以使用状态字段上的原子比较并交换操作,将其他线程从 DETACHED 状态转换为 GC 状态。处于 ATTACHED 状态的线程被要求自行暂停并将其状态设置为“GC”,使用现有的“eval breaker”机制。在“暂停所有世界”暂停结束时,所有处于“GC”状态的线程都被设置为 DETACHED,如果它们已暂停则被唤醒。以前处于附加状态(即执行 Python 字节码)的线程可以重新附加(将其线程状态设置为 ATTACHED)并恢复执行 Python 代码。以前处于 DETACHED 状态的线程将忽略通知。

现有的 Python 垃圾收集器使用三代。在不编译 GIL 的情况下,垃圾收集器将只使用一代(即,它将是非分代的)。这种更改的主要原因是减少多线程应用程序中“暂停所有世界”暂停的影响。频繁的年轻代收集暂停对多线程应用程序的影响将大于不频繁的收集。

与延迟和偏置引用计数的集成

为了找到未引用的对象,循环垃圾收集器计算传入引用数量与对象引用计数之间的差异。此差异称为 gc_refs 并存储在 _gc_prev 字段中。如果 gc_refs 大于零,则对象保证存活(即,不是循环垃圾)。如果 gc_refs 为零,则对象仅在其被另一个存活对象传递引用时才存活。在计算此差异时,收集器应遍历每个线程的堆栈,并且对于每个延迟引用,递增被引用对象的 gc_refs。由于生成器对象也具有带有延迟引用的堆栈,因此对每个生成器的堆栈应用相同的过程。

Python 单元测试通常使用 gc.collect() 来确保任何未引用的对象被销毁并运行其终结器。由于偏置引用计数会延迟某些被多个线程引用的对象的销毁,因此方便确保这些对象在垃圾回收期间被销毁,即使它们可能不属于任何引用循环。在其他线程暂停时,垃圾收集器线程应合并任何排队对象的引用计数,但不调用任何析构函数,即使合并后的引用计数为零。(在其他线程暂停时调用析构函数可能会引入死锁。)一旦其他线程恢复,GC 线程应在那些合并引用计数为零的对象上调用 _Py_Dealloc

容器线程安全

在 CPython 中,全局解释器锁可防止在多个线程并发访问或修改 Python 对象时内部解释器状态损坏。例如,如果多个线程并发修改同一个列表,GIL 可确保列表的长度(ob_size)准确匹配元素数量,并且每个元素的引用计数准确反映对这些元素的引用数量。没有 GIL — 并且没有其他更改 — 并发修改将损坏这些字段并可能导致程序崩溃。

GIL 不一定能保证操作是原子性的,或者在多个操作并发发生时保持正确。例如,如果可迭代对象在 Python 中实现迭代器(或内部释放 GIL),list.extend(iterable) 可能不会显示为原子操作。同样,如果 list.remove(x) 与另一个修改列表的操作重叠,它可能会删除错误的对象,具体取决于相等运算符的实现。尽管如此,GIL 确保某些操作实际上是原子操作。例如,构造函数 list(set) 原子地将集合的项复制到新列表中,并且某些代码依赖于该复制的原子性(即具有集合项的快照)。本 PEP 保留了该属性。

本 PEP 建议使用每对象锁来提供 GIL 提供的许多相同保护。例如,每个列表、字典和集合都将有一个相关的轻量级锁。所有修改对象的操作都必须持有该对象的锁。大多数从对象读取的操作也应获取对象的锁;下面描述了少数可以在不持有锁的情况下进行的读取操作。

带有临界区的每对象锁提供比 GIL 更弱的保护。因为 GIL 不一定能保证并发操作是原子性的或正确的,所以每对象锁定方案也不能保证并发操作是原子性的或正确的。相反,每对象锁定旨在提供与 GIL 类似的保护,但互斥仅限于单个对象。

容器类型的实例上的大多数操作都需要锁定该对象。例如:

  • list.appendlist.insertlist.repeatPyList_SetItem
  • dict.__setitem__, PyDict_SetItem
  • list.clear, dict.clear
  • list.__repr__, dict.__repr__, 等。
  • list.extend(iterable)
  • setiter_iternext

某些操作直接作用于两个容器对象,并了解这两个容器的内部结构。例如,list.extend(iterable) 针对特定的可迭代类型(如 set)有内部特殊实现。这些操作需要锁定两个容器对象,因为它们同时访问这两个对象的内部。请注意,list.extend 的通用实现只需要锁定一个对象(列表),因为另一个对象是通过线程安全的迭代器 API 间接访问的。锁定两个容器的操作有:

  • list.extend(list)list.extend(set)list.extend (dictitems) 以及其他针对参数类型特殊化的实现。
  • list.concat(list)
  • list.__eq__(list), dict.__eq__(dict)

一些简单的操作可以直接通过原子访问实现,不需要锁,因为它们只访问一个字段。这些操作包括:

  • len(list),即 list_length(PyListObject *a)
  • len(dict)
  • len(set)

少数精选的操作会乐观地避免锁定以提高性能。这些操作需要特殊的实现以及内存分配器的配合:

  • list[idx] (list_subscript)
  • dict[key] (dict_subscript)
  • listiter_next, dictiter_iternextkey/value/item
  • list.contains

借用引用

每对象锁定提供了 GIL 提供的许多重要保护,但在少数情况下它并不足够。例如,依赖将借用引用升级为“拥有”引用的代码在某些情况下可能不安全:

PyObject *item = PyList_GetItem(list, idx);
Py_INCREF(item);

GIL 确保在访问和 Py_INCREF 调用之间,没有其他线程可以修改列表。没有 GIL——即使有每对象锁定——另一个线程也可能修改列表,导致 item 在访问和 Py_INCREF 调用之间被释放。

有问题的借用引用 API 补充了返回“新引用”但其他方面等效的函数:

  • PyList_FetchItem(list, idx) 用于 PyList_GetItem
  • PyDict_FetchItem(dict, key) 用于 PyDict_GetItem
  • PyWeakref_FetchObject 用于 PyWeakref_GetObject

请注意,一些返回借用引用的 API,例如 PyTuple_GetItem,没有问题,因为元组是不可变的。同样,并非上述所有 API 的使用都有问题。例如,PyDict_GetItem 通常用于解析函数调用中的关键字参数字典;这些关键字参数字典实际上是私有的(其他线程无法访问)。

Python 临界区

简单的每对象锁定可能会引入在 GIL 运行时不存在的死锁。线程可能会同时持有多个对象的锁,因为 Python 操作可以嵌套。对象上的操作可以调用其他对象上的操作,从而获取多个每对象锁。如果线程尝试以不同的顺序获取相同的锁,它们将死锁。

本 PEP 提出了一种名为“Python 临界区”的方案,以隐式释放每对象锁,从而避免死锁。为了理解该方案,我们首先介绍一种避免死锁的通用方法,然后提出一种性能更好的改进方法。

避免死锁的一种方法是允许线程一次只持有单个操作的锁(或多个锁)(通常是一个锁,但如上所述,有些操作涉及两个锁)。当线程开始嵌套操作时,它应该暂停任何外部操作的锁:在开始嵌套操作之前,释放外部操作的锁,当嵌套操作完成时,重新获取外部操作的锁。

此外,任何活动操作的锁都应在可能阻塞的操作(如 I/O)周围暂停(即,会释放 GIL 的操作)。这是因为锁和阻塞操作之间的交互可能以与多个锁之间的交互相同的方式导致死锁。

为了提高性能,本 PEP 提出了上述方案的一种变体,该变体仍然避免死锁。不是在嵌套操作开始时立即暂停锁,而是仅在线程将阻塞(即,将释放 GIL)时才暂停锁。这减少了嵌套操作的锁获取和释放次数,同时避免了死锁。

Python 临界区的拟议 API 是以下四个宏。它们旨在公开(可供 C-API 扩展使用),但不属于受限 API:

  • Py_BEGIN_CRITICAL_SECTION(PyObject *op);:通过获取引用对象的互斥锁开始一个临界区。如果对象已被锁定,则在当前线程等待引用对象解锁之前,释放所有未完成临界区的锁。
  • Py_END_CRITICAL_SECTION;:结束最近的操作,解锁互斥锁。如果最近的上一个临界区(如果有)当前已暂停,则恢复它。
  • Py_BEGIN_CRITICAL_SECTION2(PyObject *a, PyObject *b);:通过获取两个对象的互斥锁开始一个临界区。为了确保一致的锁定顺序,获取顺序由内存地址决定(即,内存地址较低的互斥锁首先被获取)。如果任何一个互斥锁已被锁定,则在当前线程等待引用对象解锁之前,释放所有未完成临界区的锁。
  • Py_END_CRITICAL_SECTION2;:行为与 Py_END_CRITICAL_SECTION 相同,但解锁两个对象。

此外,当线程从 ATTACHED 状态转换为 DETACHED 状态时,它应该暂停任何活动的临界区。当从 DETACHED 转换为 ATTACHED 时,如果存在,则应恢复最近暂停的临界区。

请注意,同时锁定两个容器的操作需要使用 Py_BEGIN_CRITICAL_SECTION2 宏。嵌套两次调用 Py_BEGIN_CRITICAL_SECTION 是不够的,因为内部临界区可能会释放外部临界区的锁。

乐观地避免锁定

dictlist 的一些操作会乐观地避免获取每对象锁。它们有一个不获取锁的快速路径操作,但当另一个线程并发修改该容器时,可能会回退到获取字典或列表锁的较慢操作。

具有乐观快速路径的操作是:

  • PyDict_FetchItem/GetItemdict.__getitem__
  • PyList_FetchItem/GetItemlist.__getitem__

此外,dictlist 的迭代器使用上述函数,因此它们在返回下一个项时也乐观地避免锁定。

这些函数避免获取锁有两个动机。主要原因是即使对于简单的应用程序,它对于可伸缩的多线程性能也是必要的。字典在模块中保存顶级函数和类的所有方法。这些字典在多线程程序中固有地被许多线程高度共享。在多线程程序中,加载方法和函数时这些锁上的争用会抑制许多基本程序的高效扩展。

避免锁定的次要动机是减少开销并提高单线程性能。尽管与大多数操作相比,锁获取开销较低,但访问列表和字典的单个元素是快速操作(因此锁定开销相对更大)且频繁(因此开销影响更大)。

本节描述了在不加锁的情况下实现字典和列表访问的挑战,然后描述了本 PEP 为解决这些挑战而对 Python 解释器进行的更改。

主要的挑战在于从列表或字典中检索一个项并递增该项的引用计数并非原子操作。在检索项和递增引用计数之间,另一个线程可能修改列表或字典,从而可能释放先前检索到的项的内存。

尝试解决此问题的一个不完全方法是将引用计数递增转换为条件递增,仅当引用计数不为零时才递增。此更改不足以解决问题,因为当 Python 对象的引用计数达到零时,将调用对象的析构函数,并且存储对象的内存可能会被其他数据结构重用或返回给操作系统。相反,本 PEP 提出了一种技术,以确保引用计数字段在访问期间保持有效,从而使条件引用计数递增是安全的。此技术需要内存分配器(mimalloc)的协作以及对列表和字典对象的更改。所提出的技术类似于读-复制-更新(RCU)[6],这是一种在 Linux 内核中广泛使用的同步机制。

list_item(实现 list.__getitem__ 的 C 函数)的当前实现如下:

Py_INCREF(a->ob_item[i]);
return a->ob_item[i];

提议的实现使用条件增量 (_Py_TRY_INCREF) 并具有额外的检查:

 PyObject **ob_item = atomic_load(&a->ob_item);
 PyObject *item = atomic_load(&ob_item[i]);
 if (!item || !_Py_TRY_INCREF(item)) goto retry;
 if (item != atomic_load(&ob_item[i])) {
   Py_DECREF(item);
   goto retry;
 }
 if (ob_item != atomic_load(&a->ob_item)) {
   Py_DECREF(item);
   goto retry;
}
return item;

当对列表的并发修改导致上述快速、无锁路径失败时,“retry”子例程实现锁定的回退路径:

retry:
  PyObject *item;
  Py_BEGIN_CRITICAL_SECTION(a->ob_mutex);
  item = a->ob_item[i];
  Py_INCREF(item);
  Py_END_CRITICAL_SECTION(a->ob_mutex);
  return item;

dict 实现的修改是相似的,因为列表和字典检索的相关部分都涉及从已知索引处的数组加载项/值。

条件递增之后的额外检查是必要的,因为该方案允许内存立即重用,包括以前存放 PyObject 结构或 listdict 数组的内存。如果没有这些额外检查,如果 Python 对象占据的内存以前存放了一个不同的 PyObject,而该 PyObject 的内存以前存储了列表中的一个项,则函数可能会返回一个从未在列表中的 Python 对象。

Mimalloc 针对乐观 listdict 访问的更改

该实现对内存分配器提出了额外的限制,包括对 mimalloc 代码的一些更改。了解 mimalloc 的实现背景有助于理解所需的更改。来自 mimalloc 的单个分配称为“块”。mimalloc“页面”包含大小相同的连续块。mimalloc“页面”类似于其他分配器中的“超块”;它不是操作系统页面。mimalloc“堆”包含各种大小类的页面;每个页面属于单个堆。如果一个页面的所有块都没有分配,则 mimalloc 可能会将该页面重新用于不同的大小类或不同的堆(即,它可能会重新初始化该页面)。

列表和字典访问方案通过部分限制 mimalloc 页面复用,从而使引用计数字段在访问期间保持有效。mimalloc 页面的受限复用通过为 Python 对象提供单独的堆来实现[7]。这确保了即使在访问期间项被释放并且内存被新对象重用,新对象的引用计数字段仍放置在内存中的相同位置。引用计数字段在分配过程中保持有效(或为零)。

支持 Py_TPFLAGS_MANAGED_DICT 的 Python 对象,其字典和弱引用字段位于 PyObject 头之前,因此其引用计数字段相对于其分配的起始位置有不同的偏移量。它们存储在单独的 mimalloc 堆中。此外,非 GC 对象存储在自己的堆中,以便 GC 只需查看 GC 对象。因此,Python 对象有三个 mimalloc 堆,一个用于非 GC 对象,一个用于带托管字典的 GC 对象,一个用于不带托管字典的 GC 对象。

Mimalloc 页复用

将对 mimalloc 页复用的限制保持在一个较短的时间段内是有益的,以避免增加整体内存使用。精确地将限制限于列表和字典访问将最大限度地减少内存使用,但会需要昂贵的同步。另一方面,将限制保持到下一个 GC 周期将避免引入任何额外的同步,但可能会增加内存使用。

本 PEP 提出了一个介于这两个极端之间的系统,基于 FreeBSD 的“GUS”[8]。它结合使用全局和每线程计数器(或“序列号”)来协调确定何时可以安全地将空 mimalloc 页面重新用于不同的堆或不同的大小类,或将其返回给操作系统:

  • 存在一个单调递增的全局写序列号。
  • 当 mimalloc 页面为空时,它会被标记为当前的写序列号。线程也可以原子地递增全局写序列号。
  • 每个线程都有一个本地读序列号,记录它观察到的最新写序列号。
  • 线程可以在不进行列表或字典访问时观察写序列号。参考实现在 mimalloc 的慢路径分配函数中执行此操作。此操作执行的频率足以有用,但不会引入显著开销。
  • 存在一个全局读序列号,它存储所有活动线程的读序列号的最小值。线程可以通过扫描每个线程的本地读序列号来更新全局读序列号。参考实现在分配新的 mimalloc 页面之前执行此操作,如果存在可能被重用的受限页面。
  • 当全局读序列号大于页面标记号时,空闲的 mimalloc 页面可以被重新用于不同的堆或大小类。

全局读序列号大于页面标签的条件是充分的,因为它确保任何具有并发乐观列表或字典访问的线程都已完成该访问。换句话说,没有线程正在访问已释放页面中的空块,因此该页面可以用于任何其他目的,甚至返回给操作系统。

乐观 dictlist 访问总结

本 PEP 提出了一种线程安全的列表和字典访问技术,该技术通常避免获取锁。这降低了执行开销,并避免了在常见操作(如调用函数和方法)中出现的一些多线程扩展瓶颈。该方案通过对 mimalloc 页重用施加临时限制来工作,以确保对象被释放后其引用计数字段仍然有效,从而使条件引用计数增量操作是安全的。限制施加在 mimalloc 页而不是单个对象上,以增加内存重用的机会。一旦系统可以确定没有涉及空 mimalloc 页的未完成访问,这些限制就会被解除。为了确定这一点,系统结合使用轻量级的每线程序列计数器,并在页为空时对其进行标记。一旦每个线程的本地计数器大于页的标记,该页就可以用于任何目的或返回给操作系统。当循环垃圾回收器运行时,限制也会被解除,因为“停顿世界”暂停确保线程没有对空 mimalloc 页的未完成引用。

专业化解释器

专用解释器在没有 GIL 的情况下运行时,需要进行一些更改才能实现线程安全。

  • 通过使用互斥锁来防止并发专用化。这可以防止多个线程写入同一个内联缓存。
  • 在没有 GIL 的多线程程序中,每个字节码只专用化一次。这可以防止线程读取部分写入的内联缓存。
  • 锁定还确保 tp_version_tagkeys_version 的缓存值与缓存的描述符和其他值保持一致。
  • 对内联计数器的修改使用“宽松原子操作”。换句话说,某些计数器减量可能会被遗漏或覆盖,但这不会影响正确性。

Py_mod_gil

--disable-gil 构建中,当加载扩展时,CPython 将检查一个新的 PEP 489 风格的 Py_mod_gil 槽。如果该槽设置为 Py_mod_gil_not_used,则扩展加载正常进行。如果该槽未设置,解释器将暂停所有线程并启用 GIL,然后继续。此外,解释器将发出一个可见的警告,指明扩展名,说明 GIL 已启用(以及原因),以及用户可以采取哪些步骤来覆盖它。

PYTHONGIL 环境变量

--disable-gil 构建中,用户还可以通过设置 PYTHONGIL 环境变量在运行时覆盖行为。设置 PYTHONGIL=0 会强制禁用 GIL,从而覆盖模块槽逻辑。设置 PYTHONGIL=1 会强制启用 GIL。

PYTHONGIL=0 覆盖很重要,因为即使不线程安全的扩展在多线程应用程序中仍然有用。例如,可能只想从单个线程使用扩展,或者通过锁来保护访问。在这种情况下,即使有 GIL,也已经有一些不线程安全的扩展,用户已经需要采取这些步骤。

PYTHONGIL=1 覆盖有时对调试很有用。

基本原理

非分代垃圾收集

本 PEP 提议从分代循环垃圾回收器切换到非分代回收器(当 CPython 在没有 GIL 的情况下构建时)。这相当于只保留一个世代(“老”世代)。提出这一更改有两个原因。

循环垃圾回收,即使只针对年轻代,也需要暂停程序中的其他线程。作者担心频繁回收年轻代会抑制多线程程序的有效扩展。这是年轻代(而非老年代)的担忧,因为年轻代是在固定数量的分配后进行回收的,而老年代的回收是根据堆中活动对象的数量按比例安排的。此外,在没有 GIL 的情况下,高效地跟踪每个世代中的对象是很困难的。例如,CPython 当前使用每个世代中对象的链表。如果 CPython 保持这种设计,这些列表将需要实现线程安全,并且目前尚不清楚如何高效地实现。

分代垃圾回收在许多其他语言运行时中都取得了很好的效果。例如,许多 Java HotSpot 垃圾回收器实现都使用多个代 [11]。在这些运行时中,年轻代通常能提高吞吐量:由于很大一部分年轻代通常是“死的”,GC 能够回收大量内存,相对于所做的工作量而言。例如,几个 Java 基准测试显示,超过 90% 的“年轻”对象通常会被回收 [12] [13]。这通常被称为“弱分代假说”;观察结果是大多数对象都死得早。由于 CPython 使用引用计数,这种模式是相反的。虽然大多数对象仍然死得早,但它们在引用计数达到零时被回收。在垃圾回收周期中幸存下来的对象很可能继续存活 [14]。这种差异意味着分代回收在 CPython 中的效果远不如许多其他语言运行时 [15]

dictlist 访问中乐观地避免锁定

本提案依赖于一种方案,该方案在访问列表和字典中的单个元素时,大部分时间避免获取锁。请注意,这并非“无锁”算法意义上的“无锁”和“无等待”算法,后者保证了向前进展。它只是在常见情况下避免获取锁(互斥体),以提高并行性并减少开销。

一个更简单的替代方案是使用读写锁来保护字典和列表访问。读写锁允许并发读取,但不允许更新,这对于列表和字典来说似乎是理想的。问题在于读写锁的开销很大,并且可扩展性差,尤其是在临界区很小的情况下,例如单个元素字典和列表访问 [9]。读者的可扩展性差源于读者都必须更新相同的数据结构,例如 pthread_rwlocks 中读者的数量。

本 PEP 中描述的技术与 RCU(“读-复制-更新”)[6] 相关,在较小程度上也与危险指针相关,这两种都是众所周知的优化并发、以读为主的数据结构的方案。RCU 在 Linux 内核中广泛用于以可扩展的方式保护共享数据结构。本 PEP 中的技术和 RCU 都通过推迟回收来工作,同时读者可能正在访问并发数据结构。RCU 最常用于保护单个对象(如哈希表或链表),而本 PEP 提出了一种保护更大内存块(mimalloc“页”)的方案 [10]

对该方案的需求主要是由于 CPython 中使用了引用计数。如果 CPython 仅依赖于追踪垃圾回收器,那么该方案可能就没有必要了,因为追踪垃圾回收器已经以所需的方式推迟了回收。这并不能“解决”扩展问题,而是将许多挑战转移到了垃圾回收器实现上。

向后兼容性

本 PEP 在使用 --disable-gil 标志构建 CPython 时,会带来一些向后兼容性问题,但这些问题在使用默认构建配置时不会发生。几乎所有向后兼容性问题都涉及 C-API。

  • 由于支持偏向引用计数所需的 Python 对象头更改,不带 GIL 的 CPython 构建将与标准 CPython 构建或稳定 ABI 不兼容。C-API 扩展将需要专门针对此版本进行重建。
  • 依赖 GIL 来保护 C 代码中的全局状态或对象状态的 C-API 扩展,在没有 GIL 的情况下运行时,将需要额外的显式锁定才能保持线程安全。
  • 在没有 GIL 的情况下,以不安全的方式使用借用引用 的 C-API 扩展将需要使用返回非借用引用的等效新 API。请注意,只有某些使用借用引用 的情况才值得关注;只有对可能被其他线程释放的对象的引用才会产生问题。
  • 自定义内存分配器 (PyMem_SetAllocator) 要求将实际分配委托给之前设置的分配器。例如,Python 调试分配器和追踪分配器将继续工作,因为它们将分配委托给底层分配器。另一方面,彻底替换分配器(例如,使用 jemalloc 或 tcmalloc)将无法正常工作。
  • Python 对象必须通过标准 API 分配,例如 PyType_GenericNewPyObject_Malloc。非 Python 对象 不得 通过这些 API 分配。例如,目前通过 PyObject_Malloc 分配缓冲区(非 Python 对象)是可以接受的;以后将不允许这样做,缓冲区应改用 PyMem_MallocPyMem_RawMallocmalloc 进行分配。

对于 Python 代码来说,潜在的向后兼容性问题更少。

  • 由于使用了延迟引用计数,代码对象和顶级函数对象的析构函数和弱引用回调会延迟到下一次循环垃圾回收。
  • 由于偏向引用计数,某些被多个线程访问的对象的析构函数可能会稍微延迟。这种情况很少见:大多数对象,即使是那些被多个线程访问的对象,只要它们的引用计数为零,就会立即销毁。Python 标准库测试中有两个地方需要调用 gc.collect() 才能继续通过。

分发

本 PEP 对 Python 的分发提出了新的挑战。至少在一段时间内,将有两个版本的 Python 需要单独编译的 C-API 扩展。C-API 扩展作者可能需要一些时间来构建 --disable-gil 兼容包并将其上传到 PyPI。此外,一些作者可能会犹豫是否支持 --disable-gil 模式,直到它被广泛采用,但采用可能取决于 Python 丰富的扩展集的可用性。

为了缓解这一问题,作者将与 Anaconda 合作,分发 --disable-gil 版本的 Python 以及来自 conda 渠道的兼容包。这使得构建扩展的挑战集中化,作者相信这将使更多人能够更快地使用没有 GIL 的 Python。

性能

为了使 CPython 在没有 GIL 的情况下实现线程安全而进行的更改,会增加 --disable-gil 构建的执行开销。对于仅使用单个线程的程序和使用多个线程的程序,性能影响不同,因此下表分别报告了这些类型程序的执行开销。

pyperformance 1.0.6 上的执行开销
英特尔 Skylake AMD 锐龙 3
单个线程 6% 5%
多个线程 8% 7%

用于衡量开销的基线是 PR 19474 中的 018be4c,它实现了 Python 3.12 的不朽对象。执行开销的最大贡献是偏向引用计数,其次是按对象锁定。出于线程安全考虑,使用多个线程运行的应用程序只会对给定的字节码进行一次专门化;这就是为什么使用多个线程的程序的开销比仅使用一个线程的程序大。然而,禁用 GIL 后,使用多个线程的程序也应该能够更有效地利用多个 CPU 核心。

请注意,本 PEP 不会影响 CPython 默认(非 --disable-gil)构建的性能。

构建机器人

稳定的构建机器人也将包含 --disable-gil 构建。

如何教授此内容

作为实现 --disable-gil 模式的一部分,作者将编写一份“HOWTO”指南 [18],用于在没有 GIL 的情况下运行 Python 时使包兼容。

参考实现

有两个 GitHub 存储库实现了不带 GIL 的 CPython 版本。

nogil-3.12 基于 Python 3.12.0a4。它对于评估单线程执行开销和作为本 PEP 的参考实现很有用。它对于评估 C-API 扩展兼容性作用不大,因为许多扩展目前与 Python 3.12 不兼容。由于 3.12 移植时间有限,nogil-3.12 实现并未跳过所有延迟引用计数。作为临时解决方案,该实现在生成多个线程的程序中,将使用延迟引用计数的对象“永生化”。

nogil 仓库基于 Python 3.9.10。它对于评估实际应用程序中的多线程扩展和扩展兼容性很有用。它比 nogil-3.12 仓库更稳定且经过充分测试。

备选方案

Python 目前支持多种启用并行化的方式,但现有技术存在显著限制。

多进程

多进程库允许 Python 程序启动并与 Python 子进程通信。这允许并行化,因为每个子进程都有自己的 Python 解释器(即每个进程有一个 GIL)。多进程存在一些显著的限制。进程间通信受限:对象通常需要序列化或复制到共享内存。这会引入开销(由于序列化)并使基于多进程构建 API 变得复杂。启动子进程也比启动线程更昂贵,特别是使用“spawn”实现时。由于 Python 重新初始化,启动一个线程大约需要 ~100 µs,而启动一个子进程大约需要 ~50 ms (50,000 µs)。

最后,许多 C 和 C++ 库支持多线程访问,但不支持跨多个进程的访问或使用。

在 C-API 扩展中释放 GIL

C-API 扩展可以在长时间运行的函数周围释放 GIL。这允许一定程度的并行性,因为当 GIL 被释放时,多个线程可以并发运行,但获取和释放 GIL 的开销通常会阻止其有效扩展到几个线程之外。许多科学计算库在计算密集型函数中释放 GIL,而 CPython 标准库在阻塞 I/O 周围释放 GIL。

内部并行化

用 C 语言实现的函数可以在内部使用多个线程。例如,英特尔的 NumPy 发行版、PyTorch 和 TensorFlow 都使用这种技术来内部并行化单个操作。当基本操作足够大以至于可以高效并行化时,这种方法效果很好,但当有许多小操作或操作依赖于某些 Python 代码时则不然。从 C 调用 Python 需要获取 GIL——即使是简短的 Python 代码片段也会抑制扩展。

被拒绝的想法

为什么不使用并发垃圾收集器?

许多最近的垃圾回收器都是大部分并发的——它们通过允许垃圾回收器与应用程序并发运行来避免长时间的“停顿世界”暂停。那么为什么不使用并发回收器呢?

并发回收需要写屏障(或读屏障)。作者不知道如何在不大幅破坏 C-API 的情况下向 CPython 添加写屏障。

为什么不弃用 PyDict_GetItem 而支持 PyDict_FetchItem

本 PEP 提出了一个新的 API PyDict_FetchItem,其行为类似于 PyDict_GetItem,但返回的是一个新引用而不是借用引用。如 借用引用 中所述,一些在有 GIL 的情况下安全的借用引用 的使用,在没有 GIL 的情况下是不安全的,需要被返回新引用的函数(如 PyDict_FetchItem)替换。

本 PEP 并未提议弃用 PyDict_GetItem 和其他返回借用引用的类似函数,原因有几个:

  • 即使在没有 GIL 的情况下运行,许多借用引用 的使用也是安全的。例如,C API 函数通常使用 PyDict_GetItem 从关键字参数字典中检索项目。这些调用是安全的,因为关键字参数字典只对单个线程可见。
  • 我早期尝试过这种方法,发现将 PyDict_GetItem 全盘替换为 PyDict_FetchItem 经常会引入新的引用计数错误。在我看来,引入新的引用计数错误的风险通常大于遗漏在没有 GIL 的情况下不安全的 PyDict_GetItem 调用的风险。

为什么不使用 PEP 683 不朽化?

PEP 683 一样,本 PEP 提出了一种 Python 对象的“不朽化”方案,但两个 PEP 使用了不同的位表示来标记不朽对象。这些方案不能完全相同,因为本 PEP 依赖于偏向引用计数,它有两个引用计数字段而不是一个。

未解决的问题

改进的专业化

Python 3.11 版本引入了快速化和专门化,作为“更快 CPython”项目的一部分,显著提高了性能。专门化用更快的变体替换慢速字节码指令 [19]。为了保持线程安全,使用多线程(且在没有 GIL 的情况下运行)的应用程序只会对每个字节码进行一次专门化,这可能会降低某些程序的性能。支持多次专门化是可能的,但这需要更多研究,并且不属于本 PEP 的一部分。

Python 构建模式

本 PEP 引入了一种新的构建模式 (--disable-gil),它与标准构建模式不兼容 ABI。额外的构建模式增加了 Python 核心开发人员和扩展开发人员的复杂性。作者认为一个有价值的目标是结合这些构建模式,并通过运行时控制全局解释器锁,可能默认禁用。实现这一目标的路径仍然是一个悬而未决的问题,但可能的路径可能如下所示:

  1. 2024 年,CPython 3.13 发布,支持 --disable-gil 构建时标志。CPython 有两个 ABI,一个带 GIL,一个不带。扩展作者同时针对这两个 ABI。
  2. 在 2-3 个版本之后(即 2026-2027 年),CPython 发布,GIL 由运行时环境变量或标志控制。GIL 默认启用。只有一个 ABI。
  3. 再经过 2-3 个版本(即 2028-2030 年),CPython 切换到默认禁用 GIL。GIL 仍然可以通过环境变量或命令行标志在运行时启用。

本 PEP 涵盖了第一步,其余步骤作为开放问题。在这种情况下,将有两到三年的时间,扩展作者需要为每个受支持的 CPU 架构和操作系统额外构建一个 CPython。

集成

参考实现修改了 CPython 中大约 15,000 行代码,并包含了 mimalloc,其代码量也约为 15,000 行。大多数更改对性能不敏感,可以包含在 --disable-gil 和默认构建中。一些宏,例如 Py_BEGIN_CRITICAL_SECTION,在默认构建中将是空操作。作者预计不会有大量的 #ifdef 语句来支持 --disable-gil 构建。

单线程性能的缓解措施

本 PEP 中提出的更改将增加 --disable-gil 构建相对于带有 GIL 的 Python 构建的执行开销。换句话说,它的单线程性能会变慢。有一些可能的优化可以减少执行开销,特别是对于只使用单个线程的 --disable-gil 构建。如果长期目标是拥有一个单一构建模式,这些优化可能值得考虑,但优化选择及其权衡仍然是一个悬而未决的问题。

参考资料

致谢

感谢 Hugh Leather、Łukasz Langa 和 Eric Snow 对本 PEP 草案的反馈。


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

最后修改:2025-02-01 08:55:40 GMT