Jaa


免费的午餐之后的选择 —— 体验Visual Studio 2010中的并行开发

    若干年前我在学校里进行图形图像处理方面的实验项目。当时我们面临的一大挑战,是项目的实时性要求。同学们聚集在一起讨论可行的解决办法,其中最简单直接的,则是向导师申请经费,购买更高端的硬件系统。的确,对于若干年前的程序员来说,即使程序的运行速度不够快,那也是暂时的,随着客户使用的计算机更新换代,性能方面的问题就自行解决了,这不啻为免费的午餐。

免费的午餐已经结束

    CPU的计算能力仍旧在不断提升中,与其说要感谢摩尔定律,不如说要感谢一代代CPU设计师们不懈的努力。那为什么说免费的午餐已经结束了呢?简单来说就是,由于散热和功耗等物理条件的限制,处理器主频的提升遭遇了瓶颈。业界已经不能提供指数级增长的更快的处理器,而只能选择提供指数级增长的更多的处理器。更加详尽的论述可以参考《程序员》2006年11月号的译文《免费午餐已经结束——软件历史性地向并发靠拢》(https://blog.csdn.net/hsutter/archive/2006/08/29/1136281.aspx),在此不再赘述。最近笔者在国内某家在线电脑商城查询主流笔记本电脑的CPU配置,配置单核CPU的笔记本只有6台在售,而双核笔记本的数量是245台。这进一步证明了多核已经成为毋庸置疑的主流。

    在这样的发展趋势下,如果把今天在单核下实现的应用程序拿到一台64核的机器上运行,你会看到任务管理器显示如下的画面——只有1/64的计算能力得到了利用:

1

图表 1 任务管理器显示64核机器上运行单线程应用程序,只有一个核在计算

    既然传统的应用程序再也无法顺其自然地在更高端的硬件设备上获得更高的性能回报,我们要问的是,程序员们面临的选择是什么?Herb Sutter在他的原文中非常明确地给出了答案:并行计算。如果在四年前说“并发将是软件开发史上的又一个重大变革”是一个预测,那么今天,并行计算已经成为软件开发的核心趋势之一(援引自Soma的博客中文版https://blog.joycode.com/soma/archive/2010/03/04/115899.joy)。

是到了亲自下厨的时候了。

自己烹饪并不容易

    我们都知道,应用程序的开发并不仅仅是写代码这么简单。从需求分析,程序的设计编写,到调试和性能优化,以及测试、发布和后期维护,这一系列的行为都有其复杂性。而并行计算在程序原有的复杂性上,更平添了一个维度。在这个过程中,程序员会遇到各种各样的问题:

1. 我的性能目标是什么?应用程序性能瓶颈在哪里?

2. 这个任务可以并发执行么?哪种形式的并行最适合我,任务并行,数据并行,还是管道(Pipeline)并行?

3. 如果可以利用并行,性能究竟能提升多少?

4. 该怎么测试?如何确保一定的覆盖率?

在实际编程过程中,更加细节的问题也会涌现:

5. 并发执行的线程之间如何同步?共享的内存应该如何保护?怎样避免死锁?

6. 为什么我并发执行的任务反倒使得性能下降了?或者为什么性能提升不如我预想的那么多?

7. 资源分配是否合理?任务可以在处理器之间平均地分配么?

8. 代码可读性和可维护性如何?我的程序扩展性怎么样?如果在更多核的机算计上运行会如何?

9. 为什么同样一段程序运行多次的结果不一样?如何重现执行程序中的错误?用什么方法调试我的应用程序?

    毋庸置疑,开发并行应用程序要比开发传统的串行应用程序难。那么一些准备好的食材,一套可供参考的菜谱,和一系列顺手的厨具,是不是能让自己烹饪的过程稍微容易一点呢?

食材,菜谱和厨具很重要

    2007年,微软宣布成立并行计算平台组,致力于简化并行应用程序的开发。随着Visual Studio 2010和.NET Framework 4的发布在即,我们看到这一努力的初步结果。
2

图表 2 Visual Studio 2010和.NET Framework 4对并行开发的支持

食材——并发运行时(Concurrency Runtime)

    不论是任务并行、数据并行,还是管道并行,都意味着要把一个耗时的任务或数据分割成更小的单位。分割的颗粒度往往是由任务的性质决定的。细颗粒度并行的好处主要体现在可扩展性和负载平衡上。假设一个耗时的任务只被粗略分割成四个子任务并发执行,那么它对于多于四核的机器的扩展性就不够好;哪怕是在四核的机器上运行,也无法做到实时动态的负载平衡,可能发生三个子任务早早完成,而另一个任务还在一个核上苦苦等待的悲剧性事件。

    为了支持细粒度并行,Visual Studio 2010中为C++实现了一套并发编程框架,支持常用的协同任务调度和硬件资源(CPU和内存)管理。.NET Framework 4也在已有的线程池(Thread Pool)基础上,实现了协同任务调度和work-stealing算法。Work-stealing算法充分利用了细颗粒度并行的优势,保证空闲的线程依照一定的顺序,从本地、全局,甚至是其他线程的任务队列中“偷取”任务执行。当然,默认的任务调度器可以被扩展或配置以支持特殊的调度策略。

菜谱——编程模型

    高抽象层次的、统一的编程模式是简化并行程序开发的一个重要方向。因此你会在Visual Studio 2010和.NET Framework 4中看到许多新的语言和库功能,以及一系列面向任务的并行结构和算法。

    在C++用户方面,并行模式库(Parallel Pattern Library)引入了支持任务并行的任务(Tasks)和任务组(Task Groups)概念。Concurrency::task_handle类包含执行细颗粒度任务的代码,用来代表一个任务。而Concurrency::task_group类用来组织、调度、等待或者取消某个或某些任务。你可以直接写如下的代码来并发执行若干个任务:

image

    为了降低将串行应用程序改写为并行应用程序的难度,并行模式库提供了parallel_for和parallel_for_each,你可以很容易地将自己的for和for_each循环改写为并发执行,而丝毫不会降低代码的可读性;同时对于管道并行,Agents Library提供了基于数据流编程模型的C++模板库,利用进程内消息传递,隔离了对共享资源的访问。

    与此相对应,在托管代码方面,任务并行库(Task Parallel Library)引入System.Threading.Tasks.Task类,以及Parallel.For和Parallel.ForEach来支持任务或数据并行。

image    

    此外值得一提的是PLINQ(Parallel Language-Integrated Query)。PLINQ作为LINQ的并行实现,对内存内的IEnumerable数据源进行分区,随后利用系统内的多核,并行在子数据源上操作。

image

厨具——开发工具

    好的开发工具是成功的一半。Visual Studio 2010里包含了两个新的调试器窗口和一个新的性能可视化剖析器(Profiler)。

在MSDN你能找到详尽的关于调试基于任务的并行应用程序的文档(https://msdn.microsoft.com/en-us/magazine/ee410778.aspx),在此不再详述。有了这两个调试器窗口,你可以在代码同等的抽象层面上——也就是任务——进行调试,你可以看到任务的状态,彼此之间的关系,调用堆栈等等。当然如果你感兴趣,也可以看到任务对应的线程,并利用一个全局的统一视图来查看所有线程的调用堆栈,以及彼此之间的关系。

3

图表 3 并行堆栈窗口(Parallel Stacks Window)

4

图表 4 并行任务窗口(Parallel Tasks Window)

    性能优化是程序员明知山有虎偏向虎山行的重要原因,那么一个全面的性能探查器自然是必不可少的。Visual Studio 2010里的并行性能可视化探查器让你看到应用程序对资源的利用情况,程序的哪个部分是受计算量限制的;你也可以看到线程的执行情况,阻断的原因,线程在不同的核之间切换的情况,等等。你也可以在线程执行的不同时间点上,跳转到相应的调用堆栈,去研究造成线程阻断的根本原因。

5

图表 5 探测器视图——CPU利用率

6

图表 6 探测器视图——线程

总结

    在谈到并行应用程序开发为什么困难的问题时,很多人会提到“思维定式”——我们从一开始接受的就是串行的编程教育,导致我们不习惯“并行思考”。要改变这种思维定式可能需要一段时间,就当前的并行应用程序开发来看,大多数程序员还是更习惯于首先从串行应用着手,确定了性能瓶颈后,针对关键代码段进行并行化。我相信Visual Studio 2010和.NET Framework 4的推出有助于简化并推动并行应用程序的开发,前文提到的几个在并行开发过程中会遇到的问题也会得到部分的解答。

    在微软研究院,还有一些正在进行的研究项目,比如Axum(一种针对域的并发编程语言),DryadLINQ(在大型PC集群上进行大规模数据并行操作的编程环境),CHESS(用于查找和重现bug的并发测试工具)等等。随着研究的深入和更多并行编程模式的推出和实践,我们面对并行计算的挑战已经做好了准备,你呢?

曹阳(项目经理)

文已被收录于《程序员》2010年4月刊。

Comments