1.1 单核 CPU
在早期的单核 CPU 时代还没有线程的概念,只有进程。操作系统作为一个大的“软件”,协调着各个硬件(如CPU、内存,硬盘、网卡灯)有序的工作着。在双核 CPU 诞生以前,我们用的 Windows 操作系统依然可以一边用 word 写文档一边听着音乐,作为整个系统唯一可以完成计算任务的 CPU 是如何保证两个进程“同时进行”的呢?时间片轮转调度!
注意这个关键字「轮转」。每个进程会被操作系统分配一个时间片,即每次被 CPU 选中来执行当前进程所用的时间。时间一到,无论进程是否运行结束,操作系统都会强制将 CPU 这个资源转到另一个进程去执行。为什么要这样做呢?因为只有一个单核 CPU,假如没有这种轮转调度机制,那它该去处理写文档的进程还是该去处理听音乐的进程?无论执行哪个进程,另一个进程肯定是不被执行,程序自然就是无运行的状态。如果 CPU 一会儿处理 word 进程一会儿处理听音乐的进程,起初看起来好像会觉得两个进程都很卡,但是 CPU 的执行速度已经快到让人们感觉不到这种切换的顿挫感,就真的好像两个进程在“并行运行”。
如上图所示,每一个小方格就是一个时间片,大约100ms。假设现在我同时开着 Word、QQ、网易云音乐三个软件,CPU 首先去处理 Word 进程,100ms时间一到 CPU 就会被强制切换到 QQ 进程,处理100ms后又切换到网易云音乐进程上,100ms后又去处理 Word 进程,如此往复不断地切换。我们将其中的 Word 单独拿出来看,如果时间片足够小,那么以人类的反应速度看就好比最后一个处理过程,看上去就会有“CPU 只处理 Word 进程”的幻觉。随着芯片技术的发展,CPU 的处理速度越来越快,在保证流畅运行的情况下可以同时运行的进程越来越多。
1.2 多核 CPU
随着运行的进程越来越多,人们发现进程的创建、撤销与切换存在着较大的时空开销,因此业界急需一种轻型的进程技术来减少开销。于是上世纪80年代出现了一种叫 SMP(Symmetrical Multi-Processing)的对称多处理技术,就是我们所知的线程概念。线程切换的开销要小很多,这是因为每个进程都有属于自己的一个完整虚拟地址空间,而线程隶属于某一个进程,与进程内的其他线程一起共享这片地址空间,基本上就可以利用进程所拥有的资源而无需调用新的资源,故对它的调度所付出的开销就会小很多。
以 QQ 聊天软件为例,上文我们一直都在说不同进程如何流畅的运行,此刻我们只关注一个进程的运行情况。如果没有线程技术的出现,当 QQ 这个进程被 CPU “临幸”时,我是该处理聊天呢还是处理界面刷新呢?如果只处理聊天,那么界面就不会刷新,看起来就是界面卡死了。有了线程技术后,每次 CPU 执行100ms,其中30ms用于处理聊天,40ms用于处理传文件,剩余的30ms用于处理界面刷新,这样就可以使得各个组件可以“并行”的运行了。于是乎我们可以提炼出两点关于多线程的适用场景:
1.3 线程的生命周期
这里简单了解一下线程从创建到退出的过程。首先是「创建」一个新线程,等待 CPU 来执行;当 CPU 来执行时,如果该线程需要等待另外某个事件被执行完后才能执行,那该线程此时是处于「阻塞」状态;如果不需要等待其他事件,线程就可以被「运行」了,也可以称为正在占用时间片;时间片用完后,线程会处于「就绪」状态,等待下一次时间片的到来;所有任务都完成后,线程就会进入「退出」状态,操作系统就会释放该线程所分配的资源。
2.1 中断操作
既然在时间片轮转的过程中含有中断的操作,那么访问某些数据时就会产生很有意思的现象。为了更好的理解可重入和线程安全,我们从它们的起源开始讲起。
20世纪60年代中期开始,计算机系统进入第三代发展时期,一大批功能完善、集成度高的 CPU 涌入市场。这一时期的计算机除了 CPU 运行速度加快以外,还出现了中断装置、输入输出通道等。由于这些技术的快速发展,多个进程共享计算机硬件设备就成了操作系统的研究核心。而时间片轮转调度机制可以很好的解决这个问题,拥有该功能的操作系统称为「多任务操作系统」,例如我们熟知的 Windows。除此以外在嵌入式设备中的操作系统也拥有类似的技术,它们共同的特点就是 CPU 一会儿处理 A 进程,一会儿处理 B 进程,在切换任务的过程中都会有中断操作。
以下代码可以直观的感受到中断操作是个怎样的过程:
#include
#include
#include
#include
#include
using namespace std;
void signalHandler( int signum )
{
qDebug() << "Receiv signal (" << signum << ").";
}
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
signal(SIGINT, signalHandler);
while (1) {
qDebug() << "Go to sleep...";
sleep(1);
}
return a.exec();
}
上述代码很简单,要想让操作系统中断某个进程就必须将中断信号传给这个进程,而 signal(SIGINT, signalHandle) 函数的作用就是一旦产生中断(SIGINT,中断信号的一种),该进程就会执行 signalHandle() 函数。在该进程运行过程中我们手动按下 Ctrl+C 人为的产生一个中断信号,此时就会执行 signalHandle() 函数,即输出“Receiv signal...”信息。
中断操作就会引发接下来我们要讲的可重入问题。
2.2 可重入问题
可重入问题是在多任务的环境下诞生的,此时还没有多线程什么事。在嵌入式系统、实时操作系统下遇到可重入问题的次数会很多,比如某个硬件产品中的 MCU(也叫单片机微控芯片)上挂了很多传感器,如加速度、光感应、陀螺仪等。这些传感器属于公共资源,任何一个进程都有权去访问它们的数据,这些数据称为「全局变量」,即 global_value。
假设该产品的程序员在读取这些传感器上的数据是采用定时和中断的方式进行,写了两个程序(A 进程和 B 进程)去修改某个全局变量 global_value 。其中 A 进程要修改 global_value 5次,而 B 进程直接赋值一次。会有这么一种情况出现,A 进程刚刚修改到第三次时系统出现了中断信号,CPU 被强制调度去执行 B 进程,B 进程修改了 global_value 值后 CPU 再度返回 A 进程继续运行代码来进行第四次修改 global_value 的值。但是,此时 A 进程所面对的 global_value 已经不是它离开前的值了,那么这段代码就是不可重入的。这段代码如果是放在某某函数中,就称这个函数是不可重入函数。
在单进程中也会出现因访问全局变量而产生不可预期的结果,如下列代码所示。在正常运行过程中产生中断操作与否会导致不同的结果出现。
#include
#include
#include
#include
#include
using namespace std;
int global_value = 0;
void signalHandler( int signum )
{
int i = 0;
while (i++ < 5) {
++global_value;
qDebug() << "Global value is " << global_value;
sleep(1);
}
}
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
signal(SIGINT, signalHandler);
signalHandler(2);
qDebug() << "The value is " << global_value;
return a.exec();
}
因此,可重入函数就是那种执行期间被中断,重新恢复到断点继续执行时内部数据在任何情况下都不会发生改变的函数。
2.3 线程安全
计算机的发展来到多线程时代,既然线程是一种轻量级进程,那么切换线程时产生的中断也会带来同样的问题。
如上文所述,与进程不同的是,多个线程共享同一个进程的地址空间,切换的前后会保存上下文环境。问题来了,既然保存了上下文环境,怎么还会出现依赖的环境发生了改变的现象呢?这是因为中断时保存的上下文环境仅限于返回地址、寄存器等少量的上下文,而函数内部使用的全局变量、static 静态变量、缓存等并不在保护之列。因此,如果这些值在函数中断期间发生了改变,那么当函数返回断点继续运行时产生的结果就不可预料了。
因此,可重入的场景多存在于多个进程同时调用,而线程安全是多存在于多个线程同时调用。
面对访问公共数据所遇到的这些问题,显然对其访问必须是序列化的,即 A 线程必须原子级别的执行123步骤后,B 线程才能执行相同的步骤。要做到这一点,通常的做法是加互斥锁、信号量等同步线程方面的协调操作。
3.1 线程类
首先就是 QThread 类,它是所有线程类的基础,该类提供了很多低级的 API 对线程进行操作,每一个 QThread 对象都代表一个线程。使用该类开新线程并运行某段代码的方式一般有两种:(1)调用 QObject 的 moveToThread() 函数将 QObject 对象移到新开的 QThread 线程对象中,这样 QObject 对象中所有的耗时操作都会在新线程中被执行;(2)继承 QThread 并重写 run() 函数,将耗时操作的代码放入这个函数里执行就可以了。除此以外,还有 QThreadStorage 类用于存储主线程的数据,当然这属于辅助性的类,是否采用取决于产品的设计思路。详情参考《Qt 多线程编程之敲开 QThread 类的大门》。
上文我们说过“进程的创建、撤销与切换存在着较大的时空开销”,因此出现了线程这种轻量级进程技术。如果还想进一步的降低系统资源开销,人们想出了一个办法,就是让执行完所有任务的线程不被销毁,让它处于“待命”的状态等待新的耗时操作“进驻”进来。Qt 提供了 QThreadPool 和 QRunnable 这两个类来对线程进行重复的使用。使用的方法一般是将耗时操作放入 QRunnable 类的 run() 函数中,然后整体把 QRunnable 对象扔到 QThreadPool 对象中就可以了。详情参考《Qt 多线程编程之降低线程的开销》。
为了加快写代码的速度,我们不可能每个场景都非得用 QThread 这种低级类。如果遇到上文所述“多线程访问数据会遇到的挑战”的那样,每次我都手动加互斥锁必然会加大我们的工作量。因此,Qt 提供了 QtConcurrent 模块,该模块中有很多高级函数用于处理一些常见的并行计算模式,最大的特点就是无需再使用互斥锁这种很低级的操作,全都封装好了。除此以外,QFuture、QFutureWatcher、QFutureSynchronizer 类提供了一些辅助性的操作。详情参考《Qt 多线程编程之高级函数》。
3.2 解决访问共享资源遇到的问题
到这一步我们已经可以游刃有余的开新线程去执行耗时操作,接下来就要解决多线程编程所面临的核心问题了,主要思路有两个:(1)既然同时访问不可行,那我们可以让线程有序的排队去处理,称为「同步线程」;(2)既然造成这种混乱局面的罪魁祸首是中断机制,那我们可以不让它被中断,称为「原子操作」。
同步线程就是让多个线程有序的去处理同一个变量,不要抢不要挤。有时候 A 线程需要等待 B 线程,强制线程彼此等待的原则称为互斥,这是一种保护共享资源的常用技术。QMutex 就是提供互斥操作的基本类,它可以让 A 线程访问某个全局变量时加上锁,那么在 A 线程没有执行完的情况下,B 线程是无法访问这个变量的,直至 A 线程处理完后进行解锁操作。除此以外,还有 QReadWriteLock、QSemaphore、QWaitCondition 这些辅助类来提高多线程的效率。详情参考《Qt 多线程编程之同步线程》。
解决“多线程访问数据”的另一条思路就是原子操作。原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始就一直运行到结束,中间不会有任何的切换存在。因此,原子操作根本就不需要同步线程了。这种技术很复杂,复杂到 C 语言中根本就没有这样的操作,因为大部分的原子操作都是用汇编语言实现的。强大的 Qt 提供了原子操作的类,如 QAtomicInteger、QAtomicPointer 类。详情参考《Qt 多线程编程之原子操作》。
3.3 不同线程类的适用场景
虽然 Qt 提供了三种线程类(参考上文“3.1线程类”),但是它们的适用场景不同。在接下来的文章中我会逐个展开来讲解,最后会用一篇文章来总结,详情参考《这些 Qt 多线程类该用在哪里》。