多线程编程是C++开发者,必须理解和熟用的东西,它其实非常简单,但是也非常容易出问题,继而衍生了非常多的概念。
诸如: 多线程同步、多线程互斥、线程池、原子操作等等。
线程是CPU时间调度的最小单位,它是一段单一的顺序执行流(C++一些优化,可能会破坏这个执行流,其实说破坏也不是破坏,C++优化导致编译的结果的执行流与我们代码执行流不一致,因此当我们调试优化的代码,断点针会乱跳的缘故,后续会有专门关于原子指针的一些讲解,该篇中,会提到这些东西)。
因为线程只是一段执行流程,而进程才是资源调用的最小单位,因此所有线程会共享所属进程的资源。而进程至少需要一个线程,
一般称其为主线程,而对于进程来说,线程理论上越多,能够获取更多CPU事件,但是切换线程是有代价的,它需要将当前线程的状态
进行压栈并对新线程的运行状态的入栈, 在单核CPU的情况下,你可以理解多线程,为一个人扛下了多件事情,你在任何一件事情的
时候,执行到了任意阶段,你都需要强制中断执行其它的事情,为了保证下次能够无缝的接着执行上次的事情,那么当你切换回去做
那件事情时候,你就需要恢复做那件事情的状态,具体到底做了什么事情,可以百度寄存器、一级缓存、二级缓存、RAM等相关的知识,
这里我们了解一下就OK了。而windows下采取的是优先级+抢占调度算法来调度线程,参考:http://c.biancheng.net/view/1257.html
多核CPU和单核CPU最大的区别在于多核CPU同时运行的线程会有多个,而单核只有1个,这就表示,单核的情况下,如果它等待一个锁
释放且不释放时间片,那么它只能耗尽时间片,因为不切换时间片,锁是不会被释放,而多核就不一样了。
通常在对线程包装时,往往会封装一个对象,例如:windows的API: CreateThread和__beginThread, 都会返回一个线程句柄,线程句柄它
其实是为了控制线程,它的生命周期可以高于线程的声明周期,也可以低于线程生命周期,因此千万不要把线程句柄和线程本身混淆。
尤其在Qt中, QThread是一个线程类,它实例化的对象只是一个维护线程的对象,它可以启动线程、终止线程、等待线程,但
你不要把它实例化的对象和线程本身混淆,因为线程本身的生命周期,可能高于该对象的声明周期,也有可能低于其声明周期。
多线程的应用,是一件相对复杂事情,首先我们需要明白,为什么要使用线程,线程有哪些运行模式,如何安全使用线程,如何简单的使用
线程,多线程的崩溃有哪些特征,那么下面,我将基于这些点,进行讲解。
哪些场景需要使用线程
多申请CPU的时间
上面说到,线程是CPU时间调度的最小单位,一个进行多申请线程,就能够多获得CPU的执行时间,当然我们不能无限的申请,考虑到切换线程,
本身也是有损耗的,因此合理申请一些线程,能够一定程度进行提速,例如:Filmora启动加速所用到Alpha启动框架。
让主线程有更平凡的环境
Qt开发,主线称为UI线程,为了保障UI界面的操作响应即时,界面刷新更流畅,对于一些复杂耗时的业务,就需要使用子线程去处理。如果我们
在主线程处理耗时的任务,会导致Freeze的假象。例如:Filmora中音频波纹图的绘制。
需要更稳定且平凡的环境
为了保证主界面不卡顿,通常主界面需要保证其平凡性,但是不可避免,会有卡顿的时候,我们假设有一个守护者进程,专门监听主界面是否
CRASH,并尝试重启,其中一个策略,就是不断给守护者进程发送心跳,假设1秒发送一次心跳,3秒未收到心跳包,则表示已经Crash,如果
在主线程发送心跳,一旦执行一个耗时的操作,就会错误的人为,主进程已经Crash,因此单独开启一个线程发送心跳包,就显得格外重要。
例如:Filmora中视频渲染线程。
本身就有消息循环的第三方库
基于Qt的UI开发,主界面一般性情况都有Qt包装的消息循环,如果某一个模块需要第三方库支持,且该库自己包装了消息循环,或者while循环,
亦或者本身存在一些必须调用的阻塞性接口,那么我们只能单独开一个线程,去加载该模块。
多线程有哪些不安全的行为
一个进程的所有线程都共享该进程的所有的资源,它的不安全性,可以总结一个句话,就是线程执行环境的资源的不安全性,亦或者就是与其它
线程共享资源的不确定性,大致分为两类:1、容器的多线程读写,2、线程的生命周期高于执行环境的声明周期,别的情况暂没有想到。
容器的多线程读写
这玩意,我们都知道,用锁,高级一点,用读写锁,递归锁等。这里分开讲解两点,为什么要锁?可不可以不用锁?
为什么要锁?我们以vector为例,我们知道vector在添加的时候,有一个扩容的过程,扩容通常的情况下,认为需要重新申请一块大一点的内存,把原本的
数据copy过来(元素不存在copy行为,只是最简单浅拷贝)。但是假设另外一个线程在访问,然后切换到当前线程进行插入并扩容,在切换到
该线程,显然此时后续的迭代器全部失效,那就会崩溃。
可不可以不用锁呢? 标准库中几乎所有的容器,考虑性能问题,都不是线程安全的,因此,我们都需要锁,我们需要锁的核心原因,是多线程同时访问的不安全
性,如果不用锁,可以考虑以下几个思路。
1、通常情况,多线程读写,一般具备读写分明,就是有的线程,专门负责收集数据,并写入数据,其它的线程,仅仅只是读数据,如果读线程不需要那么及时
的数据,可以考虑多份缓存,比如,写进程单独一份数据,写完告知另外一个线程,我已发生更改,另外一个线程根据行为同步一次数据,这样操作,可以
保证读写的数据不是同一份容器,这样操作,如果读的行为非常的平凡,就能够提高读的效率,但数据可能不是最新的。
缺陷非常明显:1. 数据并不是实时的,具有短周期的差异性,2. 需要更多的内存。
2、方案1的缺陷,需要更多的内存,有时候是不可接受的,同时也不想用锁,因为用锁会极大降低效率,例如Everything的文件搜索,windows的文件系统变更
是非常平凡的,尤其是C盘,1秒可能会变更10多次,而搜索时,又希望达到毫秒级,使用锁,肯定没法接受。具体方案比较复杂,但容器之所以不能同时
读写的原因,就是写的操作,可能会短期内破坏容器结构,因此我们可以考虑自定义一个写操作不会破坏结构的容器。
我以前实现一个类Everything搜索引擎,搜索速度几乎媲美Everything,由于权限以及其他一些原因,最终是服务+多客户端的实现方式,所以不是简单的跨
线程,而是跨进程,加载系统中所有的文件数据,需要使用200-300M的内存,所以使用的共享内存建立的内存池。服务用于扫描系统中的文件,并监听系统
文件的变更(用到MFT+USN)。而客户端提供搜索的方案,为了让搜索更快速,且不能够读取到无效数据,怎么读写变得尤为重要
其实就是遵守以下几个原则:
1. 写操作不能破坏容器结构,删除,仅仅修改一个标记量,并收集该项到容器中,新增,则重新获取一个可用且连续的内存,更新数据。
2. 单项还有一个标记量,如果写时,正在读,则等着读完再写。
3. 如果读该项,发现该项正在写,则直接跳过(为了保证搜索效率,不能等)。
4. 写完之后,会通过命名管道,通信的方式通知到客户端,完成更新。
线程的生命周期高于执行环境的生命周期
这个问题,目前是Filmora的重症问题,这个问题,我们Filmora的一个实例的来讲解。在讲解实例之前,我们来看看这样一个例子。
struct BBB{
void func(){ qDebug() << "BBB:func"; }
int a = 3;
void func2() { qDebug() << "BBB:func2-" << a; }
virtual void func3() { qDebug() << "BBB:fun3";}
};
BBB* bbb = nullptr;
bb→func(); // 会崩溃吗?
bb→func2(); // 会崩溃吗?
bb→func3(); // 会崩溃吗?
有人会觉得,所有情况都会崩溃,空指针调用,肯定都崩溃,但实测你会发现,第一种情况不会崩溃。我在动态多态一篇中,
有讲到类普通成员函数的本质,类成员函数只不过隐藏了一个this参数的普通函数,就是调用一个类的普通成员函数,就是
调用了一个普通函数,只不过第一个参数传了一个this, 传了一个空的this,你没有用,它怎么可能会崩溃呢?
func2使用了this→a,肯定没有,崩溃,func3是虚函数,调用时根据虚函数表,动态决定调用哪个函数,空指针没有虚函数表,
崩溃。
总结而言:一个类对象的普通函数被调用,如果该对象已经释放,但如果你没有访问类的成员变量,是不会崩溃,一旦访问
大多数情况,会崩溃。
实例


