作者:Łukasz Langa
译者:豌豆花下猫@Python猫
在一年一度的 Python 核心开发者 sprint 会议期间,我们与 Sam Gross 举行了一次会议,他是 nogil 的作者。nogil 是 Python 3.9 的分叉版本,移除了 GIL。这是一份非正式的会议纪要。

简单总结

Sam 的工作证明了以他的方式删除 GIL 是可行的,即生成的 Python 解释器的性能良好,并且可以随着 CPU 内核的增加而扩展。为了最终达到正面的效果,还需要有其它看似无关的解释器工作。
目前还不可能将 Sam 的更改合并到 CPython,因为他的更改是针对 3.9 分支进行的,便于用户拿当前 pip 可安装的库和 C 扩展对 nogil 解释器进行测试。如果要合并 nogil,就不得不基于 main 分支进行更改(目前 main 分支已规划为 3.11)。
不要指望 Python 3.11 会移除 GIL。 将 Sam 的工作合并到 CPython 本身将是一个艰苦的过程,但这仅仅是所需的一部分:在 CPython 移除 GIL 之前,需要为社区制定一个良好的向后兼容的迁移计划。这些都还没有计划好,所以我们认为时机还没到。
有些人在谈论如此巨大的变化时提到了 Python 4。核心开发人员当前没有计划发布 Python 4,事实上恰恰相反:我们正积极地避免发布 Python 4,因为 Python 2 到 3 的转换对社区来说已经足够困难了。现在考虑或者担心 Python 4,肯定还为时过早。

介绍 nogil

Sam 发布了他的代码,同时还有一篇详细的文章,解释了该项目的动机和设计。
他的设计可以总结为:
  • 为了线程安全,将 Python 内置的分配器pymalloc替换成mimalloc ,对字典和其它集合对象采用无锁读写,同时提升效率(堆内存布局允许在不维护显式列表的情况下找到 GC 跟踪的对象)
  • 用有偏见的引用计数(biased reference counting)替代非原子的急切的引用计数(non-atomic eager reference counting):
    • 将每个对象与创建它的线程(称为 owner thread)绑定;
    • 对象在 owner thread 内使用时,采用快速的非原子的局部型引用计数;
    • 对象在其它线程内使用时,采用较慢的但原子的共享型引用计数;
  • 为了加快跨线程的对象访问(因为会被原子的共享型引用计数拖慢),引入两种技术:
    • 有些特殊对象是永生的,这意味着它们的引用计数永远不会被计算,也永远不会被释放:这包含像 None、True、False 这样的单例对象,小整数和常驻的字符串,以及静态分配的内置类型 PyTypeObjects;
    • 其它全局可访问对象使用延迟引用计数(deferred reference counting),如顶级的函数、代码对象和模块;它们不是永生的,并不总是在程序的生命周期内存活;
  • 调整循环的垃圾回收器成一个单线程的 stop-the-world 垃圾回收器:
    • 等待所有线程在一个安全点(任何字节码的边界)挂起;
    • 不等待阻塞在 I/O 的线程(使用PyEval_ReleaseThread ,相当于在当前 Python 中释放 GIL);
    • 高效地构造对象的列表,以便即时地释放:得益于mimalloc, GC 跟踪的对象都保存在一个单独的轻量级的堆中;
  • 将全局进程的 MRO 缓存迁移到局部线程里,避免查找 MRO 时的争用;缓存失效仍然是全局性的;
  • 修改内置的集合类对象,使之成为线程安全的。
Sam 的设计文档包含了这些设计元素的细节,包含线程状态与 GIL API 的信息,以及解释器和字节码的其它修改(用带有累加器的寄存器 VM 替换堆栈VM;通过避免创建 C 语言的栈帧来优化函数调用;ceval.c 的其它变更;标签指针的使用;LOAD_ATTR、LOAD_METHOD、 LOAD_GLOBAL 操作码的线程安全的元数据;等等)。我建议你完整地阅读它。
Python猫注:上文出现的“stop-the-world”,有时缩写成“STW”,这是多数垃圾回收器的工作机制,表示在垃圾回收器工作时,其它线程全部暂时挂起,从而保证引用对象的准确更新,其缺点是对程序性能有所影响;“MRO”是“method resolution order”的缩写,即“类方法解析顺序”,表示在所有基类中搜索成员方法时的次序。

