bigywater.tech
Django 系列 · 第五篇

signal.connect() 之后,receiver 什么时候会被悄悄丢弃,一个抛异常的接收者又会不会打断整条广播?

刚给自己的 bigywater 项目挂了一组 post_save/post_delete 信号做文章备份,顺手把 django.dispatch.Signal 的源码和几个反直觉行为在本机真实跑了一遍——弱引用会在什么条件下悄悄让 receiver 消失且不报任何错,同一个 receiver 被注册两次会不会真的触发两次,以及一个 receiver 抛异常时,send() 和 send_robust() 到底谁会把剩下的 receiver 跑完。

0 次调用
本机真实实测:connect() 时没留任何变量引用,receiver 立刻被回收,信号发了但一次都没触发
C 一次都没跑
本机真实实测:send() 遇到 B 抛异常直接中断,C 再也没机会执行;send_robust() 会让 C 照常跑完

真实源码:connect() 怎么做弱引用和去重

Signal 内部就是一张 receivers 列表,connect() 往里面塞元组,核心逻辑就三件事。

django/dispatch/dispatcher.py · django @ 6.0.7, L59 def connect(self, receiver, sender=None, weak=True, dispatch_uid=None): ... if dispatch_uid: lookup_key = (dispatch_uid, _make_id(sender)) else: lookup_key = (_make_id(receiver), _make_id(sender)) # 没传 dispatch_uid,就靠 id(receiver) 去重 if weak: ref = weakref.ref receiver_object = receiver if hasattr(receiver, "__self__") and hasattr(receiver, "__func__"): ref = weakref.WeakMethod # 绑定方法:弱引用 __self__,不是弱引用方法对象本身 receiver_object = receiver.__self__ receiver = ref(receiver) weakref.finalize(receiver_object, self._flag_dead_receivers) with self.lock: self._clear_dead_receivers() if not any(r_key == lookup_key for r_key, _, _, _ in self.receivers): self.receivers.append((lookup_key, receiver, sender_ref, is_async)) # 去重:key 一样就不重复添加

三个关键点:①去重靠 lookup_key——默认是 (id(receiver), id(sender)),不同的函数对象天然有不同的 id,所以"看起来一样"的两个 receiver 只要不是同一个对象,就会被当成两个不同的订阅;②默认用弱引用(weak=True),绑定方法额外做了特殊处理——弱引用的是方法所属的实例 __self__,不是方法对象本身;③weakref.finalize 只是注册一个"死亡回调",真正清理死引用的动作在 _clear_dead_receivers() 里,会在下次 connect()/disconnect()/send() 时才真正执行。

真实实测:receiver 真的没了,信号啥反应都没有

弱引用意味着:只要你自己不再持有 receiver 所在对象的任何引用,这个对象随时可能被回收——回收之后,send() 不会报错,只是安安静静地不调用它。

真实实测
sig = Signal()
diag_ref = weakref.ref(h)          # h 是某个 Handler() 实例
sig.connect(h.on_signal)           # weak=True,默认值
print('refcount:', sys.getrefcount(h))

del h
gc.collect()
print('diag_ref():', diag_ref())
responses = sig.send(sender='second-call')
print('responses:', responses)
diag_ref(): None responses: []

h 被真实回收之后,diag_ref() 变成 None,再 send() 得到的是空列表——没有异常,没有日志,什么提示都没有。这也是为什么 Django 文档特别提醒:不要用一个连局部变量都不保留的匿名函数去 connect()。

真实实测
sig = Signal()
calls = []
sig.connect(lambda sender, **kw: calls.append(sender))   # 没有任何变量保留这个 lambda 的引用
sig.send(sender='hello')
print('实际被调用次数:', len(calls))
实际被调用次数: 0

connect() 调用一结束,这个 lambda 就没有任何强引用了——弱引用甚至等不到 gc.collect(),CPython 的引用计数会立刻把它回收掉,send() 从第一次开始就找不到它。@receiver 装饰器修饰的模块级函数不会踩这个坑(函数对象被模块的全局命名空间一直引用着),但如果是在某个方法内部临时 connect() 一个局部定义的闭包或 lambda,就是这个下场。

连 send() 的返回值都不能乱存:responses 列表里装的是真实重建出来的绑定方法对象,它本身握着 __self__ 的强引用——如果你把某次 send() 的返回值长期存在全局变量里,反而会让本该被弱引用放生的对象一直活下去,行为完全反过来。

