📹 第三章 FFmpeg二次开发采集并预览本地摄像头
📄 一句话概括

直连是「当场调」,排队是「投进信箱」。 跨线程时只能用排队——因为不能直接动别人线程里的对象。

📝 本节和前面几节的关系

信号和槽都讲完了,这一节讲「怎么接上」,重点是线程那两种。 这一节是 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 秒实验

  1. 把 q.put 换成 q.put(timeout=0.001),在队列满时抛异常,体会「非阻塞投递」的写法。
  2. 把工作线程改成 while True: pass(不取队列),看投递多久会被阻塞——队列满之后 put 就会等。
  3. 给工作线程加一个「已退出」标志,在主线程投递前检查它,体会「线程退出后消息丢失」怎么防。

常见误区

  1. 以为 Auto 看的是「当前在哪个线程执行」。 它看的是「发送方对象和接收方对象各自属于哪个线程」。
  2. 以为工作线程可以直接改界面。 QWidget 只能在主线程碰,违反了「可能」崩——概率和负载相关, 测试机上常常不复现。
  3. 以为线程退出后排队信号还会执行。 不会,直接丢弃,不报错。必须在线程退出前把消息处理完。
自测三问(点开对答案) 1. Auto 是按什么判断直连还是排队的? —— 比较发送方对象和接收方对象各自所属的线程。相同就直连,不同就排队。 不是看「当前在哪个线程执行」。 2. 排队信号的最大风险是什么? —— 接收方线程退出后,队列里的信号永远不会执行,而且不报错。要保证线程退出前把消息处理完。 3. 跨线程显示图像的标配做法是什么? —— 工作线程造 QImage,用排队信号送到主线程,主线程再转成 QPixmap 显示。 QImage 可以跨线程,QPixmap 不行。

下一节把坑集中踩一遍:四个不报错的错误,每一个都能真复现。

相关笔记