早期的基准测试

pyperformance 基准测试套上,作为概念验证的 nogil 解释器比 3.9 快 10%。据估计,在解释器的全部修改中,移除 GIL 会导致性能变慢 9%,主要是因为有偏见的引用计数和延迟引用计数。换句话说,Python 3.9 加上 nogil 的所有更改,但不移除 GIL 本身,可以快 19%。然而,这样并不能解决多核的可伸缩性问题。
顺便说一下,nogil 的一些更改,比如将 C 调用栈与 Python 调用栈解耦,已经在 Python 3.11 中实现了。事实上,我们有针对当前 main 分支的初步的基准测试 ,结果表明在单线程的性能上,Python 3.11 比 nogil 快 16%
需要有更多的基准测试,特别是使用 Larry Hastings 在对 Gilectomy 进行测试时使用的基准测试(当时基于 Python 3.5,后来移植到 3.6 alpha 1)。
Python猫注:gilectomy 是由 GIL ectomy 两个单词组合而成,ectomy 是一个医学上的术语“切除术”,可见这个项目的用意跟 nogil 是一样的!这是 5-6 年前的项目,作者曾在 PyCon 大会上做过几次分享。但这个项目反而导致 Python 总体性能下降了,最后无疾而终。

gilectomy 项目作者在 PyCon 上的分享:

2015年分享:https://www.youtube.com/watch?v=KVKufdTphKs

2016年分享:https://www.youtube.com/watch?v=P3AyI_u66Bw

2017年分享:https://www.youtube.com/watch?v=pLqv11ScGsQ

Sam 提醒我们,一个用户程序在无 GIL 的 Python 上的伸缩性实际上取决于最终的代码。如果不进行测试,就不可能预测代码在没有 GIL 的情况下表现如何。因此,如果提供一个单一的数字来说明无 GIL 的 Python 速度会提升 x 倍,这是不负责任的。

会议中向 Sam 提出的问题

为了清晰易懂,这里的问题基于会议上的内容进行了重新排序。答案是由 Sam 的回答转述而来的,并得到了他阅读草稿后的认可。要注意的是,核心团队的成员可能对其中一些主题有其它观点。

Q:有哪些可感知的风险是阻碍 nogil 项目合入到 CPython 中的?

目前的代码库已经证明了它在技术上的可行性。它可以运行,而且比普通的 CPython 解释器和 Gilectomy 项目更具有可伸缩性和好性能。我在该项目中投入了将近两年的全职工作。
这完全取决于社区对 C 扩展程序的改造程度,以确保它们不会导致解释器彻底崩溃。然后,剩下的长尾就是社区要以一种既正确又可扩展的方式在应用程序中采用自由线程。这两个是最大的挑战,但我们必须乐观应对。

Q:你打算如何改进你的工作?对 commit 次序有什么建议吗?你将如何保持你的工作与 main 分支的同步?

Sam 目前正在重构他的工作,最初是基于 3.9.0a3,将匹配 3.9.7 最终版本。这项工作的一部分是将 commit 重构为逻辑单元,以便更好地说明哪些内容需要更改(哪些地方改了,以及为什么要改)。
目前还不计划把这项工作移到 main 分支(未来的 3.11),因为这个分支太不稳定了。相比之下,3.9 有大量已发布的可通过 pip 安装的库和 C 扩展,可用于测试。这使得 Sam 能够评估该项目与真实世界的第三方代码的行为。基于 main 的修改将花费不少时间,而这些时间本可以花在改进无 GIL 的解释器上,所以,现在就基于主分支的话,还为时过早。
将工作进行分割然后再合并是可行的,但必须记住,许多更新需要在串联起来时,性能才会提升。单独而言,它们会导致(暂时的?)性能下降。
核心开发者注:我们现在不能合并对 3.9 分支所做的更改。在项目的这个阶段使用 3.9 是有意义的,但关键的是要将它分割成可消费的数据块,然后一个一个地合并到 main 分支中。一块一块地做,很有可能会损害性能,但这是唯一现实的集成途径。

