刚给自己的 bigywater 项目挂了一组 post_save/post_delete 信号做文章备份,顺手把 django.dispatch.Signal 的源码和几个反直觉行为在本机真实跑了一遍——弱引用会在什么条件下悄悄让 receiver 消失且不报任何错,同一个 receiver 被注册两次会不会真的触发两次,以及一个 receiver 抛异常时,send() 和 send_robust() 到底谁会把剩下的 receiver 跑完。
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 所在对象的任何引用,这个对象随时可能被回收——回收之后,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,就是这个下场。
上一节的 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' 这种稳定字符串,是免费的保险。
位置在 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 精确过滤。
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()——这是一个明确的设计取舍,不是疏漏。
三个接收者 A、B(会抛异常)、C,先看 send() 场景,再看 send_robust() 场景。
数据来自独立验证过的 Python 模拟——用真实的"顺序调用 + 是否捕获异常"逻辑复现两种场景,每一步接收者的状态(未调用/调用中/成功/报错/被跳过)都和真实实验(上面两段 send()/send_robust() 的输出)逐条核对过。