简单的描述一下,构造时,设置监听回调,其它线程会调用queryTimbreFinished, 对象释放时,会断开次回调。
对象没有释放,肯定不会崩溃,对象释放时,断开回调,也不会崩溃。是不是发现怎么也不会崩溃呢??
但是,回调的调用速度虽然快,但它并非原子操作啊,它是可以被拆分的,假设回调刚好执行一半,假设这里
刚好执行到invokeMethod之前,亦或者invokeMethod刚好完成入栈,切换到主线程,主线程恰好释放了当前
对象,再切换会该线程,会崩溃吗?
当然会崩溃,当然你们可能会想,怎么会那么凑巧,这就是为什么,你本地没法复现的原因,但是这样的代码,
是经不住压力测试,低概率,经不住基数变大,因此外网用户一定会存在崩溃的。
怎么解决这样的问题呢?
一些不安全的方法,踩过的坑
我以前用过很多方案,踩过很多坑哈,我一一列出来。
1、加锁
我们成员变量中,定义一个QMutex,是在invokeMethod前锁住,调用完成之后解锁,然后释放时,也锁一下。
这样,我们如果正在调用invokeMethod,主线程必须等我调用完invokeMethod之后才能释放,至于invokeMethod
调用完成之后,会压入一个关于this的消息到主线程中,如果执行了该回调,肯定会崩溃,但是Qt的事件循环机制,
会保证这点,它在释放完成之后,会清空这个消息。
但是你们有没有发现,这就是一个脱裤子放屁的方法,因为在锁之前,仍然是可以切换到主线程释放当前对象的,
这个锁都被释放了啊!!! 锁是this的一部分,本身就不安全的啊。
当然这个手段,显然降低了崩溃率,因为invokeMethod调用复杂度远远高于锁的本身,使用锁已经大大的降低了
线程执行流程时可拆分的范围。
2、判断一下this是否安全
我们用一个全局容器,QSet<XXX*>, 当前构造时,压入当前指针,释放时,弹出当前指针,在调用invoke时,我们
判断一下,当前指针是否被释放,如果被释放,则不调用,没有被释放,则调用invoke。
这样是不是非常的安全???
当然不是拉,判断跟执行并非原子操作啊,刚判断完是安全的就切换到主线程释放了当前对象,在切回来,咋办?
依然会崩溃。
显然这种操作,又又又更加降低了崩溃的概率。因为可切换的范围,更小了呦,甚至外网的崩溃率,可能偶尔才有一个哦。
当然记得QSet<XXX*>最好是包装一下,这是容器,要上锁。
安全的方法是什么呢?
当然啦,如果持有线程句柄,你可以考虑,在释放时,等待线程执行完成,那也是安全的哦。
可是有一些线程回调,你没法持有线程句柄。怎么办了?
其实也非常简单,就是不要在线程调用任何关于this的成员变量,第一步讲到过,调用普通成员函数,即便this变成野指针
甚至是空指针,都没有关系的啦,只要你没有访问this的成员变量,它是不会崩溃。
当然又有人会说,我都不调用成员变量,我用this干嘛,确实哈,不过有一点,调用this不一定崩溃,this只不过一个指针,
指针只不过是一个存储了地址的变量而已,只要不直接或者间接执行this->,它就不会崩溃。
这里我们讲一个不会崩溃方案,就是使用SendCustomEvent,调用它为什么会安全呢?留给你们一点自己琢磨的空间。
如何简单的进行多线程编程
使用线程本身是非常简单的,但是它容易出问题,所以为了不让它出问题,代码写着写着变得更复杂,甚至又复杂又不安全。
所以,我们怎么怎么既简单又安全的进行多线程编程呢?
相信在座的各位,学习C++编程,最开始就是写一些简单的算术编程,程序一运行就退出,学会了while之后,就开始编写
电话薄,而Qt库就是把while循环成为了一个消息泵,或者叫做事件泵。主线程也是线程,同样其它的线程的使用方式,也就是那么
几种,不管使用那种方案,注意线程的执行环境是否安全即可。
这里我说一下,我之前做过一个样例,当时我写了一个局域网类通信的模块,我时限专门写了一个Demo, 该Demo的所有行为
都是发生主线程的,编写完成之后,再往主程序集成的时候,我封装了一个门面,并且在门面中启动一个线程,然后在线程中
构造了此通信模块,这样简单的处理之后,然后你会惊喜的发现,整个模块都移植到线程中,我只需要在门面中,简单封装几个
跨线程接口,就能很好的运行起来。
其实理解多线程,一定按多人协同的工作的模式,单独写的Demo, 整个模块只运行一个网络模块,移植到主进程,考虑到该模块
需要更平凡的环境,因此,我们需要单独招聘一个人,专门负责该模块,但是切记,该模块的事情,一定单独交给这个人(线程),
其它人就不要深入插一脚了,你要使用该模块干什么事,你告知这个人就可以了。
如果你需要从模块获取数据呢?
你可以选择问他,要它去查,你着急你就等着,你不着急,让它查好了再告知你。最危险的方式,就是你直接上手查,虽然最后一种方式
是最危险的,考虑到负责该模块的人,可能手头上有很多事了,没法及时告诉你,即便是最危险的,你也需要亲自上手,这个时候
你就需要考虑对数据上锁,保证其安全性。
线程池
理解多线程编程,我们多人协作的方式去理解和安排,大多数情况就能避免一些崩溃问题,理解线程池,我们也按多人协作的方式来理解
下面我们不举实例,而是采取漫谈的形式,我们假设,有这么一个工厂,他可以生成很多的产品,但是由于市场需求不稳定,有时候需要
生成的多,有时候生成的少,现在生成货物堆积在车间,我们需要招聘工人搬运到仓库去。
作为工厂方,需要招聘工人,那么两个方案:1. 招聘长期工人,2. 找平短期工人,我们假定市场的工人,可以随时招聘的到。
招聘长期工,显然不划算啊,考虑淡季和旺季生产速度不一样,招聘长期工,会导致浪费人力啊。
那就招聘短期工,有专门负责招聘工人的人,我们称其为招聘方,最简单的运行模式,就是有货物堆积,就开始摇人,来一个先给钱,
让它把东西搬运到仓库去,它就可以走了,但这种方式,显然有问题啊:
因为生产速度有时候慢,有时候快啊,这就导致一下一个问题,就是生产速度非常非常快的时候,招聘方就需要不停的摇人啊,摇人虽然
说是随叫随到,但也是有代价的,再者说,考虑搬运速度,可能会导致搬运通道严重堵塞啊。
因此,招聘工人,需要有上限,同时也希望招聘短期工人不要搬完一件货物就跑,明知道仓库有堆积,你回头继续搬啊。
于是又换了一种策略。
招聘方发现有货物堆积时, 招聘工人,然后登记再册,如果当前招聘的工人,但是如果已经达到上限,则不管,让现有工人慢慢搬。
而工人在搬完一件货物,都需要回头去看仓库有没有堆积货物,如果有堆积货物,就继续搬运,没有,告知招聘方,结算工资,并从册子划掉自己。
显然这种模式,大大优于第一种模式。
但是它让然有一个缺陷,工人搬完货物,回头发现没有货物,结完工资,正准备走,发现又有货物堆积了,于是,招聘方立马叫住它,不要走,
又有活了,重新上册,搬运货物吧,显然很麻烦,为了避免这种情况。
招聘方,建立了一个休息区,短期工人搬完货物,且没有继续要搬运货物时,可以先去休息区,休息一下。你可以选择休息半个小时,
如果还没有货物,再告知招聘方,我要走啦,结算工资把。
而招聘方发现货物堆积时,首先看有没有在休息区的工人,如果有,则唤醒他们,继续搬运,没有,则看再册的数量是否达到上限,如果没有达到,
继续招聘工人,达到上限,则不管拉。
但是有没有发现,短期工,显然他们只是普工,就是啥也干,但是都不是什么技术活,像这种具有规模的工厂,专门有一个招聘方,显然比较合适。
实际情况,还有一些场景或者公司,不具备这种规模的啊。找短期工,可就没那么简单,招手即来,挥手即去,上哪儿找啊,找一个长期工也不划算。
因此,有一种公司就应运而生了,那就是外包公司,他们就普通外包公司,仍然是以任务为驱动,他们会尝试去收集各种公司派单,或者一些个人派单。
然后再合理招聘工人,这些工人就是不停处理任务,会公司,看看有没有新的任务啊,没有的话,公司坐一会儿,结完钱回家,或者干别的私活去。
这就线程池的模式,我们来总结一下,线程池的一些特点:
- 线程池是典型的生产者和消费者模式,极限情况下,会出任务堆积过重。
- 线程池适合处理短期任务,如果所有再册的工人,都处理长期任务,那么就会导致任务堆积。
- 线程池需要处理任务,他们是没有关联型的,且无法保证处理结果的顺序。
聪明的你,有没有,如果一个线程池,只有一个线程,是不是没有第三个缺陷,且和带消息循环的单线程是不是差不多了呢?
是的,所以如果你的任务,需要保证执行结果的顺序,那就不要使用线程池,而是考虑单独开一个有消息循环的线程。
同样有消息循环的线程,同样也满足1,2特征。
多线程崩溃的特征
低概率性
上面我们说了,很多凑巧,凑巧切换到别的线程,把当前线程的执行环境,给破坏了,导致当前线程崩溃。因此它具备
低概率性的特征。
随机性
单核的情况下,多线程的崩溃,取决于,什么时候切换线程,线程切换的点,具备随机性,当切换回来时,遇到被破坏
的环境,则会产生崩溃,因此,通常崩溃,具备随机性。
运行环境的差异性
通常线程切换导致的崩溃,只有在某一段局部代码,发生切换时,可能会导致崩溃,因此,在不同性能机器上,就容易
出现差异性,性能好的机制,可能会让,在某一段局部代码,发生切换的可能降低几乎为0的情况,而性能差的有或者
部分接口调用慢的情况,都可能会导致在那一段,可能会导致在哪一段局部代码触发切换的概率增加。
评论(0)