Chapter 35: Immortal Objects (PEP 683)
本章讨论 CPython 为什么要让一部分对象进入“长期存活且引用计数保持固定”的状态。读完本章后,读者应能追踪一个高频对象从 Python 代码进入 CPython 对象头部后的成本来源,判断引用计数写入、缓存行失效、垃圾回收、fork 后写时复制、多解释器共享和 no-GIL 方向之间的关系。
贯穿本章的材料是 None 这类高频内置单例。Python 代码经常返回、传参、比较、存入容器、从函数中传递 None。从语言层看,None 是不可变单例;从 CPython 内部看,传统引用计数模型仍会在对象头部反复写入 ob_refcnt。这个写入动作会把“看起来不可变”的对象变成“运行时状态仍在变化”的对象。
PEP 683 把这类问题收束到一个实现策略:给部分对象设置特殊的 immortal refcount,使普通引用计数更新路径识别该对象并保持这个状态。Python 3.12 的发布说明把 PEP 683 列入 C API 相关变化,并提到 _Py_IMMORTAL_REFCNT、_Py_IsImmortal 和 interned unicode 相关状态等 CPython 内部接口变化。这里的讨论以 CPython 3.12+ 为边界;其它 Python 实现可以采用不同内存模型。
本章的主线是:对象身份和语义不可变只说明 Python 层行为稳定;对象头部是否被运行时写入,决定它能否安全、低成本地被多个执行上下文共享。immortal object 解决的是后一类问题。
35.1 immortal object
immortal object 指 CPython 内部被标记为“引用计数不会按普通对象方式增减”的对象。它仍然是普通 Python 对象:有 identity,有 type,有对象头部,也能被变量、容器、frame 和 C API 引用。差异落在对象头部的引用计数字段上:当 refcount 等于约定的 immortal 值时,普通 Py_INCREF() 和 Py_DECREF() 路径会识别出该对象,并保持这个 refcount 状态。
先看一个 Python 层现象。下面的代码没有暴露 CPython 对象头部,却能构造出两类对象的对比:None 是高频共享单例,Marker() 是当前代码临时创建的普通 heap object。
import sys
class Marker:
pass
shared_singleton = None
local_object = Marker()
holder = [shared_singleton, local_object]
print(sys.getrefcount(shared_singleton))
print(sys.getrefcount(local_object))
这段代码的目的不是要求读者记住 sys.getrefcount() 的具体数字。它要展示两个判断入口:同样被名字和容器引用,None 的生命周期由解释器全局对象策略管理,local_object 的生命周期由当前引用图管理。在 CPython 3.12+ 中,None 这类对象可能显示为非常大的引用计数值;这个值表达“immortal 状态”,不表达真实持有者数量。
普通 heap object 的生命周期可以用 引用增加 → 引用减少 → refcount 到 0 → 释放 描述。immortal object 的关键变化是 refcount 到 0 这条普通释放路径对它不成立。对象被设置为 immortal 后,引用计数更新路径保留特殊值,因此它不会因为普通 DECREF 下降到 0。它的释放由解释器最终化阶段、静态分配对象生命周期或专门清理策略决定。
这个设计只改变 CPython 内部对象管理策略,不改变 Python 层语义。None is None、True is True、内置静态类型的 identity 规则和普通名字绑定规则仍按语言语义工作。读者分析这类对象时,应把“Python 层对象行为”与“CPython 对象头部写入策略”分开:前者解释代码结果,后者解释运行时成本和共享边界。
35.2 cache line optimization
cache line optimization 要解决的是引用计数写入带来的硬件缓存压力。cache line(缓存行)是 CPU cache 和内存之间传输数据的基本块。对象头部里的 ob_refcnt 和对象的其它头部字段通常位于同一片内存区域;只要运行时修改 refcount,对应缓存行就会被写入并在多核场景下产生一致性开销。
以 None 为例,许多函数把它作为默认返回值、缺省标记或分支结果。传统引用计数路径中,每次把 None 放进局部变量、参数、临时栈槽或容器时,CPython 都可能执行 INCREF;引用离开作用域或容器删除元素时,又会执行 DECREF。对象本身没有语义变化,但对象头部的计数字段发生写入。
在单核或低并发程序中,这类写入通常表现为微小开销。多核程序、多个解释器并行运行、C 扩展频繁跨边界传递对象时,成本会被放大。两个 CPU core 同时访问同一个热门对象时,refcount 写入会让缓存行在 core 之间反复转移。GIL 限制同一解释器内 Python 字节码并行执行,但 C 代码、子解释器方向和未来 free-threaded build 仍会把共享对象写入成本推到前台。
immortal object 的优化点很窄:让高频共享对象的引用计数更新变成 no-op。no-op 在这里表示识别出 immortal 状态后不再写入 refcount。这样,None、True、False、部分解释器启动对象或内部共享对象在大量传递时,可以减少对象头部写流量。
这个优化不等同于“所有 Python 对象都更快”。普通 heap object 的 refcount 仍然是生命周期判断依据,临时对象创建、容器扩容、属性查找、函数调用、解释器分派等成本仍然存在。immortal object 只切中一类成本:热门共享对象上的反复 refcount 写入。分析性能时,应先确认程序是否大量触达这类对象,再判断 immortal object 是否处在关键路径上。
35.3 GC bypass
GC bypass 在本章中指部分 immortal/static 对象脱离普通“引用计数归零触发释放”和部分 GC 元数据更新路径。CPython 同时使用引用计数和循环垃圾回收。引用计数负责绝大多数对象的即时释放;循环 GC 负责处理容器之间的环状引用。immortal object 改变的是对象能否通过普通 refcount 下降到 0 被释放,以及某些 GC 相关头部状态是否仍需要按普通对象修改。
普通 heap object 的释放路径比较直接:当最后一个强引用离开,Py_DECREF() 把 refcount 减到 0,运行时调用类型的 deallocator,并递归释放它持有的对象。容器对象如果参与循环 GC,还会带有 GC header,用于跟踪、遍历和清理循环引用。这里有两个状态来源:对象头部的 refcount,GC 系统维护的跟踪状态。
immortal object 的路径不同。它的 refcount 保持在特殊值,普通 DECREF 不会把它推到 0。对于静态分配的内置对象,生命周期通常与解释器或进程级运行期绑定。对于解释器初始化后被标记为 immortal 的内部对象,清理责任转移到解释器最终化阶段或明确的内部清理流程。
这里需要区分三个层级。第一,Python 语言层的可达性分析仍然成立:一个普通列表、字典或用户对象被引用图持有时就存活。第二,CPython refcount 对普通对象仍然负责即时释放。第三,immortal/static 对象进入特殊路径,读源码时应从对象分配方式、类型定义、是否 GC-tracked、是否被标记 immortal 共同判断清理方式。
因此,看到一个对象没有按普通 refcount 下降路径释放时,结论不能直接写成“GC 漏掉了它”。稳定判断顺序是:先确认对象是否为静态对象或解释器全局对象,再确认它是否有 immortal refcount,然后确认它是否参与循环 GC,最后再看最终化阶段是否有专门清理。这个顺序能把内存泄漏、静态生命周期和 intentional immortality 分开。
35.4 runtime optimization
runtime optimization 的核心是把解释器反复使用的对象从普通生命周期管理成本中移出。CPython 启动后会准备大量基础对象:None、True、False、内置类型对象、常见异常类型、interned string、运行时内部表项等。这些对象被解释器、标准库、C API 和用户代码频繁访问。它们的共同特征是:语义上长期稳定,访问频率高,写入 refcount 的收益很低。
对这类对象,普通 refcount 的“每次引用都写一次”的策略会产生不成比例的成本。None 被传递一百万次,程序并不需要通过一百万次增减来证明它仍然存在。解释器已经知道它是全局单例,并且它的释放时机由 runtime 生命周期控制。immortal object 把这类对象的生命周期知识显式编码到 refcount 状态里。
这也是 PEP 683 把功能定位为 CPython 内部实现的原因。Python 用户代码不需要、也不应依赖某个对象是否被标记为 immortal。今天某个 interned string、静态类型或启动对象被优化为 immortal,后续版本可以调整具体集合。对 Python 代码来说,稳定规则仍是语言语义;对 CPython 源码阅读来说,immortal 状态是解释性能、共享和清理路径的实现证据。
运行时优化还涉及 C API 兼容。许多扩展模块通过引用计数协议管理对象。PEP 683 的策略是让普通 Py_INCREF()、Py_DECREF() 识别 immortal refcount 并保持 no-op;稳定 ABI 或旧扩展如果直接修改 ob_refcnt,会削弱优化效果,也可能触发兼容边界。Python 3.12 之后的文档和发布说明把这些变化放在 C API 语境中,是因为风险主要出现在 C 扩展和 CPython 内部接口上。
读者以后看到 _Py_IMMORTAL_REFCNT、_Py_IsImmortal、静态类型初始化或 interned unicode 状态时,应把它们归入 runtime optimization 证据链:对象长期存在,refcount 写入成本高,解释器选择用特殊状态减少写入,并把清理责任放到普通对象释放路径之外。
35.5 copy-on-write
copy-on-write(写时复制)描述的是 fork 或类似内存共享机制中的页面复制规则。父进程准备好内存状态后 fork 出子进程,操作系统会让父子进程先共享物理内存页。只要某一方写入共享页,内核就为写入方复制该页。这样,读取共享数据成本低,写入共享数据会产生额外内存占用。
Python Web 服务常用 pre-fork 模型:主进程完成解释器启动、模块导入、配置加载和部分缓存构建,再 fork 出多个 worker。这个模型希望子进程尽量共享父进程已经准备好的只读内存页。问题在于,CPython 传统 refcount 会把许多“语义只读”的对象页变成“运行时会写”的页。
继续看 None。worker 处理请求时大量传递 None,每次 INCREF/DECREF 都可能写入 None 所在页。如果这个页来自 fork 前的父进程,写入会触发 copy-on-write。对象值没有改变,页面却因为 refcount 改变而被复制。高频全局对象、静态类型对象和 interned 字符串都可能参与类似成本。
immortal object 通过减少 refcount 写入来降低这类页面复制。对象处于 immortal 状态时,普通引用计数路径识别出特殊值并保持不写对象头部。这样,fork 后 worker 访问这些对象时,更可能继续共享父进程页面。PEP 683 明确把减少 copy-on-write 列为动机之一,尤其面向大规模 pre-fork 部署。
这个结论有清晰边界。用户代码写入普通列表、字典、实例属性或缓存结构时,copy-on-write 仍会发生。immortal object 只减少特定对象头部因 refcount 更新产生的写入。分析 pre-fork 内存占用时,应按对象类型区分:静态共享对象的 refcount 写入、普通 heap object 的真实数据写入、容器内容变更、扩展模块内部缓存写入,它们对应不同页面复制来源。
35.6 object sharing
object sharing 关注多个执行上下文能否引用同一个对象。CPython 的多解释器方向希望一个进程内存在多个 interpreter state。每个解释器有自己的模块表、线程状态、异常状态和部分 runtime 数据。共享对象如果含有会被多个解释器同时修改的字段,就需要锁、原子操作或复制策略。
传统 refcount 让共享变得困难。即使对象语义不可变,只要 ob_refcnt 会被两个解释器同时写入,这个对象就是运行时可变对象。共享 None、True、静态类型对象或 interned string 时,两个解释器都可能对同一个对象执行 INCREF/DECREF。没有统一 GIL 保护时,这些写入需要同步;使用每解释器一把 GIL时,共享对象 refcount 仍然跨解释器竞争。
immortal object 给共享对象增加一个明确条件:对象的运行时状态在普通引用路径中保持不变。refcount 不再被增减后,共享 None 这类对象就不需要围绕 refcount 写入建立同步。对象仍需满足语义不可变、内部状态稳定、类型行为安全等条件;immortal refcount 只解决对象头部计数写入这一项。
这个区别对源码阅读很有用。看到 CPython 把某个全局对象迁移到 interpreter state,通常说明它带有解释器私有状态。看到某个静态对象被设计为 immortal,则说明它适合跨上下文共享,或者至少可以减少共享时的 refcount 写入压力。二者都服务隔离和并行,但策略相反:前者通过复制或分区降低共享,后者通过固定状态让共享更安全。
可复用判断顺序是:先判断对象是否可变,再判断它是否持有解释器私有状态,再判断它的 refcount 是否会被跨上下文写入,最后判断是否存在 immortal 标记或专门共享策略。这个顺序能解释为什么同样是“全局对象”,有的对象会被移入 per-interpreter state,有的对象会被 immortal 化。
35.7 no-GIL preparation
no-GIL preparation 指 immortal object 对 free-threaded CPython 方向的支撑作用。PEP 703 提出让 GIL 在 CPython 中成为可选构建方向。没有全局 GIL 后,多个线程可以同时执行 Python 代码,运行时必须重新处理对象引用计数、容器同步、内存分配、GC 和 C API 兼容等问题。
引用计数是 no-GIL 方案中的高压区域。普通对象仍需要正确管理生命周期,多个线程同时持有和释放对象时,refcount 更新需要原子操作或其它同步策略。原子写比普通写更昂贵;如果对象是 None、True 这类所有线程都高频访问的全局对象,原子 refcount 会形成热点。
immortal object 的作用是把一部分热点对象从原子引用计数压力中移出。对象被识别为 immortal 后,INCREF/DECREF 可以保持 no-op,线程无需围绕该对象的 refcount 执行原子增减。这不会解决所有 no-GIL 问题,但它减少了全局共享单例、静态对象和内部常量上的同步需求。
这里应区分三个结论。第一,immortal object 是 no-GIL 方向的基础配套之一。第二,no-GIL 还需要对象锁、容器并发策略、内存分配器调整、GC 协调、C 扩展迁移等机制。第三,普通用户对象仍然需要常规生命周期管理,用户把一个可变对象放到多线程共享结构中时,immortal object 不会替它提供业务级线程安全。
因此,阅读 free-threaded CPython 相关材料时,可以把 immortal object 放在“减少共享对象 refcount 写入”的位置。它回答的问题是:哪些对象可以通过固定 refcount 从并发写热点中移走。它不回答的问题是:可变容器如何并发修改、C 扩展如何声明线程安全、对象析构如何排序。这些属于 no-GIL 方案的其它层级。
35.8 immortal refcount
immortal refcount 是 CPython 内部的约定值。PEP 683 的基本策略是:对象 refcount 等于某个特殊值时,运行时把它视为 immortal。这个值在 CPython 内部以 _Py_IMMORTAL_REFCNT 这类符号表达,检测可通过内部 helper 完成。读者需要掌握的是语义约定,不需要依赖具体数字。
普通 refcount 是一个近似“当前强引用数量”的运行时计数。这里说“近似”,是因为 sys.getrefcount() 自身会临时增加引用,C API 借用引用和新引用也会影响观察方式。immortal refcount 的语义更特殊:它是状态标记。看到一个极大的 refcount 值时,稳定结论是“这是 CPython 内部标记该对象为 immortal 的方式”,而不是“当前有这么多个 Python 引用”。
INCREF/DECREF 路径的关键分支可以用简化伪代码表示。下面不是 CPython 源码,只表达决策结构。
// Simplified pseudocode, not CPython source.
void incref(PyObject *obj) {
if (is_immortal(obj)) {
return;
}
obj->ob_refcnt += 1;
}
void decref(PyObject *obj) {
if (is_immortal(obj)) {
return;
}
obj->ob_refcnt -= 1;
if (obj->ob_refcnt == 0) {
dealloc(obj);
}
}
这段伪代码展示了 immortal refcount 的全部工程含义:它把生命周期判断从普通对象路径中分流出去。普通对象仍按 refcount 管理;immortal object 在引用计数更新入口被识别并返回;释放责任移动到静态生命周期或 runtime finalization。
C 扩展代码的安全判断也应基于这个分支。扩展模块应使用公开引用管理 API,让 CPython 版本自行处理 immortal 细节。直接读取、写入或编码 ob_refcnt 的具体值,会把代码绑定到 CPython 私有实现上。旧稳定 ABI 扩展如果直接修改 refcount,可能削弱 immortal object 的性能收益;这属于兼容边界,而不是 Python 层语义变化。
本章可迁移的检查顺序可以固定为六步:先看对象是否为 CPython 内置单例、静态类型或内部共享对象;再看它是否处在 CPython 3.12+ 的版本边界;再看它是否有特殊 refcount;再看 INCREF/DECREF 是否仍写对象头部;再看是否参与 GC 或最终化路径;最后把它放回 cache line、copy-on-write、object sharing 或 no-GIL 的成本模型中判断影响。
最小自检任务
阅读下面代码,不需要运行。假设解释器是 CPython 3.12+,并且 None 被实现为 immortal object,Token() 创建普通 heap object。请判断 shared 和 token 在引用计数、释放路径、fork 后共享页、no-GIL 原子写压力上分别有什么差异。
class Token:
pass
def build_values(flag: bool):
shared = None
token = Token()
values = [shared, token]
if flag:
return values
return [shared]
答案要点
shared 绑定的是 None。在 CPython 3.12+ 的假设下,它属于解释器管理的 immortal object。函数局部变量、列表元素和返回值路径会持有它,但普通 INCREF/DECREF 识别 immortal refcount 后保持 no-op;它的 refcount 不用于统计真实引用数量,也不会通过普通 DECREF 到 0 的路径释放。
token 绑定的是 Token() 创建的普通 heap object。它的生命周期取决于当前引用图。values 持有它时对象存活;返回 values 时调用方继续持有它;返回 [shared] 时,局部 values 离开后其中的 token 引用被释放,若没有其它引用,普通 refcount 路径会走向析构。
fork 后共享页方面,shared 的对象头部因 immortal refcount 少了普通引用计数写入,更容易保持父子进程共享页面。token 是函数运行期创建的普通对象,通常出现在 worker 自己的堆分配中;它的创建、引用变化和释放会写入对应内存区域。
no-GIL 原子写压力方面,shared 这类全局高频对象可以通过 immortal no-op 减少 refcount 原子增减热点。token 仍需要普通生命周期管理;如果它被多个线程共享,运行时仍要保证引用计数和对象状态安全。最终结论是:immortal object 优化共享单例和静态对象的运行时写入成本,普通 heap object 仍按引用图和对象生命周期分析。
本章知识点总结
- 主问题:immortal object 解释的是 CPython 对高频长期对象的引用计数写入成本控制。
- 对象边界:Python 层 identity 和 CPython 对象头部写入策略属于两个分析层级。
- 特殊计数:immortal refcount 是状态标记,不表示真实 Python 引用数量。
- 更新路径:普通 INCREF/DECREF 识别 immortal 状态后保持 no-op。
- 缓存压力:热门对象的 refcount 写入会制造缓存行一致性成本,immortal object 能减少这类写流量。
- 释放路径:immortal object 不通过普通 refcount 到 0 路径释放,清理责任落到静态生命周期或 runtime finalization。
- GC 边界:分析内存行为时要同时看对象是否静态、是否 immortal、是否 GC-tracked、是否有专门清理流程。
- 运行时优化:
None、True、False、部分静态类型和内部共享对象是典型优化对象,具体集合属于 CPython 私有实现细节。 - 写时复制:pre-fork 模型中,减少共享对象 refcount 写入可以降低 copy-on-write 页面复制。
- 共享对象:多解释器共享对象需要稳定运行时状态,immortal refcount 消除了对象头部计数写入这一类竞争。
- no-GIL 支撑:free-threaded CPython 需要控制共享对象上的原子写压力,immortal object 是其中一个基础配套。
- 扩展边界:C 扩展应通过引用管理 API 处理对象生命周期,直接依赖
ob_refcnt数字会绑定 CPython 私有实现。 - 判断顺序:先看对象类别和版本边界,再看 refcount 状态、更新路径、GC 参与方式和并发共享成本。