文章目录
前言
我们都听说过摩尔定律:预计18个月会将芯片的性能提高一倍。早期的计算机时钟频率较低,比如1985年intel 80386 工作频率只有20 MHZ,提升CPU 核心的时钟频率带来的性能收益更大,到2006年Intel Core 2 处理器已经能够达到3.5 GHZ 的工作频率了。从2007年开始,CPU 时钟频率的提升就变得缓慢了,主要因为CPU 的功耗随时钟频率呈幂律增长,需要根据散热技术和制程工艺在性能与功耗间寻求平衡,既然CPU 时钟频率提升有限了,怎么继续按照摩尔定律提升性能呢?
CPU 是用来处理计算任务的,想要在单位时间内处理更多的计算任务,除了提升单核心的时钟频率让其计算的更快之外,还可以增加CPU 核心数,让多个CPU 核心协同计算,CPU 开始往多核心方向发展,到2019年AMD EPYC 2 代已经达到64核心128线程了。为了充分发挥多核心CPU 的性能,操作系统和编程语言对并发执行的支持越来越好,各种编程语言也陆续提供了并发编程的函数库,比如C++11 就新增了并发编程的线程支持库。我们想要让多核CPU 更好的发挥性能,更高效的为我们的程序服务,掌握并发编程思想还是很有必要的。
一、何为并发
刚开始接触计算机编程语言时,我们编写一个程序,在main入口函数中调用其它的函数,计算机按我们设定的调用逻辑来执行指令获得结果。如果我们想在程序中完成多个任务,可以将每个任务实现为一个函数然后根据业务逻辑逐个调用。但如果我们想让多个任务几乎同时执行(时间间隔很小,我们感觉是同时执行的一样),比如一边放歌一边显示歌词,恐怕实现起来就会有明显的顿挫感(比如先播放一句歌声,然后显示一行歌词),影响交互体验。
随着我们对计算性能的要求越来越高,多核心处理器很快普及流行。如果我们想让自己开发的程序更高效的运行,自然要充分发挥多核心处理器的优势。在多核心处理器上同时运行多个任务,比在单核心处理器上顺序执行多个任务高效的多。像单片机这种单核心处理器,在任务较多或者多个任务需要几乎同时执行时,也需要应用多任务并发编程提高对包括处理器在内的各硬件资源的利用效率。
1.1 并发与并行
说了这么多,那什么是并发呢?简单来说,并发指的是两个或多个独立的活动在同一时段内发生。并发在生活中随处可见:比如在跑步的时候同时听音乐,在看电脑显示器的同时敲击键盘等。
与并发相近的另一个概念是并行。它们两者存在很大的差别,图示如下:
- 并发:同一时间段内可以交替处理多个操作,强调同一时段内交替发生。
- 并行:同一时刻内同时处理多个操作,强调同一时刻点同时发生。
1.2 硬件并发与任务切换
既然并发是在同一时间段内交替发生即可,不要求同时发生,像单片机上的单核处理器也是可以支持并发多任务处理的,所以有单片机上跑的RTOS(Real-time operating system)诞生。单核心处理器上的多任务并发是靠任务切换实现的,跟多核处理器上的并行多任务处理还是有较大区别的,但对处理器的使用和多任务调度工作主要由操作系统完成了,所以我们在两者之间编写应用程序区别倒是不大。下面再贴个直观的图示:
- 双核处理器并行执行(硬件并发)对比单核处理器并发执行(任务上下文切换)
- 双核处理器均并发执行(一般任务数远大于处理器核心数,多核并发更常见)
1.3 多线程并发与多进程并发
前面一直在聊多任务并发,但计算机术语中用得更多的是线程与进程,三者的主要区别如下:
- 任务:从我们认知角度抽象出来的一个概念,放到计算机上主要指由软件完成的一个活动。一个任务既可以是一个进程,也可以是一个线程。简而言之,它指的是一系列共同达到某一目的的操作。例如,读取数据并将数据放入内存中。这个任务可以作为一个进程来实现,也可以作为一个线程(或作为一个中断任务)来实现。
- 进程:资源分配的基本单位,也可能作为调度运行的单位。可以把一个进程看成是一个独立的程序,在内存中有其完备的数据空间和代码空间。一个进程所拥有的数据和变量只属于它自己。例如,用户运行自己的程序,系统就创建一个进程,并为它分配资源,包括各种表格、内存空间、磁盘空间、I/O设备等。然后,把该进程放人进程的就绪队列。进程调度程序选中它,为它分配CPU以及其它有关资源,该进程才真正运行。所以,进程是系统中的并发执行的单位。
- 线程:执行处理器调度的基本单位。一个进程由一个或多个线程构成,各线程共享相同的代码和全局数据,但各有其自己的堆栈。由于堆栈是每个线程一个,所以局部变量对每一线程来说是私有的。由于所有线程共享同样的代码和全局数据,它们比进程更紧密,比单独的进程间更趋向于相互作用,线程间的相互作用更容易些,因为它们本身就有某些供通信用的共享内存:进程的全局数据。
由上面的定义可以看出,一个进程和一个线程最显著的区别是:线程有自己的全局数据。线程存在于进程中,因此一个进程的全局变量由所有的线程共享。由于线程共享同样的系统区域,操作系统分配给一个进程的资源对该进程的所有线程都是可用的,正如全局数据可供所有线程使用一样。
在Mac、Windows NT等采用微内核结构的操作系统中,进程的功能发生了变化:它只是资源分配的单位,而不再是调度运行的单位。在微内核系统中,真正调度运行的基本单位是线程。因此,实现并发功能的单位是线程。在Linux系统中,线程的实现和进程并不特别区分,线程只不过是一种特殊的进程。多进程并发编程与多线程并发编程的区别主要在有没有共享数据,多进程间的通信较复杂且代价较大,主要的进程间通信渠道有管道、信号、文件、套接字等。由于C++没有提供进程间通信的原生支持,后续主要介绍多线程并发编程,和多线程间的同步与通信。
二、如何使用并发
2.1 为什么使用并发
在应用程序中使用并发的原因主要有两个:关注点分离和性能。事实上,我甚至可以说它们差不多是使用并发的唯一原因;当你观察的足够仔细时,一切其他因素都可以归结到这两者之一(或者可能是二者兼有)。
- 关注点分离:通过将相关的代码放在一起并将无关的代码分开,可以使你的程序更容易理解和测试,从而减少出错的可能性。你可以使用并发来分隔不同的功能区域,即使在这些不同功能区域的操作需要在同一时刻发生的情况下;若不显式地使用并发,你要么被迫编写任务切换框架,要么在操作中主动地调用不相关的一段代码。
- 更高效的性能:为了充分发挥多核心处理器的优势,使用并发将单个任务分成几部分且各自并行运行,从而降低总运行时间。根据任务分割方式的不同,又可以将其分为两大类:一类是对同样的数据应用不同的处理算法(任务并行);另一类是用同样的处理算法共同处理数据的几部分(数据并行)。
知道何时不使用并发与知道何时使用它一样重要。基本上,不使用并发的唯一原因就是在收益比不上成本的时候。使用并发的代码在很多情况下难以理解,因此编写和维护的多线程代码就有直接的脑力成本,同时额外的复杂性也可能