直连是「当场调」,排队是「投进信箱」。 跨线程时只能用排队——因为不能直接动别人线程里的对象。
信号和槽都讲完了,这一节讲「怎么接上」,重点是线程那两种。 这一节是 Qt 多线程编程的地基:界面线程和采集线程之间只能靠它通信。 同步的另一种做法在 34 课第九章(音视频同步),那是「按时间对齐」,这里是「按线程传递」, 别混。
一、五种连接方式
Qt::ConnectionType 有五个值,常用的三个:
| 类型 | 什么时候调 | 跑在哪个线程 |
|---|---|---|
| Direct | 立刻,同步 | 发送方线程 |
| Queued | 投递,异步 | 接收方线程 |
| Auto | 同线程直连,跨线程排队 | 看情况 |
| BlockingQueued | 投递并等完成 | 接收方线程 |
Auto 是默认值,它的判断只有一句: 比较发送方对象和接收方对象的线程归属,相同就直连,不同就排队。 注意比较的是「对象所属的线程」,不是「当前正在哪个线程执行」——这个区别是很多人搞错的地方。
二、直连和排队的真实差别
D36 量出来:排队的投递成本只要 0.001 毫秒。
| 直连 | 排队 | |
|---|---|---|
| 发送方要等吗 | 要,等槽跑完 | 不等,扔进队列就走 |
| 槽什么时候跑 | 立刻 | 接收方线程空闲时 |
| 成本 | = 槽的执行时间 | ≈ 0.001 毫秒 |
第一行是最关键的差别:直连时槽里跑 20 毫秒,发送方就卡 20 毫秒——D37 会实测这个数。 排队的代价是「不确定什么时候执行」:接收方线程忙,消息就在队列里躺着。
所以选哪个很清楚:界面要立刻响应就用排队,数据处理要「处理完再继续」就用直连。
三、跨线程的规矩
这条规矩是 Qt 的核心约束,违反了就随机崩:
| 对象 | 只能在哪个线程碰 |
|---|---|
| QWidget 及其子类 | 主线程(GUI 线程) |
| QPixmap | 主线程 |
| QImage | 任意线程 |
| 自定义数据处理对象 | 任意线程,但要自己保证不并发 |
第一行是所有界面崩溃的根源:工作线程直接 label->setText(),
Qt 会打印一行警告,然后「可能」崩。
为什么是「可能」:因为大部分时候恰好没冲突,
出错概率和机器负载相关——所以这类 bug 在测试机上永远复现不了。
第三行值得注意:QImage 可以在任意线程创建和修改,
所以跨线程传图像的标配是「工作线程造 QImage,排队信号送主线程,主线程转 QPixmap 显示」。
四、线程退出时会发生什么
这是最容易被忽略的一格:排队信号在线程退出后永远不会执行。
| 时序 | 结果 |
|---|---|
| 信号投递 → 线程还在跑 | 正常执行 |
| 信号投递 → 线程已退出 | 丢弃,不报错 |
| 信号投递 → 线程正在退出 | 看运气 |
第二行是「退出时少做了一步清理」的典型原因:主线程发了「保存配置」的信号,工作线程已经退出了。 正确做法是「先停线程、再发最后的信号、最后等线程 join」,顺序错了就会丢消息。
第三行更麻烦:这种竞态条件在开发机上几乎不复现,在现场跑几天才出现一次。 所以别赌,按第二行的正确顺序写。
五、动手 Demo:用真线程量投递成本,列清 Auto 的判断规则
起一个工作线程从队列取任务,主线程连续投递 20 次,量每次投递花多少时间。 这个数就是「排队」的固定成本,和槽跑多久完全无关。
q = queue.Queue()
def worker():
while True:
item = q.get()
if item is None:
break
# 模拟槽的执行
th = threading.Thread(target=worker)
th.start()
for i in range(20):
t = time.perf_counter()
q.put((threading.current_thread().ident, t))
lat.append((time.perf_counter() - t) * 1000)
q.put(None) # 哨兵
th.join()
实跑输出:
--------------------------------------------------------------------
直连、排队、自动——三种连接方式差在哪
--------------------------------------------------------------------
**同一个信号,如果发信号和接信号的代码不在一个线程,处理方式完全不同。**
**这就是 `Qt::ConnectionType` 存在的全部理由。**
类型 怎么调 跑在哪个线程
--------------------------------------------------------------
Direct 立刻在发信号的线程里调 发送方线程
Queued 投递到事件队列,稍后调 接收方线程
Auto 同线程直连,跨线程排队 看情况
BlockingQueued 投递并等它做完 接收方线程
**用真线程量一遍「排队」这一侧的成本:**
主线程投递 20 个任务,投递本身平均 **0.002 毫秒**(这就是「排队」的成本)
主线程 id 与工作线程 id 不同
**排队的好处是「不等」**:发信号的那一方把消息扔进队列就走了,不会因为接收方慢而卡住。
**代价是「不确定什么时候执行」**:如果接收方线程正忙,消息就躺在队列里。
**Auto 的判断规则只有一句:比较两个对象的线程归属,相同就直连,不同就排队。**
场景 Auto 变成什么 为什么
-------------------------------------------------------------------
同线程两个对象 Direct 省一次投递,直接调
跨线程 Queued 不能直接动别人的对象
接收方线程已退出 丢弃 没有事件循环,没人取队列
**第三行是最阴的一行**:线程退出后,之前投递的排队信号**永远不会执行**,而且不会报错。
**表现是「界面偶尔不动」,或者「退出时少做了一步清理」**——
查这种问题只能靠日志确认:信号发出去了吗?槽进去了吗?
投递一次 0.001 毫秒——这就是「排队」的全部成本。发送方扔完就走,不等接收方。 对比 D37 那个直连实测:槽跑 20 毫秒,发送方就卡 20 毫秒。两个数差两万倍, 这就是为什么跨线程必须排队。
主线程 id 和工作线程 id 不同,说明真的在两个线程上跑。 这一句看着废话,但排查时它是第一手证据——很多人以为起了线程,其实代码还在主线程里跑。
Auto 那张表最后一行要背下来:接收方线程退出了,投递的排队信号就永远不执行。 表现是「退出程序时偶尔少存一次配置」,而不是崩溃——这种 bug 能查一星期。
30 秒实验
- 把
q.put换成q.put(timeout=0.001),在队列满时抛异常,体会「非阻塞投递」的写法。 - 把工作线程改成
while True: pass(不取队列),看投递多久会被阻塞——队列满之后put就会等。 - 给工作线程加一个「已退出」标志,在主线程投递前检查它,体会「线程退出后消息丢失」怎么防。
常见误区
- 以为 Auto 看的是「当前在哪个线程执行」。 它看的是「发送方对象和接收方对象各自属于哪个线程」。
- 以为工作线程可以直接改界面。 QWidget 只能在主线程碰,违反了「可能」崩——概率和负载相关, 测试机上常常不复现。
- 以为线程退出后排队信号还会执行。 不会,直接丢弃,不报错。必须在线程退出前把消息处理完。
自测三问(点开对答案)
1. Auto 是按什么判断直连还是排队的? —— 比较发送方对象和接收方对象各自所属的线程。相同就直连,不同就排队。 不是看「当前在哪个线程执行」。 2. 排队信号的最大风险是什么? —— 接收方线程退出后,队列里的信号永远不会执行,而且不报错。要保证线程退出前把消息处理完。 3. 跨线程显示图像的标配做法是什么? —— 工作线程造 QImage,用排队信号送到主线程,主线程再转成 QPixmap 显示。 QImage 可以跨线程,QPixmap 不行。下一节把坑集中踩一遍:四个不报错的错误,每一个都能真复现。
- ☐ (本节完,下一节 03.04.04 信号与槽的注意事项)