17、观察者模式实现(二):使用 Python 的 weakref 避免内存泄漏

上一节我们手写了一个观察者模式,功能是跑通了。但说实话,那个版本有个挺隐蔽的坑——内存泄漏。你可能觉得 Python 有垃圾回收,怎么还会泄漏?嗯,我当年也是这么想的,直到线上服务跑了三天,内存蹭蹭往上涨,我才意识到问题的严重性。

这一节,我们就来聊聊怎么用 Python 的 weakref 模块,把这个坑填上。

问题重现:强引用带来的“幽灵”对象

先看一个简单的例子。假设我们有一个主题(Subject),它内部维护了一个观察者列表。每个观察者都是一个对象实例。

class Subject:
    def __init__(self):
        self._observers = []  # 强引用列表

    def attach(self, observer):
        self._observers.append(observer)

    def detach(self, observer):
        self._observers.remove(observer)

class Observer:
    def __init__(self, name):
        self.name = name

    def update(self, message):
        print(f"{self.name} 收到: {message}")

表面上看没问题。但你想过没有:如果观察者对象被删除了,但主题还持有它的引用,会发生什么?

我举个例子。你在一个 GUI 系统中注册了一个窗口作为观察者。用户关掉了这个窗口,按理说窗口对象应该被回收。但因为主题还强引用着它,这个窗口对象就变成了“僵尸”——内存占着,但再也用不到了。

⚠️ 注意: 这是观察者模式最经典的内存泄漏场景。主题的生命周期往往比观察者长,一旦忘记解注册,观察者就永远无法被回收。

weakref 登场:弱引用的妙用

Python 的 weakref 模块,说白了就是允许你引用一个对象,但不增加它的引用计数。当对象只剩下弱引用时,垃圾回收器会毫不犹豫地把它收走。

我个人习惯用 weakref.ref 或者 weakref.WeakSet 来管理观察者。这里我推荐 WeakSet,因为它自动处理重复添加和移除不存在的元素,省心不少。

核心思路: 把观察者列表从 list 换成 weakref.WeakSet,这样当观察者对象被外部删除时,它会自动从集合中消失。

改造后的代码实现

import weakref

class Subject:
    def __init__(self):
        # 使用 WeakSet 替代 list
        self._observers = weakref.WeakSet()

    def attach(self, observer):
        self._observers.add(observer)

    def detach(self, observer):
        # WeakSet 的 discard 不会抛异常
        self._observers.discard(observer)

    def notify(self, message):
        # 遍历时,已经死亡的对象会自动跳过
        for observer in self._observers:
            observer.update(message)

class Observer:
    def __init__(self, name):
        self.name = name

    def update(self, message):
        print(f"{self.name} 收到: {message}")

    def __del__(self):
        print(f"{self.name} 被销毁了")

注意看 __del__ 方法,我是为了演示才加的。实际项目中别依赖它,因为它的调用时机不确定。

验证效果:内存真的被释放了

我们来跑个测试:

def test():
    subject = Subject()
    obs = Observer("观察者A")
    subject.attach(obs)
    subject.notify("第一条消息")

    print("删除观察者...")
    del obs  # 手动删除观察者

    subject.notify("第二条消息")  # 此时观察者已不在列表中
    print("测试结束")

test()

输出结果:

观察者A 收到: 第一条消息
删除观察者...
观察者A 被销毁了
第二条消息
测试结束

看到了吗?del obs 之后,观察者对象立即被销毁了。第二次 notify 时,已经没有观察者了。这就是弱引用的威力——主题不再“绑架”观察者的生命周期

用 SVG 图理清关系

下面这张图展示了强引用和弱引用的区别:

强引用(内存泄漏) Subject Observer 强引用 弱引用(安全) Subject Observer 弱引用 Observer 被删除后,仍留在列表中 Observer 被删除后,自动从集合中移除

还有哪些坑?我踩过的几个

weakref 也不是银弹。我分享几个实战中遇到的坑:

  • 回调函数不能用弱引用:如果你把普通函数或 lambda 作为观察者,weakref 会报错。因为函数对象不是“可弱引用”的。解决办法是用 weakref.WeakMethod 来包装绑定方法。
  • 遍历时集合可能变化:虽然 WeakSet 在遍历时会跳过已死亡的对象,但如果你在遍历过程中添加或删除元素,还是可能出问题。我建议在 notify 时先转成列表:for obs in list(self._observers):
  • 不要依赖 __del__:我见过有人用 __del__ 来自动解注册,但 Python 不保证 __del__ 的调用时机。更稳妥的做法是在观察者的 close()dispose() 方法中显式调用 detach
💡 小技巧: 如果你需要支持普通函数作为观察者,可以用 weakref.WeakKeyDictionary 来存储函数和它的弱引用代理。不过说实话,我一般直接要求观察者必须是对象实例,这样用 WeakSet 最省事。

完整示例:带自动清理的观察者

最后,我给出一个更完整的版本,结合了 WeakSetWeakMethod

import weakref

class SafeSubject:
    def __init__(self):
        self._observers = weakref.WeakSet()

    def attach(self, observer):
        # 如果 observer 是对象,直接添加
        self._observers.add(observer)

    def attach_method(self, obj, method):
        # 如果观察者是一个绑定方法,用 WeakMethod
        self._observers.add(weakref.WeakMethod(method, self._cleanup))

    def _cleanup(self, ref):
        # 当弱引用对象被回收时,自动清理
        pass

    def notify(self, message):
        for observer in list(self._observers):
            if isinstance(observer, weakref.WeakMethod):
                method = observer()
                if method:
                    method(message)
            else:
                observer.update(message)

# 使用示例
class Button:
    def __init__(self, label):
        self.label = label

    def on_click(self, event):
        print(f"{self.label} 按钮点击: {event}")

subject = SafeSubject()
btn = Button("提交")
subject.attach(btn)
subject.notify("用户点击了提交按钮")

这个版本里,WeakMethod 确保即使观察者对象被删除,也不会留下悬空引用。不过说实话,日常开发中 WeakSet 已经够用了,WeakMethod 属于锦上添花。

总结一下

观察者模式的内存泄漏,说白了就是主题活得太久,观察者死得太晚。用 weakref 解决这个问题,是 Python 社区的标准做法。我个人建议:

  • 优先用 weakref.WeakSet 管理观察者集合
  • 如果观察者是绑定方法,考虑 weakref.WeakMethod
  • 永远不要假设观察者会主动解注册
  • notify 时做一次快照(转成 list),避免遍历时集合变动

嗯,这一节的内容就到这里。代码不多,但背后的原理值得你多琢磨几遍。下次遇到观察者模式,记得问自己一句:我的观察者,真的能被回收吗?


公众号:蓝海资料掘金营,微信deep3321