真实实测:同一个 receiver 被注册两次,信号触发两次

上一节的 lookup_key 默认用 id(receiver) 去重——如果同一份 signals.py 代码被 Python 当成两个不同模块各自执行一次(比如一次用绝对路径 import article.signals,一次因为某些相对导入配置又被当成 import signals 执行了一遍),两次各自定义出来的函数对象 id 不同,connect() 完全看不出它们是"同一个"接收者。

真实实测
def make_receiver():
    def on_post_save(sender, **kwargs):
        calls.append(sender)
    return on_post_save

r1, r2 = make_receiver(), make_receiver()
print('r1 is r2:', r1 is r2)
sig.connect(r1); sig.connect(r2)          # 不传 dispatch_uid
sig.send(sender='PostSaved')
print('实际被调用次数:', len(calls))
r1 is r2: False 实际被调用次数: 2

同一次 post_save,你的备份逻辑真的会跑两遍。connect() 支持的 dispatch_uid 参数就是为了解决这个问题——用一个稳定的字符串代替 id(receiver) 做去重键,不管背后是不是同一个函数对象。

真实实测
u1, u2 = make_receiver(), make_receiver()
sig2.connect(u1, dispatch_uid='article.signals.on_post_save')
sig2.connect(u2, dispatch_uid='article.signals.on_post_save')   # 同一个 dispatch_uid
sig2.send(sender='PostSaved')
print('实际被调用次数:', len(calls2))
实际被调用次数: 1

说个题外话:上一篇里给 bigywater 项目写的 article/signals.py 用的是 @receiver(post_save, sender=Post),没传 dispatch_uid。正常的 runserver 一个进程只会 import 这个模块一次,不会踩上面这个坑,但只要以后有一天这份代码被从两条不同路径各 import 一次(常见于测试环境或者奇怪的相对导入配置),文章备份就会被写两次——加一个 dispatch_uid='article.write_post_backup' 这种稳定字符串,是免费的保险。

真实源码:pre_save / post_save 到底在哪一行触发

位置在 Model.save_base() 里,一头一尾各发一次,sender 传的是类,不是那个正在保存的实例。

django/db/models/base.py · django @ 6.0.7, L920 def save_base(self, raw=False, force_insert=False, force_update=False, using=None, update_fields=None): ... cls = origin = self.__class__ ... if not meta.auto_created: pre_save.send(sender=origin, instance=self, raw=raw, using=using, update_fields=update_fields) # L946 ... updated = self._save_table(raw, cls, force_insert or parent_inserted, force_update, using, update_fields) ... if not meta.auto_created: post_save.send(sender=origin, instance=self, created=(not updated), update_fields=update_fields, raw=raw, using=using) # L981

sender=origin 传的是 self.__class__(比如 Post 这个类本身),这也是为什么 @receiver(post_save, sender=Post) 里的 sender 要写模型类而不是某个实例——connect() 存的 sender_ref 是对这个类对象的弱引用,类对象只要模块还在,就不会被回收。created 也不是靠"保存前 pk 是不是 None"这种简单判断算出来的,而是直接取 _save_table() 真实执行完 UPDATE/INSERT 之后的返回值取反——如果你手动指定了一个数据库里还不存在的 pk,UPDATE 会真实地影响 0 行,然后自动退化成 INSERT,created 依然正确地是 True。

真实实测
# 用 bigywater 项目自己的 Post/Series 模型验证
pre_save.connect(on_pre, sender=Post)
post_save.connect(on_post, sender=Post)

p = Post(title='t', url='zz-signal-demo.html', series=s, ...)
print('保存前 p.pk =', p.pk)
p.save()
p.title = '改标题'; p.save()

s2 = Series.objects.first(); s2.save()   # 只订阅了 Post,Series 保存应该没反应
保存前 p.pk = None pre_save sender=Post instance.pk=None post_save sender=Post instance.pk=83 created=True pre_save sender=Post instance.pk=83 post_save sender=Post instance.pk=83 created=False Series 保存后触发次数(应为 0): 0

结果完全符合源码:第一次保存(插入)时 pre_save 触发时 pk 还是 None,post_save 触发时数据库已经把主键分配回来了(83),created=True;改完标题再保存,created 正确变成 False;Series 单独保存完全不会触发只订阅了 sender=Post 的接收者——_live_receivers() 会按 sender 的 id 精确过滤。

真实实测:一个 receiver 抛异常,广播会不会被打断

