最近在开发中遇到一个很奇怪的问题 ,槽函数与信号连接后,在代码执行中发出信号,槽函数始终进不去。一开始认为是connect调用传参不对,打了下返回值是true,而且在输出中也没有相关warnning输出。信号连接和调用处打断点所处线程为子线程,跨线程信号槽调用,所以猜测跟多线程有关。排查过程中各种换信号、改参数都没有效果,偶然间把接收对象的创建移至主线程,槽函数居然执行成功了。本着有问题看手册的原则,在手册中找到了如下的说明:
大体意识是说:Qobject对象是有线程归属的,或者说其存活在特定线程中。当接收到队列连接的信或 投递的事件,槽函数或事件处理在其归属线程中执行。下面的图比较形象
重点是下面的Note: 如果一个对象没有归属线程(也就是thread()接口调用返回值为空),或者其所属线程没有运行中的消息循环,队列连接的信号或投递的事件是无法正常接收的。
下面的话也很重要:QObject的默认归属线程是创建这个对象的线程,具体属于哪个线程对象可通过thread()接口查询,如果要改变其归属线程可调用moveToThread()接口。
好,先记住这段话,对后面问题分析很有用。接下来通过代码描述问题出现的场景:
GUITest::GUITest(QWidget *parent)
: QMainWindow(parent)
{
.....
// 启线程,延迟5s发信号
CTestThread* ptr_thread = new CTestThread(this);
QTimer::singleShot(5000, this, [this]() {
qDebug() << "==== SignalTestSlot";
emit SignalTestSlot();
});
ptr_thread->start();
}
///
class CSignalSlot : public QObject
{
Q_OBJECT
public:
CSignalSlot(QObject* ptr_parent = nullptr);
public slots:
void OnSlot();
};
class GUITest;
class CTestThread : public QThread
{
Q_OBJECT
public:
CTestThread(GUITest* ptr_test, QObject* ptr_parent = nullptr);
virtual void run() override;
private:
GUITest* ptr_test_{ nullptr };
CSignalSlot* ptr_owned_{ nullptr };
};
CTestThread::CTestThread(GUITest* ptr_test, QObject* ptr_parent /*= nullptr*/)
: QThread(ptr_parent),
ptr_test_(ptr_test)
{
if(ptr_owned_ != nullptr)
delete ptr_owned_;
}
// 线程执行的Run函数
void CTestThread::run()
{
while (true)
{
QThread::sleep(2);
if (ptr_owned_ == nullptr)
{
// 子线程中创建对象,所以ptr_owned_ 所属对象为子线程
ptr_owned_ = new CSignalSlot;
connect(ptr_test_, &GUITest::SignalTestSlot, ptr_owned_, &CSignalSlot::OnSlot, Qt::QueuedConnection);
}
}
}
CSignalSlot::CSignalSlot(QObject* ptr_parent /*= nullptr*/)
: QObject(ptr_parent)
{
}
// 槽函数执行
void CSignalSlot::OnSlot()
{
qDebug() << "==== onslot";
}
为了模拟跨线程的场景,代码中在子线程的run函数中创建CSignalSlot 对象,然后在主线程中GUITest构造函数中延迟发送信号,保证信号发送时receiver已创建,并关联了信号槽,这是怎么看都不觉得有问题的代码,测试中却发现槽函数不会执行。按照之前分析,可能原因是CSignalSlot 所属线程的消息循环未启动或已退出,即未处于运行状态。手册中有说明:调用QThread的exec会开消息循环,调用start接口默认会执行exec,因此也会开消息循环。问题就出在下面这段话:
start启线程后是否执行exec取决于run接口的实现,默认是调exec;如果重写了run接口,修改了默认行为,不再执行exec,也就不会运行消息循环。因此TestThread创建的CSignalSlot对象(跨线程队列连接的)槽函数是不会执行的。由此也可以推断,标准线程std::thread如果结合QObject使用信号槽,槽函数也是不能执行的。
分析的结论有点像是跨线程信号槽的错误打开方式,那就重来吧。针对跨线程的信号槽使用,只能用QThread->start(),然后moveToThread将对象移至目标线程,这样可以保留消息循环,让槽函数正确执行,这大概也是moveToThread的意义所在。
对QObject在多线程场景下的使用,以及接口调用的可重入性,在手册中也有说明(如下截图),这些规则是比较容易被忽略的。
QObject设计是只在单线程中使用的,对父子对象也有同线程的约束。其实也不难理解,Qt对象管理的最大特色在于父子关系的管理,而这也是其弱点所在,因为对对象中保存了父对象的指针,很多接口都是非线程安全的,调用逻辑跟父、子对象有复杂的关联关系,之前我们讲到对象是有归属线程的,如果这时候父子线程不在同一线程,临界数据的访问就会引入很多问题,所以对象只在单个线程中用这是基本约束。文档中还强调QThread的run接口中创建的对象不要将QThread对象指定为父对象,二者归属不同的线程,这是很显然的。
另有交代(NOTE)
跨线程的信号槽使用,上面只分析了线程未启EventLoop的场景,如果上述方法对你现在的问题不适用。还有一个常见的原因:信号中带自定义的数据类型, 原则上讲,所有队列连接的信号槽,自定义类型的参数,都要注册元类型。一个元类型注册就可能引入很多问题,不妨检查:
- 排查是否有遗漏了注册元类型;
- 注册时的类型名称与信号中的类型名称是否匹配,经常会出现注册时带了命名空间,信号使用时又没带命名空间的情况;
- 槽函数的参数类型与注册类型是否一致,比如都带或都没带命名空间。
此外,一个自定义类型不能够多次注册,关注下VS的输出,能够看到对应的warnning。多次注册时qt会视为运行时异常,直接abort。
还有一点小技巧,如果某个类型是三方库定义的,你想作为你的信号参数用,又担心多次注册问题(还是有可能的,首先三方库设计上肯定不可能注册元类型,一是没考虑这种场景,二压根不用qt库;那么上层应用随时可能拿来注册下)。此时,可以通过Typedef,定义一个唯一的别名,然后对这个别名注册元类型,亲测可用。
如果还是不行,那就看看连接之后是否有disconnect的地方,或者connect时候发送信号的对象是否有创建,经常对象还没创建就connect,槽函数自然是进不去的。这种情况qt会有warning输出,万能的VS输出用起来。
好了,都交代完了,如果解决了你的问题记得点赞收藏。