Python 基础体系 · 第 34/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 内存与垃圾回收:引用计数、循环 GC、分代与诊断

在讨论 Python 内存时,首先要区分三个层次:

  1. 对象是否仍然可达:程序中是否还有路径能够访问该对象。
  2. 对象是否已经释放:对象占用的 Python 堆内存是否已经进入释放流程。
  3. 进程是否把内存归还给操作系统:即使对象释放了,内存分配器也可能暂时保留这部分内存供后续 Python 对象复用。

“对象已经不可用”“对象已经被垃圾回收”“进程 RSS 已经下降”不是同一个事件。Python 的引用计数和循环垃圾回收主要解决第 2 个问题;内存分配器是否把空间归还操作系统,则是更低一层的实现问题。

下文讨论的是 CPython 3.14,并特别说明 3.14 小版本之间的差异。Python 语言规范并不要求所有 Python 实现都使用引用计数;引用计数、gc 的代际策略以及许多对象布局细节,主要是 CPython 的实现行为。CPython 当前使用引用计数,并辅以延迟检测循环引用的机制。(docs.python.org)

一、对象布局:对象不仅有“值”

1. Python 对象的基本组成

在 CPython 中,几乎所有 Python 对象都位于堆上。一个对象至少需要携带两类运行时信息:

  • 它的类型;
  • 它的引用计数。

在 C API 中,所有对象都可以抽象为 PyObject *。对象的具体类型决定它如何解释自身的内存,以及如何执行析构、遍历引用和清除引用等操作。CPython 官方文档明确指出,即使整数对象也具有类型和引用计数。(docs.python.org)

可以把一个对象抽象成:

对象地址
┌──────────────────────────────┐
│ 类型指针                     │ ──> int、list、MyClass ...
│ 引用计数                     │
│ GC 相关信息(若被 GC 管理)  │
│ 类型自身的数据               │
│ 对其他对象的引用             │ ──> 字典、列表、属性等
└──────────────────────────────┘

这不是 Python 语言层面的可见结构,而是理解 CPython 行为的模型。对象的“值”只是其中一部分。例如,一个用户自定义对象通常还可能包含:

  • 实例字典 __dict__
  • 弱引用列表;
  • 类型指针;
  • GC 跟踪所需的附加信息;
  • 用户属性所引用的其他对象。

因此,sys.getsizeof() 返回的是对象本身的浅层大小,而不是对象图的总大小。它只计算直接归属于该对象的内存,不递归计算对象引用的内容;如果对象由垃圾回收器管理,返回值还会包含 GC 相关开销。(docs.python.org)

import sys

numbers = [1, 2, 3]
nested = [numbers, numbers]

print(sys.getsizeof(numbers))
print(sys.getsizeof(nested))

这里的 nested 只包含两个指针槽位以及列表自身的管理结构;它不会把 numbers 的大小重复计算进去。更重要的是,numbers 被引用了两次,但内存中仍然只有一个列表对象:

nested ──┬──> numbers
         └──> numbers

所以,下面两个问题必须分开:

  • sys.getsizeof(nested):这个列表容器自身多大?
  • nested 及其所有子对象总共占用多少内存?

后者需要遍历对象图、去重并处理共享引用,不能简单地把每个对象的 getsizeof() 相加。

2. __dict____slots__ 与对象布局

普通用户类的实例通常支持动态属性:

class User:
    pass

u = User()
u.name = "Ada"
u.age = 37

这些属性通常存储在实例的 __dict__ 中。实例对象本身和实例字典是两个对象:

u ──> User 实例 ──> __dict__ ──> "name"、"Ada"、"age"、37

__slots__ 可以改变实例布局:

class CompactUser:
    __slots__ = ("name", "age")

v = CompactUser()
v.name = "Ada"
v.age = 37

在不声明 __dict__ 的情况下,实例不再自动拥有普通的实例字典;属性可以放入固定的槽位中。这通常减少每个实例的布局开销,但也带来限制:

  • 不能随意添加未声明的属性;
  • 实例默认不能被弱引用,除非显式加入 "__weakref__"
  • 多重继承时可能出现槽位冲突;
  • __slots__ 不会递归压缩属性指向的对象;
  • 如果父类仍然有 __dict__,子类声明 __slots__ 也不能完全消除字典。

