Chapter 34: Pymalloc Architecture
读 Python 内存曲线时,最容易误判的现象是:对象已经离开作用域,进程常驻内存仍然维持高位;短时间创建大量小对象后,id() 可能很快复用;把一个对象多放几个字段,峰值可能突然变化。本章要把这些现象放回 CPython 的分配器层级中解释,读完后应能定位一块内存大致经过哪个 allocator、判断它受到 size class 还是对象级 freelist 影响,并区分对象生命周期、block 复用、pool 保留和 arena 归还之间的边界。
贯穿本章的观察材料是一段短小对象 churn:程序反复创建 tuple、list、dict、临时 frame 和少量 bytes,随后立刻释放引用。Python 层看到的是对象创建与销毁;CPython 层看到的是 PyObject_Malloc()、PyMem_Malloc()、对象类型自己的 freelist、pymalloc pool、arena 和底层系统 allocator 之间的协作。二者观察到的时间尺度不同,因此内存 profile 中的“释放”常常表现为 allocator 内部可复用,而非操作系统层面的常驻内存立即下降。
本章默认讨论 CPython 的常规 GIL-enabled 构建。Python 3.14 的 C API memory management 文档说明,pymalloc 面向小对象,当前阈值为 512 bytes;常规构建中 PyMem 和 PyObject 域默认使用 pymalloc,free-threaded build 默认使用 mimalloc。实现细节还会随版本、编译选项和平台变化;涉及 pool 大小、size class 数量和 arena 归还策略时,本文以 CPython 3.14 的公开文档和 Include/internal/pycore_obmalloc.h 中的结构说明作为阅读边界。
下面的代码只用于建立观察对象。它不要求运行,也不把输出当作跨版本契约;它帮助后续小节反复回到同一组分配现象。
import sys
def churn(rounds: int) -> list[int]:
seen = []
for index in range(rounds):
pair = (index, index + 1)
box = [pair, {"index": index}]
payload = b"x" * 64
seen.append(id((box, payload)))
return seen
这段代码会制造多个层级的分配:tuple、list、dict、bytes 都是 Python object;list 的元素数组和 dict 的内部表属于对象内部缓冲;函数调用使用 frame;seen 保存整数地址值,使临时对象本身在每轮结束后可以被释放。后续分析的核心判断是:对象引用计数归零后,CPython 会走对象析构路径,但底层内存可能先回到对象 freelist、pymalloc pool 或系统 allocator 的缓存层。
34.1 arena / pool / block
pymalloc 的最小工作问题是:大量短生命周期小对象频繁申请几十到几百 bytes 时,直接把每次申请交给系统 malloc() 会让调用成本、元数据成本和碎片风险放大。pymalloc 用三层结构解决这个问题:arena 是从操作系统或底层 allocator 取得的大块地址空间,pool 是从 arena 中切出的固定大小区域,block 是 pool 内按某个 size class 切出的可交付小块。
在 CPython 3.14 的常规说明中,小请求按 size class 归类;请求大于 512 bytes 时进入底层 allocator 路径。pycore_obmalloc.h 的说明把小请求归到一组固定尺寸:例如 1 到 8 bytes 会按 8 bytes block 处理,9 到 16 bytes 会按 16 bytes block 处理,直到 505 到 512 bytes。64-bit 当前实现还可能使用 16-byte 对齐和更大的 pool;因此正文中更稳妥的判断方式是看“请求大小落入哪个 class”,再看该 class 当前有没有可用 pool。
arena、pool、block 的关系可以按一次小对象分配来读:
这张图只表达 pymalloc 的小对象路径。object request 可以来自 PyObject_New()、PyObject_GC_New() 或对象类型自己的分配逻辑;真正交给用户代码的仍然是一个初始化后的 Python object。pymalloc 负责交付 raw block,类型初始化负责把 block 解释成 tuple、list、dict 或其它对象布局。
pool 的核心约束是“一个 pool 服务一个 size class”。当一个 40 bytes 的请求落入 40 bytes class,它会从该 class 的 pool 中拿 block;同一个 pool 内的空闲链表也只复用这个 class 的 block。这个约束减少了分割和合并的成本,因为释放一个 block 时只需把它挂回所属 pool 的 free list。代价是内部碎片:实际对象需要 33 bytes 时,分配器可能交付 40 bytes block,差额留在 block 内部。
arena 的作用是管理更大尺度的保留与归还。一个 arena 里包含多个 pool;部分 pool 处于使用中,部分 pool 可复用,部分 arena 可能达到空闲到可归还的条件。由于只要 arena 中仍有某些 pool 被长寿命对象占用,整个 arena 就难以下放到底层 allocator,Python 层短命对象释放后,进程常驻内存仍可能维持高位。
对 churn() 来说,每轮临时 tuple、list、dict 和 bytes 的对象壳大概率落在小对象路径。它们释放后,block 先进入 pool 的可用集合;下一轮相同或相近大小的对象会优先复用这些 block。于是 CPU 侧少了系统分配调用,profile 侧则会看到“对象死亡”和“进程内存下降”之间存在明显时间差。
34.2 small object allocator
small object allocator 的入口判断是请求字节数。CPython 文档给出的外部边界很直接:pymalloc 优化小于等于 512 bytes、生命周期短的小对象;更大的请求会回落到 PyMem_RawMalloc()、PyMem_RawRealloc() 或其它底层路径。这个边界说明了一个工程事实:Python 对象的创建速度,很多时候受益于“对象壳与小缓冲走快速池化路径”。
小对象路径和大对象路径的成本来源不同。小对象路径的成本主要来自 size class 定位、pool free list 操作、对象初始化和引用计数维护;大对象路径的成本主要来自底层 allocator、系统页映射、复制或扩容,以及 allocator 自身的碎片策略。对 profile 来说,两个路径的峰值含义也不同:小对象堆积可能表现为 arena 数量增加;大对象堆积更可能直接推动系统 allocator 和 RSS 上升。
churn() 中的 payload = b"x" * 64 看起来只是 64 bytes 内容,但 bytes 对象需要对象头和数据区。小 payload 通常仍落在小对象分配范围;把 payload 改成几十 KiB 后,数据区会跨出 pymalloc 的小对象边界。这个变化会让内存曲线从“pool 内复用”为主,转向“底层 allocator 管理大块”为主。
def payload_size_path(size: int) -> bytes:
return b"x" * size
这个片段的判断顺序是:先估算对象自身和内部数据合计需要多少字节,再判断请求是否处在小对象阈值内,最后看对象类型是否还有自己的专门分配策略。size=64 和 size=65536 在 Python 层都是 bytes 创建,在 allocator 层会触发不同的成本模型。
小对象 allocator 还会影响“释放后内存为何不降”的解释。引用计数归零时,对象析构会释放对象占用的 block;这个 block 回到 pymalloc 后,仍属于当前进程的 private heap。只有当 pool、arena 的状态满足归还条件,底层 allocator 才有机会向操作系统释放更大范围的地址空间。释放对象与减少 RSS 属于两个层级的动作。
版本边界需要放在这里讲清:Python 3.13+ 的 free-threaded build 默认 allocator 可以变成 mimalloc;常规 GIL build 的 PyMem 与 PyObject 域仍以 pymalloc 为主。读一份内存 profile 前,应先确认解释器版本、构建模式、PYTHONMALLOC 配置和平台 allocator。相同 Python 代码在不同构建下可能有相似语义,却呈现不同内存曲线。
34.3 freelist
freelist 是对象类型自己维护的复用层。它的工作对象通常是“对象壳”或某种固定形态的内部结构:释放时先把壳放入类型专属缓存,下一次创建同类对象时先从缓存取出,再重新初始化字段。它位于对象语义与通用 allocator 之间,常见于 tuple、list、frame 等高频对象的实现路径,具体对象、上限和策略随 CPython 版本调整。
freelist 与 pymalloc pool 的层级不同。pymalloc 只知道“我要交付一个 N bytes 的 block”;freelist 知道“这是一个可重新初始化的 PyListObject 壳”或“这是某类 tuple 壳”。因此 freelist 命中时,甚至可以减少重新向 pymalloc 申请 block 的次数。freelist 未命中时,类型分配逻辑才继续进入 PyObject_GC_New() 或 PyObject_Malloc() 等路径。
对 churn() 来说,临时 tuple 和 list 释放后可能先被对象级 freelist 接收。下一轮创建相同类型对象时,CPython 可以复用这个壳。于是 id() 的重复出现有了一个具体解释:id() 在 CPython 中通常对应对象地址;当旧对象已经析构,地址所在 block 或对象壳被重新用于新对象,新对象的 id() 就可能等于旧值。
def possible_id_reuse() -> tuple[int, int]:
first = id(tuple(range(3)))
second = id(tuple(range(3)))
return first, second
这个例子只能说明 CPython 可能快速复用地址。first == second 既不保证发生,也不表达两个对象同时存活。判断对象身份时,只有同一时间窗口内的 is 比较才有语义价值;把历史 id() 当作长期唯一编号,会把 allocator 复用误读成对象延续。
freelist 也会让“对象生命周期”和“内存观察”出现差异。对象引用计数归零后,析构动作已经完成;但对象壳可能保留在 freelist 中等待复用。profile 工具看到的保留内存,可能是类型缓存、pymalloc pool、arena 或系统 allocator 缓存中的任意一层。排查泄漏时,必须先确认对象数量是否持续增长,再讨论底层内存是否归还。
工程上应把 freelist 视为一种对象级缓存。它提升高频创建销毁路径的吞吐,也会让微基准更不稳定:第一次运行会填充缓存,后续运行可能命中缓存;不同 Python 版本可能改变 freelist 上限;debug build、free-threaded build 或特殊环境变量也可能改变路径。对 benchmark 来说,预热、固定解释器版本和重复测量比单次耗时更可靠。
34.4 allocator hierarchy
CPython 的 allocator hierarchy 解决的是“谁有权分配哪类内存、谁负责释放”的问题。Python C API 把内存分配分成多个 domain:Raw domain 面向系统级通用缓冲,可在没有 attached thread state 的情况下工作;Mem domain 面向 Python 管理下的通用缓冲;Object domain 面向 Python object。官方文档明确要求,同一块内存的 allocate 和 free 必须使用同一 domain。
常见入口可以按责任分层:
- Raw domain:
PyMem_RawMalloc()/PyMem_RawFree()面向系统 allocator 级别的通用内存。 - Mem domain:
PyMem_Malloc()/PyMem_Free()面向 Python private heap 内的非对象缓冲。 - Object domain:
PyObject_Malloc()/PyObject_Free()面向 Python object memory。 - Type allocator:
PyObject_New()、PyObject_GC_New()、tp_alloc、tp_free等负责把 raw memory 连接到具体对象布局和 GC 约定。
这个层级对扩展模块非常直接。创建一个新的 Python 对象时,应走类型分配接口或 Object domain;给扩展模块内部算法临时申请 C buffer 时,应走 Mem domain、Raw domain 或 C library allocator,并保持成对释放。把 malloc() 得到的内存交给 PyObject_Free(),或者把 PyObject_Malloc() 得到的 block 交给 free(),会破坏 allocator 元数据和 debug hook 的判断。
/* 简化示意:真实扩展还需要错误处理和对象初始化约定 */
char *buffer = PyMem_Malloc(size);
if (buffer == NULL) {
return PyErr_NoMemory();
}
/* use buffer inside Python-managed extension code */
PyMem_Free(buffer);
这段 C 代码的关键点是 domain 成对。buffer 来自 PyMem_Malloc(),释放时回到 PyMem_Free()。如果这个 buffer 被包装成 Python object 的内部状态,类型的析构函数仍然要沿同一 domain 释放它。对象本身的壳、内部数组、外部库 buffer 可能分别属于不同 domain;析构函数需要逐项按来源释放。
allocator hook 让工具可以替换或包裹某个 domain 的分配器。Python 文档中的 PyMem_SetAllocator() 规定了初始化阶段和运行后阶段的不同约束;运行后替换通常应包裹现有 allocator。这个限制来自运行时一致性:已有 block 的元数据、debug hook、tracemalloc 记录和释放路径都假设 allocator 关系稳定。
对 churn() 这种 Python 层代码,用户看不到 allocator domain;但 CPython 内部每个对象和内部缓冲都会落到某个 domain。内存排查时,第一步应区分“Python 对象数量增长”“对象内部 buffer 增长”“C 扩展自己分配的外部内存增长”。三者可能同时表现为 RSS 上升,定位路径完全不同。
34.5 memory fragmentation
memory fragmentation 指 allocator 中可用内存存在,但形状、位置或生命周期分布导致它难以满足后续请求或难以下放给底层系统。pymalloc 中最常见的碎片来源有四类:size class 内部向上取整、pool 只服务单一 class、arena 中长寿命对象夹住短寿命对象、workload 的大小分布不断变化。
size class 带来内部碎片。请求 17 bytes 可能使用 24 bytes block;请求 505 bytes 使用 512 bytes block。差额属于当前 block,别的对象无法使用。这个损耗换来固定尺寸 block 的快速复用。对大量小对象程序来说,这个取舍通常合算;对尺寸刚好跨 class 的对象来说,微小字段变化可能被放大成明显内存差异。
pool 带来局部外部碎片。一个 pool 只服务一个 size class,因此某个 class 的 pool 即使还有空位,也无法直接服务另一个 class 的请求。当 workload 同时制造许多 size class 的对象时,不同 class 会持有各自的 pool 集合。对象死亡后,某些 pool 可以复用,某些 pool 等待更多同 class 请求,arena 层面仍可能保留。
arena 带来归还粒度问题。只要 arena 中仍有活动 pool 或被占用 block,整个 arena 就很难整体释放。长寿命对象和短寿命对象混在同一个 arena 中,会让短寿命对象释放后的空位长期留在进程内部。这是许多 Python 服务在流量峰值后 RSS 下降缓慢的常见原因之一。
long_lived = []
def mix_lifetimes(rounds: int) -> None:
for index in range(rounds):
long_lived.append((index,))
temporary = [(index, value) for value in range(200)]
del temporary
这个例子把长寿命 tuple 和短寿命 tuple 混在同一轮分配压力中。temporary 删除后,大量短命对象释放;long_lived 继续持有一部分对象。allocator 层看到的是部分 block 仍被占用,部分 block 回到 pool。只看 RSS 会得到粗糙结论;更稳妥的排查顺序是先看 Python 对象数,再看分配尺寸分布,再看 arena 与底层 allocator 行为。
碎片问题还受 workload shape 影响。批处理任务常见“集中分配、集中释放”,arena 更容易形成整体空闲;长时间运行的服务常见“持续小流量、偶发高峰、长寿命缓存”,arena 中的寿命交错更明显。相同总分配量在不同生命周期分布下,会产生完全不同的内存保留曲线。
34.6 allocation class
allocation class 是 pymalloc 对请求尺寸的归类结果。它把任意小请求映射到固定 block 尺寸,使同类 block 可以在同类 pool 内快速复用。理解 allocation class 的价值在于:对象大小的细微变化,可能改变它进入的 pool、内部碎片和缓存命中情况。
以 CPython 3.14 的内部说明为例,小请求上限为 512 bytes;size class 由对齐粒度决定。32-bit 和 64-bit、常规构建和实验构建的细节可能不同,官方文档和 sys._debugmallocstats() 更适合确认当前解释器的具体情况。正文中的稳定判断是:先把请求大小向上对齐到 class,再评估该 class 的 pool 数量和对象生命周期分布。
一个对象的“Python 层大小”通常由多部分组成:对象头、可变长度区域、指针数组、哈希表槽位、元素引用以及对象内部额外字段。sys.getsizeof() 只能返回对象自身报告的浅层大小;它不会递归统计引用到的其它对象,也不会直接告诉你 allocator class。把它作为估算工具可以,作为完整内存归因会漏掉对象图。
import sys
small_tuple = (1, 2, 3)
small_list = [1, 2, 3]
small_dict = {"a": 1, "b": 2, "c": 3}
print(sys.getsizeof(small_tuple))
print(sys.getsizeof(small_list))
print(sys.getsizeof(small_dict))
这个片段的目标是观察浅层对象大小差异。tuple 把元素引用放在对象自身的可变尾部;list 对象壳保存指向元素数组的指针,元素数组另行分配;dict 有哈希表结构。三者都可能属于“小对象”语义场景,但具体请求会落到不同 class 或不同内部缓冲路径。
allocation class 对性能和内存都有影响。相同类型对象,如果字段数量、元素数量或内部容量跨过 class 边界,pool 复用集合会改变;如果请求超过小对象阈值,路径会转向底层 allocator。对微优化来说,单个对象差几十 bytes 通常价值有限;对千万级对象、缓存对象和长期驻留对象来说,class 边界会累积成可见差异。
排查时可以按这个顺序定位:先确认对象类型和数量,再估算浅层大小,然后判断是否跨越小对象阈值,接着区分对象壳、内部数组和外部 buffer,最后用 tracemalloc、sys.getsizeof() 或 debug build 工具补充证据。工具只能回答局部问题,结论必须回到对象图和 allocator class 的关系。
34.7 object reuse
object reuse 是 CPython 内存系统的正常行为。它包括对象级 freelist 复用、pymalloc block 复用、pool 复用、arena 复用,以及底层 allocator 对释放内存的缓存。它带来的直接工程后果是:地址复用、内存曲线滞后、泄漏误判和 benchmark 波动。
id() 观察最容易受到 object reuse 影响。CPython 中 id(obj) 通常取自对象地址;对象销毁后,地址可被后续对象使用。历史 id() 相同只说明同一地址在不同时间被使用,无法证明两个对象存在身份关系。需要长期标识时,应使用业务 ID、递增序号或 uuid 这类逻辑身份。
内存调试也要把 reuse 纳入判断。真正的泄漏通常表现为对象数量、引用链或外部资源句柄持续增长;allocator reuse 表现为对象数量回落但进程内存保留。前者要追引用图、缓存容器、闭包、traceback、全局状态和 C 扩展所有权;后者要追 size class、arena、freelist、底层 allocator 和 workload 生命周期。
benchmark 中的 reuse 会改变冷启动和热路径差异。第一次运行可能分配 arena、初始化 pool、填充 freelist;后续运行可能直接从已有结构拿 block。测量对象创建成本时,应说明是否包含预热、是否固定 PYTHONMALLOC、是否使用 debug hook、是否跨进程重复。单进程内连续多次测量会越来越受到缓存状态影响。
对服务端程序,object reuse 的正向用法是把高频临时对象压在稳定生命周期内,让 allocator 复用已经建立的 pool;同时控制长寿命缓存的大小和对象形状,使 arena 更容易形成可复用空间。粗暴地在 Python 层手动调用 gc.collect(),只会推进循环垃圾回收;它无法直接命令 pymalloc 释放所有 arena,也无法清空每个对象类型的缓存策略。
回到本章贯穿材料,churn() 的正确读法是:临时对象每轮完成语义生命周期,引用计数和析构路径处理对象状态;allocator 把底层 block 留给后续相同或相近大小请求;少量地址复用和 RSS 滞后是这种设计的可观察结果。解释 Python 内存问题时,应同时回答“对象还活着吗”“block 可复用吗”“arena 可归还吗”“底层 allocator 是否下放给系统”。
最小自检任务
阅读下面的代码,判断它在 CPython 常规构建中可能产生哪些 allocator 现象。要求说明:哪些对象可能走小对象路径,为什么 id() 可能重复,为什么 gc.collect() 后 RSS 仍可能维持高位,以及排查时应先看哪类证据。
import gc
cache = []
def run_once() -> tuple[int, int]:
first = id(tuple(range(4)))
temp = [(index, index + 1) for index in range(50_000)]
cache.append({"marker": len(cache)})
del temp
gc.collect()
second = id(tuple(range(4)))
return first, second
答案要点
tuple(range(4))、小 tuple、dict 对象壳和列表中的大量临时元素通常属于小对象分配场景,是否确切落入 pymalloc 要看对象实际请求大小、版本和构建。temp 删除后,大量临时对象的引用计数会下降并进入析构路径;释放出来的对象壳或 block 可能进入 freelist、pymalloc pool 或其它缓存层。
first 和 second 可能相同,因为第一个临时 tuple 在 id() 调用结束后已经没有引用,后续 tuple 创建可以复用相同对象壳或相同地址。相同历史地址无法证明两个对象同时存在,也无法证明对象身份延续。
gc.collect() 只处理循环垃圾回收相关的不可达对象。这里大量临时对象多半已经由引用计数释放;pymalloc 仍可能保留 pool 和 arena 用于后续小对象请求。cache 还持续保存小 dict,使某些对象长期存活并可能让 arena 难以整体归还。RSS 维持高位时,排查顺序应先看 cache、全局容器和对象数量,再看对象尺寸分布、allocator class、arena 保留和底层 allocator 行为。
本章知识点总结
- 三层结构:pymalloc 用 arena、pool、block 把小对象请求转换成可复用的固定尺寸分配。
- 小对象边界:CPython 常规构建中,小于等于 512 bytes 的请求通常进入 pymalloc 快速路径,更大请求回落到底层 allocator。
- pool 约束:一个 pool 服务一个 size class,因此释放 block 后通常先服务同类尺寸请求。
- arena 粒度:arena 是更大尺度的保留与归还单位,少量长寿命对象可能让整块 arena 继续留在进程内。
- freelist 层级:freelist 属于对象类型自己的复用层,它能在进入通用 allocator 前复用对象壳。
- domain 成对:Raw、Mem、Object domain 负责不同内存类型,同一块内存必须用同一 domain 的接口分配和释放。
- 碎片来源:size class 取整、pool 专用、寿命交错和 workload shape 都会影响内存保留曲线。
- class 判断:allocation class 把请求大小映射到固定 block 尺寸,微小大小变化可能改变 pool 路径。
- 地址复用:CPython 中历史
id()可能重复,这是对象壳或 block 复用的结果。 - RSS 解读:对象释放、block 可复用、arena 可归还和系统 RSS 下降属于不同层级。
- 版本边界:free-threaded build、debug hook、
PYTHONMALLOC和平台 allocator 会改变内存路径与 profile 表现。 - 排查顺序:先看对象数量和引用链,再看尺寸分布与 allocator class,最后分析 arena、freelist 和底层 allocator。