系列文章撰写至今已到了第四篇。从这篇开始,我们会在几篇文章内,集中讨论并发与多线程,以及如何在 C++ 中编写多线程的代码。对于使用其它语言的程序员,应当也能从中获取到有益的信息。
并发与并行
在此,我们讨论的一系列概念中,最简单也是最基本的,是并发 (Concurrency)。并发的「并」指的是在时间上同时,而「发」指得是事件的发生。因此,所谓的并发,指的就是「两个或者多个事件同时发生」。
这是一个相当简练的定义。你可能对它有了一些理解,但是大概率,你还没有理解透彻。因此,我们尝试举几个例子看看。
在学生时代,很多同学都尝试过所谓的「一心多用」。我见过厉害的学生,可以在上数学课的时候,一边听讲,一边赶工物理作业。更普遍一些的例子,大部分人,都能一边走路一边打电话、一边吃饭一边聊天。这些事例都属于「并发」的范畴,这也是通常大家理解的并发。
实际上,还有一种类型的并发。有一个笑话,说的是:你们别看美国强大,实际上,咱们中国人在工作的时候,人家美国人在呼呼大睡。你看,在同一时间,「中国人工作」和「美国人睡觉」这两件事情,同时发生了,那么这就是并发。
上述两类例子的区别在于,第一类例子,我们说的都是「一个人」同时干不同的事情;而第二类例子,说的是「多个人」分别同时干不同的事情。当然,这都算是并发。
并行 (Parallel) 的概念则与并发稍有不同。相较于并发,并行的要求更加严格。在说明什么是并行之前,我们首先再看一些例子。
上面说到,一些厉害的学生可以在上数学课的时候,同时做物理作业。由于人们在做数学任务和物理任务时,涉及到大脑的区域是几乎重叠的,所以人们不可能真的同时处理不同的数学和物理任务。这些学生的特异功能,实际上在于他们能够快速地在两类任务之间做切换。这也就是说,实际上在具体到某一时刻,这些学生要不然在听数学课,要不然在做物理题。
与之不同的是,中国人工作和美国人睡觉,这两件事情,真真切切在同一时刻是同时发生的。
这两类例子之间的区别也就比较明确了:第一类例子,具体到某一时刻,事件并没有同时发生;而第二类例子,具体到某一时刻,事件也是同时发生的。我们说,第一类例子只能说是并发;而第二类例子则可称是并行,既是并发也是并行。
在大多数的讨论中,我们不需要严格区分并发与并行。只在少数特定的情况下,区分并发和并行才有意义。索性,这些情况根据上下文,是很容易就能判断出来的。
计算机中的并发
系列文章的第二篇介绍了操作系统以及进程、线程相关的知识。其中,对于操作系统,我们说操作系统基本上就完成了两件事情:
- 对上提供抽象接口供调用;
- 对下负责硬件资源的管理。
而硬件资源特别是 CPU 的计算资源是昂贵的,所以操作系统就有责任尽可能高效地利用硬件资源。对于 CPU 资源来说,人们先后发展了多道程序、分时系统与多任务系统等任务调度方式。其中,分时系统将 CPU 的执行时间分成小片,分配给多个任务交替使用。当这些分片足够短的时候,作为操作者的人类,就无法分辨出其中的「交替」的部分了。通过这样的方式,在操作系统的层面,计算机首先有了「并发」。不过,这种并发并不是真正的并行。
稍微仔细的思考,就不难发现,真正的并行是对计算机硬件层面的要求。如果硬件不支持在同一时刻处理多个任务,那么不论操作系统如何优化,整个计算机都只能并发而不能真正意义上的并行起来。
真正的并行,要等到多处理器计算机(各种超级计算机都属此类)和单芯多核处理器装配到计算机中了。这些机器,在硬件层面可以在同一时刻处理多个任务,所以有了真正并行的可能性。与只是并发的情况相比,真正的并行可以避免任务切换过程所需上下文切换带来的开销。因此,一般来说,真正的并行,CPU 的使用效率要更高一些。
当然,这并不是说任务切换在多处理器计算机或者单芯多核处理器计算机上就不存在了。比如,在单芯双核处理器上同时处理 3 个或以上任务,那么不管任务如何调度,都会需要做上下文切换。
多进程与多线程
系列第二篇文章介绍了进程和线程的概念。对于操作系统来说,一个任务可以由进程完成,也可以由线程来完成。因此,很自然地,在计算机中实现并发就有两种方式:多进程和多线程。
进程和线程最本质的区别,在于隔离与共享上的不同。对于多进程来说,各个任务之间由操作系统保证了相互隔离。若要在多进程之间进行数据的传递、共享,必须要依赖操作系统信号、套接字、文件、管道等等。对于多线程来说,各个任务实际上处在同一进程空间,大多数的资源都能在内存空间中传递、共享,十分方便。但是另一方面,由于隔离不严,所以会出现十分棘手的「线程安全」问题。
出于以下一些考虑,这篇文章及其可以预期的后续,言及「并发」都默认使用多线程来实现。
- C++ 标准目前只支持了多线程,而没有支持多进程;
- 进程间通信的开销较大,而线程安全的问题是可以解决的。
为什么/为什么不
并发当然有其好处,但也不是没有代价。从程序员的角度来说,至少编写并发的代码会复杂很多。因此,思考「为什么要用并发」和「为什么不用并发」是有意义的。
为什么:性能
在 CPU 的进步过程中,基本可以把 CPU 设计者们的努力分成两个阶段。
- 提高时钟频率以及单指令流性能;
- 专注于多线程和多核结构。
在 2005 年以前,CPU 的设计者们拼命地提高 CPU 时钟频率,不断做执行优化,想方设法提高缓存的命中率。但是这些方面的提高,终究是有天花板的。因此,在 2005 年,随着 Intel 和 AMD 开始在 CPU 中引入多核技术,基本上宣告了以往通过提高始终频率以及单指令流性能的方式提高 CPU 性能的死亡。
对于程序员来说,这意味着,以往做需要坐等更加厉害的 CPU 出现,就能自动获得更好性能的日子,一去不复返了。程序员们必须学着让自己的程序,并发起来,以便充分利用多核 CPU,甚至多个 CPU 的计算能力。
对于并行来说,它提高程序性能的方式基本有两种。
- 将原本的任务,在算法或者数据上拆分成若干部分,并发执行;
- 不拆分原本的任务,但是并发地执行多份任务。
这里,前者是在更短的时间内完成了同样的任务;而后者是在同样的时间内完成了更多的任务。
为什么:关注点分离
所谓关注点分离 (separation of concerns, SOC),指的是在编写代码时,将不相关的功能对应的代码分开。对于我们的主题来说,分离关注点,可以让我们的程序在不同的线程执行不同的任务,以便使业务逻辑更加清晰。
举例来说,大家都爱使用的「网易云音乐」,至少在逻辑上可以分成两个部分。
- 用于响应用户交互的界面;
- 用于播放音乐的后台。
当然,你可以把这两个部分放在一个线程里执行。但这就意味着,你的代码,必须把这两部分内容,放在一起,以供调用。而若是将这两个部分放在分别的两个线程中执行,两部分代码就可以分开了;我们只需要做好线程之间的通信、响应就可以了。
为什么不:收益低于代价
这基本上是放之四海而皆准的真理了。不过,总还会有值得分析的东西。
为了理解什么时候可能发生「收益低于代价」,我们也应该从两个方面去分析。
- 什么情况可能降低收益;
- 什么情况可能提高代价。
首先,启动线程,意味着操作系统需要为线程分配相关资源,并划定堆栈空间。因此,启动线程不可避免地会有固定的开销。于是,如果线程上执行的任务非常琐碎,执行时间与线程启动、销毁的时间可以比拟的话,那么使用多线程收益就很低了,也就不划算了。
其次,线程是有限的资源。一方面,每个线程都需要自己的堆栈空间,过多的线程可能耗尽虚存空间。(当然,对于 64 位的机器来说,这种情况基本不会遇到)另一方面,线程 ID 是有上线的,会有类似 *nix 下 fork 炸弹的问题。再者,哪怕线程可以无限制地开启,线程使用到的其他资源,也是有限制的。最后,大量的线程,意味着 CPU 需要频繁地在线程之间做上下文切换,占用 CPU 执行时间。总的来说,当前处理的线程数量,如果已经较多,那么再开启新的线程,边际收益就非常低了。此时,再使用多线程,就不划算了。
从代价的角度来说,编写和维护多线程的代码通常是困难的。使用并发的代码,通常情况下会难以理解。因此,编写和维护并发的代码,会平添很多脑力成本。此外,由于增加了代码的复杂性,出错的可能性、排查错误的难度,都会升高。
因此,综合上面这些因素,如果使用多线程带来的性能收益以及分离关注点带来逻辑清晰方面的收益比不上开发并发代码带来的成本,那么就不应该使用并发了。
C++ 与并发
多线程进入 C++ 标准
很多人可能会问,以前 C++ 也能写多线程的程序。为什么要把这件事单独拿出来说呢?
是的,没错。如果 C++ 以前不能写多线程的程序,那么 C++ 可能在十年前就会被逐渐淘汰了。但是,在多线程进入 C++ 标准之前,人们使用 C++ 编写多线程的程序,只能依赖操作系统提供的 API。比如,在 POSIX 环境下,就只能使用 pthread
库,实现多线程;在 Windows 下,又得换成 CreateThread
了。
也许你已经发现了,C++ 标准收容多线程最大的意义就在于「承认」。在以前,C++ 标准是不承认有多线程这么回事的。因此,人们若是想要实现多线程,就不得不依赖于平台相关的 API。这也就给代码的跨平台移植带来了很大的困难。而在 C++ 11 收容多线程之后,使用标准库提供的多线程设施,C++ 程序员就可以很简单地编写出多线程的程序,而无需担心底层平台如何。
多线程标准库的效率
标准库所作的工作,实际上是在各个平台上,将平台提供的接口抽象出来,成为统一的方案。所谓抽象就有代价,因此使用多线程的标准库编写的程序,在效率上一定存在天花板。
但是,如同我们在C++ 的几个基本原理和技术中提到的那样,C++ 尝试了所谓的零开销地抽象机制(zero-overhead abstraction mechanism)。也就是说,C++ 在提供统一的抽象接口时,尽可能地避免了抽象带来的性能损失。换用更加通俗的话来说
- 不存在在任何情况下,效率都比标准库设施高的实现;
- 因为,如果存在这种实现,它就会成为新的标准库。
因此,在主流平台上使用 C++ 进行并发编程,我们应该首先考虑使用标准库提供的设施。
下一篇开始,我们将介绍如何使用 C++ 提供的标准库,进行并发编程,以及避免一些常见的「坑」。