例如:

import weakref

class WithoutWeakref:
    __slots__ = ("value",)

class WithWeakref:
    __slots__ = ("value", "__weakref__")

a = WithoutWeakref()
b = WithWeakref()

try:
    weakref.ref(a)
except TypeError as exc:
    print(type(exc).__name__)

print(weakref.ref(b))

输出会类似:

TypeError
<weakref at 0x...; to 'WithWeakref' at 0x...>

这说明对象布局不仅影响内存大小,也影响生命周期观察工具和可用协议。weakref 不会增加被观察对象的强引用,因此适合验证对象何时真正失去可达性;但能否创建弱引用,取决于类型布局。

二、引用计数:对象何时可以立即释放

1. 引用计数的定义

对某个对象 o,记:

RC(o)=当前持有 o 的强引用数量RC(o) = \text{当前持有 } o \text{ 的强引用数量}

强引用可能来自:

  • 另一个容器对象;
  • 模块全局变量;
  • 函数局部变量;
  • 调用栈中的参数;
  • C 扩展中的强引用指针。

当最后一个强引用被释放时:

RC(o)=0RC(o) = 0

CPython 通常立即进入对象的释放流程。对于包含其他对象引用的复合对象,释放它还会递减其子对象的引用计数,于是释放可能沿着引用边级联传播。CPython C API 使用 Py_INCREF() 增加强引用,使用 Py_DECREF() 释放强引用;当计数降为零时,类型对应的释放函数会被调用。(docs.python.org)

在 Python 代码中,下面的赋值并不是复制对象:

items = []
alias = items

状态变化可以表示为:

步骤 1:
items ──> list

步骤 2:
items ──┬──> list
alias  ─┘

此时 itemsalias 是两个名称,但它们指向同一个对象。

import sys

items = []
print(sys.getrefcount(items))

alias = items
print(sys.getrefcount(items))

del alias
print(sys.getrefcount(items))

sys.getrefcount() 的结果通常比直觉多 1,因为调用函数时,参数本身会暂时形成一个引用。此外,某些 immortal 对象具有非常大的特殊引用计数,因此不能把返回值当作精确的“真实引用数量”;官方建议除了判断 0 或 1 外,不要依赖其精确数值。(docs.python.org)

2. del 删除的是名称,不是对象

下面的代码中:

x = []
y = x

del x

del x 删除的是名称 x 与对象之间的一条引用边:

删除前:
x ──┬──> list
y ──┘

删除 x 后:
y ──> list

对象不会因为某个名称被删除就必然销毁。只有当所有强引用都消失时,引用计数才可能变为零:

x = []
y = x

del x
del y

在普通 CPython 构建中,第二个 del 通常会使列表立即进入释放流程。但这不等于 Python 语言对所有实现都保证对象在此刻完成销毁,也不等于底层分配器一定立刻把内存交还给操作系统。

3. 引用计数的局限:它只会沿着“减法”传播

引用计数适合处理无环对象图。例如:

root ──> A ──> B ──> C

root 被删除时:

  1. rootA 的引用消失,RC(A) 减一;
  2. RC(A) 变为零,释放 A
  3. AB 的引用消失,RC(B) 减一;
  4. RC(B) 变为零,释放 B
  5. 继续处理 C

但是,引用计数无法仅凭“计数是否为零”识别孤立的环。

三、循环引用为何需要 GC

1. 一个最小循环

a = []
b = []

a.append(b)
b.append(a)

引用图为:

a ──> list A ──> list B <── b
                  │         │
                  └─────────┘

更准确地写:

变量 a ──> A
变量 b ──> B
A ──> B
B ──> A

当外部变量被删除:

del a
del b

外部引用消失,但内部引用仍然存在:

A ──> B
B ──> A

于是:

RC(A)1RC(A) \ge 1

因为 B 仍引用 A;同理:

RC(B)1RC(B) \ge 1

所以引用计数永远不会归零,单纯依靠引用计数无法释放它们。

这就是循环垃圾回收器存在的根本原因:它不只计算“有多少引用”,还要判断这些引用是否来自对象集合内部。

2. 从对象图推导循环垃圾

设候选对象集合为:

S={A,B}S = \{A, B\}

