浅谈Qt信号槽与事件循环的关系

2022-11-13 14:11:17 循环 信号 浅谈

关于信号槽与事件循环,相关的文章非常多了,本文不做过多介绍。本文主要是通过简单的几个例子,尝试解释信号槽与事件循环的关系,帮助进一步理解。

一、信号槽

类中声明的信号,实际也是声明一个函数,其实现由moc机制自动生成在moc文件里,信号触发意味着函数调用:

// widget.h , Widget类
signals:
    void widgetSignal1();
// moc_widget.cpp
void Widget::widgetSignal1()
{
    QMetaObject::activate(this, &staticMetaObject, 0, nullptr);
}

Qt中通过QObject::connect建立起信号与信号或槽之间的连接,信号触发(也即函数调用)时,查找连接信息,从而触发槽的调用。

QObject::connect,参数可以指定连接类型(Qt::ConnectionType),可以确定槽以什么样的方式执行。常用自动连接、直接连接、队列连接。自动连接信号触发时,根据当前线程与接收者(receiver)所在线程是否相同,选择直接连接或者队列连接的执行逻辑。

二、事件循环

很多GUI框架都有事件循环这个概念,借由事件队列来驱动程序执行不同的逻辑。简单理解就是,线程内维护一个事件队列,当事件队列为空时,线程等待新的事件到来。有事件时,线程取出一个事件,调用该事件对应的处理过程。

UI线程(主线程),通常事件会比较多,例如鼠标键盘输出、重绘等。自定义的线程(QThread实例),也可以启动一个属于自己的事件循环,事件多数由程序自己产生。

而Qt的信号槽的机制,一部分也是依赖事件循环实现跨线程执行槽。

三、关系

尽管常说Qt的信号槽依赖事件循环,但实际运用起来,总是出现各种各样的问题。这里写几个使用例子,帮助总结一下。

1. 基本写法

先做个简单的测试,在当前线程创建对象并触发信号:

TestObject * object = new TestObject();
connect(this, SIGNAL(widgetSignal1()), object, SLOT(doTest1()));

qDebug() << "emit in thread: " << QThread::currentThreadId();
emit widgetSignal1();
qDebug() << timer.elapsed();
void TestObject::doTest1()
{
    qDebug() << "doTest1 in thread: " << QThread::currentThreadId();
    QThread::currentThread()->msleep(1000);
}

此时输出:

emit in thread:  0x3bd0
doTest1 in thread:  0x3bd0
1000

如果将connect改为队列连接:

emit in thread:  0x1fe0
0
doTest1 in thread:  0x1fe0

至少可以看出,信号的触发时的线程与槽执行线程一致,并且默认连接时,似乎等槽执行完成后,才执行后面的代码。而强制使用队列队列连接时,槽的执行被延迟,如果深入研究的话,会发现此时Qt生成了一个QMetaCallEvent事件,事件循环参与其中。

2. 加入额外的线程

这里接涉及不同方式的影响,1. 继承QThread重写QThread::run不启动事件循环;2. moveToThread使用默认事件循环;3. QtConcurrent线程接口和std::thread开启线程;4.信号触发者和接收者创建时机; 5.信号触发时的线程。这几种情况又相互交错,非常复杂。

(下面的测试代码不释放对象,不考虑内存泄漏,如果某些测试与预期不符,可能是信号多次连接的问题)

继承QThread,并重写QThread::run

这是初学者最常用的一种写法,QThread子类定义信号或者槽,run内触发信号。此时就涉及到一个非常重要的知识点:对象的所在线程是创建该对象时线程,这也意味着,尽管QThread::run方法是在线程中执行,但QThread对象仍旧是属于创建它的线程:

MyThread * thread = new MyThread();    // MyThread继承自QThread
thread->start();
connect(this,SIGNAL(widgetSignal1()), thread, SLOT(doThreadSlot()));
qDebug() << "emit in thread: " << QThread::currentThreadId();
emit widgetSignal1();
qDebug() << timer.elapsed();

输出:

emit in thread:  0x52c
doThreadSlot in thread:  0x52c
2000

此时,触发的时直接连接的逻辑,输出跟上面基本写法里一样。也可以调用QObject::thread,看看线程id是否与创建时的线程一致。

如果重写QThread::run方法,在run内触发MyThread信号:

// Widget类
void Widget::on_pushButton_clicked()
{
    MyThread * thread = new MyThread();
    connect(thread,SIGNAL(progressChanged()), this, SLOT(onProcessChanged()));
    thread->start();
}
// MyThread类
void MyThread::run()
{
    qDebug() << "emit in thread: " << QThread::currentThreadId();
    emit progressChanged();
}

测试输出,线程不一致。

