6.5 多核时代的并发编程

几十年来,计算机硬件工业一直与摩尔定律相吻合:每18个月台式计算机的运行速度就会翻一倍。除了对算法和软件架构的改进,软件开发者们还依赖摩尔定律让他们的软件跑得更快。然而,由于单CPU的时钟频率越来越难提高,制造商们转而把精力放在增加CPU的核心数量上。这使得软件开发者们有机会让他们的程序真正并行地运行起来。图6-28展示了在单核CPU和多核CPU上运行并发程序的区别。

{%}

图 6-28 在单核CPU和多核CPU上运行并发程序的区别

如图6-28所示,当在单核CPU上运行多线程程序的时候,每一时刻CPU只可能运行一个线程。但由于操作系统内核会根据调度策略切换CPU运行的线程,所以一般情况下我们会感觉多个线程在同时运行。此处的同时运行实际上只能算作是并发运行。这就是该图左半部分向我们展示的内容。在该图的右边,一个双核心的CPU之上运行着一个拥有4个线程的进程。其中,线程1和线程2在CPU核心1上运行,线程3和线程4在CPU核心2上运行。在同一时刻,在线程1和线程2中只会有一个被运行,而在线程3和线程4中也只会有一个被运行。我们也可以说,这4个线程是被并发运行的。但是,在同一时刻,CPU核心1和CPU核心2上会分别运行着某一个线程。此时,我们可以说这两个线程正在被并行的运行。我们由此可以看出并发运行和并行运行的区别。并发运行是指多个任务被同时发起(或者说开始)运行,但是在同一时刻这些任务不一定都处于运行状态。这取决于CPU核心或者CPU的数量。相比之下,并行运行指的是在同一时刻可以有多个任务真正地同时运行。并行运行的必要条件是多CPU核心或/和多CPU的计算环境。由此可见,并行运行是并发运行的一个更高级的层次。或者说,并行运行的一个必要条件就是并发运行。

通过上面的图示和说明,我们可以得出一个结论:让程序真正在多核CPU上并行的运行起来的前提是采用某种并发编程方式来编写程序。在这个前提下,操作系统内核能够通过调度使多个进程或线程并行的运行于不同的CPU核心之上。这可以更加充分地利用计算机硬件以进一步提高程序的运行性能。因此,当代软件开发者的一个主要的开发或维护任务就是让程序被更加高效地并发运行。这里的高效是指,在保证程序的正确性和可伸缩性的前提下提升程序的响应时间和吞吐量。

在这里,我们再一次提到了这两个重要的程序性能测量指标——响应时间和吞吐量。响应时间是指从计算请求被递送到计算结果部分可用之间的实际耗时。对于一个长期运行的程序(比如各种服务端程序)来说,响应时间是非常重要的性能指标。吞吐量是指在一个时间单元(比如秒或分钟)之内程序完成并输出结果的计算任务的数量。采用多进程或多线程编程是可以使此指标提升的主要方法。不过,这也取决于实际运行程序的计算机硬件的性能(比如CPU的时钟频率和核心数量)。

然而,与并发编程关系更加紧密的是程序的正确性和可伸缩性。正确性是指程序的行为应该与程序设计者的意图严格一致。即使在它被并发地运行的时候也应该如此。因为进程或线程间的上下文切换可能发生在任何时刻,所以并发程序应该保证那些被并发执行的操作的有效性和完整性。这一般可以通过我们之前讲到的各种同步方法和原子操作来实现。并发程序的可伸缩性主要体现在增加CPU核心数量的情况下,其运行速度不会受到负面的影响。乍一看,这应该是理所当然的。因为CPU核心数量的增加意味着计算机硬件性能的增强。这种增强理应使程序运行得更快。但是,事实上,并发程序运行速度的提升曲线会随着CPU核心数量的增加而趋于平缓。这种趋于平缓的态势主要取决于并发程序中的原子操作和同步方法的数量和执行耗时。对同一个程序来说,其中的原子操作和同步方法的数量越多、执行耗时越长,其运行速度的提升曲线趋于平缓的态势就越明显。为什么会这样?其根本原因就在于这些操作和方法的复杂性。这里的复杂性倒不是说我们使用它们会有多复杂,而是说它们的实现会比较复杂。就互斥量来说,为了消除竞态条件,它让多个线程不能同时执行临界区中的代码。换句话说,各个线程只能串行的执行这些被同步的代码。这可以使并发程序得以正确地运行。可是,这种在并发运行过程中的串行化执行是需要付出代价的。这需要操作系统内核和计算机硬件(主要指CPU)的共同努力才能够完成。这涉及一些必要的底层协调工作,尤其是当CPU核心不止一个的时候。这种协调工作本身就会对CPU运行程序的效率产生不可小视的负面影响。类似的协调工作越多,这种负面影响就会越大。在绝大多数情况下,CPU核心数量的增加也意味着在执行原子操作和同步方法的时候需要更多的协调工作。显然,这对并发程序的运行性能是有害的。这也是我们之前建议应该尽量编写可重入的函数的原因之一。其目的就是消除函数中的同步方法。不过,让并发程序的运行速度丝毫不受到因CPU核心数量的增加而产生的负面影响是不可能的。我们只能尽力而为之。

