信号槽的坑不多,但每一个都「不报错」——不崩、不警告,只是行为不对。 四个坑:对象销毁、重复连接、槽里做重活、跨线程改界面。
前六节把机制讲全了,这一节把坑集中踩一遍。 四个坑我都用 Python 复现了,虽然 Python 和 C++ 的表现不同, 但根因是同一个——这一点比现象更重要。 内存管理那一套在 34 课第八章,那一章量过多线程队列的问题。
一、坑一:对象销毁了,连接还在
这是 Qt 里最经典的崩法,也是最难查的。
| 语言 | 表现 | 为什么 |
|---|---|---|
| C++ | 崩溃(野指针调用) | 函数指针还指着已释放内存 |
| Python | 内存泄漏(对象删不掉) | 绑定方法抓着对象引用 |
两种表现完全相反,但根因一样:连接是「单向引用」,信号不知道自己连的对象还在不在。 C++ 是忘太早,Python 是忘不掉——都说明这个连接没人管理。
Qt 的解法是 connect 时传 context 对象:connect(sender, &S::sig, context, &R::slot),
context 销毁时连接自动断。这是 Qt5 最重要的改进之一。
二、坑二:同一个槽连了两次
D37 实测:连两次,发一次信号,槽执行两次。
| 场景 | 后果 |
|---|---|
| 按钮点一次跳两页 | 用户以为程序抽风 |
| 日志写两条 | 对不上账 |
| 计数器加两次 | 数据全错 |
这个错的可怕之处是「偶发」:如果 connect 写在某个「每次打开页面都会执行」的函数里,
第一次正常,第二次重复。
防法有三条:连之前判断、用 Qt::UniqueConnection、或者把 connect 放在只执行一次的构造里。
我建议第三条——从结构上避免,比运行时去重可靠。
三、坑三:槽里做重活
D37 实测:直连时槽里跑 20 毫秒,发送方就卡 20 毫秒。
这就是「界面卡住」的完整解释:
| 步骤 | 发生什么 |
|---|---|
| 用户点按钮 | 系统消息进事件队列 |
| 分发器处理 | 直连调用槽 |
| 槽里跑 20 毫秒 | 主线程被占住 |
| 这 20 毫秒里 | 重绘、点击、拖动全部积压 |
第三行是关键:主线程被占住时,系统消息还在进队列,但没人取——于是界面「假死」。 超过 200 毫秒用户就能感觉到,超过 5 秒操作系统会标记「未响应」。 解法就一句:槽里只发一个「我要干活」的消息,真活交给工作线程,干完再用排队信号送回来。
四、坑四:跨线程改界面
这一条在 3.4.3 提过,这里要把它讲成「可检查的规则」。
| 对象 | 能跨线程吗 |
|---|---|
| QWidget 及所有控件 | 不能,只能主线程 |
| QPixmap | 不能 |
| QImage | 能 |
| QByteArray、QString | 能(隐式共享,只读时安全) |
第一行违反了「可能」崩,而且概率和机器负载相关,测试机上常常一次都不复现。 所以只能靠「写的时候就别这么写」,不能靠测试发现。
一个可执行的检查方法:在所有 setText、setPixmap、
update 的调用点上问一句「这行跑在哪个线程」——答不上来就是隐患。
五、动手 Demo:四个坑,四个都能真踩一遍
对象销毁、重复连接、槽里跑重活、跨线程改界面——前三个能用 Python 复现出同样的现象, 第四个只能靠规则。 注意 Python 和 C++ 的表现不同,但根因一致。
# 坑一:删了对象,连接还在
a = Widget("A")
sig.connect(a.handle)
del a
gc.collect()
sig.emit("1") # Python 里 A 还能收到,因为方法抓着它
# 坑二:同一个槽连两次
s2 = Signal(unique=False)
fn = lambda x: out2.append(x)
s2.connect(fn)
s2.connect(fn)
s2.emit("hit") # 执行两次
# 坑三:槽里跑重活,发送方要等
t = time.perf_counter()
s3.emit(1) # 槽里 while 20 毫秒
print((time.perf_counter() - t) * 1000)
实跑输出:
--------------------------------------------------------------------
四个坑,四个都能真踩一遍
--------------------------------------------------------------------
**信号槽的坑不多,但每一个都很安静——不崩溃、不报错,只是行为不对。**
**坑一:对象销毁了,连接还在。**
A 被删掉后发一次信号,实际收到的是:A 收到 1、B 收到 1
**Python 这边没事**,因为 `connect` 抓的是绑定方法,对象被方法引用着,删不掉。
**C++ 那边就是崩溃**:对象析构了,函数指针还指着那块内存,**一发信号就是野指针调用。**
Qt 的解法是 `connect(sig, obj, &Obj::slot)` 三参数形式——**对象销毁时连接自动断开**。
**坑二:同一个槽连了两次,执行两次。**
只发一次,槽执行了 **2 次**——重复连接不是错误,Qt 允许。
**表现是「按钮点一次跳两页」**,而且很难想到是 connect 写了两次。
**防法**:连之前判断,或者用 `Qt::UniqueConnection`。
**坑三:信号里做重活,把发送方拖死。**
直连时,发送方要等槽跑完:**20.0 毫秒**。
**这就是「界面卡住」的典型成因**:槽里做了耗时操作,而直连是在发送方线程里同步执行的。
**解法**:槽里只发一个「要去干活」的消息,真活交给工作线程。
**坑四:跨线程直接改界面。**
这不是「信号槽的坑」而是「不按信号槽做」的坑:工作线程直接调 `setText()`,
Qt 会打印一行警告然后**可能**崩——「可能」是因为它看运气。
**规则只有一条:界面对象只在主线程碰,跨线程一律用排队信号。**
**四个坑的共同点:都不报错。**
所以排查时不要问「哪里写错了」,要问「**谁调了谁、在哪个线程调的**」。
坑一在 Python 里是「A 还能收到 1」——因为绑定方法抓着对象,del 只是删了名字。
换 C++ 就是崩溃。
这个反差特别值得记:同一个设计缺陷,两种语言两种死法。
坑二实测执行 2 次,Signal(unique=False) 就是模拟 Qt 的默认行为。
注意 Qt 默认是去重的,这里为了演示才关掉。
坑三实测 20.0 毫秒,和槽里的耗时一分不差——直连就是同步调用,没有任何优化空间。 想让发送方不等,只有排队一条路。
30 秒实验
- 把坑一的
del a换成a = None,看行为变不变——体会「删名字」和「删对象」的区别。 - 在坑三的槽里加
time.sleep(0.001)循环 1000 次,看发送方等多久——sleep和忙等的区别在这里。 - 给坑二的信号加上
disconnect再连,看是不是就只调一次了。
常见误区
- 以为对象删了连接会自动断。 C++ 里不会,会崩;Python 里对象删不掉(引用还在),变成泄漏。 都要靠 context 对象。
- 以为重复连接 Qt 会自动去重。 Qt 默认确实去重(同一个函数), 但两个不同的 lambda 就是两个槽。Python 里尤其容易踩。
- 以为槽里慢一点没关系。 直连时发送方同步等待,槽里跑 20 毫秒界面就卡 20 毫秒, 超过 5 秒系统标「未响应」。
自测三问(点开对答案)
1. 对象销毁后连接还在,C++ 和 Python 各是什么表现? —— C++ 崩溃(野指针调用);Python 内存泄漏(绑定方法抓着对象)。根因都是「连接没人管理」。 2. 为什么槽里做耗时操作会让界面卡住? —— 直连时槽在发送方线程(主线程)同步执行,主线程被占住,系统消息进队列但没人取,界面就假死了。 3. 怎么判断一行代码有没有跨线程改界面的风险? —— 在每个 setText/setPixmap/update 的调用点问「这行跑在哪个线程」。答不上来就是隐患。下一节讲 moc:Qt 为什么需要一个「编译前先跑一遍」的工具。
- ☐ (本节完,下一节 03.04.05 元对象工具)