Qt使用事件循环,信号,stop变量,sleep阻塞,QWaitCondition+QMutex条件变量,退出子线程工作

前言:

个人笔记,欢迎探讨。

背景:

有时候需要子线程处理一些重复性工作,而父线程可以作为控制端来随之终止或者暂停它。甚至,线程间如果需要协同工作时,更需要一种合适的交互方式。学习过程中,确实感受到了一些概念理解的重要性,因此留贴记录。

事件循环:

之前写过日志:

Qt事件循环(QCoreApplication::processEvents,exec)的应用_大橘的博客-CSDN博客_qt实现循环任务

Qt线程的this,volatile,exec(),moveToThread()_大橘的博客-CSDN博客

都是因为实现线程控制。自己总结了一些概念,最重要的就是——事件循环。

按照官方手册的说法,所谓事件循环,就是个消耗资源很小的死循环,可以让线程暂停当前工作,去处理消息队列。所以用到了很多场景:

主程序main函数最后有个exec事件循环,就是为了主窗体显示之后,不要退出程序,而是无限制等待处理消息队列,所以运行程序时,窗体不会一闪而过,而是待在那里去响应用户的各种操作。windows的消息响应机制就是基于类似的原理。

实现子线程时,如果通过重写run函数的方式,最后都要加个exec事件循环,这样线程执行到这里就会处于“待机”状态,随时响应槽函数。如果采用moveTothread方式实现子线程,相当于run函数中还是默认执行了exec事件循环,因此放在子线程中的对象代码,不用特殊处理,执行完毕后,子线程会自动等在那里,可以随时响应槽函数。

子线程工作循环与标记变量bStop:

有时候,需要在子线程中执行某种重复性操作时,有一种写法是,写一个while循环。要退出子线程时需要退出循环,退出循环的条件往往来自于外界改变,比如用一个标志变量——bStop。

while (!bStop)
{
    ...
}
或:
while (true)
{
    if (bStop)
    {
        break;
    }
    ...
}

上述循环中,bStop是标记变量,用于控制退出循环。标记变量可以在外部直接被赋值,也可以接受信号,在槽函数中被赋值。

阻塞与时间片:

上述带标志变量的while循环,一般我会写成这样:

    while (!m_bStop)
    {
        
        //加入事件循环,用于处理消息队列,比如槽函数的响应。
        QCoreApplication::processEvents();

        //阻塞线程,释放时间片,用于执行其它线程。
        QThread::msleep(100);
    }

其中sleep是必须的,执行sleep时,阻塞当前线程,让出时间片去执行其它线程。比如当前线程的父线程。我写的父线程中有这样一段用于终止子线程:

    //改变子线程中的标志变量。
    //运行于子线程中的那个对象名叫m_obj,由于上述while循环中使用了sleep,
    //所以位于父线程中的此处代码才有机会被执行。
    m_obj->m_bStop = true;

    //取得子线程的指针句柄。
    QThread *thd = m_obj->m_thd;

    //清理子线程中的对象。
    //这里注意,deletelater的定义是,在其所在线程返回事件循环时被执行。
    m_obj->deleteLater();
    m_obj = nullptr;

    //清理子线程指针。
    if (nullptr != thd)
    {
        thd->quit();
        thd->wait();
        thd->deleteLater();
        thd = nullptr;
    }

所以,子线程的while循环中,由于执行sleep让出了时间片,所以才有机会让父线程的代码被执行,才有机会被父线程修改标记变量m_bStop。

Deletelater:

但是,父线程清理资源时,deletelater会在子线程回到事件循环时,亦即执行消息队列时被执行。也就是子线程的while中调用事件循环的地方。所以,一旦子线程在事件循环中响应了所有的消息队列(槽函数)之后,马上会执行deletelater,一定会报错。因为此时还没有跳出while循环,但是整个对象空间被释放了,bStop也失效了。就像野指针一样,这个while野循环会一直运行,再也没有机会退出了。戏剧性的是,它并不是卡死,而是直接报错。

所以上面的写法中,要点在于m_bStop的判断一定要在事件循环之前,一旦标记退出,就马上退出,不再执行事件循环,也就不会在不适宜的时候执行deletelater,也就不报错了。

执行顺序是这样的:

//子线程while循环。

//子线程调用sleep,让出时间片。

//父线程执行,改变标记变量m_bStop。

//父线程由于写了deletelater,但它不会立即执行。
//清理子线程代码处写了wait,所以它会等待子线程执行完毕后,自动清理。

//子线程下一轮循环,先检查标记变量m_bStop,退出while循环。
//压根就没机会执行显式的事件循环。

//子线程循环退出后,相当于返回了子线程的run函数中的exec事件循环。
//此时执行父线程上面写的deletelater,从而清理完成。

//完美结束。

使用信号方式终止子线程工作循环:

但是很多时候我更愿意通过信号槽的方式来实现控制,所以就写成了下面这样:

    //子线程代码
    while (true)
    {
        
        //加入事件循环,用于处理消息队列,比如槽函数的响应。
        QCoreApplication::processEvents();

        //父线程发完信号以后,交回时间片时被执行。
        if (m_bStop)
        {
            break;
        }

        //阻塞线程,释放时间片,用于执行其它线程。
        QThread::msleep(100);
    }




    //子线程槽函数
    //while中执行事件循环时,处理消息队列,所以会执行此槽函数,用于响应父线程发来的信号。
    void Obj::onStop()
    {
        m_bStop = true;
    }
    //父线程代码

    //这里我定义了信号,通过发信号给子线程来终止它。
    emit sigStop();

    //发送信号之后阻塞父线程,等待子线程执行完毕
    //子线程此时会执行事件循环之后的代码。
    //所以如果这里采用sleep的方式,阻塞的时间要大于等于子线程中sleep的时间,
    //否则在子线程还没来得及处理完,又回到这里执行,会在不恰当的时机执行deletelater,报错。
    QThread::msleep(150);
    //wait的方式,理论上更靠谱,但貌似不能在本线程执行wait,或许QWaitCondition+QMutex更适合。
    //this->thread()->wait();

    //取得子线程的指针句柄。
    QThread *thd = m_obj->m_thd;

    //清理子线程中的对象。
    //这里注意,deletelater的定义是,在其所在线程返回事件循环时被执行。
    m_obj->deleteLater();
    m_obj = nullptr;

    //清理子线程指针。
    if (nullptr != thd)
    {
        thd->quit();
        thd->wait();
        thd->deleteLater();
        thd = nullptr;
    }

上述在父线程获得执行权后,先向子线程发送停止信号,然后父线程阻塞,让子线程有机会继续执行。

在子线程while循环中,事件循环处理完消息队列,马上就判断标记变量,用于退出循环。父线程采用临时交回时间片的方式,让子线程得以善后。避免了deletelater在事件循环中被误执行。

执行顺序是这样的:

//子线程while循环

//子线程调用sleep,让出时间片

//父线程响应用户操作,开始执行终止代码,向子线程发送停止信号

//父线程调用sleep或等待

//子线程调用事件循环时,处理消息队列。 
//响应父线程发来的停止信号,执行槽函数,改变标记变量m_bStop。

//子线程退出事件循环,接着判断m_bStop,退出while循环,结束执行。

//父线程继续执行,调用deletelater清理子线程中的对象

//父线程清理子线程对象资源

//完美结束。

上述父线程采用了sleep方式阻塞,它的阻塞时长不能小于子线程sleep时长,否则当父线程阻塞已经结束时,子线程还没来得及执行,父线程执行随后的清理操作时,依然会报错。

父线程中使用sleep实现阻塞等待的方式,亲测没有问题,但理论上不严谨,是否会被os调度扰乱了时序,这点我目前不确定是否担心多余。在缺乏严谨论证的情况下,我认为需要考虑不要采用sleep阻塞的方式。

如果使用QWaitCondition+QMutex,理论上更严谨。

我也想过其它方法。

比如,销毁子线程对象的时候,发送终止信号,被终止的对象完成必要操作后,要回复一个信号,父线程收到后再清理资源。

比如,父线程发送终止信号后,写个循环,过一会儿查询一下子线程中的状态,等变过来再清理资源。

QWaitCondition+QMutex方式:

上面说过,最重要是时序问题。父线程作为主控发送停止信号,子线程作为执行者要听话。核心是子线程退出while循环前,不能让父线程执行清理操作。很显然一些概念浮出水面,同步,互斥。互斥其实隐含了阻塞时机。

尝试过几次之后我自认为领悟到了QWaitCondition的意义,为什么它把互斥锁作为必须输入的参数。就是为了控制时序。

简单看看我提炼后的代码:

//父线程

    m_obj->m_mutex.lock();
    m_obj->m_condition.wait(&m_obj->m_mutex);
    m_obj->m_mutex.unlock();

//子线程

    while (!m_bStop)
    {
        QCoreApplication::processEvents();

        if (m_bStop)
        {
            break;
        }

        ...

        QThread::msleep(100);
    }
    m_mutex.lock();
    m_condition.wakeAll();
    m_mutex.unlock();

分别在wait和wake的前后加上锁控制就可以了。

假设先是子线程执行了wake唤醒,显然是无效的唤醒操作,但不会报错,仅仅是无效指令。说明子线程由于其它原因先退出循环了。

假设先是父线程执行了wait等待,说明子线程一直在工作状态,是父线程要主动中断它,那就必须等待子线程退出循环之后再执行资源清理。

所以其实关于QWaitCondition+QMutex,网上有不少博客,但我看到了大多是生产者消费者那个例子,对于我的情况不太适用,但确实有启发。

总结:

以上两种方式我都试过,直接在父线程改变子线程的标记变量的方式,简单粗暴有效。发信号的方式代码多,但是用起来可能心里舒服点,纯主观感受。因为我觉得通过发信号的方式控制子线程,更“人性化”一些。

但无论哪种方式,刚开始做时都比较烧脑,思路要清晰。各路大神有无更好的方式?


版权声明:本文为u012999461原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。