说到这里,读者可能会意识到,程序的正确性和可伸缩性之间有时候会存在一定的矛盾。事实也确实是这样。当并发程序中包含了对共享数据的操作的时候,保证这些操作的并发安全总是必须的。这也是保证并发程序的正确性的重要方法之一。其中,最直截了当的保证手段就是使用编程语言或者某些函数库提供的原子操作和同步方法。当然,这些操作和方法最终还是由操作系统和计算机硬件来支持的。使用这些保证并发安全的手段就意味着给程序的可伸缩性施加了更强的约束。这种约束与程序的运行性能是成反比的。由此可见,如果想在多CPU核心的计算环境中进一步提升并发程序的运行性能,以更加高效的方式来实现代码块的并发安全性应该是我们努力的方向。显然,减少对原子操作和各种同步方法的使用是最简单和有效的。但是,我们通常很难做到这一点。因为保证程序的正确性永远是第一要务。因此,以更加得当的方式使用这些操作和方法就至关重要了。下面,就此问题给出几条建议。

  • 控制临界区的纯度。临界区中仅应包含操作共享数据的代码。也就是说,尽量不要把无关代码囊括其中,尤其是各种相对耗时的I/O操作(注意,调用打印函数也会引发I/O操作)。夹杂无关代码只会让临界区的界限模糊并进一步影响程序的运行性能。

  • 控制临界区的粒度。由于粒度过细的临界区会增加底层协调工作的发生次数,所以有时候我们会粗化临界区。如果存在相邻的多个临界区,并且它们内部都是操作同一个共享数据的代码,那么就应该合并它们。若在它们之间夹杂着一些无关代码,则应该试着调整这些代码的位置,即把它们放在合并后的临界区的前面或后面。如果实在无法调整,我们就需要在临界区的纯度和粒度之间进行权衡。简单地说,如果其间没有长耗时的无关代码,那么就把它们合并在一起,否则就只能放弃合并。总之,我们应该优先考虑减少粒度过细的临界区。

  • 减少临界区中代码的执行耗时。提高临界区的纯度可以减少临界区中代码的执行耗时。但是,如果操作共享数据的代码本身执行起来就很耗时,那又该怎么办呢?这分为两种情况。第一种情况,临界区中包含了对几个共享数据的操作代码。在这种情况下,无论这些操作不同共享数据的代码之间是否存在强关联,我们都可以考虑把它们拆分到不同的临界区中,并使用不同的同步方法加以保护。这里不存在粒度过细的问题,因为它们针对的是不同的共享数据。不过这需要注意另外一些问题(详见6.3节中讲到的因互斥量的使用不当而造成的死锁问题)。第二种情况,临界区中仅包含了操作同一个共享数据的代码。这时我们往往不能通过调整临界区的方式达到减少耗时的目的。因为粒度过细的临界区反而会增加额外的时间消耗。所以,正确的做法应该是检查其中的业务逻辑和算法并加以改进以减少耗时。

  • 避免长时间持有互斥量。线程长时间持有某个互斥量所带来的危害是明显的。同样明显的是,在减少临界区中代码的执行耗时的同时可以减少线程持有相应互斥量的时间。不过,有时候使用另一个方法同样可以起到很好的作用。这个方法就是使用条件变量。条件变量会适时地对互斥量进行解锁和锁定操作,所以线程持有互斥量的时间会大大减少。在临界区中的代码会等待共享数据的某个状态的情况下,使用条件变量往往会达到非常好的效果。

  • 优先使用原子操作而不是互斥量。这样做的理由是,使用互斥量一般会比使用原子操作所造成的程序性能损耗大很多。并且,随着CPU核心数量的增加,这一差距会被进一步拉大。当我们的共享数据的结构非常简单(比如基础数据类型的数值)的时候,建议使用原子操作来代替附加了互斥量操作的代码。原子操作会直接利用硬件级别的原语来保证操作的成功和数据的并发安全。不过,遗憾的是,对于结构稍复杂一些的共享数据,原子操作就无能为力了。因此,这条建议的适用范围是比较有限的。

上述这些建议的最终目标都是在不失去程序的正确性的前提下最大限度地提高程序的可伸缩性。为什么要提高程序的可伸缩性?其原因是,对于程序的运行性能的提升来说,为运行它的计算机添加更多的CPU核心(或者更换一台拥有更多CPU核心的计算机)往往会更加直接、简单,甚至廉价。退一步说,它起码是提升程序运行性能的一个有力手段。而使用多进程以及多线程编程则是另一个有力的手段。不过后者需要编程人员掌握一定的技巧。在没有这样的人员支持或者条件不充分的情况下,增强计算机硬件的性能通常会是首选。这也是云计算和弹性计算在当下如此火热的主要原因之一。话说回来,我们应该尽量让施加在并发程序上的这种“软提升”和“硬提升”在效果上产生叠加而不是抵消。在多核时代,怎样更好地利用并行计算提升程序的运行性能已经是一个应用程序开发者必须要考虑的问题了。值得庆幸的是,Go语言有能力助你一臂之力。