Qt 中多线程间传递信号经验总结
神秘的信号丢失
有一个程序,都写了大半年了,之前定义的一套信号机制挺好用的,但改成多线程之后,发现根本不能触发了。
开始怀疑是线程忙等问题,还尝试加QApplication::processEvents()
等手段,一无所获。
折腾一天之后,看了一眼调试控制台的输出信息,发现问题其实造给我明白输出在调试控制台里面了:
我在信号中传递了一个自定义类型的数据。在单线程模式下,这样的传递随意写。但是,如果要跨线程传递信号,信号的参数就必须是注册过的“Qt元类型”。否则,即使是一个简单的枚举量,也是不能跨线程传递的,这个问题在编译时没有警告。
猜测类似于引用和复制?单线程间可以直接引用或者传指针,跨线程之后就必须用一些复杂的手段复制。
非要传递自定义类型,其实也容易处理:
首先,在类型定义处:#include <QObject>
,然后在类型定义后面另起一行Q_DECLARE_METATYPE(XLogLevel)
;
随后,一般是在main
函数里面,QApplication
初始化之后(不清楚是否有要求,我是这样写的),qRegisterMetaType<XLogLevel>("XLogLevel");
一下。字符串内容估计不重复就行。
然后,此类型的变量就可以跨线程传递了。
有用的信号连接类型
日常用connect
最常用的用法是四个参数,其实第五个参数在跨线程环境下非常重要。这个参数控制连接的行为,先说可以写的选项:
Qt::AutoConnection
默认值,使用这个值则连接类型会在信号发送时决定。如果接收者和发送者在同一个线程,则自动使用Qt::DirectConnection
类型。如果接收者和发送者不在一个线程,则自动使用Qt::QueuedConnection
类型。Qt::DirectConnection
槽函数会在信号发送的时候直接被调用,槽函数运行于信号发送者所在线程。效果看上去就像是直接在信号发送位置调用了槽函数。这个在多线程环境下比较危险,可能会造成奔溃。Qt::QueuedConnection
槽函数在控制回到接收者所在线程的事件循环时被调用,槽函数运行于信号接收者所在线程。发送信号之后,槽函数不会立刻被调用,等到接收者的当前函数执行完,进入事件循环之后,槽函数才会被调用。多线程环境下一般用这个。Qt::BlockingQueuedConnection
槽函数的调用时机与Qt::QueuedConnection
一致,不过发送完信号后发送者所在线程会阻塞,直到槽函数运行完。接收者和发送者绝对不能在一个线程,否则程序会死锁。在多线程间需要同步的场合可能需要这个。Qt::UniqueConnection
这个flag可以通过按位或(|)与以上四个结合在一起使用。当这个flag设置时,当某个信号和槽已经连接时,再进行重复的连接就会失败。也就是避免了重复连接。
要用好这几个参数,首先记住一条原则:但凡直接修改 GUI
的操作,都要放到主线程中。
然后,在同一个线程中,Qt::AutoConnection
或者说实际上的Qt::DirectConnection
,几乎相当于直接写函数调用,会先执行完槽函数,再继续执行emit
的下一行,与直接调用的区别就是不用你手动#include
和传递对象指针了。
在不同的线程中,一些的简单的线程模型下,我们会希望传递信号之后,发送信号的线程就停下来等待另一个线程执行完毕,这时候要用Qt::BlockingQueuedConnection
。反过来,如果你希望当前这一段函数执行完了再“发送信号”,那就用Qt::QueuedConnection
,这是跨线程的默认行为,但如果顺序很重要,最好是不要依赖默认行为,增强代码可读性。
我说的“当前这一段函数执行完了”,指的是代码里面可以直接关联的函数执行完毕,下一次触发要等一个新的信号传递过来的情况。不是说当前函数的返回。这个结合下面的
moveToThread
理解。
关于 moveToThread 函数
Qt 程序的运行,我们可以发明一个说法,叫“一段一段”地运行。所谓“一段”,就是一个函数调用另一个函数,函数套函数,这是即使不懂Qt只是明白C++的人都很容易理解的模型。
但是,在Qt程序中,main
函数与其他函数是断开的,main
函数的结尾一般是return app.exec();
而非调用下一层函数。而程序中其他函数的原始调用方,往往是一个信号;例如用户点击按钮,然后槽函数就被触发了,然后槽函数再调用其他业务函数,一串一串,但这样的调用最终会返回。
这个过程中,所触发的送往其他线程信号,或者使用Qt::QueuedConnection
所连接的本线程信号不再讨论之列,前者是因为他们是另外一“段”了,后者是因为在研究线程时,他们可以简单视作一次直接调用。
等到函数调用一层一层返回,最终槽函数返回,在表观层次上,我们所直接写的测斜代码失去了对 CPU 的控制权。
这个时候 CPU 在干嘛呢?在后台检测下一个信号(稍后触发的或者刚才emit
了但还没执行的),并进行处理。
这与线程有何关系?关系在于,同一“段”一定是在同一个线程中执行的;在不调用 windows 底层 API 的情况下,不同的“段”才有可能被分配到不同的线程。
而在Qt中,对某个对象moveToThread
,并不是神奇地做到了但凡这个对象的函数都放到另一个线程进行,而是指:假如说moveToThread
之后,这个对象的槽函数做为一段的起点,那么就在所move
到的线程中执行这一“段”。
假设有A
、B
两个对象,B
中保存着A
的指针,并且B
被move
到了一个子独立线程,而A
仍然在主线程(GUI线程),然后我们触发了一个连接到B
的某个槽函数的信号,则B
的这个槽函数是运行在独立线程中的。
此时B
的这个槽函数通过指向A
的指针或者引用调用了一个A
的成员函数,这个成员函数在那个线程执行?答案是在子独立线程,而非GUI线程。
那如果B
的这个槽函数触发了一个连接到A
的某个槽函数的信号呢?这个槽函数将在主线程(GUI线程)执行。如果这函数耗时较长,界面一样会卡顿,即使你没有点击任何控件。
即使上面两种情况所调用的
A
的成员函数/槽函数是同一个。
如果需要方便地在某个对象的“所属”线程下调用它的一个函数,而不管来源线程是哪个,需要用QMetaObject::invokeMethod
机制来调用。QMetaObject::invokeMethod
也有多种连接方式可选,控制实际行为是发完信号不管,还是阻塞等待返回。
此处,一个比较喵的应用是:在子线程提交了一组对 GUI 进行更改的信号之后,当前函数还不能结束(也就是这一“段”还要继续),但是又需要这些 GUI 更改立即生效(默认情况下,要等到子线程这一段结束,这些信号才被实际送出,简单理解为快递员把寄件压手里了),可以用如下代码:
// 强制主进程处理一次消息
QMetaObject::invokeMethod(
qApp, []() { QCoreApplication::processEvents(); }, Qt::BlockingQueuedConnection);
因为qApp
对象是毫无疑问的主线程,同时又像全局变量一样随处可得,这个写法十分方便。