对集合中的每个对象,定义:

  • in_total(o):对象收到的全部引用数;
  • in_internal(o):来自集合 S 内其他对象的引用数;
  • in_external(o):来自集合外部的引用数。

则:

in_external(o)=in_total(o)in_internal(o)in\_external(o) = in\_total(o) - in\_internal(o)

对于上面的循环:

A 收到:
- B 的 1 条内部引用
- 外部引用 0 条

B 收到:
- A 的 1 条内部引用
- 外部引用 0 条

因此:

in_external(A)=11=0in\_external(A) = 1 - 1 = 0

in_external(B)=11=0in\_external(B) = 1 - 1 = 0

如果一个对象集合中所有对象都没有外部引用,那么这个集合就是“从程序根不可达”的候选垃圾。GC 可以通过遍历容器之间的引用关系,计算这种内部引用,并尝试清除对象之间的链接。

这是一种帮助理解的抽象模型。真实 CPython GC 需要遵循对象类型提供的遍历和清除协议,而不是简单扫描任意内存。支持循环 GC 的容器类型需要提供遍历引用的 tp_traverse;可变容器通常还需要提供能够断开内部引用的 tp_clear。(docs.python.org)

3. GC 的工作不是“找所有没用的变量”

GC 不能直接判断一个对象是否符合业务语义上的“没用”。它判断的是对象图上的可达性:

解释器根
├── 模块全局变量
├── 当前栈帧
├── 活跃线程
├── 注册表和缓存
└── 其他运行时根

只要仍存在从根到对象的引用路径,对象就是可达的。例如:

cache = {}

def build():
    value = bytearray(10_000_000)
    cache["large"] = value

即使 build() 返回后局部变量消失,cache["large"] 仍然持有对象。此时这不是 GC 失效,而是程序仍然保留着一条有效引用路径。

相反,循环中的对象可能每个都拥有非零引用计数,但整个循环从根不可达。这正是引用计数和可达性分析之间的差别。

四、分代收集与阈值

1. 为什么要分代

分代 GC 的假设是:新创建的对象更可能很快死亡,长期存活的对象更可能继续存活。

因此,CPython 把被 GC 管理的对象按存活次数分组:

  • 第 0 代:新对象;
  • 第 1 代:经历过一次收集仍然存活的对象;
  • 第 2 代:更老的对象。

对象如果在一次收集中存活,就会晋升到更老的一代;第 2 代是最老的一代。年轻代收集频繁但扫描范围小,老年代收集较少但成本更高。(docs.python.org)

需要区分“所有 Python 对象”和“被 GC 跟踪的对象”。不包含对其他 Python 对象引用的原子对象,通常不需要循环 GC 支持;列表、字典、实例等容器则可能需要被跟踪。可以用以下 API 观察某个对象是否当前被跟踪:

import gc

print(gc.is_tracked(42))
print(gc.is_tracked([]))

class User:
    pass

print(gc.is_tracked(User()))

具体结果属于 CPython 实现行为,不能当作跨实现保证。gc.is_tracked() 适合诊断对象是否进入循环 GC 的观察范围,而不是用来推导所有对象的精确布局。

2. 阈值的含义

import gc

print(gc.get_threshold())
print(gc.get_count())

gc.get_threshold() 返回三个阈值:

(threshold0, threshold1, threshold2)

gc.get_count() 返回当前计数:

(count0, count1, count2)

在 CPython 的代际模型中,GC 主要跟踪自上次收集以来的对象分配与释放差值。概念上可以写成:

net=allocationsdeallocationsnet = allocations - deallocations

当:

net>threshold0net > threshold0

就会触发一次以第 0 代为起点的收集。第 0 代被检查若干次后,才会进一步检查第 1 代;threshold1 控制这种晋升检查的相对频率。第 2 代的收集策略更复杂,不能简单理解为“每 threshold2 次就完整扫描一次”。(docs.python.org)

例如:

import gc

old = gc.get_threshold()
print("old:", old)

gc.set_threshold(700, 10, 10)
print("new:", gc.get_threshold())

gc.set_threshold(*old)

这段代码修改的是自动收集触发频率,不是对象存活规则:

  • 提高 threshold0,通常意味着更少触发年轻代收集;
  • 降低 threshold0,通常意味着更频繁检查循环;
  • 阈值过低可能增加 GC 开销;
  • 阈值过高可能让循环垃圾在内存中停留更久。

