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%。但是,一旦应用已知的缓解措施,实现就接近于性能中性。
待办事项:更新最新分支的性能影响(对于 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 的支持变得简单得多。
避免写时复制
对于某些应用程序,将应用程序置于所需的初始状态,然后为每个工作进程派生进程是有意义的。这可以带来很大的性能提升,尤其是内存使用方面。一些企业 Python 用户(例如 Instagram、YouTube)已经利用了这一点。但是,上述引用计数语义大大降低了这些好处,并导致了一些次优的变通方案。
还要注意,“派生”并不是唯一使用写时复制语义的操作系统机制。另一个例子是 mmap
。与仅使用“可死亡”对象相比,任何此类实用程序都可能会在涉及永生对象时从更少的写时复制中受益。
基本原理
提议的解决方案非常明显,以至于此提案的两位作者都独立地得出了相同的结论(以及或多或少的实现)。Pyston 项目 使用了类似的方法。也考虑了其他设计。近年来,在 python-dev 上也讨论了几种可能性。
替代方案包括
- 使用高位标记“永生”,但不更改
Py_INCREF()
- 向对象添加显式标志
- 通过类型实现(
tp_dealloc()
是一个空操作) - 通过对象类型对象进行跟踪
- 使用单独的表进行跟踪
以上方法中的每一种都使对象永生,但它们都没有解决上面描述的引用计数修改带来的性能损失。
在每个解释器的 GIL 的情况下,唯一现实的替代方案是将所有全局对象移动到 PyInterpreterState
中,并添加一个或多个查找函数来访问它们。然后,我们必须在 C-API 中添加一些 hack 以保持对那里公开的可能对象的兼容性。使用永生对象,情况要简单得多。
影响
好处
最值得注意的是,上面示例中描述的情况将从永生对象中受益匪浅。使用预派生的项目可以放弃其变通方案。对于每个解释器的 GIL 项目,永生对象极大地简化了现有静态类型以及公共 C-API 公开的对象的解决方案。
总的来说,对对象的强大不可变性保证使 Python 应用程序能够更好地扩展,尤其是在 多进程部署 中。这是因为它们可以利用多核并行性,而无需像现在这样在内存使用方面做出重大权衡。我们刚刚描述的情况以及上面 动机 中描述的情况反映了这种改进。
性能
一个简单的实现显示性能下降了 2%(使用 MSVC 时下降 3%)。通过应用一些基本缓解措施,我们已经证明了性能可以恢复到接近中性。请参见下面关于缓解措施的部分。
从积极方面来看,永生对象在与预分叉模型一起使用时可以节省大量的内存。此外,永生对象为 eval 循环中的专门化提供了机会,从而提高性能。
向后兼容性
理想情况下,此仅供内部使用的功能应该完全兼容。但是,它确实在某些情况下涉及对引用计数语义的更改。只有永生对象受到影响,但这包括 None
、True
和 False
等高频使用的对象。
具体来说,当涉及到永生对象时
- 检查引用计数的代码将看到一个非常非常大的值
- 新的无操作行为可能会破坏以下代码:
- 依赖于引用计数始终递增或递减(或具有来自
Py_SET_REFCNT()
的特定值)的代码 - 依赖于任何特定的引用计数值(除了 0 或 1)的代码
- 直接操作引用计数以在其中存储额外信息
- 依赖于引用计数始终递增或递减(或具有来自
- 在 32 位 3.12 之前的稳定 ABI扩展中,对象可能会由于意外永生而泄漏
- 此类扩展可能会由于意外取消永生而崩溃
同样,这些行为变化仅适用于永生对象,而不适用于用户将使用的绝大多数对象。此外,用户无法将对象标记为永生,因此用户创建的对象永远不会出现这种行为变化。依赖于全局(内置)对象任何行为变化的用户已经遇到了麻烦。因此,总体影响应该很小。
还要注意,检查引用泄漏的代码应该可以正常工作,除非它检查相对于某些永生对象硬编码的小值。由Pyston注意到的问题不应该适用于此处,因为我们不修改引用计数。
请参阅下面关于公共引用计数详细信息的部分以了解更多讨论。
意外的永生
假设,一个非永生对象可能会被 incref 到足以被视为永生的魔法值。这意味着它永远不会被 decref 回到 0,因此它会意外泄漏(永远不会被清理)。
使用 64 位引用计数,这种意外情况非常不可能发生,我们不必担心。即使故意使用 Py_INCREF()
在紧密循环中进行操作,并且每次迭代仅花费 1 个 CPU 周期,它也需要 2^60 个周期(如果永生位为 2^60)。在 5 GHz 的高速下,这仍然需要近 250,000,000 秒(超过 2,500 天)!
还要注意,它不太可能成为问题,因为它在引用计数恢复到 0 且对象被清理之前都不会有影响。因此,任何达到该魔法“永生”引用计数值的物件都必须再次 decref 那么多次,才会注意到行为的变化。
同样,魔法引用计数唯一可能被达到(然后反转)的现实方法是故意为之。(当然,可以使用 Py_SET_REFCNT()
高效地完成同样的事情,但这更不可能是意外。)在那种情况下,我们不认为这是此提案关注的问题。
在具有更小最大引用计数的构建版本中,例如 32 位平台,后果并不那么明显。假设魔法引用计数为 2^30。使用与上面相同的规格,意外使对象永生大约需要 4 秒。在合理条件下,对象意外永生仍然极不可能发生。它必须满足以下条件
- 针对非永生对象(因此不是高频使用的内置对象)
- 扩展进行 incref 而没有对应的 decref(例如,从函数或方法返回)
- 在此期间没有其他代码 decref 该对象
即使以更低的频率,达到意外永生(在 32 位上)也不会花费很长时间。但是,然后它必须经历相同数量的(现在是无操作的)decref,然后该对象才会有效地泄漏。这是极不可能的,特别是因为计算假设没有 decref。
此外,这与 32 位扩展如何已经可以将对象的引用计数 incref 超过 2^31 并将其变为负数并没有太大区别。如果这是一个实际问题,那么我们早就听说过了。
在所有上述情况下,该提案均不认为意外永生是一个问题。
稳定的 ABI
本 PEP 中描述的实现方法与编译到稳定 ABI 的扩展兼容(意外永生和意外取消永生除外)。由于稳定 ABI 的性质,不幸的是,此类扩展使用 Py_INCREF()
等直接修改对象 ob_refcnt
字段的版本。这将使永生对象的所有性能优势失效。
但是,我们确实确保在这种情况下永生对象(大部分)保持永生。我们将永生对象的初始引用计数设置为一个值,我们可以通过该值识别该对象为永生,并且即使扩展修改了引用计数,该值也会继续保持不变。(例如,假设我们使用其中一个高引用计数位来指示对象是永生的。我们将初始引用计数设置为仍然匹配该位的高一些的值,例如下一个位的一半。请参阅_Py_IMMORTAL_REFCNT。)在最坏的情况下,在这种情况下,对象会感受到“动机”部分中描述的影响。即使这样,总体影响也不太可能很大。
意外的取消永生
旧稳定 ABI 扩展的 32 位版本可以将意外永生提升到一个新的水平。
假设,这样的扩展可以将对象的引用计数 incref 到魔法引用计数值上方下一个最高位的值。例如,如果魔法值为 2^30,而初始永生引用计数因此为 2^30 + 2^29,那么扩展需要进行 2^29 次 incref 才能达到 2^31 的值,从而使对象变为非永生。(当然,这么高的引用计数可能会导致崩溃,无论是否永生对象。)
更成问题的情况是,此类 32 位稳定 ABI 扩展疯狂地 decref 已经永生的对象。继续以上面的示例,它需要进行 2^29 次非对称 decref 才能降到魔法永生引用计数值以下。因此,像 None
这样的对象可以变为非永生,并受到 decref 的影响。这仍然不是问题,除非以某种方式 decref 继续作用于该对象,直到其达到 0。对于静态分配的永生对象,如 None
,如果扩展尝试释放该对象,则会使进程崩溃。对于任何其他永生对象,释放可能没问题。但是,运行时代码可能期望以前永生的对象永远存在。该代码可能会崩溃。
同样,发生这种情况的可能性极小,即使在 32 位构建版本中也是如此。它需要对该对象进行大约十亿次 decref 而没有对应的 incref。最有可能的情况如下
许多函数和方法返回对 None
的“新”引用。与非永生对象不同,3.12 运行时基本上永远不会在将 None
提供给扩展之前进行 incref。但是,扩展在完成后会对其进行 decref(除非它将其返回)。每次与该对象进行此类交换时,我们都离崩溃更近一步。
在 32 位 Python 进程的整个生命周期中,这种某种形式的交换(与单个对象)发生十亿次的可能性有多大?如果这是一个问题,该如何解决?
至于可能性有多大,目前尚不清楚。但是,缓解措施非常简单,因此我们可以安全地继续假设它不会成为问题。
我们稍后讨论可能的解决方案。
其他 Python 实现
此提案是特定于 CPython 的。但是,它确实与 C-API 的行为相关,这可能会影响其他 Python 实现。因此,上面向后兼容性中描述的行为变化的影响也适用于此处(例如,如果另一个实现与特定的引用计数值(非 0)或引用计数变化方式紧密耦合,那么它们可能会受到影响)。
安全影响
此功能对安全性没有已知影响。
可维护性
这不是一个复杂的功能,因此它不应给维护人员带来太多心理负担。基本实现不会触及太多代码,因此它对可维护性的影响不大。由于性能损失缓解,可能会有一些额外的复杂性。但是,这应该仅限于我们在初始化后使所有对象永生并在稍后在运行时最终化期间显式地释放它们的地方。此代码应相对集中。
规范
此方法涉及以下基本更改
- 将_Py_IMMORTAL_REFCNT(魔法值)添加到内部 C-API 中
- 更新
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 的不可变性。
永生可变对象
任何对象都可以被标记为永存。我们不建议任何限制或检查。但是,在实践中,使对象永存的价值与其可变性相关,并取决于它被用于应用程序生命周期中足够一部分的可能性。在某些情况下,将可变对象标记为永存是有意义的。
许多永存对象的用例都集中在不可变性上,以便线程可以安全有效地共享这些对象而无需加锁。出于这个原因,可变对象(如 dict 或 list)永远不会被共享(因此没有永存性)。但是,如果能够充分保证通常可变的对象实际上不会被修改,那么永存性可能是合适的。
另一方面,一些可变对象永远不会在线程之间共享(至少在没有像 GIL 这样的锁的情况下不会)。在某些情况下,使其中一些对象永存也可能在实践中可行。例如,sys.modules
是一个每个解释器的 dict,我们不期望它在相应的解释器终结之前被释放(假设它没有被替换)。通过将其设为永存,我们将在 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()
将不受影响。)
待办事项:阐明 _Py_RefTotal
的状态。
此外,永存对象不会参与 GC。
永生全局对象
所有运行时全局(内置)对象都将被设为永存。包括以下内容
- 单例(
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
。此类函数和宏可以更新为删除任何引用计数操作。
在 eval 循环中针对永生对象进行专门化
有机会优化 eval 循环中涉及特定已知永存对象(例如 None
)的操作。通用机制在 PEP 659 中进行了描述。另请参阅 Pyston。
其他可能性
- 将每个驻留字符串标记为永存
- 如果共享,则将“驻留”字典标记为永存,否则共享所有驻留字符串
- (Larry,MAL)将为模块解序列化的所有常量标记为永存
- (Larry,MAL)在它们自己的内存页中分配(不可变)永存对象
- 使用 32 个最低有效位进行饱和引用计数
意外取消永生的解决方案
在 意外取消永存 部分,我们概述了永存对象可能产生的负面后果。在这里,我们看看处理该问题的一些选项。
请注意,我们在此列举解决方案是为了说明有令人满意的选项可用,而不是规定如何解决问题。
另请注意以下几点
- 这仅在 32 位稳定 ABI 的情况下才重要
- 它仅影响永存对象
- 没有用户定义的永存对象,只有内置类型
- 大多数永存对象将是静态分配的(因此如果调用
tp_dealloc()
,则必须失败) - 在实践中,只有少数永存对象会被足够频繁地使用,以至于可能遇到这个问题(例如
None
) - 要解决的主要问题是来自
tp_dealloc()
的崩溃
解决方法的一个基本观察结果是,当满足某些条件时,我们可以将永生对象(immortal object)的引用计数重置为_Py_IMMORTAL_REFCNT
。
考虑到所有这些,一个简单而有效的解决方案是在tp_dealloc()
中重置永生对象的引用计数。NoneType
和bool
已经有一个tp_dealloc()
,如果触发则会调用Py_FatalError()
。基于某些条件的其他类型也是如此,例如PyUnicodeObject
(取决于unicode_is_singleton()
)、PyTupleObject
和PyTypeObject
。事实上,对于所有静态声明的对象,相同的检查都很重要。对于这些类型,我们将重置引用计数。对于其余情况,我们将引入检查。在所有情况下,tp_dealloc()
中检查的开销应该小到可以忽略不计。
其他(不太实用)的解决方案
- 定期重置永生对象的引用计数
- 仅对高使用率的对象执行此操作
- 仅当导入了一个稳定的ABI扩展时才执行此操作
- 提供一个运行时标志来禁用永生性
([讨论线程](https://mail.python.org/archives/list/python-dev@python.org/message/OXAYWH47ZGLOWXTNKCIW4YE5PXGHNT4Y/)包含更多详细信息。)
无论我们最终采用哪种解决方案,如果需要,我们以后都可以做其他事情。
待办事项:添加一条说明,指出已实现的解决方案不影响整体的~性能中性~结果。
文档
永生对象的行为和API是内部的,是实现细节,不会添加到文档中。
但是,我们将更新文档,使关于引用计数行为的公共保证更加清晰。具体包括
Py_INCREF()
- 将“增加对象o的引用计数”更改为“指示获取对象o的新引用”。Py_DECREF()
- 将“减少对象o的引用计数”更改为“指示不再使用之前获取的对象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
仅在使用定义了Py_TRACE_REFS
的CPython构建时才分配。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
上次修改时间:2024-06-12 18:00:45 GMT