背景:
个人学习多线程控制,写了一些博文用于记录。
【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博客
但无论哪种方式,刚开始做时都比较烧脑,思路要清晰。各路大神有无更好的方式?