threshold0 = 0 会禁用自动收集,但不会禁用引用计数的即时释放;它只是停止自动循环 GC。可以通过 gc.collect() 手动触发收集。(docs.python.org)

3. Python 3.14 的版本敏感行为

“Python 3.14 的 GC”不能忽略小版本:

  • Python 3.14.0 到 3.14.4 曾使用增量式 GC 实现;
  • 由于生产环境中出现内存压力问题,Python 3.14.5 又恢复为 Python 3.13 使用的分代 GC;
  • 在 3.14.0 到 3.14.4 中,threshold2 被忽略;
  • 在 3.14.5 起,threshold2 的行为恢复以维持原有分代 GC 行为;
  • generation=1 在 3.14.5 起重新表示中间代收集,以保持与 3.13 的行为一致。(docs.python.org)

因此,如果生产环境运行的是 Python 3.14,应至少记录完整版本:

python --version
python -VV

不能只写“我们使用 Python 3.14”,因为 3.14.2 与 3.14.7 的 GC 行为并不完全相同。

五、finalizer、__del__ 与不可回收风险

1. del 不等于调用 __del__

class Resource:
    def __del__(self):
        print("finalizer called")

obj = Resource()
del obj

del obj 做的第一件事是删除名称绑定,通常表现为减少对象的一条强引用;只有当对象进入销毁流程时,__del__() 才可能被调用。官方数据模型明确说明,del x 不会直接调用 x.__del__()。(docs.python.org)

因此,以下说法都不严谨:

del 语句就是析构
对象离开作用域就一定立即调用 __del__
__del__ 一定在解释器退出时执行

__del__() 的执行时间、执行线程和周围状态都可能不适合做复杂工作。其内部异常会被忽略,并向 sys.stderr 输出警告;如果它获取锁或调用阻塞资源,还可能造成死锁。解释器关闭阶段,全局变量和模块也可能已经被清理。(docs.python.org)

2. finalizer 遇到循环时发生什么

早期 Python 中,带有 __del__() 的循环对象可能因为无法确定安全的析构顺序而进入不可回收状态。PEP 442 改变了这一点:现代 CPython 通常可以收集带有 __del__() 的循环对象,因此它们不再因为仅仅拥有 Python 层的 __del__() 就自动进入 gc.garbage。(docs.python.org)

但这并不意味着 finalizer 没有风险。关键问题包括:

  1. finalizer 的执行顺序不应假定;
  2. 一个对象被清理后,另一个对象可能仍然持有它的引用;
  3. finalizer 可以执行任意 Python 代码;
  4. finalizer 可以让对象“复活”;
  5. C 扩展如果没有正确实现 GC 协议,仍可能制造真正不可回收的对象。

3. 对象复活

对象复活是指 finalizer 在对象即将销毁时,又把自己保存到外部可达位置:

import gc

resurrected = None

class Lazarus:
    def __del__(self):
        global resurrected
        resurrected = self

obj = Lazarus()
del obj

print(resurrected is not None)
print(gc.is_finalized(resurrected))

第一次删除后,__del__() 把对象保存到全局变量 resurrected,因此销毁被中止。对象重新变得可达,但它已经经历过 finalization。gc.is_finalized() 可以用于观察这一状态。CPython 文档还指出,复活对象是否再次调用 finalizer 具有实现相关行为;当前支持 GC 的对象会保留 finalized 标记,相关细节未来仍可能变化。(docs.python.org)

因此,finalizer 不适合作为普通资源管理机制。文件、锁、数据库连接、临时目录等资源需要确定性的释放时,应使用上下文管理器:

with open("data.txt", encoding="utf-8") as f:
    content = f.read()

对象垃圾回收与外部资源关闭是两件事。垃圾回收只能说明对象不再需要,不能替代业务层的 close()、事务提交或锁释放。

4. gc.garbage 的正确理解

import gc

gc.collect()
print(gc.garbage)