Q:可以只引入寄存器 VM 和编译器而不做其它更改吗?在不改变引用计数或 GIL 的情况下使用寄存器 VM 会有什么特殊的困难吗?

VM 使用延迟/永生的引用计数。可以将其转换为只使用经典的引用计数,但最终结果的效率还不清楚(例如,出于性能考虑,堆栈上的所有对象都使用了延迟引用计数)。

Q:跟前一问相反的问题:只引入 nogil,而不使用新的寄存器 VM,会有什么困难呢?

虽然新的 VM 只提高了性能,而不是准确性,但它也提高了可伸缩性,使得无 GIL 的 Python 可以充分利用 CPU 内核而不发生争用。因此要使用 3.11 解释器也是可行的,但最好保留一些寄存器 VM 的设计思想,这对可伸缩性和线程安全很重要。这需要做大量的工作。但是将寄存器 VM 更新成跟 main 分支一样(以及修复遗留的 bug),也需要大量的工作。这两种选择都是可行的。

Q:对于那些不希望自己的代码被其它线程并行运行的 C 扩展,有什么建议么?在适应新的自由线程环境之前,难道不需要 CPython 给它们提供一些 API 来弥补差距吗?

这需要花时间。目标是渐进式采纳,最终推广至大多数 C 扩展。GIL 可以作为解释器启动时的一个选项。如果没有启用 GIL,并且 C 扩展不支持新的操作模式,可能就要产生告警或者不让其导入。Python 社区不得不适配 C 扩展,让它们适应无 GIL 的模式。
作为概念验证的 nogil 项目,默认使用无 GIL 模式,并接受任何 C 扩展。如果它被 CPython 采用了,那么在开始时默认应该启用 GIL(要求在启动 Python 时使用 -X nogil 禁用 GIL),以便让第三方库做适配。然后,在发布几个版本后,默认值再切换成无 GIL 的模式。
虽然要移植全部东西并不容易(并行是很难的),但在多数情况下,移植并不会很难,特别是对于封装外部库的 C 扩展来说。
核心开发者注:有大量的“暗物质” Python 代码(和 C 扩展)不是开源的。我们需要小心不去破坏它们,因为它们的用户可能无法做出所需的更改,或者向上游报告问题给我们。特别地,有些 C 扩展使用 GIL 来保护它们自己的内部状态。这是一个很大的担忧,可能是采用无 GIL Python 的一个很大的障碍。

Q:你会添加一个 PEP-489 的“插槽”么,以便 C 扩展用来表示其支持 nogil,这样当遇到不支持 nogil 的库时,就不让它导入?

很多人也提过,这可能是一个好主意,但我不完全清楚这意味着什么。选择无 GIL 模式并不能保证没有 bug。相反,在默认情况下,我们运行所有的扩展(现在的 nogil 就是这么做的)。不兼容的扩展可以使用 PyInit 模块的代码,主动地询问解释器是否启用了 GIL,如果不兼容的话,就在导入时产生警告甚至异常。

Q:在运行期启用 nogil 是一项长期可行的选择,还是过渡性的功能呢?

理想的结局是 CPython 不再有 GIL,句号。然而,预计将有一个漫长的社区适应期。我们希望避免从 Python2 到 Python3 过渡时的断裂。准确地说,我们希望过渡得越平滑越好,即使这意味着需要延展更长的时间。

Q: 确认一下,最终状态是只有 nogil,并且不支持再开启 GIL 么?

目前我们还不确定。理想的结局是只存在一个无 GIL 的 Python,但尚不清楚这能否实现。