pre_save/post_save 用的是 send(),不是 send_robust()——这个选择本身就有真实后果。

django/dispatch/dispatcher.py · django @ 6.0.7, L179 def send(self, sender, **named): ... for receiver in sync_receivers: response = receiver(signal=self, sender=sender, **named) # 没有 try/except,谁炸了就直接往外抛 responses.append((receiver, response)) ... return responses
django/dispatch/dispatcher.py · django @ 6.0.7, L291 def send_robust(self, sender, **named): ... for receiver in sync_receivers: try: response = receiver(signal=self, sender=sender, **named) except Exception as err: self._log_robust_failure(receiver, err) # 记日志,不往外抛 responses.append((receiver, err)) # 异常本身作为"响应"存进结果 else: responses.append((receiver, response)) return responses
真实实测
# 三个 receiver:A 正常、B 抛 ValueError、C 正常
sig.connect(receiver_a); sig.connect(receiver_b_boom); sig.connect(receiver_c)
try:
    sig.send(sender='x')
except ValueError as e:
    print('send() 抛出了异常并中止:', repr(e))
print('实际执行顺序:', order)
send() 抛出了异常并中止: ValueError('B 挂了') 实际执行顺序: ['A ran', 'B about to raise']

C 一次都没机会执行——send() 里的 for 循环没有任何异常保护,B 一炸,整个循环连同 Model.save() 这次调用都被中断,异常原样冒到调用方。post_save 用的正是这个 send()——所以给它挂的任何一个 receiver(比如上一篇写的备份逻辑)只要抛异常,.save() 本身就会失败,后台编辑表单会直接报 500,而不是"备份失败但保存成功"这种更温和的结果。

真实实测
sig2.connect(receiver_a); sig2.connect(receiver_b_boom); sig2.connect(receiver_c)
responses = sig2.send_robust(sender='x')
print('实际执行顺序:', order)
for receiver, response in responses:
    print(f'  {receiver.__name__} -> {response!r}')
实际执行顺序: ['A ran', 'B about to raise', 'C ran'] receiver_a -> None receiver_b_boom -> ValueError('B 挂了') receiver_c -> None

同样三个 receiver,换成 send_robust(),C 照常跑完,B 的异常被原样收进返回值的元组里,而不是往外抛。m2m_changed、request_finished 这类"多个第三方 app 可能各自挂了钩子"的信号更适合用 send_robust() 语义去调用,但 Django 自己内部驱动 save()/delete() 时用的是 send()——这是一个明确的设计取舍,不是疏漏。

演示:同一次广播,send() 中断 vs send_robust() 跑完

三个接收者 A、B(会抛异常)、C,先看 send() 场景,再看 send_robust() 场景。

Signal 广播演示
send()

数据来自独立验证过的 Python 模拟——用真实的"顺序调用 + 是否捕获异常"逻辑复现两种场景,每一步接收者的状态(未调用/调用中/成功/报错/被跳过)都和真实实验(上面两段 send()/send_robust() 的输出)逐条核对过。

参考与说明

  • 本文全部真实实验(弱引用导致 receiver 悄悄失效、未保留引用的 lambda 立即被回收、重复注册导致信号触发两次及 dispatch_uid 修复、sender 精确过滤与 created 语义、send() 中断广播与 send_robust() 跑完全部接收者)均在本机真实运行的 Django 6.0.7 上完成(pre_save/post_save 那组实验直接用的是 bigywater 项目自己的真实 Post/Series 模型,测试数据保存后已清理干净),数据未做删改。
  • 源码引用(django/dispatch/dispatcher.py 的 Signal.connect/send/send_robust、django/db/models/base.py 的 Model.save_base)取自本机通过 pip 安装的 Django 6.0.7 包本身,并逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的广播模型(顺序调用、异常是否被捕获、后续接收者是否被跳过)由 Python 独立实现并交叉验证过——参数直接取自真实实测过的 send()/send_robust() 两组输出,每一步接收者状态都核对过与真实结果一致。
  • 没有涉及:异步接收者(asend/asend_robust)的并发调度细节、use_caching=True 时 sender_receivers_cache 的缓存失效时机、ModelSignal 支持用 "app_label.ModelName" 字符串延迟绑定 sender 的 apps.lazy_model_operation 机制、信号在数据库事务回滚时并不会跟着"撤销"(post_save 已经真实发出,即使外层事务后来失败)这个更容易踩坑的话题。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号