3. 循环引用陷阱
说实话,循环引用这个问题,我当年刚入行时踩过一个大坑。
那是一个后台服务,跑了大概两周,内存从 200MB 一路飙到 2GB。我查了三天,最后发现罪魁祸首就是两个对象互相「抱死」了——谁也释放不了谁。嗯,今天我们就来彻底搞懂它。
3.1 什么是循环引用?
简单说,就是 A 引用了 B,B 又引用了 A。Python 的垃圾回收器(GC)靠引用计数来清理对象,但遇到这种「你中有我,我中有你」的情况,引用计数永远降不到零。
你想想看:
- A 对象被创建,引用计数 = 1
- B 对象被创建,引用计数 = 1
- A.b = B,B 的引用计数变成 2
- B.a = A,A 的引用计数变成 2
- 删除外部引用后,A 和 B 的引用计数都变成 1
- 但 1 ≠ 0,所以它们永远留在内存里
这就是典型的「内存泄漏」——对象明明没人用了,却因为互相指着,GC 拿它们没办法。
3.2 实战演示:一个活生生的泄漏案例
我在项目中遇到过类似场景:一个缓存系统里,缓存对象持有回调函数的引用,回调函数又持有缓存对象的引用。结果就是缓存越积越多,最后 OOM。
来看一个简化版:
import gc
import sys
class Parent:
def __init__(self, name):
self.name = name
self.child = None
class Child:
def __init__(self, name):
self.name = name
self.parent = None
# 创建循环引用
p = Parent("爸爸")
c = Child("儿子")
p.child = c
c.parent = p
# 删除外部引用
del p
del c
# 手动触发 GC
gc.collect()
# 检查无法回收的对象
unreachable = gc.garbage
print(f"无法回收的对象数量: {len(unreachable)}")
# 输出:无法回收的对象数量: 2
看到了吗?两个对象明明已经没有任何外部变量指向它们了,但 GC 还是回收不了。因为它们互相指着,引用计数永远不为零。
gc.garbage 在 Python 3.4+ 默认只包含带有 __del__ 方法的循环引用对象。但循环引用本身依然存在,只是 GC 能处理大部分情况。不过,如果你的对象定义了 __del__ 方法,那就真的「死透了」。
3.3 为什么循环引用这么难排查?
说实话,循环引用泄漏有个很坑爹的特点——它不会立刻爆出来。你写单元测试时,对象创建完就销毁了,根本看不出问题。但生产环境跑上几天几周,内存就像温水煮青蛙一样慢慢涨上去。
我曾经排查过一个线上问题,用 objgraph 库画出了对象引用图,才发现两个看似无关的模块之间,通过事件回调形成了循环引用。那感觉就像在迷宫里找出口,找到了才恍然大悟。
常用的排查工具:
gc.get_objects()— 列出所有被 GC 跟踪的对象objgraph.show_refs()— 可视化对象引用关系tracemalloc— 跟踪内存分配来源
3.4 weakref:打破循环的利器
那怎么解决呢?Python 提供了 weakref 模块。它的核心思想很简单:弱引用不增加引用计数。
说白了,弱引用就像「偷偷看一眼」——你知道那个对象存在,但你不「抱住」它。如果对象被其他地方释放了,弱引用会自动变成 None。
改造上面的例子:
import weakref
import gc
class Parent:
def __init__(self, name):
self.name = name
self.child = None
class Child:
def __init__(self, name):
self.name = name
self._parent_ref = None # 用弱引用存储
@property
def parent(self):
return self._parent_ref() if self._parent_ref else None
@parent.setter
def parent(self, value):
if value is not None:
self._parent_ref = weakref.ref(value)
else:
self._parent_ref = None
# 创建对象
p = Parent("爸爸")
c = Child("儿子")
p.child = c
c.parent = p
# 删除外部引用
del p
# 此时 c.parent 自动变成 None
print(c.parent) # 输出:None
关键点在于 weakref.ref(value) 创建了一个弱引用。当 p 被删除后,c._parent_ref() 返回 None,循环被自然打破。
3.5 更优雅的方式:weakref.WeakValueDictionary
除了 weakref.ref,还有几个更高级的容器:
| 容器 | 说明 | 适用场景 |
|---|---|---|
weakref.WeakValueDictionary |
值被弱引用,键被强引用 | 缓存、对象池 |
weakref.WeakKeyDictionary |
键被弱引用,值被强引用 | 给外部对象附加元数据 |
weakref.WeakSet |
元素被弱引用 | 观察者集合、事件监听器 |
weakref.finalize |
对象被回收时执行回调 | 资源清理、日志记录 |
举个例子,用 WeakValueDictionary 实现一个不会泄漏的缓存:
import weakref
class ExpensiveObject:
def __init__(self, data):
self.data = data
print(f"创建对象: {data}")
def __del__(self):
print(f"销毁对象: {self.data}")
class Cache:
def __init__(self):
self._cache = weakref.WeakValueDictionary()
def get(self, key):
return self._cache.get(key)
def set(self, key, value):
self._cache[key] = value
# 使用
cache = Cache()
obj = ExpensiveObject("大数据")
cache.set("key1", obj)
print(cache.get("key1")) # 存在
del obj # 删除外部引用
print(cache.get("key1")) # 自动变成 None
输出:
创建对象: 大数据
<__main__.ExpensiveObject object at 0x...>
销毁对象: 大数据
None
看到没?当外部引用 obj 被删除后,缓存自动失效。不需要手动清理,也不会泄漏。
3.6 核心逻辑图
下面这张图展示了循环引用的形成与 weakref 的破解方式:
3.7 避坑指南
我曾经在项目里犯过一个低级错误:用 weakref.ref 时忘了调用它。记住,weakref.ref(obj) 返回的是一个可调用对象,不是原对象本身。要获取原对象,必须调用 ref()。
ref = weakref.ref(obj)
actual_obj = ref() # 必须调用!
if actual_obj is not None:
# 对象还存在,可以安全使用
pass
另外,WeakValueDictionary 的值如果被其他地方强引用着,它不会自动清理。这个「弱」只针对字典内部——外部强引用依然有效。别搞混了。
嗯,循环引用这块其实不难,难的是意识到它的存在。很多 Python 开发者写了几年代码都没遇到过,直到某天线上服务挂了才追悔莫及。提前用 weakref 打好预防针,比事后排查要省心得多。