QThread::run的默认实现时启动一个事件循环,上面的重写没有启动事件循环。这里就出现了第二个关键点:为什么没有事件循环,信号还是正常触发了? 当然你可能会怀疑,也许Qt背后偷偷启动了个呢。

QtConcurrent线程接口和std::thread试试

TestObject * object = new TestObject();
connect(this, SIGNAL(widgetSignal1()), object, SLOT(doTest1()));
QtConcurrent::run([this](){
    qDebug() << "emit in thread: " << QThread::currentThreadId();
    emit widgetSignal1();
});

输出:

emit in thread:  0x3088
0
doTest1 in thread:  0x2ac0

槽正常执行,并且使用了队列触发,将QtConcurrent换成std::thread后,也是同样的结果。因此,信号触发时,是不需要当前线程有事件循环,因为是通过查找连接信息并根据接收者所在线程来确定是否需要构造事件。

使用moveToThread方式创建线程

moveToThread可以切换指定对象的所属线程,该方法不是线程安全的,仅允许在对象的所在线程将该对象移动到其他线程。也就是说,将对象从线程A移动到线程B后,可以在线程B里将对象再移动到线程A,但不能在A线程里调用 moveToThread。

文档里指明,不允许对象父子在不同的线程。moveToThread前,不应该指定对象的parent。

QThread * thread=  new QThread();
TestObject * object = new TestObject();
connect(this, SIGNAL(widgetSignal1()), object, SLOT(doTest1()));
object->moveToThread(thread);
thread->start();    //启动线程
emit widgetSignal1();    //触发信号
QTimer::singleShot(1000, this, SIGNAL(widgetSignal1()));
QThread::msleep(10); 
thread->quit();

这段代码,将TestObject实例object移动到线程,并启动线程,触发一次信号,使用QTimer::singleShot延迟1s再次触发一次信号。最后结束线程事件循环。测试结果显示,第二次的信号并没有触发槽。 因为事件循环提前关闭了。

(休眠10ms是为了避免第一次的信号触发后,线程事件循环还未开始处理就退出了。如果不休眠10ms,多次执行这段代码,第一次信号还是有概率触发槽函数的,这就是线程。)

如果上面的代码改成:

QThread * thread=  new QThread();
TestObject * object = new TestObject();
connect(this, SIGNAL(widgetSignal1()), object, SLOT(doTest1()));
object->moveToThread(thread);
thread->start();
QTimer::singleShot(1000, this, SIGNAL(widgetSignal1()));
QTimer::singleShot(2000, thread, SLOT(start()));
thread->quit();

多加一句延迟启动线程,测试结果显示,第二次的信号触发的槽成功执行。可见跨线程触发信号会产生事件并投递到接收者所在线程队列。

在不同的线程中创建对象

上面所有的测试代码都是在主线程创建的对象,主线程事件循环一般情况下总是存在的,如果换成 QtConcurrent 或者 std::thread中创建对象呢?

不用测试也能推测出来,如果接收者所在线程不存在事件循环,那么跨线程的触发槽不会触发,因为没有办法处理。(但可以在其他线程创建完成后,移动到有事件循环的线程中)。

队列阻塞连接

(Qt的信号槽连接类型还支持队列阻塞模式,后面再补充吧)

四、总结

上面的测试,也没有把所有可能的情况覆盖。比如再引入QEventLoop可能会出现什么问题。

最后做个简单的总结,Qt的信号触发时,根据连接类型、接收者所在线程选择槽的调用方式。

  • 自动连接,信号触发时线程 = 接收者所在线程,此时直接调用
  • 自动连接,信号触发时线程 ≠ 接收者所在线程,产生事件投递到接收者线程事件循环
  • 如果是队列连接,产生事件投递到接收者线程事件循环

也就是,信号的触发不关心触发者所在线程有没有事件循环。只有选择了队列方式,产生了事件,才会依赖接收者所在的事件循环处理。因此,信号总是会触发,如果槽没有执行,也是接收者的问题。

五、另外一些问题

std::thread和QtConcurrent接口创建的线程差异

一开始我以为信号的触发也对线程有一定的要求,比如必须是QThread。但实际std::thread内也可以触发信号。
在这样的线程中创建对象A,并连接其他线程对象B的信号到A的槽,QtConcurrent可以在线程生存周期内,调用QCoreApplication::processEvents处理对象B触发的信号,而std::thread没有这样的能力。可能QtConcurrent内部是通过QThread实现的,std::thread为什么没有这样的能力(毕竟QObject::thread是可以获取信息的)?

QTimer不能在非QThread线程内启动,也许也是因为两者的差异引起的。

QTimer::singleShot启动0延时,因为不需要真的启动计时器,不依赖线程的队列产生超时事件,又都可以用。

到此这篇关于浅谈Qt信号槽与事件循环的关系的文章就介绍到这了,更多相关Qt信号槽与事件循环内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

相关文章