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 日
决议:
Discourse 线程

目录

注意

指导委员会接受了 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 中利用并行性提出的挑战经常出现在强化学习中。Heinrich Kuttler 是 NetHack 学习环境的作者,也是 Inflection AI 的技术人员,他写道

强化学习的最新突破,例如在 Dota 2星际争霸NetHack 上取得的突破,依赖于使用异步演员-评论家方法并行运行多个环境(模拟游戏)。Python 中直接的多线程实现由于 GIL 争用,无法扩展到超过几个并行环境。多处理(通过共享内存或 UNIX 套接字进行通信)增加了很大的复杂性,实际上排除了从不同工作进程与 CUDA 的交互,严重限制了设计空间。

Manuel Kroiss 是 DeepMind 强化学习团队的软件工程师,他描述了 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 连接利用到最大限度。当然,我们首先尝试使用 multiprocessing 包,但后来发现,大多数 CPU 时间都花在了数据处理阶段之间的传输上,而不是数据处理本身。基于 GIL 的 CPython 多线程实现也是一个死胡同。当我们发现“nogil”分支的 Python 时,一个人不到半天的时间就调整了代码库以使用这个分支,结果令人震惊。现在,我们可以专注于数据采集系统开发,而不是微调数据交换算法。

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

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

我想要更好地了解当前的多线程情况,所以我重新实现了 HMMER 的部分内容,这是一种用于多序列比对的标准方法。我选择这种方法是因为它既强调单线程性能(评分),也强调多线程性能(搜索序列数据库)。当仅使用 8 个线程时,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 上下文(与进程内的线程不同)。

Olivier Grisel 是 scikit-learn 的开发者,也是 Inria 的软件工程师,他描述了如何必须在 scikit-learn 相关库中绕过 GIL,从而导致用户体验更加复杂和令人困惑

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

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

NumPy 及其周围的软件包栈中的一个关键问题是,NumPy 仍然(大部分)是单线程的——这影响了用户体验和围绕它构建的项目的重要部分。NumPy 在其内部循环(执行繁重工作)中确实会释放 GIL,但这还不够。NumPy 没有提供解决方案来充分利用单台机器的所有 CPU 内核,而是将此留给了 Dask 和其他多处理解决方案。这些解决方案效率不高,使用起来也更笨拙。这种笨拙主要体现在用户在使用(例如)dask.array(它包装了 numpy.ndarray)时需要关注的额外抽象和层。它还出现在用户必须显式地了解和管理的过度订阅问题中,可以通过环境变量或第三方包 threadpoolctl 来管理。主要原因是 NumPy 调用 BLAS 进行线性代数——它无法控制这些调用,它们默认情况下会通过 pthreads 或 OpenMP 使用所有内核。

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

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

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

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

在 PyTorch 中,Python 通常用于协调约 8 个 GPU 和约 64 个 CPU 线程,对于大型模型,扩展到 4k 个 GPU 和 32k 个 CPU 线程。虽然繁重的工作是在 Python 之外完成的,但 GPU 的速度使得即使是 Python 中的协调也无法扩展。由于 GIL 的存在,我们经常使用 72 个进程来代替一个进程。在这种情况下,日志记录、调试和性能调优的难度要大几个数量级,导致开发者效率持续下降。

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

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

即使是 GPU 密集型工作负载也经常有 CPU 密集型组件。例如,计算机视觉任务通常需要在数据输入管道中执行多个“预处理”步骤,例如图像解码、裁剪和调整大小。这些任务通常在 CPU 上执行,并可能使用 Python 库(如 PillowPillow-SIMD)。为了让 GPU 不断“获得”数据,必须在多个 CPU 内核上运行数据输入管道。

与单个 CPU 内核相比,GPU 性能的提升使多核性能更加重要。让 GPU 保持完全占用变得越来越困难。这样做需要高效地使用多个 CPU 内核,尤其是在多 GPU 系统上。例如,NVIDIA 的 DGX-A100 拥有 8 个 GPU 和两个 64 核 CPU,以确保 GPU 不断“获得”数据。

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

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

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”(表示“线程”)。

使用 --disable-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_shared 用于有偏引用计数实现。该 ob_gc_bits 字段用于存储之前存储在 PyGC_Head 中的垃圾回收标志(参见 垃圾回收(循环回收))。该 ob_mutex 字段在一个字节中提供每个对象的锁。

永生化

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

对于永生化对象,Py_INCREFPy_DECREF 宏是无操作的。这避免了在多个线程同时访问这些对象的引用计数字段时发生争用。

这种提出的永生化方案与 PEP 683 非常相似,该方案已在 Python 3.12 中采用,但在永生化对象的引用计数字段中的位表示略有不同,以与有偏引用计数和延迟引用计数配合使用。另请参见 为什么不使用 PEP 683 永生化?.

