信号槽就是一行 connect,但它顺手解决了三件难事:解耦、跨线程、自动断开。 代价是排队投递会积压——投得比处理得快,延迟就一直在涨。
前两章用的是 LibVLC 的回调:一个 C 函数指针,加一个 void* 用户数据。
信号槽是同一件事的面向对象版本,但多了一层「跨线程自动排队」。
这一层既是它的价值,也是它的坑。
一、connect 的四要素
一个连接由四样东西组成:谁发、发的是哪个信号、谁收、收到后调哪个函数。 第五样是「怎么投递」,后面单独讲。
| 要素 | 在代码里 | 说明 |
|---|---|---|
| 发送者 | 信号所属的对象 | 必须是 QObject 派生 |
| 信号 | &QMediaPlayer::positionChanged |
只能声明、不能实现 |
| 接收者 | 槽所属的对象 | 也必须是 QObject 派生 |
| 槽 | &MyPlayer::onPos |
可以是普通成员函数 |
同一个连接有五种写法,差别很大:
| 写法 | 检查时机 | 缺点 |
|---|---|---|
SIGNAL() / SLOT() 宏 |
运行期查元对象表 | 拼错不报错,只在控制台打警告 |
| 函数指针 | 编译期 | 重载的信号要写 qOverload |
| lambda | 编译期 | 捕获的对象可能先死 |
| 仿函数 | 编译期 | 不绑定接收者,不会自动断开 |
QMetaMethod |
运行期 | 啰嗦,仅特殊场景用 |
推荐函数指针:拼错信号名这种事,应该在你按下编译键时就失败,而不是等程序跑到那一步、
在控制台角落里打一行 No such signal。
lambda 那行的坑要说清:函数指针写法会记录接收者,接收者析构时连接自动断开。
lambda 如果没写接收者参数(connect(sender, sig, this, [](){}) 里的 this),就等于「没有接收者」——对象销毁后信号照发,lambda 里访问成员就是野指针。
二、连接方式决定在哪条线程执行
这是信号槽最容易误解的地方:connect 本身不决定线程,决定的只是「投递方式」。
| 连接方式 | 什么时候用 | 执行在谁的线程 |
|---|---|---|
AutoConnection |
默认,占绝大多数 | 见下面两条规则 |
DirectConnection |
同线程、要立刻执行 | 发射者的线程 |
QueuedConnection |
强制跨线程异步 | 接收者的线程 |
BlockingQueuedConnection |
要等结果 | 接收者线程,但发射者阻塞 |
UniqueConnection |
防止重复 connect | 可与上面按位或 |
AutoConnection 的两条规则,背下来就够用:
发射信号的线程 就是 接收者所属的线程 → 走直连,当场调用。
发射信号的线程 不是 接收者所属线程 → 走排队,丢进对方的事件队列。
「接收者所属的线程」指的是它被创建时所在的那条线程,不是它被 connect 时所在的线程。
把对象 moveToThread 之后,这条归属会跟着变。
排队投递有一个硬性要求:参数必须能拷贝。因为参数会被拷贝一份放进事件队列, 等接收者线程有空时才真的调用。
所以这几种参数不能进信号:
裸指针、引用——拷的是地址,原来的对象可能早没了。
自定义的 struct——除非它注册过元类型。
QImage 这种大对象——能拷,但每次都要拷一份完整像素。
第三条是性能陷阱:一帧 1080p 的 QImage 是 8 MB,60 fps 下每秒要拷 480 MB,
只为了把画面送到界面线程。这就是后面要讲「只发有新帧了」的原因。
三、排队投递的代价:积压
排队的本质是一条先进先出的队列,处理速度赶不上投递速度,队列就变长。 队列长了,延迟就是它的长度。
算一遍就清楚,一轮事件循环 16.67 毫秒(60 Hz): 每轮能处理几个 = 16.67 ÷ 单个处理耗时。 每轮会来几个 = 16.67 ÷ 发送间隔。 前者大 → 队列一直是空的;后者大 → 队列一路涨。
Demo 里那张四行表就是这个除法:「每帧发一次、槽只要 1 毫秒」时每轮能处理 16 个, 来 1 个处理 16 个,队列永远是空的。
但「解码线程猛发」那一行就反过来了:每 2 毫秒来一张、每张要 8 毫秒, 每轮来 8 张只处理 2 张,队列每轮净增 6 张。 两秒之后队列就有七百多张,用户看到的是「画面比声音慢了一秒半」。
这时候加大队列没用,因为队列不是问题,是症状。 真正的解法是「别把数据放进队列」:信号里不带帧,只带一句「有新帧了」, 帧本身放在一个共享的「最新帧」变量里,界面线程收到信号就去取。
这样队列长度恒为 1,延迟恒为一轮, 而且旧帧会被新帧覆盖、自动丢掉——这恰恰是播放想要的:追不上就丢帧,而不是越拖越晚。
四、对象死了,信号还连着吗
不会。这是信号槽比裸回调强的地方。
QObject 析构时会做两件事:把自己发出去的所有连接断掉,把自己作为接收者的所有连接也断掉。
所以「发送者死了」和「接收者死了」都不会让程序崩。
但有一个例外必须记住:已经排进事件队列的那个调用,不会被取消。
场景是这样的:解码线程发了一个排队信号,事件已经躺在界面线程的队列里,
这时候你把接收者 delete 了——等界面线程处理到那个事件,接收者已经是一块被回收的内存。
所以规矩是:要删一个有连接的对象,用 deleteLater()。它把「删除」本身也排进事件队列,
排在已有的投递事件后面,自然就不会撞上。
还有一条相关的:关闭窗口时先 player->stop() 再关,
否则解码线程还在往界面线程投递,而界面已经没了。
这类崩溃在调试器里往往看不出堆栈,因为它发生在事件派发里。
五、动手 Demo:排队投递会不会积压,算一遍就知道
这段不算图像,只算队列:每 2 毫秒投一张纸条、每轮处理 2 张,看队列长度怎么长起来。 最后那一列「折合延迟」是关键:队列长度乘以投递间隔,就是画面比声音晚多少。
frame_ms = 1000.0 / 60.0 # 一轮事件循环
q = 0 # 队列长度
nxt = 0.0 # 下一张纸条什么时候来
t = 0.0
for r in range(1, 121):
t += frame_ms
while nxt <= t: # 这一轮来了几张
q += 1
nxt += 2.0
q -= min(q, 2) # 这一轮只处理 2 张
if r in (1, 10, 30, 60, 120):
print(r, q, "%.0f 毫秒" % (q * 2.0))
实跑输出:
--------------------------------------------------------------------
排队投递会不会积压,算一遍就知道
--------------------------------------------------------------------
**Qt 的排队连接把「信号」变成「队列里的一张纸条」,界面线程每轮回调时把纸条处理掉。**
**所以积压与否,只取决于三件事:多久发一张、处理一张多久、每轮给多少时间。**
屏幕 60 Hz,一轮事件循环 16.67 毫秒。
场景 发送间隔 处理耗时 每轮能处理 结论
----------------------------------------------------------------------------------
状态变化才发 500.0 毫秒 1.0 毫秒 16 个 不积压
每帧发一次 16.7 毫秒 1.0 毫秒 16 个 不积压
每帧发一次,槽很重 16.7 毫秒 8.0 毫秒 2 个 不积压
解码线程猛发 2.0 毫秒 8.0 毫秒 2 个 **越积越多**
**第二行与第四行的差别最值得看**:发送间隔一样,但一个是「每轮只来一张」、一个是「每轮来十几张」。
第二种情况下,队列长度会一路涨,用户看到的是「延迟越来越大」。
**真跑一次队列演化**(每 2 毫秒入队一个、每轮处理 2 个):
第几轮 队列长度 折合延迟 毫秒
-----------------------------------------
1 7 14
10 64 128
30 191 382
60 380 760
120 761 1522
**队列长度在涨,而且涨得稳定——这就是「积压」。**
**折合延迟是它的直接后果**:队列里堆十个,画面就晚二十毫秒。
**解法不是「加大队列」,是「别把数据放进队列」**:
**只发「有新帧了」,让界面自己去取最新那帧**——队列长度永远不超过 1。
队列长度是 7、64、191、380、761——每十轮翻一倍多,而且完全没有回头的意思。
折合延迟那列更直观:第 120 轮时队列里堆了 761 张,按每 2 毫秒一张算,就是 1522 毫秒。 用户按下暂停,画面要一秒半之后才停。
这里的算术很简单:每轮来 8.3 张、走 2 张,净增 6.3 张。120 轮之后应该是 756 张,实测 761, 差的那几张是取整与起始相位造成的。
注意这张表里没有任何「上限」:队列会一直涨到内存耗尽。 现实中的程序不会崩,是因为发送方自己会慢下来(解码线程发现内存不够、或者被操作系统限流), 但那已经是另一个故障了。
30 秒实验
- 把
min(q, 2)改成min(q, 9),队列还会涨吗?(每轮来 8.3 张、走 9 张,答案是不涨。) - 算一下:每秒 60 个信号,每个信号带一帧 8 MB 的
QImage,光拷贝要占多少内存带宽? - 判断一下:你的「进度更新」信号,该带百分比数字,还是带整个播放状态结构体?
常见误区
- 用字符串宏写 connect。
SIGNAL(positionChanged(qint64))少一个qint64都不报错, 只在运行时打一行警告,槽根本不执行。改用函数指针写法。 - 跨线程信号里传
const char*。 指针被拷贝、指向的内存没被拷贝,接收方拿到时已经悬空。 要传QString。 - 在槽里
delete发送者。 队列里可能还有它的事件,删完再投递就是野指针。用deleteLater()。 - 以为 connect 之后槽就在自己线程里跑。 跨线程时它跑在接收者的线程,
你的
this成员变量在另一条线程被改,没加锁就是数据竞争。
自测三问(点开对答案)
1. `AutoConnection` 什么时候变成排队投递? —— 发射信号的线程与接收者所属的线程不是同一条的时候。 注意看的是「接收者被创建/迁到哪条线程」,不是 connect 发生在哪。 2. 为什么排队投递的参数必须可拷贝? —— 因为参数会被拷贝一份塞进事件队列,等接收者线程有空才执行。 指针和引用拷的只是地址,原对象可能已经不在了。 3. `deleteLater()` 与 `delete` 差在哪? —— `delete` 立刻回收内存; `deleteLater` 把删除排进事件队列,等当前这轮事件派发完再删, 避免删掉「正在被派发的对象」或「还有事件排在队里的对象」。信号槽这一层通了,下一个问题很具体:解码线程给了你一块像素,怎么把它放到窗口上。
下一节只有两个类:QImage 和 QPixmap。
- ☐ (本节完,下一节 03.02 Qt显示图像)