前言

上一节中,详细介绍了moveToThread的线程使用方式,主要是关于自定义的业务工作类和QThread线程类搭配使用。
这里给出文章链接:Qt线程使用(二)moveToThread
然而,对于许多业务场景下,光是在子线程中执行任务代码是不够的,往往需要将某些数据传递到线程外部进行后续处理。比如,我们在子线程有一个while循环不断解码获取视频流数据,需要将采集到的图像帧数据发送到外部进行渲染。在Qt中,主要通过信号槽的方式来实现数据的传递。
我们先基于上一节的代码,实现最简单的信号槽数据传递。

一、跨线程信号槽的基本实现

先准备信号和发送,在WorkerClass中添加信号,定期往外部发送日期信息,模拟数据的采集:

void sig_send_time(QDateTime time);

在doWork的循环中,我们不断发出信号:

void WorkerClass::doWork()
{
    qDebug() << "WorkerClass started in" << QThread::currentThread();
    m_stopFlag = false;

    while (!m_stopFlag.load()) {
        QDateTime current_time = QDateTime::currentDateTime();
        qDebug()<<"send time with signal: "<< current_time << QThread::currentThreadId();
        emit sig_send_time(current_time);
        
        QThread::msleep(500); // 模拟工作(500ms间隔)
    }


    m_stopFlag = true;

    emit workFinished();
}

接下来,就是外部的WorkerManager了,先准备槽函数:

void WorkerManager::slot_recv_time(QDateTime time)
{
    qDebug()<<"recv time from thread: "<< time << QThread::currentThreadId();
}

这里只做最简单的打印日期信息。
然后是信号槽绑定的地方,这里一般在创建worker对象的时候绑定。

connect(p_worker, &WorkerClass::sig_send_time, this, &WorkerManager::slot_recv_time);

运行程序,查看打印信息:
在这里插入图片描述
可以看到,我们发送和接收的信息打印都是成对出现的,符合预期。
需要注意的是,我们还打印了两处的线程id,发现它们是不一样的,这代表槽函数处的线程和发送信号处的线程不同,也就是主线程和子线程。

二、信号槽的第五位参数

信号槽是Qt的特殊机制,给我们提供了灵活的UI及消息通知等功能实现的方法。在找工作面试中,信号槽的第五位参数是一个相当高频的问题。
我们查阅connect信号槽的第五位参数,得知它是一个有关连接类型的参数,叫做ConnectionType。它的枚举定义如下:

enum ConnectionType {
    AutoConnection,
    DirectConnection,
    QueuedConnection,
    BlockingQueuedConnection,
    UniqueConnection =  0x80,
    SingleShotConnection = 0x100,
};

咱们先关注前三个,分别是自动连接AutoConnection、直接连接DirectConnection、队列连接QueuedConnection。
在我之前的面试准备中,我一般会回答到这种程度:

我知道信号槽的第五位参数,是关于信号槽的连接方式。分别有自动连接、直接连接和队列连接。如果不设置的话,默认就是自动连接。它的规则是在同一线程中,默认是直接连接,此时是信号和槽是同步的。如果是不同线程,则是队列连接,此时是异步的。

以上回答基本正确,已经包含了自动连接、直接连接和队列连接的基本定义。然而,面试官们似乎对这样的回答无感。
我事后自己反思了一下,我虽然回答上没有说错,但并没有完全讲清楚这三个参数的适用场景。
对于我来说,其实会有些纳闷——我们默认就用自动连接,不就已经足够了吗?到底什么场景下,我们会特别去设置DirectConnection和QueuedConnection呢?
答案当然也很简单,那就是将使用场景反过来:
(1)在不同线程中,使用直接连接DirectConnection,以追求槽函数的同步触发,提高传递效率;
(2)在同一线程中,使用队列连接QueuedConnection,以实现槽函数的异步处理,减少可能带来的阻塞问题。
光说不练空把戏,我们直接来尝试一下。

三、跨线程中信号槽的直接连接

从刚才的运行调试中得知,跨线程信号槽在不填写第五位参数的时候,默认是自动连接AutoConnection,打印出来的线程id是不一样的,符合预期。
当我们特意设置了自动连接和队列连接,效果也是一样的:

connect(p_worker, &WorkerClass::sig_send_time, this, &WorkerManager::slot_recv_time,Qt::ConnectionType::AutoConnection);connect(p_worker, &WorkerClass::sig_send_time, this, &WorkerManager::slot_recv_time,Qt::ConnectionType::QueuedConnection);