有偏引用计数

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

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

ob_ref_shared 字段存储共享引用计数。两个最低有效位用于存储引用计数状态。因此,共享引用计数向左移两位。 ob_ref_shared 字段使用最低有效位,因为共享引用计数可以暂时为负数;增量和减量在不同线程之间可能无法平衡。

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

  • 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 提出了一种有限形式的延迟引用计数,以避免在多线程程序中争用这些对象的引用计数字段。

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

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

请注意,使用延迟引用计数的对象在 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 堆和分配使像 dict 这样的集合通常可以在只读操作期间避免获取锁。这在集合线程安全性部分有更详细的描述。

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

这个 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,因此收集器可以依赖于该例程持续时间内的稳定(即不变的)引用计数和引用。但是,在循环检测之后,在调用对象的终结器和清除 (tp_clear) 函数时,可能会暂时释放 GIL,允许其他线程以交错的方式运行。

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

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

线程状态

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

  • ATTACHED
  • DETACHED
  • GC

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

在停止世界暂停期间,执行垃圾收集的线程需要确保没有其他线程访问或修改 Python 对象。所有其他线程必须处于“GC”状态。垃圾收集线程可以使用状态字段上的原子比较和交换操作,将其他线程从 DETACHED 状态转换为 GC 状态。处于 ATTACHED 状态的线程被请求暂停自身并将自身状态设置为“GC”,使用现有的“评估断路器”机制。在停止世界暂停结束时,所有处于“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.cleardict.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_nextdictiter_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:
  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 对象,如果 Python 对象占用的内存以前保存了一个不同的 PyObject,而该对象的内存以前保存了列表中的一个项目。

针对乐观 listdict 访问的 Mimalloc 更改

该实现需要对内存分配器施加额外的约束,包括对 mimalloc 代码进行一些更改。了解 mimalloc 实现的一些背景信息有助于理解所需的更改。从 mimalloc 分配的单个分配称为“块”。Mimalloc “页”包含连续的块,这些块的大小都相同。Mimalloc “页”类似于其他分配器中的“超级块”;它不是操作系统页。Mimalloc “堆”包含各种大小类的页;每个页都属于单个堆。如果页的块都不被分配,则 mimalloc 可能会将该页重新用于不同的 size class 或不同的堆(即,它可能会重新初始化该页)。

列表和字典访问方案通过部分限制 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 页面重新用于不同的堆或不同的 size class,或者将其返回给操作系统。

  • 有一个全局写入序列号,它单调递增。
  • 当 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。

  • 没有 GIL 的 CPython 构建将与标准 CPython 构建或稳定 ABI 不兼容,因为需要更改 Python 对象头以支持有偏引用计数。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。

性能

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

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

用于测量开销的基线是来自 PR 19474018be4c,它为 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 目前支持多种方法来启用并行处理,但现有技术存在重大限制。

多处理

multiprocessing 库允许 Python 程序启动和与 Python 子进程通信。这允许并行处理,因为每个子进程都有自己的 Python 解释器(即,每个进程有一个 GIL)。多处理有一些实质性的限制。进程之间的通信受到限制:对象通常需要被序列化或复制到共享内存。这会引入开销(由于序列化)并使在多处理之上构建 API 变得复杂。启动子进程也比启动线程更昂贵,尤其是在“生成”实现中。启动线程需要约 100 µs,而生成子进程需要约 50 毫秒(50,000 µs),因为 Python 重新初始化。

最后,许多 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_FetchItem 代替 PyDict_GetItem

此 PEP 提出了一个新的 API PyDict_FetchItem,其行为类似于 PyDict_GetItem,但返回一个新引用而不是一个借用引用。如 借用引用 中所述,在没有 GIL 的情况下运行时,一些使用借用引用的方法是不安全的,需要用像 PyDict_FetchItem 这样的返回新引用的函数来替换,这些函数返回新引用。

此 PEP 建议弃用 PyDict_GetItem 和类似的返回借用引用的函数,原因有以下几点

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

为什么不使用 PEP 683 永生化?

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

未解决的问题

改进的专业化

Python 3.11 版本在更快的 CPython 项目中引入了快速化和专门化,从而大大提高了性能。专门化用更快的变体替换了缓慢的字节码指令 [19]。为了保持线程安全,使用多个线程(并在没有 GIL 的情况下运行)的应用程序只会专门化每个字节码一次,这可能会降低某些程序的性能。有可能支持多次专门化,但这需要更多调查,不在此 PEP 的范围内。

Python 构建模式

此 PEP 引入了一种新的构建模式(--disable-gil),它与标准构建模式不兼容。额外的构建模式增加了 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 架构和 OS 多构建一个 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

最后修改时间: 2024-03-06 10:40:20 GMT