Q:如果这些特性标志会持续很长一段时间,这是否意味着我们需要大幅增加测试矩阵?

是的,测试矩阵需要加倍。然而,测试无 GIL 版本可能是判断经典的 GIL 版本是否有效的一个很好的预测器。有必要偶尔(每晚?)运行启用了 GIL 的测试。
核心开发者注:如果不做测试,代码将加速退化。在 CPython 中,由于需要运行时间(例如测试引用泄漏时),我们不会在每次更改时都运行所有测试,但如果有更改导致每日测试失败,我们会立即回退更改,因为在已经失败的构建点之后,很可能会出现其它的回归问题。

Q:你认为多个 Python 解释器并行运行,每个解释器一个 GIL 怎么样?

Python猫注:给大家科普一下这个问题的背景,PEP-554 提议实现多解释器来解决 GIL 的问题。这是在 2017 年提出的,受到挺多关注。在 2019 年时,我曾翻译过《Has the Python GIL been slain?》介绍它。但是,目前该提案依然是草稿状态,具体的开发情况不甚明朗。
跟无 GIL 提案相比,这既是互补的,又是相互竞争的。在无 GIL 解释器中也可以支持副解释器。
目前还不清楚多解释器方案能否实现。有了 nogil,就不需要担心跨线程共享对象,也不需要担心 C 扩展的兼容性,因为有了多解释器,就没有任何状态是真正全局的,因此需要特别地隔离。对于可变对象,在多解释器之间传递时,需要某种形式的序列化/反序列化。对于不可变对象,解释器可能会添加特殊的支持,但如果它们不是已知的不可变的内置类型,用户代码就需要适配这些对象。这是从 PyTorch 的相关工作中得到的启发,它使用了某种形式的多解释器。
由于我最感兴趣的用例实际上是科学数据(PyTorch 训练工作流),直接而有效地共享数据的能力对多线程性能至关重要。如果采用多解释器,这种共享只能在 C 扩展级别上开启,与无 GIL 的 Python 相比,将导致更多使用 C/C++ 代码。

Q:你已经详细介绍了字典和列表的实现。其它可变类型例如队列、集合、数组等等,是如何实现的呢?

nogil 是一个开发中的项目。由于字典和列表在解释器的内部运作中很普遍,所以它们的开发最多。同样地,队列的开发已经完成,但其它类型还没有。集合是下一个要覆盖的重要内容。
队列非常重要,因为它被concurrent.futuresasyncio 用于并发线程之间的通信。队列比字典和列表简单,它使用细粒度的锁而不是无锁读取。其它的对象很可能需要组合使用。
这项工作很棘手,因为在获取和释放锁时需要小心,例如 Py_DECREFs 是可重入的。还可以考虑使用更“粗粒度”的锁,但当然了,这些锁都有死锁的风险。

Q:nogil 有多依赖 mimalloc? 如果我们把它作为一个编译期选项,可以用或不用它,那么使用平台的 malloc 来代替没有 C 预处理器地狱的低性能构建是否可行?

mimalloc 不仅仅是用于线程安全。它对于启用字典的无锁读取是必要的,还支持高效的 GC 追踪。
mimalloc 的维护者对显式地支持 CPython 很感兴趣,并且乐意为实现这一点进行必要的更改。
其它实现的 malloc 据说也稳定支持 CPython:在 Facebook 中使用的jemalloc,在谷歌中使用tcmalloc,尽管集成得较少,更像是默认分配器的简单替换。(Python猫注:前文提到的 mimalloc 是微软的)
核心开发者注:Christian Heimes 和 Pablo Galindo Salgado 正在评估 CPython 使用 mimalloc。早期测试在平均上(几何平均数)没有性能衰退,大多数基准测试做得更好,少数基准测试做得稍微差一些。还有一些待评估的问题:
  • mimalloc 的 API 和 ABI 的稳定性;
  • 授权许可;
  • 跨所有 CPython 支持的平台的可移植性,例如 stdatomic.h 仅在 C11 中可用;
  • 集成分析和检测工具(Valgrind、asan、ubsan 等等);
  • 可能还有其它。