现在,我们尝试改为直接连接:

connect(p_worker, &WorkerClass::sig_send_time, this, &WorkerManager::slot_recv_time,Qt::ConnectionType::DirectConnection);

运行程序,并观察打印的线程id:
在这里插入图片描述
可以看到,发送方和接收方的线程id是一样的,符合预期。
问题来了,线程一样或不一样,这有什么区别吗?
我先介绍一种常见的业务场景:在子线程的while循环中,通过tcp连接不断获取图像帧数据,此时需要将采集到每一帧数据发送到外部进行渲染,这就用到了信号槽的传递方式。同时,这也是典型的生产者-消费者模型。
这存在一个问题,那就是消费速度跟不上生产速度,可能导致消费滞后,任务量越积越多,最终导致崩溃。说得再直白一点,如果连接方式是队列连接,且子线程中每秒采集30帧图像数据,主线程中每秒只能处理15帧数据,那么就会有15帧数据堆积在事件循环中,等待着被处理。如果图像帧的显示需要实时,那这会造成非常明显的画面滞后问题,是不能被允许的。
这种情况下,一般会寻求更高效的渲染方式,或者选择丢帧,以确保画面的实时性。还有一种思路,就是确保生产和消费的同步,也就是生产一帧,消费一帧,然后再生产一帧。
所以,为了确保高效同步的数据传递,也就有了跨线程直接连接的同步通信了。
最后再总结一下,什么场景下跨线程可以用直接连接:
(1)希望生产者等待消费者处理完后,再继续
(2)希望保持数据实时性,不希望堆积数据
(3)线程安全,或已经加锁保护了共享资源
(4)接收方只是数据处理,不涉及GUI操作。
是的,直接连接情况下,算是子线程直接操作对象,要记得qt中ui相关控件只能在主线程中操作。如果在槽函数中想当然地QPixmap->setPixmap(data);,可能会导致崩溃。

四、QMetaObject::invokeMethod

类似于信号槽的,有一种基于Qt元对象的invokeMethod写法,这种更类似于回调函数,在一些示例代码中也挺常见的。
使用时,需要先向工作类传递外部槽函数的类对象指针。

void WorkerClass::set_ptr_obj(QObject *obj)
{
    ptr_obj = obj;
}

void WorkerClass::doWork()
{
    qDebug() << "WorkerClass started in" << QThread::currentThread();
    m_stopFlag = false;

    while (!m_stopFlag.load()) {
        QDateTime current_time = QDateTime::currentDateTime();
        qDebug()<<"send time with signal: "<< current_time << 

        if(ptr_obj){
            QMetaObject::invokeMethod(ptr_obj, "slot_recv_time",
                                      Qt::DirectConnection,Q_ARG(QDateTime, current_time ));

        }

        QThread::msleep(500); // 模拟工作(500ms间隔)
    }


    m_stopFlag = true;

    emit workFinished();
}

可以看到,它也是需要传递Qt::DirectConnection的,所以和connect的直接连接是一样的,也是基于信号槽。
但它相比于信号槽的写法,也是有一些缺点的:
(1)使用字符串来表示槽函数方法名,编译时无法检查
(2)需要传递对象指针,违背信号槽的解耦初衷
(3)可读性和可维护性差
(4)需要元对象系统解析的额外开销
(5)出现问题较难排查
所以能用信号槽的,还是用信号槽吧。

五、同一线程中信号槽的队列连接

说完了跨线程的直接连接,我们可以反过来测试一下在同一线程中的队列连接。因为是同一线程,我就不搞额外的类了,直接在MainWindow中添加信号和槽函数,并进行绑定。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
通过界面按钮来触发信号。
最后就是信号槽绑定:

connect(this, &MainWindow::sig_same_thread, this, &MainWindow::slot_same_thread, Qt::ConnectionType::AutoConnection);
connect(this, &MainWindow::sig_same_thread, this, &MainWindow::slot_same_thread, Qt::ConnectionType::DirectConnection);
connect(this, &MainWindow::sig_same_thread, this, &MainWindow::slot_same_thread, Qt::ConnectionType::QueuedConnection);

同样的,三种方式都尝试一下。
同一线程下,自动连接和直接连接是一样的。我们观察信号触发和槽函数的代码:

void MainWindow::on_btn_thread_same_clicked()
{
    emit sig_same_thread();
    qDebug()<<"sig_same_thread";
}

