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

背景:

个人学习多线程控制,写了一些博文用于记录。

【Qt线程-1】this,volatile,exec(),moveToThread()

【Qt线程-2】事件循环(QCoreApplication::processEvents,exec)的应用

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

【Qt线程-4】事件循环嵌套,BlockingQueuedConnection与QWaitCondition比较

【Qt线程-5】生产者&消费者模型应用(多态,子线程控制,协同,事件循环)

【Qt线程-6】获取当前线程id,thread()和currentThreadId(),不是想当然那样,不使用信号槽可能看不出区别

【Qt线程-7】QThread、QThreadPool+QRunnable怎么选

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

线程安全:

以下提及的OS调度和时间片,是代码层面的大致叙述,并不严谨。OS会自己把各任务分成时间片执行。比如说,两段优先级相同的耗时代码,假设都没有写sleep,分别运行于不同线程时,它们也可能是交替执行的。也可以想象为,OS会在代码中按照调度需要,分别在各代码相应位置插入了若干sleep,让程序有若干“断点”,公平竞争,谁也不要独霸系统资源。如此,很可能在轮换时间片时,引起线程安全问题。

所以下面的叙述,只能先在理论假设下进行。有些实现方式最后定义为不推荐,是有原因的。不安全的代码,也许当时测试不出问题,但总有出问题的概率。

事件循环:

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

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

主程序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的意义,为什么它把互斥锁作为必须输入的参数。就是为了控制时序。

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

//父线程代码
 
    //先获得竞争锁,保证emit之后马上wait。
    m_obj->f_Wait_Prepare();

    //发信号通知子线程来终止它。
    emit sigStop();
 
    //父线程开始阻塞等待。
    m_obj->f_Wait();

    //父线程已经被子线程唤醒。
 
    //取得子线程的指针句柄。
    QThread *thd = m_obj->m_thd;
 
    //清理子线程中的对象。
    m_obj->deleteLater();
    m_obj = nullptr;
 
    //清理子线程指针。
    if (nullptr != thd)
    {
        thd->quit();
        thd->wait();
        thd->deleteLater();
        thd = nullptr;
    }
//子线程

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

        if (m_bStop)
        {
            break;
        }

        ...

        QThread::msleep(100);
    }

    f_WakeUp();//此时已经退出while循环,唤醒父线程。


/* 子线程里写了两个函数,分别用于阻塞和唤醒父线程。调用的时候方便。
 * 尤其比较复杂的场景,看着简洁易懂。
 *
 * 后来又把获得锁单独写了个prepare函数。更严谨一些。按照qt手册的说法,父
 * 线程调用emit后,应该马上执行后面的wait语句,等到子线程回到它的事件循环时,
 * 它才会执行槽函数。但实测中发现,线程并发时,不用锁或者原子操作控制,它还
 * 是可能被调度打乱时序。亦即:虽然是跨线程的信号槽,也是队列connect模式。但
 * 总有一种可能:父线程执行emit之后,子线程会马上执行槽函数,而后父线程再wait,
 * 这是不希望看到的。
 * 
 * 至少我认为,os对线程的调度,如果不人为干涉,是没法预料的。
 */
void Obj::f_Wait_Prepare()
{
    //阻塞父线程前,先获得竞争锁,保证emit和wait连续执行。
    m_mutex.lock();
}
void Obj::f_Wait()
{
    //阻塞父线程,让它原地等待
    m_condition.wait(&m_mutex);
    m_mutex.unlock();
}
void Obj::f_WakeUp()
{
    //唤醒父线程,让它继续干活
    m_mutex.lock();
    m_condition.wakeAll();
    m_mutex.unlock();
}

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

执行顺序是这样的:

//子线程while循环
 
//子线程调用sleep,让出时间片
 
//父线程响应用户操作,开始执行终止代码,向子线程发送停止信号
 
//父线程阻塞等待

//os不会再主动调度阻塞状态的父线程,这是阻塞的意义
 
//子线程调用事件循环时,处理消息队列。 
//响应父线程发来的停止信号,执行槽函数,改变标记变量m_bStop。
 
//子线程退出事件循环,接着判断m_bStop,退出while循环。

//子线程唤醒父线程。
//由于os本轮调度还未执行完,父线程被唤醒后是等待调度就绪状态,只要轮到它,它会立即执行。
//可以认为它立即开始了。

//子线程随后的代码,再没有循环体,无论是分几次或者一口气执行完,都不会有影响。
//执行完函数体后,就回到QThread的exec事件循环,俗称待机。
 
//父线程由于调用了deletelater清理子线程中的对象。
//要么此时子线程还没回到exec,父线程只管往前走,等子线程到exec时,会自动清理。
//要么此时子线程已经到了exec,马上被清理。

//父线程清理子线程对象资源时,由于调用了thd->wait。
//其实和条件变量的wait一样道理,它会阻塞父线程,直到子线程执行完run函数。确保子线程已经over。

//父线程把子线程对象彻底清理。
 
//完美结束。

可见,父线程的等待和唤醒完全受控,可以精准把握时机,非常好。

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

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

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

代码注释中,我特别改过一次。还是觉得os对时序的调度,一定要人为控制细节,否则无法预料。即使qt手册有提及。

总结:

总的来看,父线程终止子线程操作有几种方式:

1、使用m_bStop标记变量,简单粗暴,要考虑好事件循环相关情况影响。直接暴露成员变量的方式,不推荐。适合学习测试用。

2、使用信号槽+阻塞时长,从习惯和某种程度上,可能容易阅读,但使用阻塞时长保证时序的方式,个人认为不严谨,不推荐。适合学习测试用。

3、使用条件变量QWaitCondition+QMutex方式,可以有效控制父线程的阻塞与唤醒时机,如果父线程的操作不可逆转和等待,貌似使用这种方式是必须的。比如:在父线程的析构环节中,增加终止子线程操作的方式,析构过程一旦开始就不可阻挡,必须要中途有效地临时阻塞,来等待子线程退出。

4、使用信号槽异步回复的方式。亦即:父线程发送终止信号后,先不进行后续清理操作,子线程完成后,回复一个信号告知父线程,再触发父线程的清理操作。此种方式要求父线程的操作可持续等待,而不是像上一种那样不可逆转和等待。因为不存在父线程阻塞,所以更适合父线程是ui界面的情况。可以保证界面流畅。

5、如果受控的子线程只有槽函数需要执行,还有一种更简单的方式,绑定信号槽使用阻塞队列模式,亦即connect函数的最后一个参数,BlockingQueuedConnection。参照另一篇博客:

【Qt线程-4】事件循环嵌套,BlockingQueuedConnection与QWaitCondition比较_大橘的博客-CSDN博客

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

  • 9
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值