3、循环引用陷阱:对象互相引用导致无法回收,实战演示与weakref解决方案

说实话,循环引用这个问题,我当年刚入行时踩过一个大坑。

那是一个后台服务,跑了大概两周,内存从 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 还是回收不了。因为它们互相指着,引用计数永远不为零。

⚠️ 注意: 在 CPython 中,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,循环被自然打破。

💡 我的习惯: 在回调、观察者模式、缓存等场景中,我默认使用弱引用。比如事件监听器、信号槽、回调函数——这些地方最容易产生循环引用。提前用 weakref,能省去后面排查内存泄漏的大量时间。

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 的破解方式:

循环引用 vs weakref 解决方案 ❌ 循环引用(泄漏) Parent A 引用计数: 1 Child B 引用计数: 1 A.child = B B.parent = A 删除外部引用后 引用计数: 1 → 永远不归零 GC 无法回收! ✅ weakref 方案(安全) Parent A 引用计数: 1 Child B 引用计数: 1 A.child = B B.parent = weakref.ref(A) 删除外部引用后 A 引用计数归零 → 被回收 B.parent 自动变为 None →

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 打好预防针,比事后排查要省心得多。