Chapter 70: gcmodule.c and obmalloc.c
Python 程序里的对象生命周期通常先表现为三个可见现象:对象在最后一个强引用消失后被释放,带环的容器对象需要等一次 cyclic GC 才被清理,大量短生命周期小对象会让进程内存曲线和 id() 结果呈现复用特征。本章把这三个现象放回 CPython 的两个核心部件中解释:gcmodule.c 代表 cyclic GC 路径,obmalloc.c 代表小对象分配路径。
本章沿用 Roadmap 的 gcmodule.c 名称。读当前 CPython 源树时,循环 GC 的核心实现需要按版本核对文件名,例如 CPython main 分支中的 Python/gc.c、free-threaded 相关实现和 Garbage collector design。内存分配侧则主要看 Objects/obmalloc.c 和 Python/C API Memory Management。
读完本章,读者应能追踪一个 Python 容器对象从创建、被 GC tracking、形成引用环、被 generation 策略选中、通过 tp_traverse 判定不可达、通过 tp_clear 打断引用,到底层小对象内存被 pymalloc 回收复用的路径。这里的核心判断是:引用计数解决大多数直接释放问题,cyclic GC 只补充处理容器引用环,pymalloc 管理小对象内存块复用,三者处在不同层级。
下面这段代码作为本章贯穿材料。它同时制造了普通小对象、容器引用环、GC tracking 差异和地址复用观察点。
import gc
class Node:
def __init__(self, name):
self.name = name
self.next = None
def make_cycle():
first = Node("first")
second = Node("second")
first.next = second
second.next = first
return first
def allocate_many_lists(count):
for index in range(count):
item = [index]
del item
root = make_cycle()
print(gc.is_tracked(root))
print(gc.is_tracked(root.__dict__))
root = None
collected = gc.collect()
print(collected)
这段代码中,Node 实例和它的 __dict__ 都可能参与引用环,GC 需要跟踪它们。root = None 之后,两个 Node 实例仍然互相引用,引用计数无法把它们的计数降到零。gc.collect() 触发 cyclic GC 后,解释器才能把这组从程序根集合不可达的对象识别出来并清理。allocate_many_lists() 这种短生命周期小容器会持续触发对象分配和释放,内存块复用主要落到 obmalloc.c 负责的小对象分配器路径。
这条路径可以压缩成一个运行时分层图。图中每个节点都对应本章后续小节的判断对象。
图中的关键关系是分层关系。引用计数在对象引用变化时持续工作,cyclic GC 在候选容器集合上做图分析,pymalloc 在对象释放之后处理内存块复用。把这三层混在一起会导致错误诊断:看到 gc.collect() 返回零,不能推出没有内存复用;看到进程 RSS 未下降,也不能推出对象还活着;看到 id() 复用,也不能推出两个生命周期重叠的对象身份相同。
70.1 cyclic gc
cyclic GC 的工作对象是可能形成引用环的 container object。container object 指内部可以保存其它 Python 对象引用的对象,例如 list、dict、实例对象、frame、traceback 和扩展类型中的容器对象。CPython 的基本内存回收仍以引用计数为主;cyclic GC 的职责是识别“从外部已经不可达,但内部引用让引用计数仍大于零”的对象集合。gc 模块文档也明确把它定位为对引用计数的补充。
贯穿材料中的 first 和 second 形成了一个两节点环。root 还存在时,这个环从局部变量或全局变量可达,GC 扫描时会把它当作可达对象。root = None 之后,环内部仍然互相持有引用,所以两个对象的引用计数仍然无法自然归零。此时 cyclic GC 要回答的问题是:这组对象有没有来自候选集合外部的引用。
CPython 的循环检测可以理解为一次候选集合内的引用扣减。GC 先为候选容器准备一个临时计数,初始值来自对象真实引用计数。接着,GC 通过类型提供的 tp_traverse 遍历候选对象指向的其它对象,并把候选集合内部引用从临时计数中扣掉。扣减完成后,仍然有外部引用贡献的对象会留下正数临时计数;没有外部引用的对象会进入 tentative unreachable 路径。这个算法使用对象自身携带的 GC 头部或 GC 状态位保存临时信息,常规模型下不需要递归地在 C 栈上展开整条对象链。
对 Python 层读者来说,tp_traverse 是 cyclic GC 能看见对象内部引用的入口。内置容器类型在 C 层实现了遍历逻辑,用户定义的 Python 类实例通过实例字典、slots、类型对象等路径暴露引用关系。扩展类型如果声明支持 GC,就需要在类型标志、tp_traverse 和通常还需要的 tp_clear 上满足 C API 约束。缺少正确的 traverse 会让 GC 无法看到对象内部边,缺少正确的 clear 会让不可达环难以被打断。
销毁不可达对象时,GC 处理顺序比“发现环然后释放”更细。典型顺序包括处理弱引用、处理 legacy finalizer、调用现代 finalizer、检查对象复活、清理仍然指向不可达对象的弱引用,再调用 tp_clear 打断对象内部边。tp_clear 的作用是把容器内部指向其它对象的强引用清掉,使真实引用计数继续下降。引用计数归零后,普通 deallocator 才负责释放对象本体。
这个过程解释了一个常见调试现象:gc.collect() 返回的数字是本次收集过程中成功收集和不可收集对象的统计结果,进程内存是否马上下降取决于后续 allocator 层级和操作系统页面回收。GC 的结论是对象图可达性结论;内存是否回到系统是 allocator 和操作系统层面的结果。
70.2 generation management
generation management 用对象年龄降低每次 GC 扫描成本。这里的对象年龄指“这个被 GC 跟踪的容器对象已经在多少轮收集中存活下来”的运行时状态,并非 Python 语义里的对象年龄。多数临时容器生命周期很短,频繁扫描年轻对象能较早清理短命环;长生命周期对象数量可能很大,全量扫描会带来明显停顿和吞吐成本。
默认 GIL 构建中的 CPython GC 使用 generation 优化。新创建并被 tracking 的容器进入年轻代。一次年轻代收集后仍然可达的对象会晋升到更老的 generation,后续被扫描的频率下降。gc.get_count() 暴露当前 allocation minus deallocation 的计数状态,gc.get_threshold() 暴露触发阈值,gc.get_stats() 暴露每个 generation 的收集次数、已收集对象数和不可收集对象数。Python 3.14 系列的 generation 细节经历过文档化调整,3.14.5 文档恢复了 generation 1 的行为说明;写性能结论时应以目标解释器版本的 gc 文档和源码为准。
贯穿材料中的两个 Node 实例如果在创建后很快失去外部引用,通常会在年轻代收集中被发现。若它们被全局缓存、闭包、frame 或长生命周期容器持有并存活多轮,它们会进入更老的 generation。后续即使它们形成环,也可能等到对应 generation 被扫描时才被处理。这个延迟是 generation 策略换取吞吐和停顿控制的代价。
generation 的触发条件来自分配压力。CPython 记录自上次收集以来的分配和释放差值。差值超过 threshold0 时,年轻代收集开始;更老 generation 的收集频率由后续阈值和长期对象比例共同控制。对旧 generation 做 full collection 的成本与长生命周期对象规模相关,所以频率必须下降。否则,构建大量长期容器的程序会把全量扫描成本叠加到每一批新对象创建上。
可以用下面的观察代码理解 generation 统计项。它的目标是把对象创建、计数阈值和显式收集联系起来,输出值随解释器状态变化。
import gc
class Payload:
pass
print(gc.get_threshold())
print(gc.get_count())
items = []
for index in range(10_000):
item = Payload()
item.self = item
items.append(item)
print(gc.get_count())
print(gc.collect(0))
print(gc.get_stats())
这段代码先制造大量可被 GC tracking 的实例,再显式收集 generation 0。items 仍然持有所有对象,所以 gc.collect(0) 不会把这些对象当作垃圾释放;如果删除 items,下一轮收集才可能把这些自环对象清理掉。这个例子说明 generation 只决定扫描候选集合,最终是否释放仍由可达性决定。
free-threaded 构建需要单独标注边界。CPython 3.13 开始引入 free-threaded 构建路径,GC 数据结构和默认 allocator 行为都和默认 GIL 构建不同。官方内部文档说明 free-threaded GC 会暂停其它执行线程来完成 collection,并使用不同的数据结构;内存管理文档还说明 free-threaded 构建的默认 mem 和 object domain allocator 是 mimalloc。讨论 generation 性能时,应先固定解释器构建类型。
70.3 object tracking
object tracking 决定一个对象是否进入 cyclic GC 的候选集合。进入 tracking 的对象需要承担额外元数据和扫描成本,所以 CPython 会让可能参与环的容器对象进入 GC 管理,把普通 atomic-like 对象留给引用计数路径。int、短字符串这类对象没有内部 Python 引用边,扫描它们不能帮助发现环;列表、字典、实例对象这类对象可能保存其它对象引用,扫描它们能帮助判断对象图可达性。
gc.is_tracked(obj) 能从 Python 层观察当前对象是否被 GC 跟踪。官方文档给出的例子显示,整数和字符串通常不被 tracking,列表通常被 tracking,字典会根据内容出现优化差异。这个结果说明 tracking 是实现策略和对象内容共同决定的状态,不能把它当作语言语义的一部分。
下面的代码展示 tracking 判断应该怎样使用。它只观察当前 CPython 运行时状态,不把结果推广到所有 Python 实现。
import gc
samples = [
0,
"name",
[],
{},
{"name": "python"},
{"node": []},
]
for value in samples:
print(type(value).__name__, gc.is_tracked(value))
这个例子的关键点在字典。一个只保存 atomic-like 键和值的字典可能被优化为不 tracking;保存列表等容器引用后,字典就需要参与 GC 候选集合。类似优化也可能出现在 tuple 等容器上:某些容器创建时先被 tracking,经过一次 GC 检查后,如果确认内容无法组成引用环,就可能被 untrack,从而降低后续扫描成本。
C 扩展类型的 tracking 边界更严格。一个扩展对象如果可能保存 Python 对象引用并形成环,需要在类型上声明 GC 支持,并实现遍历内部引用的 tp_traverse。对象初始化完成并处于一致状态后调用 tracking 入口,清理或析构过程中要在合适时机 untrack。这样 GC 扫描到对象时,看到的是结构一致的引用图。
贯穿材料中的 Node 实例能形成环,是因为实例对象可以通过 __dict__ 或 slots 持有其它对象引用。root.__dict__ 中的 next 指向另一个实例,另一个实例的 __dict__ 又指回第一个实例。GC 扫描的对象是名字绑定背后的对象图。名字消失后,对象图仍然存在,tracking 让 GC 有机会把它纳入候选集合。
排查 tracking 相关问题时,可以使用一个稳定顺序:先确认对象是否可能保存 Python 对象引用;再用 gc.is_tracked() 观察当前 CPython 状态;再判断对象内容是否可能组成环;最后用 gc.get_referrers() 或 gc.get_referents() 做调试辅助。gc.get_referrers() 返回的对象可能处于构造中或调试器持有状态,结论应回到对象图和强引用路径上验证。
70.4 pymalloc
pymalloc 是 CPython 默认构建中面向小对象的分配器。它处理的对象通常小于或等于 512 字节,并以短生命周期对象为主要优化目标。Python/C API 文档说明,pymalloc 使用 arena 这种内存映射作为上层大块来源,64 位平台默认 arena 大小为 1 MiB,32 位平台默认 arena 大小为 256 KiB;超过 512 字节的请求会回退到 raw allocator 路径。
理解 pymalloc 要先区分三个 allocator domain。raw domain 面向通用原始内存,可以在没有 attached thread state 的情况下调用,并直接请求系统 allocator。mem domain 面向 Python 私有堆中的通用 buffer,需要 attached thread state。object domain 面向 Python 对象分配,也需要 attached thread state。默认 GIL 构建中,mem 和 object domain 默认使用 pymalloc;free-threaded 构建中,官方文档说明默认 allocator 是 mimalloc,并且 object domain 只能用于 Python 对象。
贯穿材料中的 Node 实例、实例字典、短列表等对象,在默认构建下经常落入 object domain 的小对象路径。对象创建时,类型的 allocator 通过 PyObject_Malloc() 一类入口请求对象内存;对象析构时,通过对应 free 入口归还内存。归还的内存通常先回到 pymalloc 内部结构,供后续同 size class 的小对象复用。这个复用解释了为什么大量小对象创建和销毁可以保持较高吞吐。
pymalloc 的核心思路是减少每个小对象都直接调用系统 allocator 的成本。系统 allocator 调用需要跨过更重的分配器路径,还可能涉及锁、元数据维护和页面级操作。pymalloc 先从系统层拿到 arena,再把 arena 切成 pool,再把 pool 切成同 size class 的 block。小对象分配时优先从对应 size class 的可用 block 中取一块;释放时把 block 还回相应 pool。
这个策略会改变性能和内存曲线的解释方式。Python 层对象已经释放,只表示对象生命周期结束;对应 block 回到 pool,只表示 CPython 可以复用这块小对象内存;arena 是否归还操作系统,还要看 arena 内的 pool 是否都空闲以及 allocator 的回收策略。进程 RSS 暂时维持高位,常见原因是 arena 仍有部分 pool 被占用,或者系统 allocator 保留页面用于后续请求。
下面的代码展示“小对象释放后地址可能复用”的观察方式。它依赖 CPython 实现策略,输出结果不构成语言规范保证。
def find_reused_id(factory, rounds=100_000):
seen = {}
for index in range(rounds):
value = factory()
address = id(value)
if address in seen:
return seen[address], index, address
seen[address] = index
del value
return None
print(find_reused_id(lambda: []))
如果函数返回了某个三元组,含义是两个生命周期不重叠的对象使用过同一个地址。id() 在 CPython 中通常与对象地址相关,所以内存块复用会表现为 id() 复用。这个现象不破坏对象 identity 语义,因为同一时刻仍然存活的两个对象不会拥有同一个 identity。
70.5 arena/pool/block
arena、pool、block 是 obmalloc.c 中理解小对象内存的三层单位。arena 是向系统 allocator 或内存映射接口申请的大块内存;pool 是 arena 内按固定粒度划出的管理单元;block 是从 pool 中按 size class 切出的具体小对象分配单元。分层后的好处是:系统调用或系统 allocator 压力集中在 arena 级,小对象高速分配集中在 block 级,pool 负责把同一 size class 的 block 管在一起。
arena 是 pymalloc 与系统内存之间的边界。Python/C API 文档说明,arena allocator 在 Windows 上使用 VirtualAlloc() 和 VirtualFree(),在可用平台上使用 mmap() 和 munmap(),否则使用 malloc() 和 free()。这说明 arena 层关心的是大块虚拟内存来源,Python 对象字段布局属于更上层的对象结构问题。
pool 是 size class 的组织边界。同一个 pool 服务同一 size class,里面的 block 大小一致。这样分配一个 56 字节左右的小对象时,allocator 可以直接定位到对应 size class 的 used pool,并从 free block 链表上摘下一块。释放时,block 回到所属 pool 的 free list。pool 的元数据记录所属 arena、size class、已使用 block 数和可用 block 链表。
block 是 Python 对象实际拿到的内存区域。对 list、dict、实例对象这类对象来说,object allocator 返回的是对象本体需要的内存;对象内部如果还要保存动态数组或表结构,可能继续通过其它 domain 或对象专属 allocator 申请额外存储。这里要区分“对象头和对象本体的内存”与“对象内部 buffer 的内存”。例如 list 对象本体和 list 内部元素指针数组可能走不同请求。
可以用一个层级图固定这三者关系。
这个图表达的是管理单位关系。arena 不知道某个 block 对应 Python 层的哪个变量名;pool 只关心 block size class 和空闲状态;block 被对象使用时,类型对象和对象头才赋予它 Python 语义。对象释放后,block 又回到 allocator 视角,成为可复用的内存单元。
arena/pool/block 还解释了内存碎片和峰值。一个 arena 中只要仍有 pool 存在活跃 block,这个 arena 通常就难以整体归还给系统。大量 size class 分布不均的小对象会留下分散的空闲 block,这些 block 可供后续同类请求复用,却未必让进程 RSS 降低。PYTHONMALLOCSTATS 可以打印 pymalloc 的统计信息,用于观察 arena、pool、block 的分布;它回答的是 allocator 内部状态问题,不直接回答对象可达性问题。
排查内存峰值时,稳定顺序是:先判断对象是否仍可达;再判断是否存在不可达环等待 GC;再判断对象释放后 block 是否回到 allocator;再判断 arena 是否具备整体归还条件;最后才看操作系统 RSS 是否下降。每一层回答的问题不同,跨层下结论会让排查方向偏离。
70.6 object reuse
object reuse 指 CPython 把已经释放对象占用过的内存或对象专属缓存再次用于后续对象。它有多个层级:pymalloc 复用小对象 block,部分内置类型维护类型专属 free list,系统 allocator 也可能保留页面。复用的目标是降低重复分配成本和提升缓存局部性,代价是内存曲线看起来会滞后于 Python 对象生命周期。
id() 复用是 object reuse 最容易被观察到的表象。CPython 中 id(obj) 通常返回对象地址;一个对象释放后,它使用过的 block 可以分配给后续对象,于是后续对象可能得到相同的 id()。这个结论带有生命周期条件:只有旧对象已经死亡并释放,地址才可能交给新对象。两个同时存活的对象仍然需要不同 identity。
对象复用也会影响泄漏排查。gc.collect() 后对象不可达集合已经清理,tracemalloc 显示 Python 分配量下降,进程 RSS 仍然高位,这种组合通常指向 allocator 或操作系统保留内存。若 gc.get_objects()、gc.get_referrers() 和业务缓存都显示对象仍被强引用持有,则问题仍在对象图层。两类问题的证据来源不同。
类型专属 free list 需要单独说明。Python 文档提到 full collection 或最高 generation collection 会清理若干内置类型维护的 free list,但具体类型和清理效果属于实现细节。某些 free list 里的条目可能因为实现策略保留。排查 float、tuple、frame 等短生命周期对象相关内存曲线时,应同时考虑 pymalloc 和类型级缓存。
贯穿材料中的 allocate_many_lists() 会持续创建短生命周期列表。每轮 item = [index] 产生一个列表对象和内部元素数组需求。del item 后,列表对象本体进入释放路径;如果没有其它引用,它的内存可被 allocator 复用。循环运行结束后,Python 层没有保存这些列表,但 allocator 层可能已经为后续列表创建准备了可用 block。
工程判断可以按四步执行。第一步,看对象图:强引用、容器引用、frame、traceback、缓存和全局变量是否仍然保留对象。第二步,看 cyclic GC:不可达环是否需要收集,finalizer 或 weakref 是否改变清理顺序。第三步,看 allocator:小对象是否落在 pymalloc size class,释放后是否只是回到 pool。第四步,看进程内存:arena 和系统 allocator 是否把页面继续保留在进程中。
这个判断顺序能把“对象仍活着”和“内存已可复用但未归还系统”分开。前者需要修对象所有权和引用路径,后者需要调 allocator 观察方式、批处理峰值、对象批量释放时机或进程生命周期策略。对于服务进程,复用常常是吞吐优化的一部分;对于一次性批处理脚本,峰值内存和退出时间可能比复用收益更值得关注。
最小自检任务
阅读下面代码,判断 first 和 second 在 root = None 之后为什么还没有立即释放;再说明 gc.collect() 后进程 RSS 仍然可能不下降的两个原因。
import gc
class Node:
def __init__(self, name):
self.name = name
self.next = None
first = Node("first")
second = Node("second")
first.next = second
second.next = first
root = first
first = None
second = None
root = None
print(gc.collect())
答案要点
first 和 second 这两个名字被清空后,两个实例仍然通过 next 字段互相持有强引用。外部名字都消失后,这个两节点对象图从程序根集合不可达,但内部引用让引用计数暂时无法归零,所以需要 cyclic GC 扫描候选容器集合。
gc.collect() 通过 container tracking、tp_traverse 和临时引用计数扣减识别这组不可达对象,再通过 clear 路径打断内部引用。内部引用被打断后,真实引用计数继续下降,对象进入普通释放路径。
进程 RSS 仍然可能不下降有两个层级原因。第一,释放后的对象内存可能回到 pymalloc 的 pool/block 结构,供后续同 size class 小对象复用。第二,arena 或系统 allocator 可能继续把页面保留在进程内,等待后续分配请求。GC 的统计结果回答对象图可达性问题,RSS 回落回答 allocator 和操作系统页面管理问题。
本章知识点总结
- 引用计数:CPython 主要依靠引用计数在最后强引用消失时释放普通对象。
- cyclic GC:cyclic GC 补充处理从外部不可达但内部仍互相引用的 container object。
- 候选集合:GC 只扫描被 tracking 的容器对象,atomic-like 对象通常由引用计数路径处理。
- traverse:
tp_traverse让 GC 看见容器内部的 Python 对象引用边。 - clear:
tp_clear在不可达集合确认后打断内部强引用,使真实引用计数继续下降。 - generation:generation 用对象存活轮次控制扫描频率,降低长期对象带来的全量扫描成本。
- 版本边界:GC generation 细节和 free-threaded 行为具有版本与构建类型差异,需要按目标解释器核对。
- tracking 状态:
gc.is_tracked()观察的是当前实现状态,不能替代对象图分析。 - pymalloc:默认 GIL 构建中的
pymalloc面向 512 字节以内短生命周期小对象。 - allocator domain:raw、mem、object domain 分别服务系统内存、Python buffer 和 Python 对象分配。
- arena:arena 是
pymalloc向系统申请的大块内存,是页面级回收判断的关键层。 - pool:pool 服务同一 size class,把 arena 内存组织成可快速分配的小对象区域。
- block:block 是小对象实际占用并可复用的分配单元。
- id 复用:生命周期不重叠的对象可能复用同一地址,所以 CPython 中可能观察到
id()复用。 - RSS 判断:对象已释放、block 已复用、arena 已归还系统是三个不同层级的结论。