void MainWindow::slot_same_thread()
{
    qDebug()<<"slot_same_thread";
    QThread::msleep(2000);
    qDebug()<<"sleep finish";
}

槽函数中设置了打印2秒的阻塞延时。可以设想,如果是直接连接,在按钮按下之后,先是触发了信号,然后来到信号槽,在打印了“slot_same_thread”之后,需要经历2s延时,然后打印“sleep finish”,才会回到on_btn_thread_same_clicked(),打印出“sig_same_thread”
事实上也果真如此:
在这里插入图片描述
我们换成队列连接看看:
在这里插入图片描述
可以看到,这是完全相反的结果。
发送信号后,先是打印了紧接着的“sig_same_thread”,然后才会触发槽函数,打印出“slot_same_thread”,并在2s延时后打印“sleep finish”。
显然,因为设置了队列连接,所以发送信号后,会将槽函数触发的事件放到事件队列中,等待合适的时机执行。这种情况下,显然接下来的代码执行更加迫切,于是便先打印出了“sig_same_thread”。
这实际上是从同步改为了异步,完全改变了代码执行的时机。
在同线程中,如果可预想到槽函数可能导致一定的阻塞,那我们应该谨慎考虑要不要使用队列连接。或者说,我希望发送信号后,先执行完当前函数的剩余代码,再去执行槽函数相关的代码,此时也应该使用队列连接。
这是非常常见且合理的需求。如果只是一味采用自动连接,可能会导致不必要的问题和烦恼。

六、Qt::BlockingQueuedConnection

说完了三种常见的连接方式,我们开始要进阶一下了。
回顾一下刚才的跨线程直接连接,为了实现“生产等到消费完毕后再继续”发送方和接收方都处于同一线程。可事实上还有另一种更合适的选择,那就是采用BlockingQueuedConnection,阻塞队列连接。
它虽然是队列连接,但信号发送后会阻塞住,等待槽函数执行完毕后,阻塞才会结束,执行后续代码。
这种方式完美适配我们的需求,且发送方和接收方不属于同一线程,线程安全和UI操作等问题都将得到解决。
运行程序,对比一下。
直接连接:
在这里插入图片描述
阻塞队列连接:
在这里插入图片描述
需要注意的是,如果发送方和接收方意外地在同一线程,使用BlockingQueuedConnection会导致死锁。

七、Qt::UniqueConnection

Qt::UniqueConnection并不是独立的连接类型,而是一个去重标志,必须与其他类型组合使用,比如:

Qt::AutoConnection | Qt::UniqueConnection

这代表,如果相同的信号-槽对已经存在连接,则不再创建新连接。这样可以防止重复连接(比如在循环中不小心多次 connect),以及动态连接管理(不确定是否已连接时安全地尝试连接)
示例:

// 安全地连接,避免重复
connect(sender, &Sender::valueChanged, receiver, &Receiver::update,
        Qt::AutoConnection | Qt::UniqueConnection);

八、Qt::SingleShotConnection

这个类型相对陌生,是Qt6.5+才发布新增的。它表示该信号-槽连接只触发一次,触发后自动断开。
SingleShotConnection 和 UniqueConnection 一样,是一个位标志(flag),必须与其他连接类型按位或(|) 使用:

connect(sender, &Sender::finished,
        receiver, &Receiver::handleResult,
        Qt::QueuedConnection | Qt::SingleShotConnection);
// 如果只传SingleShotConnection,等价于AutoConnection | SingleShotConnection
connect(sender, &Sender::sig, receiver, &Receiver::slot, Qt::SingleShotConnection);

这相当于:

// 手动实现单次连接(旧方式)
connect(sender, &Sender::signal, receiver, [sender, receiver]() {
    // 执行槽逻辑
    doSomething();
    // 手动断开
    QObject::disconnect(sender, &Sender::signal, receiver, nullptr);
});

这适合于一些一次性初始化、避免重复ui操作、一次性资源加载完成通知。
值得一提的是,QTimer本身有一个singleShot,用于一次性的定时触发,非常方便。

QTimer::singleShot(1000, this, []{ qDebug() << "Run once!"; });

它的内部就是用 SingleShotConnection 实现的。

九、总结

至此,应该已经完全探讨完毕跨线程信号槽的使用和第五位参数的作用了。在此之前,我一度觉得直接采用默认的自动连接就好了,可当我深入了解后发现,学会了灵活运用信号槽的连接机制,确实会给我们的程序带来性能和功能的提升,甚至能在某些场合下解决我们的烦恼。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