gc.garbage 保存的是 GC 找到但无法释放的对象。现代 Python 中,普通 Python 类即使定义了 __del__(),通常也不会因此进入该列表;列表非空时,重点应怀疑:

  • C 扩展类型存在旧式 tp_del
  • C 扩展遗漏了 Py_TPFLAGS_HAVE_GC
  • tp_traverse 没有遍历所有引用;
  • tp_clear 没有正确断开引用;
  • 调试选项 DEBUG_SAVEALL 把所有不可达对象都保存了下来。
import gc

gc.set_debug(gc.DEBUG_SAVEALL)

a = []
a.append(a)
del a

gc.collect()
print(len(gc.garbage))

gc.garbage.clear()
gc.set_debug(0)

DEBUG_SAVEALL 会改变观察结果:不可达对象不会直接释放,而是保存到 gc.garbage,因此不要在开启该选项后把 gc.garbage 的数量直接当作生产泄漏数量。官方文档特别说明,DEBUG_LEAK 包含 DEBUG_SAVEALL。(docs.python.org)

六、gc 模块:从“怀疑泄漏”到对象图证据

1. 观察收集统计

import gc

for generation, stats in enumerate(gc.get_stats()):
    print(generation, stats)

典型输出类似:

0 {'collections': 12, 'collected': 85, 'uncollectable': 0}
1 {'collections': 1, 'collected': 20, 'uncollectable': 0}
2 {'collections': 0, 'collected': 0, 'uncollectable': 0}

统计值是从解释器启动以来累计的,具体数量取决于程序启动过程和导入模块,不能预先写死。每代统计至少包含:

  • collections:该代被收集的次数;
  • collected:被成功收集的对象数量;
  • uncollectable:发现但无法收集的对象数量。(docs.python.org)

如果服务运行一段时间后:

  • collections 持续增加;
  • collected 也持续增加;
  • 但进程内存短时间上升后能稳定;

这可能只是正常的临时对象和循环对象被周期性清理。真正需要警惕的是,在相同工作负载下,存活对象集合本身持续增长。

2. 使用回调记录 GC 时延

import gc
import time

def gc_logger(phase, info):
    if phase == "start":
        gc_logger.started = time.perf_counter()
    else:
        elapsed = time.perf_counter() - gc_logger.started
        print({
            "generation": info["generation"],
            "collected": info["collected"],
            "uncollectable": info["uncollectable"],
            "seconds": round(elapsed, 6),
        })

gc_logger.started = 0.0
gc.callbacks.append(gc_logger)

try:
    for _ in range(100_000):
        value = []
        value.append(value)
    gc.collect()
finally:
    gc.callbacks.remove(gc_logger)

回调在收集开始和结束时执行。结束阶段的 info 包含代数、成功收集数量和不可收集数量。回调本身应保持轻量,不能在里面执行复杂日志格式化、网络请求或再次触发 gc.collect(),否则会把诊断代码的成本混入 GC 时延。(docs.python.org)

3. gc.get_referrers() 只能用于调试

如果已经定位到某个不应存活的对象,可以查看直接引用者:

import gc

target = []
holder = {"target": target}

refs = gc.get_referrers(target)
for ref in refs:
    print(type(ref).__name__)

但这个 API 有两个重要限制:

  • 它只查找支持 GC 的容器;
  • 返回结果可能包含正在构造中的对象,或者包含调用诊断函数过程中临时产生的引用。

官方文档建议只把 gc.get_referrers() 用于调试;如果想排除已经不可达但尚未收集的循环对象,可以先调用 gc.collect()。(docs.python.org)

诊断时常见的误区是:

refs = gc.get_referrers(target)

然后因为 refs 自己持有对某些对象的引用,又得出错误结论。对象图诊断应尽量缩短观测窗口,避免把诊断变量长期保存。

七、tracemalloc:回答“内存是在哪里分配的”

1. 它跟踪什么

tracemalloc 是 Python 标准库中的内存分配追踪工具。它记录 Python 内存分配块的 traceback,可以按文件、行号或完整调用栈统计分配,并比较两个快照之间的差异。(docs.python.org)

它适合回答:

哪些 Python 代码行分配了更多内存?
某个操作前后,哪些分配增长了?
这些分配最初来自哪条调用栈?

它不能直接回答:

为什么操作系统 RSS 没有下降?
某个 C 库内部通过 malloc 分配了多少?
显卡、数据库驱动或 mmap 映射占用了多少?

