在这篇文章中我们先谈一下并发编程的几种形式。
Herb Sutter在The Pillars of Concurrency一文中介绍了并发编程的三种形式:
一是异步模型,即对任务进行粒度划分并投递到任务的执行单元中去调度执行;
二是并行计算,如何利用支持并行的数据结构和算法来利用CPU资源,提高系统的吞吐量及拓展性;
三是利用一些同步手段确保可变的共享资源的一致性。
异步的需要
为什么需要支持异步呢?
多核处理器几乎无处不在、并在云中分布的核,使得计算机体系结构变得越来越并行化和分布式化。软件程序往往越来越多的由使用了位于单个机器或跨网络的多个核的各组件组成。现代编程语言需要提供对这种并行的支持。
同时,响应性(这是响应式编程的原则之一)已成为越来越不可或缺的软件质量。响应性的意思是在进行IO操作时不是阻塞住等待它的完成。在服务器端不阻塞一个worker线程、而让它继续做其他的事情,待操作完成后等待下一个任务。在客户端不阻塞主线程或GUI线程,否则将使程序变得反应迟钝。因此能写异步代码对于管理IO操作的延迟越来越重要。例如,在WinRT中有一个规则,所有耗时超过50ms的IO密集型API只提供异步接口,甚至没有传统的阻塞式接口可调用。
异步模型
异步模型无非是指将任务进行划分,在其他执行单元中独立地运行,通过异步消息来进行通信,来避免阻塞交互线程或其他重要线程。同时,这种做法便于也对分离的任务进行测试。
异步模型最典型的应用是将耗时的任务的创建与执行分离,避免阻塞GUI主线程,保证用户与应用程序的交互得到及时的响应。不仅是耗时的计算任务(比如后台计算),一些可能阻塞的IO任务(比如等待锁,或是网络服务的响应)也会阻塞主线程。
在异步模型中,独立的任务一般会通过消息队列(而通过不是共享对象)的方式来完成通信。以桌面应用程序为例,开发者常常会选择一些非常成熟的基于消息队列的事件驱动模型。
目前最常见的异步模型的实现是通过将任务打包到线程池中进行执行,这样前端页面所运行的线程能保证对用户的动作进行实时的反馈,两者通过消息队列或者类似于消息的抽象(如Java Future
, .NET IAsyncResult
)来进行通信。
可以预见的是,在不远的将来,我们能得到一些新的工具和抽象来实现这种模型,一些可能的实现包括Actor模型(对象独立地运行在自己的环境中,相互之间通过异步消息来进行通信),不同任务之间通过管道(channels)通信,亦或是通过一些显式的语法约定来确保消息的顺序。
并行计算
并行计算要要解决的问题是尽可能地利用可用的核心来加速并行任务的计算。比如使用并行的数据结构或算法来对容器(或其他对象的集合)进行操作。新的硬件在计算能力上的停滞不前使得单线程程序的计算能力受到限制。另一方面,新的硬件提供了更多的CPU核心来支持多线程计算的并发能力。如何顺应这种潮流来加速并行任务的执行,对我们编写应用程序也提出了挑战。
伸缩性的关键并不在于将耗时任务划分到固定数量的线程中,比如游戏程序常会划分出计算线程,渲染线程和其他辅助线程。这种做法使得应用程序更倾向于在固定数量(比如K)的核心上运行,这无疑降低了程序在其他没有那么多核心的机器上的表现。尽管有些应用场景下硬件配置是固定的,像是游戏程序的结构一般保持固定,但是这种做法并没有足够的伸缩性来适应更多的并行。
相反,伸缩性的关键在于保证程序能适应不同尺度的输入(比如消息的数量,容器的尺寸)。主要的做法有两种:
一是使用一些三方库或者抽象手段来表达你想要做的事情,而不是如何去做。就目前来说,我们可以使用一些像OpenMP的工具来并行地执行一个循环,在运行时来决定如何适当的划分任务来利用现用的核心。可以期待的是,STL或LINQ会提供一些并行工具来让我们并行地对内存中的容器执行一些查询操作,比如像获取按成绩排序后所有在校学生的姓名,就像是在对SQL数据库服务进行查询一样。
二是通过既有的框架并行地执行任务。比如我们可以向线程池(如Java ThreadPoolExecutor
or .NET BackgroundWorker
)中提交任务。但是要留意的是任务的提交也是有损失的,所以我们要确定任务是值得提交的。举例来说,当我们推导一个迭代算法像快速排序时,算法的每一步都需要并行地对左右子串进行排序,当子串很小时我们需要特别处理。
未来基于work steal的运行时系统应该会使得这些问题更像简单,我们可以简单地将任务交付于并行系统,不用考虑任务的大小问题,由系统来动态地决定是否并行地处理任务。对于没有并行处理的情况,我们希望它相较于串行计算来说,性能损失可以忽略不计。
同步
同步手段可以帮助我们正确地处理共享资源。现如今的通常做法是通过锁来控制对共享对象的访问。尽管锁有这样或那样的问题,但是它仍是处理一般问题最好的工具。尽管一些框架提供了通过原子变量实现的无锁数据结构(如hash tables),但这种做法并不是通用的。因为一些通用的数据结构暂时没有无锁的实现。所以,还是学着使用锁吧。
![The Pillars of Concurrency](/concurrency-in-cpp-1/The Pillars of Concurrency.png)
小结
尽管上诉的三种形式强调不同的问题,但是他们也可以组合来解决问题。
举例来说,一个应用程序可以将复杂的树遍历操作的执行从GUI主线程中移动到后台,保证图形界面实时的反馈,而树遍历操作可以在内部使用并行算法来加速计算。尽管这两种形式使用不同的模式来解决不同的问题,但是它们可以被高效地组合利用——无论计算任务耗时如何,用户的机器性能怎样,应用程序都可以保证实时的响应;而在其他高性能的场合,应用程序的伸缩能力也保证了对资源的有效利用。
相反地,你也可以利用这种思想将一些并发工具、要求或技术拆解成基本的形式。通过更好地理解每一部分以及它们如何关联,我们也可以更准确地理解它们作为一个整体要达到的目标,并评价这种做法是否可行,有没有其他更好的方法,或者如何只改变其中一个基本组件来提高整体的表现。