前言
循环引用(reference cycle)指的是两个或多个对象互相引用,形成一个闭环。它之所以重要,是因为 CPython 的引用计数在这种结构下会失效:闭环内每个对象的计数都因为别人的引用而无法归零,于是它们既用不上、又释放不掉,只能靠循环垃圾回收器兜底。
一个流传很广的说法是"带__del__的对象进了循环就永远回收不了"。这句话在 Python 3.4 之前基本成立,但PEP 442 之后已经改变:3.4 起,带__del__的循环引用也能被安全地终结和回收。今天还留在gc.garbage里的,通常是某些 C 扩展类型。
本文讲清循环引用如何形成、gc如何找到并回收它、__del__的真实行为,以及用弱引用主动打破环的写法。
一、循环引用是怎么形成的
先看引用计数的失效过程。两个对象互相持有对方:
# 适用于 Python 3.8+
import sys
class Node:
def __init__(self, name):
self.name = name
self.peer = None
def __repr__(self):
return f"Node({self.name!r})"
a = Node("A")
b = Node("B")
a.peer = b # b 的引用 +1
b.peer = a # a 的引用 +1
print(sys.getrefcount(a)) # 通常在 3 左右
del a
del b # 两个名字都没了,但对象还在
print("调用 gc 前,A 和 B 已无人可用,却仍未释放")执行del a和del b后,模块级名字消失了,但a被b.peer引用、b被a.peer引用,两个计数都停留在 1,永远归不到 0,引用计数机制对此无能为力。这类对象叫不可达但仍被引用的对象组。
注意sys.getrefcount()的返回值通常比真实引用多一(传参产生临时引用),官方文档提醒不要依赖具体数值,所以上面的 3 只能当作粗略观察,不要写进判断逻辑。
二、gc如何找到并回收循环
gc模块的回收器只跟踪容器对象——列表、字典、集合、用户定义的实例等。它周期性地遍历这些对象之间的引用关系,找出"外部已无法到达"的闭环并整体释放。像int、str这类原子类型不会被跟踪,因为它们不可能持有对其他对象的引用。
# 适用于 Python 3.8+
import gc
class Node:
def __init__(self):
self.peer = None
print(gc.is_tracked(Node())) # True,用户定义了实例,会被跟踪
print(gc.is_tracked(42)) # False
print(gc.is_tracked("s")) # False
a = Node()
b = Node()
a.peer = b
b.peer = a
del a, b
print(gc.get_count()) # 当前三代计数,形如 (n0, n1, n2)
freed = gc.collect() # 强制全量回收,返回回收对象数与不可回收数之和
print("本次回收计数:", freed)gc.collect()的返回值是被回收对象数与不可回收对象数之和,官方文档明确写了这一点。它默认generation=2,也就是做一次全量回收。
分代策略的目的是降低开销:新对象进第 0 代,活过一次回收就晋升到第 1 代,再活一次进第 2 代;大多数对象朝生暮死,因此绝大多数回收只需扫描第 0 代。触发频率由gc.get_threshold()返回的三元组控制。
| 函数 | 用途 | 注意 |
|---|
gc.collect() | 强制回收 | 在解释器已处于回收过程中调用,效果未定义 |
gc.get_count() | 查看当前三代计数 | 只读 |
gc.get_threshold() | 查看回收阈值 | 默认值属实现细节 |
gc.get_objects() | 列出被跟踪对象 | 开销大,仅调试用 |
gc.get_referrers(*objs) | 反查谁引用了它 | 结果可能处于非法状态,仅调试用 |
gc.get_referrers()有一条官方警告:返回的对象状态可能是非法的,除了调试不要用于其他用途;并且它只能找到支持垃圾回收的容器,C 扩展类型如果引用了其他对象却不支持 GC,就找不到。
三、__del__与 PEP 442
__del__是终结器(finalizer),在对象被销毁前调用。它有两个不可靠之处:调用时机不确定,以及不保证一定被调用(例如解释器退出时)。
历史上(Python 3.4 之前),如果一个循环里的对象定义了__del__,回收器不敢贸然处理——因为终结器可能复活对象、又访问到已经半销毁的邻居。结果这些对象被标为"不可回收",丢进gc.garbage列表,也就是真正的泄漏。PEP 442 改变了这一点:从 Python 3.4 起,带__del__的循环引用可以被安全终结并回收,gc.garbage大多数时候应当是空的。
# 适用于 Python 3.8+
import gc
class Marker:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"终结 {self.name}")
x = Marker("X")
y = Marker("Y")
x.other = y
y.other = x
del x
del y
gc.collect() # 3.4+ 下两个对象都会被正常终结
print("gc.garbage 长度:", len(gc.garbage)) # 通常是 0官方文档对gc.garbage的说明是:从 3.4 起这个列表大多数时候应当为空,除非使用了tp_del槽非空的 C 扩展类型实例。也就是说,如果发现gc.garbage长期非空,多半问题出在扩展模块,而不是纯 Python 代码。
四、用弱引用打破循环
与其依赖gc兜底,更主动的做法是一开始就不制造环。典型场景是父子互相引用:父对象需要持有子对象,子对象也需要回指父对象。把其中一个方向改成弱引用(weak reference),环就断了。
# 适用于 Python 3.8+
import gc
import weakref
class Parent:
def __init__(self, name):
self.name = name
self.children = []
def add(self, child):
self.children.append(child) # 强引用子对象
child.parent = weakref.ref(self) # 回指用弱引用
class Child:
def __init__(self, name):
self.name = name
self.parent = None # 存放 weakref 对象
def get_parent(self):
return self.parent() if self.parent is not None else None
def __repr__(self):
return f"Child({self.name!r})"
p = Parent("P")
c = Child("C")
p.add(c)
print(c.get_parent()) # Parent 实例还在时,返回对象
print(p.children) # [Child('C')]
print(gc.is_tracked(c)) # True,但父子之间已无强引用环弱引用不会增加目标对象的引用计数,因此当父对象被外部释放后,WeakValueDictionary里的条目会自动消失,Child.get_parent()会返回None。常用的弱引用容器还有weakref.WeakValueDictionary、weakref.WeakKeyDictionary、weakref.WeakSet,以及用于注册清理回调的weakref.finalize。
注意:使用__slots__的类默认不支持弱引用,需要把"__weakref__"加进__slots__才会生成__weakref__描述符。
五、诊断思路
排查"内存不降",可以按下面的顺序来:
# 适用于 Python 3.8+
import gc
gc.set_debug(gc.DEBUG_LEAK) # 包含 DEBUG_SAVEALL,被回收的对象会留在 gc.garbage
# ... 复现问题的操作 ...
gc.collect()
print("疑似泄漏对象数:", len(gc.garbage))
gc.set_debug(0) # 关闭调试标志gc.DEBUG_LEAK等于DEBUG_COLLECTABLE | DEBUG_UNCOLLECTABLE | DEBUG_SAVEALL,开启后所有不可达对象都会保留在gc.garbage里供你检查,代价是它们不会被真正释放,所以诊断完记得关掉。
| 现象 | 可能原因 | 处理方向 |
|---|
| 对象删不掉、内存不降 | 循环引用 | gc.collect()验证,随后用弱引用断环 |
gc.garbage长期非空 | C 扩展类型的tp_del | 检查扩展模块 |
| 回收频率过高 | 阈值偏低或对象创建过快 | gc.get_threshold()/gc.get_stats() |
| 关闭 GC 后泄漏 | 确有循环引用 | 恢复gc.enable()并断环 |
常见坑点
- 以为
del一定释放对象
❌
``python # 适用于 Python 3.8+ a = Node() b = Node() a.peer = b b.peer = a del a, b # 循环还在,未必释放``
✅
``python # 适用于 Python 3.8+ a.peer = None b.peer = None # 主动断开引用 del a, b``
- 用
__del__做关键清理
❌
``python # 适用于 Python 3.8+ class F: def __del__(self): self.flush() # 时机不定,可能不调用``
✅
```python # 适用于 Python 3.8+ class F: def close(self): self.flush()
def __enter__(self): return self
def __exit__(self, *exc): self.close() ```
- 在循环里两边都用强引用
❌
``python # 适用于 Python 3.8+ class Child: def __init__(self, parent): self.parent = parent # 强引用父对象``
✅
```python # 适用于 Python 3.8+ import weakref
class Child: def __init__(self, parent): self.parent = weakref.ref(parent) ```
- 在解释器退出时依赖
__del__输出
❌
``python # 适用于 Python 3.8+ class A: def __del__(self): print("bye") # 退出阶段的调用顺序不保证``
✅
``python # 适用于 Python 3.8+ class A: def cleanup(self): print("bye")``
- 把
gc.collect()放进热路径
❌
``python # 适用于 Python 3.8+ for item in huge_list: process(item) gc.collect() # 每次都全量回收,开销很大``
✅
``python # 适用于 Python 3.8+ for item in huge_list: process(item) gc.collect() # 循环结束后收一次``
- 把
gc.get_referrers()的结果用于业务判断
❌
``python # 适用于 Python 3.8+ refs = gc.get_referrers(obj) if len(refs) > 1: do_something() # 结果可能处于非法状态``
✅
``python # 适用于 Python 3.8+ # 仅用于调试打印,不做逻辑判断 print(len(gc.get_referrers(obj)))``
- 在带
__slots__的类上期待弱引用可用
❌
``python # 适用于 Python 3.8+ class C: __slots__ = ("x",) # weakref.ref(C()) 会 TypeError``
✅
``python # 适用于 Python 3.8+ class C: __slots__ = ("x", "__weakref__")``
总结
| 要点 | 结论 |
|---|
| 循环引用 | 计数永不归零,引用计数无法释放 |
gc职责 | 只回收容器对象之间的循环引用 |
gc.collect() | 强制回收,返回回收对象数加不可回收数 |
__del__ | 3.4 起(PEP 442)循环中的终结器可被处理 |
gc.garbage | 3.4 起多数时候为空,非空多为 C 扩展 |
| 弱引用 | 不增加计数,用来主动断环 |
循环引用的本质是"引用计数管不到的死角",而gc就是补这个死角的。实践中的优先级应当是:先靠设计避免制造环(父强子、子弱父),再靠gc兜底,最后才用gc.collect()在关键时刻手动干预。真正的清理工作交给with语句或显式的close(),不要指望__del__。