C 扩展可以通过 tracemalloc C API 把自定义分配注册到特定 domain,但未经追踪的原生内存不会自动出现在 Python 快照中。(docs.python.org)

2. 端到端快照示例

import gc
import tracemalloc

tracemalloc.start(10)

before = tracemalloc.take_snapshot()

objects = []
for i in range(10_000):
    objects.append({
        "id": i,
        "payload": "x" * 100,
    })

after = tracemalloc.take_snapshot()

for stat in after.compare_to(before, "lineno")[:5]:
    print(stat)

del objects
gc.collect()

current, peak = tracemalloc.get_traced_memory()
print("current:", current)
print("peak:", peak)

tracemalloc.stop()

这段程序的流程是:

  1. tracemalloc.start(10) 安装内存分配追踪钩子,并为每个分配最多保存 10 层栈帧;
  2. before 记录基线;
  3. objects 持有大量字典和字符串;
  4. after 记录操作后的状态;
  5. compare_to() 计算两个快照的差异;
  6. 删除 objects 并调用 gc.collect()
  7. 通过 get_traced_memory() 观察当前追踪内存和峰值。

输出中的行号和大小依赖 Python 小版本、解释器启动状态和代码位置,但通常会出现类似:

example.py:9: size=... KiB (+... KiB), count=... (+...), average=... B

size_diff 为正,表示相较旧快照存在更多仍被追踪的分配;但它不自动证明业务对象泄漏。对象可能仍被合法缓存,也可能只是快照、循环变量或异常 traceback 持有。

3. 从进程启动就追踪

如果在程序运行一段时间后才调用:

tracemalloc.start()

则启动前已经发生的分配不会出现在后续快照中。为了覆盖尽可能完整的启动过程,可以使用:

python -X tracemalloc=10 app.py

或者:

PYTHONTRACEMALLOC=10 python app.py

nframe 越大,调用栈信息越完整,但 tracemalloc 自身需要更多内存和 CPU;应通过 tracemalloc.get_tracemalloc_memory() 评估诊断开销。(docs.python.org)

4. 一个完整的泄漏定位流程

假设服务在重复处理请求时内存不断增长,可以按以下因果链验证:

进程内存增长
    │
    ├── tracemalloc 快照差异增长?
    │       │
    │       ├── 是:定位文件、行号、调用栈
    │       │       │
    │       │       └── 检查容器、缓存、全局变量、闭包和异常引用
    │       │
    │       └── 否:怀疑 C 扩展、底层分配器、mmap 或外部资源
    │
    └── gc.get_stats() 中 collected/uncollectable 异常?
            │
            ├── collected 持续增长:可能有大量循环垃圾
            └── uncollectable 增长:检查 C 扩展 GC 协议或 DEBUG_SAVEALL

这条链路的关键是不要把“内存增长”直接等同于“GC 泄漏”。tracemalloc 提供分配位置证据,gc 提供对象收集证据,两者回答不同问题。

八、常见失败表现与反例

1. 只调用 gc.collect(),但外部引用仍存在

cache = []

def load():
    data = bytearray(10_000_000)
    cache.append(data)

load()
gc.collect()

这里 gc.collect() 不会释放 data,因为 cache 仍然引用它。解决问题的操作不是增加 GC 次数,而是明确缓存的生命周期:

cache.clear()
gc.collect()

如果清空缓存后对象仍然存活,再使用 gc.get_referrers() 或快照比较查找其他引用。

2. 把弱引用误认为缓存

弱引用不保持对象存活:

import weakref

class Item:
    pass

item = Item()
reference = weakref.ref(item)

print(reference() is item)

del item

print(reference())

删除最后一个强引用后,reference() 通常返回 None。如果需要对象继续存在,就不能只保存弱引用;如果需要缓存但不希望缓存阻止回收,可以考虑 weakref.WeakValueDictionary 等弱引用容器,但这改变的是缓存语义,而不仅是内存优化。

3. 把 sys.getsizeof() 当作进程内存

import sys

data = ["x" * 1000 for _ in range(10000)]
print(sys.getsizeof(data))

这个值只表示列表容器本身的浅层大小,不包括其中字符串对象的总大小。它更不能代表:

  • Python 分配器保留的 arena;
  • C 扩展通过原生接口申请的空间;
  • 线程栈;
  • 动态库;
  • 文件映射;
  • 内核页缓存。