Q:你的项目和 Larry 的 Gilectomy 有什么相似之处?你能利用他的项目吗?

在顶层设计上,两个项目是相似的:延迟引用计数,细粒度锁,关于返回借用的引用的挑战。没有复用 Gilectomy 的代码。

Q:你说你的项目在顶层上类似于 Larry 的 Gilectomy。他的项目也是基于延迟引用计数。然而,他在 Gilectomy 上只得到了性能下降的结果,而你的“nogil”却有很好的性能表现。你认为这种差异是怎么回事?

切换到基于寄存器的编译器和其它优化,比如由 mimalloc 提供的无锁的字典读取,以及使用延迟引用计数来避免争用,对 nogil 的扩展性和性能都至关重要。而且,在某些情况下,Python 本身变得更快了。例如, Python 3.9 中的函数调用比 Python 3.5 的要快得多。
让它支持扩展,肯定比预期要花更多的工作。

Q:有没有可能在无 GIL 模式中加入一个(不兼容的) C 扩展或剔除它吗?

顾名思义,GIL 就是一个全局锁。为了保护任意一段共享数据,它需要在所有线程上开启,包括不兼容的扩展所处的线程。
在已经运行的进程中,将无 GIL 的解释器切换为使用 GIL 的解释器是很棘手的(反之亦然)。最好的做法是在启动时选择:要么在进程中启用 GIL,要么不启用。如果 C 扩展没有标记为兼容,就引发警告或无法导入。
或者,当访问 C 扩展时,也可以“stop the world”,但这与移除 GIL 而所想达成的目的不符。
核心开发者注:到目前为止,还有其它的想法需要深入探讨。有种想法是将 GIL 转换为“单写多读”锁。在这种情况下,无 GIL 的模式将获取“多读”锁,也就是说,不会阻塞其它新代码做同样的事情。而历史遗留的代码将获得一个“单写”锁,阻塞其它所有线程执行,直到锁释放。这种设计需要保留获取/释放 GIL 的 api,nogil 已经这样做了,为了告知 GC 一个线程被阻塞在 I/O 上。

Q:有没有可能将函数标记为非线程安全的(比如使用装饰器),并让 nogil 在运行代码时加锁,以防止其它线程调用它?(有点像临时的 GIL)

如果担心的是状态被其它线程访问,则需要锁定每一次访问。这在装饰器层面上不是特别可行。正如之前说过,条件性地为不安全的代码开启 GIL 是很难实现的。

Q:用你自己的锁代替 GIL 会很困难。使用 nogil,你认为与线程相关的问题会增加么?

不清楚。对于 C API 扩展,至少有一种好的设计模式:它们通常有类似的结构,并在单个结构中保持共享状态。目前,Pybind11 看起来与这个模式距离最远,因此用它编写的 C 扩展可能需要进行大量更改。
许多复杂的 C 扩展已经不得不处理锁和多线程,因为它们的目的是尽可能多地释放 GIL,比如 numpy。所以,也许令人惊讶的是,那些项目可能更容易迁移。

下一步工作

在这次会议之后,核心开发者们讨论了将 nogil 纳入主项目的可行性,以及这对社区意味着什么。毫无疑问,这种程度的改变必须非常小心。
在作出决定之前,我们觉得先引入它的一些代码更为可行。特别地,mimalloc 看起来很有趣,已经有一个 open 的 pull 请求,旨在探索引入它。在那里可以找到基准测试的链接。
在个人层面上,我们对 Sam 所做的工作印象深刻,并邀请他加入 CPython 项目。我很高兴地告诉大家,他对此很感兴趣,为了帮助他成为一名核心开发者,我将为他提供指导。Guido 和 Neil Schemenauer 将帮我检视我不熟悉的解释器部分的代码。