PEP 683 – 不朽对象,使用固定引用计数
- 作者:
- Eric Snow <ericsnowcurrently at gmail.com>,Eddie Elizondo <eduardo.elizondorueda at gmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2022年2月10日
- Python 版本:
- 3.12
- 发布历史:
- 2022年2月16日, 2022年2月19日, 2022年2月28日, 2022年8月12日
- 决议:
- Discourse 消息
PEP 接受条件
本 PEP 在以下条件下被接受
- 意外解除不朽性的解决方案(在
tp_dealloc()中重置不朽引用计数)中的主要提议已应用 - 没有此功能的类型不会被不朽化(在 CPython 的代码中)
- 一旦实现最终确定(确认更改是值得的),PEP 将更新最终基准测试结果
摘要
目前,CPython 运行时在每个对象的已分配内存中维护少量可变状态。因此,否则不可变的对象实际上是可变的。这可能会对 CPU 和内存性能产生很大的负面影响,特别是对于提高 Python 可伸缩性的方法。
本提案强制要求,在内部,CPython 将支持将对象标记为运行时状态不再更改的对象。因此,此类对象的引用计数永远不会达到 0,因此该对象永远不会被清理(除非运行时知道这样做是安全的,例如在运行时最终化期间)。我们将这些对象称为“不朽”对象。(通常,只有相对少量内部对象会成为不朽对象。)这里的根本改进是,现在对象可以真正地不可变。
范围
对象不朽性旨在成为一项仅限内部的功能,因此本提案不包括对公共 API 或行为的任何更改(只有一个例外)。像往常一样,我们可能仍然会添加一些私有(但可公开访问)的 API 来执行诸如使对象不朽或判断对象是否不朽等操作。任何将此功能暴露给用户的努力都需要单独提出。
“行为无更改”有一个例外:不朽对象的引用计数语义在某些情况下会与用户预期有所不同。此例外和解决方案将在下面讨论。
本 PEP 的大部分内容都集中在满足上述要求的内部实现上。然而,这些实现细节并非旨在严格规定。相反,它们至少被包含在内,以帮助说明该要求所需的技术考量。实际实现可能会有所偏差,只要它满足下面概述的约束即可。此外,下面描述的任何特定实现细节的可接受性不取决于本 PEP 的状态,除非明确指定。
例如,具体的细节包括
- 如何将某物标记为不朽
- 如何识别某物为不朽
- 功能性不朽对象的哪个子集被标记为不朽
- 哪些内存管理活动对于不朽对象被跳过或修改
不仅是 CPython 特有的,而且是私有实现细节,预计将在后续版本中更改。
实现摘要
以下是实现的高级概述
如果对象的引用计数与某个非常特定的值(如下定义)匹配,则该对象被视为不朽。CPython C-API 和运行时不会修改不朽对象的引用计数(或其他运行时状态)。运行时现在将明确负责在最终化期间解除分配所有不朽对象,除非是静态分配的。(参见下面的对象清理。)
除了引用计数语义的更改之外,还需要考虑另一个可能的负面影响。不朽对象的“可接受”性能损失阈值是 2%(2022 年语言峰会上的共识)。上述方法的朴素实现使 CPython 大约慢了 4%。然而,一旦应用了已知的缓解措施,该实现将~性能中立~。
TODO:更新最新分支的性能影响(针对 GCC 和 clang)。
动机
如上所述,目前所有对象实际上都是可变的。这包括“不可变”对象,例如 str 实例。这是因为每个对象的引用计数在执行过程中使用时都会频繁修改。这对于许多常用的全局(内置)对象(例如 None)尤其重要。这些对象在 Python 代码和内部都大量使用。这导致了持续大量的引用计数更改。
所有 Python 对象的有效可变性对 Python 社区的某些部分产生了具体影响,例如旨在提高可伸缩性的项目(如 Instagram)或使 GIL 成为解释器级锁的努力。下面我们描述了引用计数修改对这些项目产生实际负面影响的几种方式。对于真正不可变的对象,这些都不会发生。
减少 CPU 缓存失效
每次修改引用计数都会导致相应的 CPU 缓存行失效。这会产生多种影响。
首先,写入必须传播到其他缓存级别和主内存。这对所有 Python 程序的影响很小。不朽对象将在这方面提供轻微的缓解。
除此之外,多核应用程序需要付出代价。如果两个线程(在不同的核心上同时运行)与同一个对象(例如 None)交互,那么它们将通过每次 incref 和 decref 相互使缓存失效。即使对于否则不可变的对象(例如 True、0 和 str 实例),情况也是如此。CPython 的 GIL 有助于减少这种影响,因为一次只有一个线程运行,但它不能完全消除这种代价。
避免数据竞争
说到多核,我们正在考虑将 GIL 设为每个解释器一个锁,这将实现真正的多核并行。除其他外,GIL 目前可以防止多个并发线程之间在 incref 或 decref 同一个对象时发生竞争。如果没有共享的 GIL,两个正在运行的解释器将无法安全地共享任何对象,即使是像 None 这样否则不可变的对象。
这意味着,要拥有每个解释器一个 GIL,每个解释器都必须拥有其自己的*每个*对象副本。这包括单例和静态类型。我们对此有一个可行的策略,但这将需要大量的额外工作和额外复杂性。
另一种方法是确保所有共享对象都是真正不可变的。不会有竞争,因为不会有修改。这是本提案中提出的不朽性将为否则不可变的对象实现的功能。有了不朽对象,对每个解释器一个 GIL 的支持将变得更加简单。
避免写时复制
对于某些应用程序,将应用程序置于所需的初始状态,然后为每个 worker 分叉进程是合理的。这可以显著提高性能,尤其是内存使用。一些企业 Python 用户(例如 Instagram、YouTube)已经利用了这一点。然而,上述引用计数语义大大降低了收益,并导致了一些次优的变通方法。
另请注意,“fork”不是唯一使用写时复制语义的操作系统机制。另一个例子是 mmap。与仅使用“凡人”对象相比,当涉及不朽对象时,任何此类实用程序都可能受益于更少的写时复制。
基本原理
所提出的解决方案足够明显,以至于两位提案作者都独立地得出相同的结论(或多或少相同的实现)。Pyston 项目使用了类似的方法。还考虑了其他设计。过去几年中,python-dev 上也讨论了几种可能性。
替代方案包括
- 使用高位标记“不朽”,但不要更改
Py_INCREF() - 向对象添加一个显式标志
- 通过类型实现(
tp_dealloc()是空操作) - 通过对象的类型对象跟踪
- 用单独的表跟踪
上述每种方法都使对象不朽,但它们都没有解决上述引用计数修改导致的性能损失。
在每个解释器一个 GIL 的情况下,唯一现实的替代方案是将所有全局对象移动到 PyInterpreterState 中,并添加一个或多个查找函数来访问它们。然后我们必须在 C-API 中添加一些 hack 来维护对其中暴露的许多对象的兼容性。有了不朽对象,故事就简单多了。
影响
好处
最值得注意的是,上述示例中描述的案例将从不朽对象中受益匪浅。使用 pre-fork 的项目可以放弃其变通方法。对于每个解释器一个 GIL 项目,不朽对象大大简化了现有静态类型以及公共 C-API 暴露的对象的解决方案。
通常,对象的强不可变性保证使 Python 应用程序能够更好地扩展,尤其是在 多进程部署中。这是因为它们可以利用多核并行,而无需像现在这样付出显著的内存使用权衡。我们刚刚描述的案例以及上面动机中描述的案例都反映了这种改进。
性能
一个天真的实现显示2% 的减速(使用 MSVC 时为 3%)。我们已经证明,在应用了一些基本缓解措施后,性能已恢复到 ~性能中立~。请参阅下面的缓解措施部分。
从积极的方面来看,不朽对象在与 pre-fork 模型一起使用时节省了大量内存。此外,不朽对象为 eval 循环中的专门化提供了机会,从而可以提高性能。
向后兼容性
理想情况下,这个仅限内部的功能将完全兼容。但是,它在某些情况下确实涉及对引用计数语义的更改。只有不朽对象会受到影响,但这包括高使用率对象,例如 None、True 和 False。
具体来说,当涉及不朽对象时
- 检查引用计数的代码将看到一个非常非常大的值
- 新的空操作行为可能会破坏以下代码:
- 具体依赖于引用计数总是递增或递减(或具有来自
Py_SET_REFCNT()的特定值) - 依赖于除 0 或 1 之外的任何特定引用计数值
- 直接操作引用计数以存储额外信息
- 具体依赖于引用计数总是递增或递减(或具有来自
- 在 32 位 pre-3.12 稳定 ABI 扩展中,对象可能由于意外不朽性而泄漏
- 此类扩展可能由于意外解除不朽性而崩溃
同样,这些行为更改仅适用于不朽对象,而不适用于用户将使用的大多数对象。此外,用户不能将对象标记为不朽,因此任何用户创建的对象都不会有这种更改的行为。依赖于全局(内置)对象任何更改行为的用户已经遇到了麻烦。因此,总体影响应该很小。
另请注意,检查引用泄漏的代码应该继续正常工作,除非它检查与某些不朽对象相关的硬编码小值。Pyston 注意到的问题不应该在这里适用,因为我们不修改引用计数。
有关进一步讨论,请参阅下面的公共引用计数详情。
意外的不朽性
假设一个非不朽对象被 incref 太多次,以至于达到了被视为不朽的魔法值。这意味着它永远不会被 decref 回 0,所以它会意外泄漏(永远不会被清理)。
对于 64 位引用计数,这种意外情况发生的可能性很小,我们无需担心。即使是故意通过在紧密循环中使用 Py_INCREF(),并且每次迭代只消耗 1 个 CPU 周期,也需要 2^60 个周期(如果那个不朽位是 2^60)。在 5 GHz 的高速下,这仍然需要近 2.5 亿秒(超过 2500 天)!
另请注意,它不太可能成为问题,因为在引用计数回到 0 并且对象被清理之前,这并不重要。所以任何达到那个魔法“不朽”引用计数值的对象,都必须再次 decref 那么多次,才能注意到行为的变化。
同样,达到(然后反转)魔法引用计数的唯一现实方式是故意为之。(当然,同样的事情可以通过使用 Py_SET_REFCNT() 高效地完成,但这更不可能是意外。)到那时,我们不认为它是本提案关注的问题。
在最大引用计数小得多的构建中,例如 32 位平台,后果不那么明显。假设魔法引用计数是 2^30。使用与上面相同的规格,意外地使对象不朽大约需要 4 秒。在合理条件下,对象被意外不朽化的可能性仍然很低。它必须满足这些标准:
- 针对非不朽对象(因此不是高使用率的内置对象)
- 扩展 increfs 没有相应的 decref(例如,从函数或方法返回)
- 在此期间没有其他代码 decrefs 该对象
即使频率低得多,达到意外不朽性(在 32 位上)也用不了多久。然而,然后它必须经历相同数量的(现在是空操作的)decrefs,然后那个对象才会有效地泄漏。这极不可能,特别是因为计算假设没有 decrefs。
此外,这与此类 32 位扩展已经可以将对象 incref 超过 2^31 并使引用计数变为负值的方式并无太大不同。如果这是一个实际问题,那么我们早就听说了。
在上述所有情况之间,本提案不认为意外不朽性是一个问题。
稳定的 ABI
本 PEP 中描述的实现方法与编译到稳定 ABI 的扩展兼容(意外不朽性和意外解除不朽性除外)。不幸的是,由于稳定 ABI 的性质,此类扩展使用 Py_INCREF() 等版本直接修改对象的 ob_refcnt 字段。这将使不朽对象的所有性能优势失效。
然而,我们确实确保在这种情况下,不朽对象(大部分)保持不朽。我们将不朽对象的初始引用计数设置为一个值,我们可以通过该值将对象识别为不朽,并且即使引用计数被扩展修改,它也继续这样做。(例如,假设我们使用其中一个高引用计数位来指示对象是不朽的。我们会将初始引用计数设置为一个更高的值,该值仍然匹配该位,例如到下一个位的一半。请参阅 _Py_IMMORTAL_REFCNT。)最坏情况下,这种情况下的对象会感受到动机部分中描述的影响。即使那样,总体影响也不太可能显著。
意外的解除不朽性
旧稳定 ABI 扩展的 32 位构建可以将意外不朽性提升到新的高度。
假设,这样的扩展可以将一个对象的引用计数增加到一个高于魔法引用计数值的下一个最高位的值。例如,如果魔法值是 2^30,并且初始不朽引用计数因此是 2^30 + 2^29,那么扩展需要 2^29 次增加才能达到 2^31 的值,从而使对象变为非不朽。(当然,如此高的引用计数很可能已经导致崩溃,无论是否是不朽对象。)
更具问题性的情况是,当这种 32 位稳定 ABI 扩展疯狂地减少一个已经不朽的对象的引用计数时。继续上面的例子,需要 2^29 次不对称的减少才能降到魔法不朽引用计数值以下。因此,像 None 这样的对象可以变得可变并受减少引用计数的影响。这仍然不是问题,直到某种方式下对该对象的减少引用计数继续进行,直到它达到 0。对于静态分配的不朽对象,如 None,如果扩展试图解除分配该对象,则会使进程崩溃。对于任何其他不朽对象,解除分配可能没问题。然而,可能会有运行时代码期望这个曾经不朽的对象永远存在。该代码可能会崩溃。
同样,这种情况发生的可能性极小,即使在 32 位构建上也是如此。它需要对该对象进行大约十亿次 decref,而没有相应的 incref。最可能的情况是:
许多函数和方法都会返回对 None 的“新”引用。与非不朽对象不同,3.12 运行时基本上永远不会在将 None 提供给扩展之前对其进行 incref。但是,扩展在完成使用后*会* decref 它(除非它返回它)。每次与同一个对象发生这种交换时,我们就离崩溃更近一步。
在 32 位 Python 进程的生命周期中,以某种形式(针对单个对象)发生十亿次这种交换的现实性如何?如果这是一个问题,该如何解决?
至于现实性,目前尚不清楚。然而,缓解措施足够简单,我们可以安全地假设它不会成为问题。
我们将在稍后研究可能的解决方案。
备选 Python 实现
本提案是 CPython 特定的。然而,它确实与 C-API 的行为有关,这可能会影响其他 Python 实现。因此,上面向后兼容性中描述的更改行为的影响也适用于此处(例如,如果另一个实现与特定引用计数值(除 0 外)或引用计数如何精确更改紧密耦合,那么它们可能会受到影响)。
安全隐患
此功能对安全性没有已知影响。
可维护性
这不是一个复杂的功能,因此不会给维护人员带来太多精神负担。基本实现没有触及太多代码,因此对可维护性影响不大。由于性能损失缓解,可能会有一些额外的复杂性。然而,这应该仅限于我们在初始化后使所有对象不朽,然后又在运行时最终化期间显式解除分配它们。这部分代码应该相对集中。
规范
该方法涉及以下根本性更改
- 向内部 C-API 添加 _Py_IMMORTAL_REFCNT(魔法值)
- 更新
Py_INCREF()和Py_DECREF(),使其对与魔法引用计数匹配的对象执行空操作 - 对任何其他修改引用计数的 API 执行相同的操作
- 停止修改不朽 GC 对象(“容器”)的
PyGC_Head - 确保所有不朽对象在运行时最终化期间被清理
然后将任何对象的引用计数设置为 _Py_IMMORTAL_REFCNT 会使其不朽。
(还有其他不在此处描述的次要内部更改。)
在以下小节中,我们将深入探讨最显著的细节。首先我们将介绍一些概念性主题,然后是更具体的方面,例如受影响的特定 API。
公共引用计数详情
在向后兼容性中,我们介绍了用户代码可能因本提案中的更改而中断的可能方式。用户可能存在的任何误解很可能在很大程度上归因于引用计数相关 API 的名称以及文档解释这些 API(以及引用计数总体)的方式。
在名称和文档之间,我们可以清楚地看到以下问题的答案:
- 用户期望什么样的行为?
- 我们做出哪些保证?
- 我们是否指示如何解释他们收到的引用计数值?
- 在什么用例下用户会将对象的引用计数设置为特定值?
- 用户是否在设置他们未创建的对象的引用计数?
作为本提案的一部分,我们必须确保用户能够清楚地理解他们可以依赖的引用计数行为的哪些部分以及哪些被视为实现细节。具体来说,他们应该使用现有的公共引用计数相关 API,并且唯一有意义的引用计数值是 0 和 1。(有些代码依赖于 1 作为对象可以安全修改的指示。)所有其他值都被视为“非 0 或 1”。
此信息将在文档中澄清。
可以说,现有与引用计数相关的 API 应该进行修改,以反映我们希望用户期望的内容。如下所示:
Py_INCREF()->Py_ACQUIRE_REF()(或仅支持Py_NewRef())Py_DECREF()->Py_RELEASE_REF()Py_REFCNT()->Py_HAS_REFS()Py_SET_REFCNT()->Py_RESET_REFS()和Py_SET_NO_REFS()
然而,这种更改不属于本提案的一部分。此处包含它是为了演示更紧密地关注用户期望,这将有利于此更改。
限制
- 确保否则不可变的对象能够真正不可变
- 尽量减少普通 Python 用例的性能损失
- 当使不朽对象化我们实际上不期望持续到运行时最终化的对象时要小心。
- 使不朽对象化并非不可变的对象时要小心
__del__和弱引用必须继续正常工作
关于“真正”不可变对象,本 PEP 不影响任何对象的有效不可变性,除了每个对象的运行时状态(例如引用计数)。因此,某个不朽对象是否真正(甚至有效)不可变只能与本提案分开解决。例如,str 对象通常被认为是不可变的,但 PyUnicodeObject 包含一些惰性缓存数据。本 PEP 对该状态如何影响 str 不变性没有影响。
不朽的可变对象
任何对象都可以标记为不朽。我们不建议任何限制或检查。然而,实际上,使对象不朽的价值与其可变性有关,并取决于它在应用程序生命周期中被充分使用的可能性。在某些情况下,将可变对象标记为不朽是有意义的。
不朽对象的许多用例都围绕着不变性,这样线程就可以安全高效地共享这些对象而无需锁定。因此,可变对象,如字典或列表,永远不会被共享(因此也不会不朽)。然而,如果有足够的保证通常可变的对象实际上不会被修改,那么不朽性可能是合适的。
另一方面,一些可变对象永远不会在线程之间共享(至少没有像 GIL 这样的锁)。在某些情况下,使其中一些对象不朽也可能很实用。例如,sys.modules 是一个每个解释器一个的字典,我们不期望它在相应的解释器最终化之前被释放(假设它没有被替换)。通过使其不朽,我们将不再在 incref/decref 期间产生额外的开销。
我们将在下面的缓解措施部分进一步探讨这个想法。
隐式不朽对象
如果一个不朽对象持有一个普通(凡人)对象的引用,那么这个被持有的对象实际上就是不朽的。这是因为该对象的引用计数永远无法达到 0,直到不朽对象释放它。
示例
- 容器,如
dict和list - 内部持有引用的对象,如
PyTypeObject及其tp_subclasses和tp_weaklist - 对象的类型(保存在
ob_type中)
因此,这些被持有的对象在被持有时是隐式不朽的。实际上,这应该没有任何实际后果,因为它实际上并没有改变行为。唯一的区别是不朽对象(持有引用)永远不会被清理。
我们不建议以任何方式更改这些隐式不朽对象。它们不应仅仅因为被不朽对象持有而被明确标记为不朽。这样做没有任何优势。
解除对象的不朽性
本提案不包括任何将不朽对象恢复到“正常”状态的机制。目前没有这种能力的需求。
此外,显而易见的方法是简单地将引用计数设置为一个较小的值。然而,此时无法知道哪个值是安全的。理想情况下,我们会将其设置为如果它没有被不朽时它会有的值。然而,该值早已丢失。因此,所涉及的复杂性使得对象难以安全地解除不朽,即使我们有充分的理由这样做。
_Py_IMMORTAL_REFCNT
我们将添加两个内部常量
_Py_IMMORTAL_BIT - has the top-most available bit set (e.g. 2^62)
_Py_IMMORTAL_REFCNT - has the two top-most available bits set
实际的最高位取决于引用计数位的现有用途,例如符号位或某些 GC 用途。在考虑现有用途后,我们将使用可能的最高位。
不朽对象的引用计数将被设置为 _Py_IMMORTAL_REFCNT(这意味着该值将介于 _Py_IMMORTAL_BIT 和下一个最高位的值之间)。然而,要检查一个对象是否不朽,我们将(按位与)其引用计数与 _Py_IMMORTAL_BIT 进行比较。
这种差异意味着即使不朽对象的引用计数被修改(例如,被旧的稳定 ABI 扩展修改),它仍然被视为不朽。
请注意,引用计数的最高两位已保留用于其他用途。这就是我们使用第三高位的原因。
该实现还可使用不朽位的其他值,例如符号位或 2^31(对于 64 位上的饱和引用计数)。
受影响的 API
现在将忽略不朽对象的 API
- (公共)
Py_INCREF() - (公共)
Py_DECREF() - (公共)
Py_SET_REFCNT() - (私有)
_Py_NewReference()
暴露引用计数的 API(未更改但现在可能返回大值)
- (公共)
Py_REFCNT() - (公共)
sys.getrefcount()
(请注意,_Py_RefTotal,以及因此的 sys.gettotalrefcount(),将不受影响。)
TODO:澄清 _Py_RefTotal 的状态。
此外,不朽对象将不参与垃圾回收。
不朽的全局对象
所有运行时全局(内置)对象都将变为不朽。这包括以下内容:
- 单例(
None、True、False、Ellipsis、NotImplemented) - 所有静态类型(例如
PyLong_Type、PyExc_Exception) _PyRuntimeState.global_objects中的所有静态对象(例如标识符、小整数)
使完整对象实际不可变(例如,用于每个解释器一个 GIL)的问题不属于本 PEP 的范围。
对象清理
为了在运行时最终化期间清理所有不朽对象,我们必须跟踪它们。
对于 GC 对象(“容器”),我们将利用 GC 的永久世代,将所有不朽化的容器推入其中。在运行时关闭期间,策略是首先让运行时尽力正常解除分配这些实例。大部分模块解除分配现在将由 pylifecycle.c:finalize_modules() 处理,我们将在其中尽最大努力清理剩余的模块。它将改变在 __del__ 期间哪些模块可用,但这在文档中已明确是不确定行为。可选地,我们可以进行一些拓扑排序,以保证用户模块在标准库模块之前被解除分配。最后,剩下的任何东西(如果存在)都可以通过永久世代 GC 列表找到,我们可以在 finalize_modules() 完成后清除它。
对于非容器对象,跟踪方法将因情况而异。在几乎所有情况下,每个此类对象都在运行时状态下直接可访问,例如在 _PyRuntimeState 或 PyInterpreterState 字段中。我们可能需要为少量对象向运行时状态添加跟踪机制。
任何清理都不会对性能产生显著影响。
性能退化缓解措施
为了清晰起见,这里列出了一些我们尝试弥补因不朽对象的朴素实现而损失的4% 性能的方法。
请注意,本节内容实际上都不属于提案的一部分。
在运行时初始化结束时,将所有对象标记为不朽
我们可以应用不朽可变对象的概念,以弥补不朽对象朴素实现所损失的 4% 性能。在运行时初始化结束时,我们可以将*所有*对象标记为不朽,并避免 incref/decref 中的额外开销。我们只需要关注那些我们计划在没有 GIL 的情况下在线程之间共享的对象的不可变性。
删除不必要的硬编码引用计数操作
C-API 的某些部分专门与我们知道是不朽的对象交互,例如 Py_RETURN_NONE。这些函数和宏可以更新以删除任何引用计数操作。
在评估循环中专门处理不朽对象
在评估循环中,有机会优化涉及特定已知不朽对象(例如 None)的操作。一般机制在 PEP 659 中描述。另请参阅 Pyston。
其他可能性
- 将每个 interned 字符串标记为不朽
- 如果共享,则将“interned”字典标记为不朽,否则共享所有 interned 字符串
- (Larry,MAL)将模块解封的所有常量标记为不朽
- (Larry,MAL)在自己的内存页中分配(不可变)不朽对象
- 使用 32 个最低有效位进行饱和引用计数
意外解除不朽性的解决方案
在意外解除不朽性一节中,我们概述了不朽对象可能带来的负面后果。这里我们看看处理这个问题的一些选项。
请注意,我们在此处列举解决方案是为了说明存在令人满意的选项,而不是为了规定问题将如何解决。
另请注意以下几点:
- 这仅在 32 位稳定 ABI 的情况下才重要
- 它只影响不朽对象
- 没有用户定义的不朽对象,只有内置类型
- 大多数不朽对象将是静态分配的(因此如果调用
tp_dealloc()则必然会失败) - 只有少数不朽对象会被频繁使用,以至于在实践中可能面临这个问题(例如
None) - 要解决的主要问题是来自
tp_dealloc()的崩溃
解决方案的一个基本观察是,当满足某些条件时,我们可以将不朽对象的引用计数重置为 _Py_IMMORTAL_REFCNT。
考虑到所有这些,一个简单而有效的解决方案是在 tp_dealloc() 中重置不朽对象的引用计数。NoneType 和 bool 已经有一个 tp_dealloc(),如果触发,会调用 Py_FatalError()。对于基于某些条件的其他类型,例如 PyUnicodeObject(取决于 unicode_is_singleton())、PyTupleObject 和 PyTypeObject,情况也是如此。事实上,对于所有静态声明的对象,同样的检查都很重要。对于这些类型,我们将转而重置引用计数。对于其余情况,我们将引入检查。在所有情况下,tp_dealloc() 中检查的开销应该小到无关紧要。
其他(不太实用)的解决方案
- 定期重置不朽对象的引用计数
- 只对高使用率对象这样做
- 仅当导入了稳定 ABI 扩展时才这样做
- 提供一个运行时标志来禁用不朽性
(讨论线程有更多细节。)
无论我们最终采用哪种解决方案,如果需要,我们都可以在以后采取其他措施。
TODO:添加一个注释,表明已实现的解决方案不影响整体的 ~性能中立~ 结果。
文档
不朽对象的行为和 API 是内部实现细节,不会添加到文档中。
但是,我们将更新文档,以更清晰地公开引用计数行为的保证。这具体包括:
Py_INCREF()- 将“Increment the reference count for object o.”改为“Indicate taking a new reference to object o.”Py_DECREF()- 将“Decrement the reference count for object o.”改为“Indicate no longer using a previously taken reference to object o.”Py_XINCREF()、Py_XDECREF()、Py_NewRef()、Py_XNewRef()、Py_Clear()类似Py_REFCNT()- 添加“引用计数 0 和 1 具有特定含义,所有其他值仅表示某处代码正在使用该对象,无论其值如何。0 表示对象未使用并将被清理。1 表示代码恰好持有一个引用。”Py_SET_REFCNT()- 提及Py_REFCNT()关于值大于 1 时可能被其他值替换
我们*可能*还会向以下内容添加有关不朽对象的说明,以帮助减少用户可能因更改而产生的任何意外:
Py_SET_REFCNT()(对于不朽对象为空操作)Py_REFCNT()(值可能大得出乎意料)sys.getrefcount()(值可能大得出乎意料)
其他可能受益于此类注释的 API 目前未记录。我们不会在其他任何地方添加此类注释(包括 Py_INCREF() 和 Py_DECREF()),因为该功能对用户来说是透明的。
参考实现
该实现已在 GitHub 上提出
未解决的问题
- 意外解除不朽性问题有多现实?
参考资料
先行实践
讨论
这在 2021 年 12 月在 python-dev 上讨论过
运行时对象状态
以下是 CPython 运行时为每个 Python 对象保留的内部状态:
- PyObject.ob_refcnt:对象的引用计数
- _PyGC_Head:(可选)对象在“GC”对象列表中的节点
- _PyObject_HEAD_EXTRA:(可选)对象在堆对象列表中的节点
ob_refcnt 是为每个对象分配的内存的一部分。然而,_PyObject_HEAD_EXTRA 仅在 CPython 构建时定义了 Py_TRACE_REFS 时才分配。PyGC_Head 仅在对象的类型设置了 Py_TPFLAGS_HAVE_GC 时才分配。通常这仅适用于容器类型(例如 list)。另请注意,PyObject.ob_refcnt 和 _PyObject_HEAD_EXTRA 是 PyObject_HEAD 的一部分。
引用计数,带循环垃圾回收
垃圾回收是某些编程语言的内存管理功能。它意味着一旦对象不再使用,它们就会被清理(例如,释放内存)。
引用计数是垃圾回收的一种方法。语言运行时跟踪对象持有的引用数量。当代码取得对对象的引用所有权或释放它时,运行时会收到通知并相应地增加或减少引用计数。当引用计数达到 0 时,运行时会清理对象。
在 CPython 中,代码必须使用 C-API 的 Py_INCREF() 和 Py_DECREF() 明确地获取或释放引用。这些宏恰好直接修改对象的引用计数(不幸的是,因为如果我们要更改垃圾回收方案,这会导致 ABI 兼容性问题)。此外,当 CPython 中的对象被清理时,它还会释放它拥有的任何引用(和资源)(在内存被释放之前)。
有时对象可能涉及引用循环,例如,对象 A 持有对象 B 的引用,而对象 B 持有对象 A 的引用。因此,即使没有其他引用(即内存泄漏),这两个对象也永远不会被清理。涉及循环的最常见对象是容器。
CPython 有专门的机制来处理引用循环,我们称之为“循环垃圾收集器”,或通常简称“垃圾收集器”或“GC”。不要让这个名字迷惑你。它只处理打破引用循环。
有关引用计数和循环垃圾回收的更详细解释,请参阅文档:
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0683.rst