因此,sys.getsizeof() 适合比较单个对象布局,tracemalloc 适合定位 Python 分配来源,进程 RSS 则需要操作系统层面的工具辅助解释。关于 Python 对象大小,官方文档明确规定 getsizeof() 不递归计算被引用对象。(docs.python.org)

4. 把 __del__ 当作可靠的资源释放钩子

class Connection:
    def __del__(self):
        self.socket.close()

这个设计的问题不是“GC 一定不会调用 __del__”,而是调用时机和周围状态不适合承载确定性资源管理。更可靠的设计是:

class Connection:
    def close(self):
        if self.socket is not None:
            self.socket.close()
            self.socket = None

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        self.close()

使用:

with Connection() as conn:
    conn.send(...)

__del__ 可以作为兜底日志或尽力清理,但不应作为业务正确性的唯一保障。解释器退出时也不保证仍存活对象的 __del__() 都会被调用。(docs.python.org)

九、生产取舍:何时调整 GC,何时不要调整

调整阈值前,应先确认问题属于哪一类:

现象 更可能的问题 首先观察
大量短生命周期循环对象 GC 触发太晚或对象设计制造了循环 gc.get_stats()gc.get_count()
对象数量稳定但 RSS 上升 分配器保留、C 扩展或外部内存 tracemalloc 与进程级内存
gc.garbage 持续增加 不可收集对象或调试选项影响 gc.get_debug()、扩展类型
某次请求后对象永久存活 仍有强引用路径 gc.get_referrers()、快照差异
GC 暂停时间升高 老年代对象过多或容器图复杂 gc.callbacks、代际统计

在明确没有循环引用的特定程序中,可以临时关闭自动 GC:

import gc

gc.disable()
try:
    run_workload()
finally:
    gc.enable()

但这只适合有证据支持的场景。关闭自动 GC 后,引用计数仍会释放 RC == 0 的对象,却不会自动处理不可达循环;如果程序后来引入缓存回调、父子对象互相引用、异常 traceback 链或第三方容器类型,原先的假设可能失效。

对于 fork() 后拥有大量长期存活对象的进程,gc.freeze()gc.unfreeze() 还可以改变对象参与后续 GC 的方式,但这属于特定进程模型下的优化,不应作为普通服务的默认配置。gc.freeze() 会把当前跟踪对象移入永久代,gc.unfreeze() 再将它们放回最老代。(docs.python.org)

十、建立一条可复现的证据链

一个可靠的内存问题分析,至少应按以下顺序建立证据:

  1. 确认运行时

    python -VV
    

    记录完整 Python 版本、构建类型以及是否为 free-threaded 构建。

  2. 确认进程级现象
    记录 RSS、堆外内存、请求次数、并发度和工作负载,避免把一次性峰值误判为泄漏。

  3. 确认 Python 分配来源
    使用 -X tracemalloc=10tracemalloc.start(10),比较稳定操作前后的快照。

  4. 确认对象生命周期
    对目标对象使用 weakref.ref() 观察其是否仍然存活,并在必要时调用 gc.collect() 进行对照实验。

  5. 确认引用路径
    使用 gc.get_referrers() 查找直接引用者,但不把诊断产生的临时引用当作原始证据。

  6. 确认 GC 行为
    记录 gc.get_count()gc.get_stats()gc.garbagegc.callbacks 中的收集耗时。

  7. 排除追踪范围之外的内存
    如果 RSS 增长而 tracemalloc 没有对应增长,应转向 C 扩展、底层分配器、内存映射和外部资源检查。

引用计数解释“最后一条强引用消失后为何通常能立即释放”;循环 GC 解释“为什么内部互相引用的孤立对象仍需要额外检测”;分代和阈值解释“为什么 GC 不会每次都扫描整个对象图”;finalizer 解释“为什么对象清理不能简单等同于资源关闭”;gctracemalloc 则分别提供对象图和分配位置的证据。

掌握这条关系链后,“Python 内存泄漏”就不再是一个笼统标签,而可以被拆分为可验证的问题:对象是否仍可达、循环是否被检测、finalizer 是否改变了生命周期、GC 是否产生不可收集对象,以及增长的内存是否根本不属于 Python 对象分配。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。