Qt 信号槽的工作原理(三)——队列连接与跨线程通信

原文链接 https://woboq.com/blog/how-qt-signals-slots-work-part3-queuedconnection.html Olivier Goffart 于 2016年02月04日

这篇博客是系列文章的一部分,用来解释 Qt 信号槽的内部实现机制。

在本文中,我们将深入探讨 Qt 队列连接(Queued Connection)背后的工作机制。

 

第一部分回顾

在第一部分中,我们看到:信号本质上只是普通的函数,其函数体由 moc 生成。信号函数所做的事情,就是调用 QMetaObject::activate,并传入一个位于栈上的参数指针数组

下面是 moc 生成的一个信号函数示例(摘自第一部分):

随后,QMetaObject::activate 会在内部数据结构中查找:哪些槽函数连接到了这个信号

正如第一部分所展示的,对每一个槽,都会执行类似下面的代码:

也就是说:

那么就会调用 queued_activate,把调用放入事件队列中。

如果是 Qt::BlockingQueuedConnection,则会走另一条分支(本文稍后会讲到,之前被跳过了)。

否则,就是 直接连接(DirectConnection),像第一部分中那样直接调用槽函数。

因此,在这篇博客中,我们将重点看看:

 

Qt 事件循环(Qt Event Loop)

一次 QueuedConnection(队列连接) 会向事件循环投递(post)一个事件,等待稍后被处理。

在投递事件时(QCoreApplication::postEvent),

事件会被压入一个按线程划分的队列中(QThreadData::postEventList)。

这个事件队列受到互斥锁的保护,因此当多个线程同时向另一个线程的事件队列投递事件时,不会产生竞态条件。

一旦事件被加入队列,如果接收者对象位于另一个线程中,

就会通过调用 QAbstractEventDispatcher::wakeUp 来通知该线程的事件分发器。

这会在事件分发器因等待新事件而休眠时将其唤醒。

如果接收者位于同一线程,事件则会在之后事件循环迭代时被正常处理。

事件会在处理它的线程中,在处理完成后立即被删除。

通过 QueuedConnection 投递的事件类型是 QMetaCallEvent

当该事件被处理时,它会以与直接连接(DirectConnection)相同的方式调用槽函数。

所有必要的信息(要调用的槽、参数值等)都存储在这个事件对象内部。

 

参数拷贝(Copying the parameters)

从信号传过来的 argv 是一个指向参数的指针数组

问题在于:这些指针指向的是信号函数栈上的参数,一旦信号函数返回,这些指针就会变得无效。

因此,我们必须把这些参数值拷贝到堆上

为此,Qt 会直接向 QMetaType 求助。

正如 QMetaType 文章中所说,

QMetaType::create 能够在已知 QMetaType ID 和一个指向原始对象的指针的情况下,拷贝任意类型的对象

那么,如何知道某个参数对应的 QMetaType ID 呢?

Qt 会从 QMetaObject 中获取参数类型的名称(moc 已经把所有类型名存进去了),

然后再用这个名字到 QMetaType 数据库中查找对应的类型 ID。

 

队列激活(queued_activate)

现在我们可以把前面的内容串起来,阅读 queued_activate 的代码了。

它由 QMetaObject::activate 调用,用于为 Qt::QueuedConnection 的槽调用做准备。

下面展示的代码做了少量简化并加了注释:

当这个事件到达接收方后,QObject::event 会设置 sender,并调用 QMetaCallEvent::placeMetaCall

后者会像第一部分所示的直接连接那样分发调用——也就是以 QMetaObject::activate 类似的方式来调用槽函数。

 

阻塞队列连接(BlockingQueuedConnection)

BlockingQueuedConnectionDirectConnectionQueuedConnection 的混合体:

该事件中还包含一个 QSemaphore(信号量)

示例代码:

这里是由 QMetaCallEvent 的析构函数 来释放(release)信号量。这样设计很合理,因为事件会在以下两种情况下被删除:

  1. 事件成功投递并处理后(也就是槽函数已经被调用后)

  2. 事件没能被投递/处理(例如接收对象被删除了)

因此无论哪种情况,等待中的线程都不会永远卡住。

BlockingQueuedConnection 在做线程间通信时很有用:

当你需要在另一个线程中调用一个函数,并且希望 等它执行完再继续 时,可以用它。

不过,这种方式使用起来必须非常小心。

BlockingQueuedConnection 的风险

使用 BlockingQueuedConnection 时必须非常小心,否则很容易造成死锁

显而易见,如果你在 同一个线程 中的两个对象之间使用 BlockingQueuedConnection 进行连接,会立刻发生死锁。

原因是:你向当前线程自己的事件循环发送了一个事件,然后又阻塞这个线程,等待该事件被处理。

但由于线程已经被阻塞,事件永远不会被处理,线程也就永远卡住了。

Qt 会在运行时检测到这种情况并打印一条警告,但 不会尝试帮你修复这个问题

有人曾建议:如果发现发送者和接收者在同一个线程里,Qt 是否可以自动退化为普通的 DirectConnection

但 Qt 并没有这么做,因为 BlockingQueuedConnection 本身就是一种 只有在你非常清楚自己在做什么时才应该使用的机制

你必须明确知道事件是从哪个线程发送到哪个线程的。

真正的危险在于:一旦你的应用中存在从线程 A 到线程 B 的 BlockingQueuedConnection

那么线程 B 绝对不能再等待线程 A,否则就会再次产生死锁。

此外,在发射信号或调用 QMetaObject::invokeMethod() 时,

你不能持有任何线程 B 也可能尝试获取的互斥锁,否则同样可能导致死锁。

一个典型问题出现在使用 BlockingQueuedConnection 来终止线程时,例如下面这段伪代码:

你不能在这里直接调用 wait(),因为子线程可能已经发射了,或者正准备发射一个信号,而这个信号会等待父线程;

但父线程此时不会再回到事件循环中,于是就产生了死锁。

所有线程清理和信息传递都必须通过在线程之间投递事件来完成,而不能使用 wait()

一种更好的写法是:

这样做的缺点是:MyOperation::cleanup() 现在是 异步调用 的,这可能会让整体设计变得更复杂。

 

总结(Conclusion)

这篇文章也为整个系列画上了句号。希望这些文章能够揭开 Qt 信号与槽的“神秘面纱”,

并且对其底层工作原理有一定了解之后,能帮助你在实际应用中更好地使用它们。