前言:
个人笔记,欢迎探讨。
背景:
有时候需要子线程处理一些重复性工作,而父线程可以作为控制端来随之终止或者暂停它。甚至,线程间如果需要协同工作时,更需要一种合适的交互方式。学习过程中,确实感受到了一些概念理解的重要性,因此留贴记录。
事件循环:
之前写过日志:
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,网上有不少博客,但我看到了大多是生产者消费者那个例子,对于我的情况不太适用,但确实有启发。
总结:
以上两种方式我都试过,直接在父线程改变子线程的标记变量的方式,简单粗暴有效。发信号的方式代码多,但是用起来可能心里舒服点,纯主观感受。因为我觉得通过发信号的方式控制子线程,更“人性化”一些。
但无论哪种方式,刚开始做时都比较烧脑,思路要清晰。各路大神有无更好的方式?