华章专业开发者书库

Visual C++并行编程实战

Parallel Programming with Microsoft Visual C++

[美]Colin Campbell

Ade Miller　著

凌杰　译

ISBN：978-7-111-38806-7

本书纸版由机械工业出版社于2012年出版，电子版由华章分社（北京华章图文信息有限公司）全球范围内制作与发行。

版权所有，侵权必究

客服热线：+ 86-10-68995265

客服信箱：service@bbbvip.com

官方网址：www.hzmedia.com.cn

新浪微博 @研发书局

腾讯微博 @yanfabook

译者序

说来凑巧，当开始着手翻译这本书的时候，我刚刚入手了自己的第一台四核计算机，而翻译工作临近完成之时，我又为自己添置了一台iPad 2（这是一台双核计算机）。由此可见，多核计算机已经完全进入了我的日常生活。鉴于自己是一个远远落后于时尚的家伙（例如我对双核手机就不太“感冒”），我毫不怀疑多核计算机已经走入了千家万户。这似乎也意味着多核应用的时代真正到来了……

先别高兴得太早。如果你仔细观察，就会发现自己并没有真正进入多核世界。因为当前使用的计算机上的大部分应用依然都是单核的。也就是说，很多时候我们只是在一台多核计算机上运行着一堆单核程序罢了。或许，你的操作系统会为这些程序进程分配单独的执行内核，但是这种粒度上的多核优势是相当有限的，我们必须为自己开发出真正意义上的多核应用程序。是的，这意味着要让程序自己具备多核处理的能力。也就是说，我们现在要并行化的不是进程，而是线程。相信很多有经验的程序员看到这里都会像我一样皱起眉头，这毫无疑问是一件非常麻烦的事，操作繁多，极易出错。最要命的是，这里的绝大部分bug都是难以重现的，更别提运用什么异常处理机制了。除非能大大地简化这一过程，否则并行编程永远都是高手们手里的玩具而已。

有趣的是，如果你问一个C++程序员，什么库用起来是最简单的？他十有八九会说是STL。若你再问他，STL有什么缺点？他十有八九又会说，它太基础了，缺少模式化应用。而这就是这本书的优势所在。

这本书所介绍的并行模式库（Parallel Patterns Library, PPL）实际上是一个模式化的C++模版库，而且作为Visual Studio 2010开发系统的一部分，它已经完全被“内置”到了这一版本的C++语言中。这意味着你可以像使用std::for_each那样使用它的并行算法，像使用std::vector＜T＞那样使用它的并行容器。除此之外，它还很好地兼容了最新的C++0x标准（现在称为C++11了）的部分特性，例如lambda表达式。这将帮助我们写出更接近C++的代码。然而，这本书令人兴奋的地方远远不止如此，更重要的是它模式化了并行编程。在PPL的帮助下，我们可以更多地从“设计”的角度来进行并行编程，这样一来，你只需要考虑如何并行化你的数据流或控制流，而不用太担心数据共享的安全或者操作同步的细节问题。当然，前提是你选对了设计模式。毕竟，设计模式不是设计模板，不能生搬硬套。你不能“手里有把锤子，就看什么都像钉子”，设计模式只是一组设计方案的高度抽象，有着很多种变化（称它为模式变体），我们必须学会灵活运用。这需要建立正确的模式化编程思维，而这就是这本书的终极目标。

作为译者，我从这本书中学到了不少东西，我的朋友高博（《设计原本》和《研究之美》的译者）曾这样总结他的翻译心得：“译者应该从他的翻译中首先获益！”我非常认可，因而我对自己的这些工作深感荣幸。当然，毕竟这是第一次独立完成一本书的翻译工作，经验不足使我对这项工作战战兢兢，有收获也有遗憾，希望它至少已经做到了“正确性”。在翻译过程中我得到了很多朋友的帮助，在此对他们致以最诚挚的谢意。

首先要感谢的是机械工业出版社的各位编辑和盛大创新院的项目经理高博先生，没有他们的帮助和鼓励，我恐怕永远都不会有机会翻译这样一本好书。接下来，必须要感谢凯捷咨询的姚皓工程师、微软亚洲研究院的徐宁工程师、EMC的朱磊工程师以及思科的金瓯工程师，他们都仔细地阅读了全书的初稿，并给出了大量的修改意见，使我避免了很多灾难性的错误。朱磊工程师和金瓯工程师甚至还帮助我翻译了前言和序言部分，对他们的帮助我铭记在心，希望这本书也能给他们带去快乐。

推荐序一

并行计算技术大约诞生于40年前，那时候它还只是一门冷僻学科（如高能物理学）和工程应用技术（如计算流体力学），是一个只有专家们才会涉足的领域。而如今这项技术已经取得了长足的进步。

这种变化的根源来自于硬件的发展趋势。崇尚一味提高处理器时钟频率的时代已经走到了终点，取而代之的是由摩尔定律所预测的芯片密度的增加，由此多核处理器和单芯片集成多处理器应运而生。如今四核处理器已经司空见惯，按照这样的趋势，几十核的处理硬件也会在不久的将来面世。

在过去的五年中，Microsoft公司借力这个技术变革的时机开发了多种并行软件，其中包括专为基于“消息传递接口”（Message-Passing Interface, MPI）程序而设计的高性能计算集群（High Performance Cluster, HPC）技术，提供基于“映射-缩减”（Map-Reduce）风格并行数据处理的Dryad平台，应用户要求提供计算处理核心的Windows Azure技术平台，为代码编写开发的并行模式库（PPL）和异步代理库，以及Microsoft .NET Framework 4的并行扩展等。

从复杂的科学和设计问题到商业应用和新型人机交互接口，多核计算对应用程序开发的方方面面产生了广泛的影响。我们以前总是自嘲说：“并行计算的确是未来趋势，只是它永远在未来。”今天看来，显然是我们过于悲观了，并行计算最终从角落走向了应用开发和IT产业的中心舞台。

但是这也带来了一个隐患。为了能让应用程序运行得更快，如今的程序员们不得不考虑怎样划分计算工作量以充分利用多核处理器的能力，而这种高级技能只有专家们才掌握。并行化编程给大多数开发人员提出了巨大的挑战，这种挑战对于很多人来说还是初次遇到。所以，怎样让诸多程序员尽快地从实践中学习并将并行化融入他们的应用程序，是当下迫在眉睫的需求。

在我一些从事计算机学科的同事中，流行着两种可能的设计思路：一是设计一种新的并行化编程语言；二是开发一个“无所不能”的并行编译器。这两种方法在学术上听起来确实很有意思，但是在面对一般开发人员的普及和简化并行化编程方面则显得力不从心。与之相对，一种更为实用的方法是为程序员提供一个封装了并行化编程复杂细节的库，然后告诉他们应该如何使用。

出于这样的目的，Microsoft Visual C++并行模式库和异步代理库提供了比以往API更高层的编程模型。举例来说，程序员可以以任务而非线程为单位来思考，这样就能避免复杂的线程管理问题。本书通过阐释将这些库放到设计模式的方式来告诉程序员如何使用它们，这样程序员们可以很快上手编写并行程序，从而提高程序的执行效率。

我相信，这本书以其对并行编程模式的强调和先进的编程模型，迈出了将并行化编程推入主流的重要的第一步。

Tony Hey

微软研究院副总裁

推荐序二

正当我们站在IT产业的重要转折点——并行硬件+移动设备=未来20年主流平台的便携式超级计算机，本书应势而生。

为了将各个硬件平台的性能发挥到极致，我们对并行应用程序的需求也越来越多。这里所谓的发掘大部分设备上的最大计算性能（包括几乎所有的台式电脑和笔记本电脑、大多数的游戏控制台以及最新的智能手机），主要是指充分利用以多核处理器为主的本地并行硬件资源，这也是超级计算机的商业化进程。另外在今后，我们将会越来越多地发现，性能的最大化往往还意味着需要逐步引入多样化的计算处理方式，从通用图形处理单元（GPGPU）等本地资源到开拓以弹性计算云形式出现的远程并行计算资源。后者是一种以非均匀性存储结构（NUMA）为主导技术对异机种集群的泛化过程，计算实体从集成到芯片集中，转而扩展到整台计算机上，进而再集中到云端，而上述的每一种计算实体在之前都有实际应用的例子。

从现在开始到可预见的将来，在计算机应用程序范畴内的“快速”不仅仅是“并行”的同义词，而应该更多地等同于“可扩展的并行”。只有可扩展的并行应用程序才具有超越现有主流设备的潜在并行性，故而当今天的程序安装在未来拥有更多并行支持的硬件上时，才能自然而然地在性能上得到飞跃。

本书介绍的Visual C++2010并行模式库（PPL）有助于将应用程序领进并行化的大门。在PPL的设计过程中，许多人付出了巨大的心血。就我自己而言，我很荣幸能够贡献自己的绵薄之力，将lambda函数设计成该语言的核心扩展，这使得创建PPL中的其他部分如同标准模板库（Standard Template Library, STL）一样，就像使用一个普通库实现算法。当然我们可以向编程语言中加入一堆特定的并行循环（我们几乎就这么做了），但是这样做很粗鲁，而且也失去了一般性。而lambda函数可以用在PPL中，也可以用在别处。加入一个这样普遍适用的语言特性比填塞许多特定情况的用例要好上千倍。

到目前为止，令我们感到欣慰的是，世界大部分地区的计算机行业已经达到了普适计算的目标——可以想象，我们已经把一台计算机放到了每一张写字桌上、每一间起居室里，甚至每一个人的口袋中。但是我们现在还处在推进普适甚而灵活弹性的超级计算进程中，这就相当于我们将一台囊括本地和远程资源的超级计算机放到每一张写字桌上、每一间起居室和每一个人的口袋里。在1984年我高中毕业那会儿，世界上最快的电脑是有4个处理器、128M内存、性能峰值为924 MFLOPS的Cray X-MP。换种通俗的方式来讲，在并行性、内存大小和处理能力方面，它只是2005年古董Xbox的零头，更无法与当今的“手机”和XBOX体感相提并论。时代在进步，而进步的步伐不仅愈迈愈大，而且愈迈愈快。

业界转而走入并行化是以多核CPU的诞生开始的（其原因请参见我几年前的评论文章《免费午餐不再》（The Free Lunch Is Over）），以通用图形处理单元、弹性云计算以及其他新型且根本性的并行革命为继来发扬光大。这股并行风潮将带来大量的新式计算资源，并将通过主流的编程语言来为我们所掌握。在Microsoft，我们每个人都为能够给这代人和子子孙孙带来业界主流并行计算平台的开发工具而感到十分高兴。尤其对于PPL，我很乐于看到用户的最终产品会有多棒。另外我很期待看到最终的产品随着时代的推进性能也随着节节攀升，因为我们让应用程序能够再次享用免费的午餐——未来二十年中的可扩展并行。

Herb Sutter

微软首席架构师

前言

这是一本介绍并行编程模式的书。在这本书中，我们使用了大量基于Microsoft Visual C++系统开发的代码示例，用以说明并行模式库（Parallel Patterns Library, PPL）和异步代理库（Asynchronous Agents Library）的使用方法。通过学习这本书中介绍的设计模式，你可以大大改善应用程序在多核计算机上的性能。而且这些模式不仅适用于现阶段的硬件环境，同时也为日后环境发展预留了扩展空间。相信随着计算机并行架构不断提升，它们的表现也会越来越好。

谁需要这本书

这本书面向那些在Windows操作系统下编写本地化代码 [1] 的程序员。当然，PPL库本身是可移植性的，因此，理论上说它并不局限于Windows平台。即便你没有并行编程方面的经验和相关知识也没有关系。不过你最好具备一些C++方面的基础知识，例如模板、标准模板库（STL）以及lambda表达式（这是Microsoft Visual Studio 2010开发系统中新增的C++特性）。另外，对于线程和进程的运行方式，你最好也应该了解一些最基本的内容。

注意：书中的所有例子都是用C++语言写的，其中用到了并行模式库（PPL）。完整的代码可以在CodePlex网站上找到（网址为http://parallelpatternscpp.codeplex.com/）。

此外，这本书还有一本姐妹篇：《Parallel Programming with Microsoft.NET》，其中介绍的模式内容几乎完全相同，只不过那些代码是托管的罢了。

为什么需要这本书

Visual Studio 2010所提供的高级并行编程功能非常简单实用，学习它们绝对要比你重新开始学习并行编程要容易得多。

例如，并行模式库（PPL）和异步代理库就是为那些编写并行程序的C++程序员准备的。它们大大简化了应用程序实现并行化和并发的过程。PPL会动态检测运行时环境的并行度，以便充分利用所有的处理器资源来提升效率。此外，PPL及代理库在工作量分配以及线程中的任务调度方面也大有作为。这些库还提供了取消功能、状态管理以及其他各种服务。它们都充分利用了Visual C++平台上的并发运行时。

Visual Studio 2010开发系统中内置了一系列用于调试并行程序的工具。例如，并行栈窗口能查看应用程序所有线程中的调用栈信息，还能让你在线程框架和栈框架之间来回切换。并行任务窗口则与线程窗口非常相似，只不过它显示的是任务信息而非线程信息。此外，在Visual Studio分析器中，你还可以通过并发可视化工具中的视图来了解应用程序是如何与硬件设备、操作系统以及计算机上的其他进程交互的。而且，我们还可以用它找出程序性能上的瓶颈，例如内核使用率、线程竞争、跨核线程迁移、同步延迟、I/O重叠区域等各类信息。

如果想进一步了解Microsoft公司的并行技术，建议参考附录C中的相关内容。

需要准备些什么

本书中所有的代码示例都可以在http://parallelpatternscpp.codeplex.com/上找到，运行它们所需的系统配置如下：

·Microsoft Windows Vista SP1、Windows 7、Windows Server 2008或者Windows XP SP3（32位或64位均可）操作系统。

·Microsoft Visual Studio 2010 SP1（用于程序性能分析的并发可视化工具只有在旗舰版（Ultimate）或者高级版（Premium）中才提供）。这里内置了支持这些示例运行的PPL和异步代理库。

如何使用本书

本书将从设计模式的角度来阐述并行编程技术。图1列出了将要介绍的具体模式以及这些不同模式之间的关系。其中，数字代表了该模式所在的章节。

图　1　并行编程模式

如你所见，除第1章外，本书的内容大体上分为两个部分，一部分讨论数据的并行化实现；另一部分侧重于讨论任务的并行化。

其中，只有并行循环和并行任务是只通过控制流来完成任务协调和排序的，而其他模式则需要同时利用控制流及数据流来完成相应的协调。其中的控制流实际上是指程序算法结构中的步骤序列。而数据流则通常指程序中有效的输入和输出。

引言部分

在第1章中，我们宽泛地介绍了程序员在并行程序开发过程中所面临的性能问题，并借此引入后续内容中将要用到的基本概念。

1.2.4节为你提供了一个表格，你可以通过它来为自己寻找适用的设计模式。

仅依靠控制流的并行化

第2章和第3章介绍的所有操作都是仅仅依靠控制流来完成相关的任务协调和排序的：

·第2章“并行循环”：并行循环模式一般应用于对某个集合或者某个索引区间的每个元素执行相同的计算。当然，前提是集合成员之间不存在任何依赖关系。如果其中出现了有依赖关系的循环，我们就需要参考第4章中的内容了。

·第3章“并行任务”：并行任务模式一般应用于处理一系列独立的异步操作。这一章将为你详细解释任务和线程在使用上的不同之处。

控制流和数据流共同协作下的并行化

第4章和第5章介绍的并行化操作都是在控制流和数据流的共同协作下完成的。

·第4章“并行聚合”：并行聚合模式一般适用于存在依赖关系（包括数据依赖）的并行循环，例如累计计算、查找集合最大值之类的。

·第5章"Future"：Future模式的使用一般与某些操作的顺序有关，因为这些操作的输出通常是另一些操作的输入。通过数据依赖关系的定向图来约束操作顺序。这些操作中有一些是可以实现并行的，而另一些则必须串行执行，这取决于输入什么时候可用。

动态任务并行化与管道应用

第6章和第7章介绍了一些更高阶的应用。

·第6章“动态任务并行化”：在某些情况下，我们需要在计算的过程中动态地添加待办事项。这一模式应用范围很广，甚至包括了图算法与排序。

·第7章“管道”：当一个程序中允许同时运行多个组件时，我们就可以利用管道来实现串联式输出，就像流水线一样，即一个组件的输出进入另一个组件的输入队列。管道满并且一个以上的组件同时处于活动状态时实现并行化。

参考资料

除了上面介绍的模式之外，这本书在附录部分还提供了其他参考资料：

·附录A“任务调度器与资源管理器”：该附录带你预览并发运行时中的任务调度器和资源管理器，并详细介绍了它们的功能。

·附录B“并行应用程序的调试与分析”：该附录详细介绍如何在Visual Studio 2010系统中调试并分析一个并行应用程序及其性能。

·附录C“技术总览”：该附录带你预览更多来自Microsoft公司的并行编程技术与设计框架。

·术语表：这里列出了本书中大部分术语的定义。

我们建议每一位读者都应该仔细阅读一下前3章，这有助于你们对这本书的内容框架形成一个最基本的概念。至于后续的章节，虽然严格来说也是按照逻辑顺序来排列的，但是从第4章开始，你完全可以按照自己喜欢的顺序来阅读。

要注意的是，对本书中的这些模式运用要对症下药，不要生搬硬套，对于自己的需求必须有彻底的认识。

人们总想用新的工具和技术来解决面临的所有问题，却没考虑新事物是否合适。俗话说得好：“当你手里有一把锤子的时候，看什么都像钉子。”“看什么都像钉子”是很容易导致一些意想不到的结果。例如下面这只小兔子的悲惨遭遇（见图2）。

你肯定不希望在自己的并行程序中遇到如此糟糕的情况，不是吗？当我们引入并行化处理时，需要花费一定的时间，同时也增加了编程的复杂度。因此如果你想取得良好的结果，应该只针对那些利大于弊的部分进行并行优化。

图　2　当你手里有一把锤子的时候，看什么都像钉子

本书未能涉及的内容

本书介绍了内容可能更侧重于与处理器有关的工作，而不是与I/O相关的工作。这样做可以使我们更合理地利用内核资源，以便让那些计算密集型的应用能最大限度地提升它的执行速度。所以本书没有着重于I/O延迟方面。不过，我们还是一些地方兼顾讨论了计算密集型和I/O密集型之间的负载平衡问题（见第7章的相关内容）。

另外，本书还讨论了如何在一个具有共享内存的单一多核节点内实现并行化的问题。请注意，这里讨论的对象不是一个具有分布式存储和网络节点的高性能计算服务器（HPC）集群。不过，即便是对开发集群应用的程序员来说，本书中的单节点中的并行技术范例也是有用的，从本质上说，集群也是由各个节点中多个处理单元共同组成的。

本书的目标

在读完本书之后，我们希望你应该：

·能独立解答每一章末尾处的练习题。

·能从本书中找出最适合你的模式。如果确实有的话，不要错过实现并行化的好机会，直截了当地将它们应用到你的并行实现中。

·能理解这些模式不适合你的原因。要做到这一点，你或许需要更仔细地阅读专业文献和深入研究或者寻求专家的帮助。

·在你所实现的模式不能正常工作时，能独立思考并找出原因，例如存在某些依赖冲突或者任务之间错误地共享了数据。

·详细阅读每一章“补充阅读”中列出的参考资料。

[1] 这里的“本地化”是相对于托管代码而言的。——译者注

致谢

通常，编写这样一本技术类书籍是需要团队合作的。例如在模式应用方面，我们征求了许多专家的意见，也查阅了与该项目相关的论坛社区。虽然这使得我们的编写过程更为漫长和复杂，但这是非常重要的。这使得本书所面向的对象和内容得到了最终的确定，不过我们还是要感谢通过各种不同方式为本书作出贡献的人们。

这本书和我们之前在《Parallel Programming with Microsoft.NET》这本书中所做的工作有着莫大的联系。尽管内容变化了许多，但其中所讨论的基本模式是相同的。由于这个分享的经历，我们要再次感谢第一本书的合作者：Ralph Johnson（伊利诺伊大学Urbana分校）和Stephen Toub（Microsoft）；接下来是全书的评审人员：Nicholas Chen、Danny Dig、Munawar Hafiz、Fredrik Berg Kjolstad以及Samira Tasharofi（都来自伊利诺伊大学Urbana分校），Reed Copsey, Jr.（C Tech开发公司）和Daan Leijen（Microsoft研究院）、Judith Bishop（Microsoft研究院），他们仔细审阅了全书并且给出了非常宝贵的意见。这些意见不仅影响了.NET那本书，在本书中也仍然发挥着显著的作用。

一旦我们掌握了如何在C++中应用这些模式，我们所面临的最大挑战就是保证技术上的准确性。我们非常信赖来自Microsoft并行计算平台（Parallel Computing Platform, PCP）小组所提供的相关信息，这些有关并行模式库及异步代理库的详细信息不仅可以用来审阅本书内容，还可以帮助我们检阅书中的代码示例。此外，Dana Groff、Niklas Gustafsson以及Rick Molloy（都来自Microsoft）在我们最初构思这本书时也提供了许多帮助。而其他几位PCP小组的其他成员也为我们奉献了大量的宝贵时间。他们是Genevieve Fernandes、Bill Messmer、Artur Laksberg以及Ayman Shoukry（都来自Microsoft）。

除了上面提到的两个程序库外，本书还引出了一系列相关的话题。幸运的是，我们非常荣幸地得到了负责相关领域的Visual Studio项目组成员的帮助。Drake Campbell、Sasha Dadiomov以及Daniel Moth（都来自Microsoft）针对附录B中的有关调试器与并发可视化工具的内容向我们提供了大量反馈意见。Pat Brenner和Stephan T.Lavavej（来自Microsoft）则审阅了我们所使用的代码实例及Microsoft基础类和标准模板库。

我们还得再次感谢Reed Copsey, Jr.（C Tech开发公司）和Samira Tasharofi（伊利诺伊大学Urbana分校）以及Paul Petersen（Intel），他们逐章逐节地审阅了全书。和第一本书一样，我们的进度安排非常紧迫，我们的审阅者都格外辛苦地帮助我们完成了这一目标。真心地感谢每一个人！

还有许多人为本书提供了宝贵意见。其中包括了Intel和Microsoft并行技术研讨日（Bellevue）的参与者，他们还为本书中提到的CodePlex网站提供了支持。

此外，我们还有一个由技术类文章作者和编辑组成的团队，他们的努力使本书读上去妙趣横生。他们是Roberta Leibovitz（Modeled Computation）、Nancy Michel（Content Masters）以及RoAnnCorbisier（Microsoft）。

Rick Carr（DCB软件测试公司）测试了本书中所有的实例和内容。

在本书中，最初的可视化设计的概念是由Roberta Leibovitz以及Colin Campbell（Modeled Computation公司）开发的，他们是一群极具天赋的设计师和阐述者。本书的设计工作由John Hubbard（Eson）担当。最后，本书各章节的卡通图案由屡获大奖的在西雅图画家Ellen Forney执笔。技术性说明则由Katie Niemer（Modeled Computation公司）负责。

第1章　引言

在多核体系环境中，CPU仪表盘上经常显示着一个问题，即计算机上只有一个内核在满负荷运行，而其他内核则都被闲置了，这使得我们的程序看上去似乎受到了CPU性能的限制，但事实上，这才仅仅利用了多核系统中的一小部分性能而已。那么，这个问题有更好的解决方案吗？

答案是简单而明确的，那就是并行编程（parallel programming）。现如今，我们越来越多地发现，自己曾一直习惯于写的并且也为所有程序员熟悉的那种串行化代码（sequential code）显然已经无法满足用户的性能需求了。为了进一步提升系统中CPU资源的使用率，我们需要将应用程序分解成能各自为战的执行片断（piece），以便使它们能够在同一时间内运行。

并行编程使我们可以同一时间内调用多个内核资源，这能有效地提升应用程序的运行速度。

当然，说起来容易做起来难。毕竟并行编程一直被认为是专家们的领域，同时也常被视为是一个雷区，其中隐藏着各种难以重现的、诡异的软件缺陷。几乎每个程序员都会跟你老生常谈一个关于神秘bug如何导致并行程序运行结果与期望大相径庭的故事。

也许，那些故事让你对自己所面临的困难有了一些清醒的认识。但幸运的是，你不是一个人在战斗，并行模式库（Parallel Patterns Library, PPL）和异步代理库（Asynchronous Agents Library）引入了一个全新的并行编程模型，该模型大大地简化了编写并行程序的工作。当然，这些简化的背后隐藏着一系列精致而复杂的算法，它们能够很好地适应于多核体系（multicore architecture）中的动态分布式计算。此外，Visual Studio2010开发系统中还内置了一系列的调试器与分析工具，以便支持这个全新的并行编程模型。

如果觉得这里的内容太过拖沓，你也可以根据自己的需要直接参考1.3节。

另外，你还可以求助于久经考验的设计模式（design pattern），在本书中，我们介绍了一些最重要的、也是最常用的并行编程模式，并提供相应的基于PPL编写的可执行代码示例，对其进行辅助说明。至于这本书的使用问题，我们建议你最好先大致浏览一遍接下来的章节中提到的6个模式，看看你的问题中是否有一些特征与这些模式是相匹配的。如果答案是肯定的，就说明这些模式（包括相应的代码示例）是值得你去深入地学习、研究的。

尽管编写并行程序确实是出了名的难，但你不是一个人在战斗。

由于大部分并行程序都应该或多或少地遵守这些设计模式，因此，我们通常很容易就能找到一个相匹配的模式来解决特定的问题。如果真的没有一个用武之地，那很可能是因为我们遇上了一个难度很大的问题，这时候我们或许就需要去寻求专家或者专业文献的帮助了。

在本书中，所有的样例代码都可以在下面的网站下载：http://parallelpatternscpp.codeplex.com/。

1.1　潜在并行化的重要意义

本书中提到的所有模式都旨在帮助我们发掘出程序中潜在的并行化。所谓的潜在并行性（potential parallelism），实质上是指，如果应用程序得到了并行硬件的支持，它就能运行得更快；即使硬件不支持并行化，它的性能也理应与其等价的串行程序相差无几。只要代码的架构设计正确，运行时环境就应该能根据计算机上具体的工作负载做出自动调整。当然，本书中的这些模式也只能对潜在并行化提供一般性的提示，毕竟谁也无法保证程序在任何情况下都能实现并行执行。但是，挖掘这种潜在并行化正是PPL编程模型的核心思想所在，值得我们为此进一步说明。

这里说明了程序中的潜在并行化无论在多核还是单核的情况下，都可以让处理器中的所有内核保持有效负荷。

在常见的并行应用程序中，有不少是为特定的硬件而编写的。以游戏控制平台为例，游戏程序员可能事先就对程序在运行时可用的硬件资源了如指掌，包括处理器内核数量和内存架构等细节。当然，对于硬件平台的全面掌握也是嵌入式应用程序开发的基本特征，例如工程进度控制（industrial process control）程序就是这样。不过，这也决定了这些程序的生命周期必须要和它们所依赖的硬件保持一致。 [1]

相反，如果是在桌面工作站或者服务器这样的通用计算平台上编写程序，我们对硬件特性的估测能力就要打些折扣了。毕竟，我们不可能总能知道有多少内核可供使用，也不太可能预先知道会有什么其他软件将与程序共同运行。

不要对应用程序的并行度进行硬编码（hard code），因为运行时可用的内核数量往往是无法预知的。

即便应用程序最初的运行环境是确定的，随着时间推移，它也是会发生变化的。在过去，程序员总是假定自己的程序在下一代硬件中会运行得更快。你现在依然可以这样认为，因为处理器主频终究还是会越来越快的。不过，对于现在的多核处理器而言，主频增长的步伐已经开始慢了下来，取而代之的是处理器中的内核越来越多。如果想让我们的应用程序在多核世界中分享到硬件进步所带来的利益，就必须要对程序设计模型做出相应地调整，以便程序在今后能运行在多核计算机上。同时，我们还应该致力于发掘程序中潜在的并行计算能力，以便体现出某种“前瞻性”。

处理器的发展趋势不再是主频速度越来越快，取而代之的是增加越来越多的内核。

最后，我们还有必要为可能的意外情况做一些准备，毕竟，总会有些用户无法获得最新的硬件，这时候，我们需要确保并行程序在单核计算机上的性能至少和只用串行代码编写的程序相差无几，换句话说，就是要让应用程序在单核和多核之间保持性能的可扩展性，让其拥有更强的硬件适应能力，并保持前瞻性。而这正是发掘潜在并行化的目的所在。

一个设计良好的并行程序在单核情况下的效率应该与相应的串行程序相差无几。

例如，我们将在第2章中介绍并行循环模式就是一个发掘潜在并行化的典型示例。对于一个将要迭代100万次的for循环来说，如果能确定其中的每个迭代体都是彼此独立，我们就完全有理由将这些迭代分解为并行计算，把工作分配到各个闲置的处理器内核中。显然，具体的分配工作取决于可用内核的数量，多数情况下，该循环的速度将与内核数成正比。

[1] 软件的生命周期高度依赖于硬件通常被视为软件设计的一大失败，这会导致生产力的浪费。——译者注

1.2　分解、协调、可扩展性共享

在这本书中，所有的模式都有一些共同的主题。你将会从后面章节中看到一个并行程序从设计到实现的全过程，主要内容包括以下三个方面：将工作分解成各个独立单元（即任务）的方法；协调并行任务运行的途径；以及共享任务所需数据的可扩展性技术。

此外，这本书所讨论的模式都属于设计模式，因此，当我们设计并实现算法，或者思考程序的整体结构时，都可以应用它们。尽管这些示例应用程序的规模都很小，但其中的原则同样适用于大型应用程序的架构设计。

1.2.1　理解任务

所谓任务（task），实质上是指一系列相互协作、共同完成某个较大操作的串行化操作序列。当我们设计一个并行程序的结构时，为任务找到一个合适的划分粒度，以便它能有效地利用硬件资源是一个十分重要的步骤。如果分得太精细，任务的管理成本将会成为程序的主要开销；而如果分得太粗糙，就可能会导致处理器的一些内核因无法分配到工作而被闲置，令程序失去了一些并行化的机会。一般情况下，任务应该分得尽可能大，确保它们彼此独立，并且让所有的内核都保持有效负荷。除了任务粒度之外，任务调度的方式也是一项不得不考虑的因素。要符合以上所有的目标，我们就必须在设计上做出一定权衡，而只有当我们对应用程序的算法和结构有了充分的认知，才能在分解问题过程中将任务划分得恰到好处。

任务是串行化的工作单元，它们应该被分得尽可能大，在彼此独立的基础上，数量也要足以让所有内核保持有效负荷。

为了让这些设计原则显得更具体一些，我们来看一个射线追踪程序实例。在这个应用中，射线追踪器（ray tracer）负责跟踪一个场景中的每条光线轨迹，并将这些轨迹合成一张轨迹图。在这个问题中，如果我们使用单个模拟射线本身作为这个并行计算的单位任务，粒度上是恰到好处的。如果任务粒度再小一些，比如将单个模拟器再进行分解，就只会增加程序的开销。因为模拟器的数量已经足以让所有内核保持有效负荷。而如果各个任务的持续时间差距过大，我们就需要划分出更多的任务来填补它们之间的空隙。

记住，任务不等同于线程（thread），任务和线程在程序调度方面有着很大的不同。任务比线程拥有更多的潜在并行化概念。一个新的线程必然会增加应用程序的并发性，而一个新的任务只是增加了这种可能性，这种可能性只有当程序获得足够多的内核资源时才能实现。

此外，将目标任务划分成这些大而少的子任务还有另一个优点，即较大的任务通常彼此间具有更多的独立性，拥有需要共享的局部变量和域的可能性也更小。然而，遗憾的是，在一些依赖于不稳定的、大型的对象图结构（object graph） [1] 的应用程序中，情况或许正好相反，这些程序中往往会存在一些拥有许多公开的类、方法以及属性的大型对象模型。这种情况下，任务越大，遇到意外的数据共享或其他副作用的可能性就越高。

总而言之，你的最终目标是问题分解成各个独立的任务，确保它们之间没有数据共享，并且任务的数量足以让处理器的所有内核保持有效负荷。此外，当你考虑内核数量的时候，也应该把下一代硬件中可能拥有更多内核的因素考虑进去。

[1] 这是一种表示对象引用关系的数据结构，后面会进一步提及。——译者注

1.2.2　协调任务

通常多个任务需要同时运行。彼此不存在依赖的任务可以并行运行。当然，也会有一些任务必须等待别的任务完成之后才能开始。任务的执行顺序和并行化程度取决于应用程序的内在算法。约束来自于如控制流（算法的步骤流程）和数据流（有效的输入输出）。

协调任务的机制有很多，具体的运用取决于我们所选择的并行模式。例如第7章中的管道模式是采用消息机制来协调任务的。而无论你选择哪一种机制来协调任务，都必须充分了解任务之间的依赖关系。只有这样，我们才有可能获得一个成功的设计。

1.2.3　可扩展性数据共享

通常来说，任务之间总免不了要共享数据。但问题是，这些任务在并行化运行的过程中很有可能会对同一块内存区域执行竞争式访问和更新，使得相关的数据变得非常不可靠。这种不在我们计划内的数据竞争所带来的后果往往是灾难性的。要想避免这个问题，解决方案之一就是使用线程同步技术。

也许，你对那些通过阻塞线程来实现的同步并发线程技术并不陌生，比如锁、原子性对比交换操作、信号量等都属于这一类技术。这些技术也确实都能有效地实现访问共享资源。事实上，我们对于数据共享的第一反应似乎也依然是加锁这一类的同步化（synchronization）机制。但是，此类机制的引入必然会降低程序的并行性（parallelism），因为同步化本身就是串行化（serialization）的一种表现形式。而且，频繁的锁权争夺操作很可能会导致任务无暇去做它们应做的事情，所以如果在程序中使用锁这样的技术来实现同步化，是非常容易出错的。

幸运的是，有一些数据共享技术可以在不降低性能或增加错误率的情况下使用。这些技术包括使用不可变的只读数据、用发送消息的方式取代更新共享变量，以及在算法中引入新的步骤，以便在一些合适的检查点（checkpoint）上合并一些可变的本地变量。可扩展性共享技术可能需要我们修改一些现成的算法。

可扩展的数据共享需要我们对算法进行一些修改。

此外，在面向对象设计中，内存中往往会形成一个复杂而高度连通的对象引用图，这使得传统的面向对象模式很难适用于可扩展的并行执行要求。对此，我们首先想到的方法或许是，在确保多任务共享的情况下，将这些庞大而互相引用的对象图成员视为可变的共享状态，并对这些成员进行外层封装，用锁等方式来强制执行串行化访问。遗憾的是，这并不是一种可扩展的共享方式。锁通常会给所有的内核带来负面的性能影响，它强制内核暂停和通信的动作会消耗一定的时间；它在代码中引入串行部分又会降低并行化的可能性。而随着内核数量的增长，锁带来的开销会使争议愈加强烈。当面对越来越多任务需要共享同一块数据的需求时，关于锁的成本控制将会成为程序性能的主要考量。

引入同步化机制（比如锁）会降低应用程序的可扩展性。

除了性能问题之外，这种依赖于同步机制的程序还会有各种各样的问题，其中就包括死锁（deadlock）。死锁通常是指在两个或者更多的任务之间发生的彼此都在等待对方释放锁的状况。事实上，大部分关于并行编程的噩梦基本上都起源于错误地使用了可变共享状态（Shared mutable state）或者锁协议（Locking protocol）。

尽管如此，如果我们在对象图（object graph）中适量地使用一些同步化元素，对于编写可扩展的并行程序是有益的。本书也会尽量谨慎地使用同步机制，你在应用中也应该慎重。事实上，你可以把锁看做并行编程当中的goto语句，尽管它很容易出错，但在某些特定情况下这是必需的，并且对于编译器及其库而言，它可以被视为一种最左派（best left） [1] 的选择。

没有人会仅仅因为性能因素就将同步机制完全排除在外，除非是正确性需要。毕竟代码本身的正确性才是最重要的。总之，在设计的过程中有限地引入同步机制也是非常重要的设计原则。不要事后才想起往应用程序里加入同步机制。

[1] 原文如此，读者可以理解为最激进的做法。作者的具体意图应该是想告诉我们，尽量在自己的程序中少使用这些同步机制，这样代码才具有可读性，也比较容易维护。然而和goto语句一样，有时候用这种简单粗暴的方式也是好处的。——译者注

1.2.4　设计方法

通常情况下，程序员会先对问题所在的问题域进行甄别，然后在据此对代码进行并行化处理，以改善性能，这样一来，下一次再遇到类似瓶颈时就可以重用这个解决方案了。特别是当我们需要对一系列现存的串行程序进行并行化处理时，这种思路非常吸引人。然而，尽管这样做或许确实能让程序的性能获得初步的提升，但正如之前所描述的那样，这里隐藏着许多陷阱。因此，传统的分析、优化技术或许并不是最好的选择。而另一个更好的选择是：在充分理解具体问题或程序的基础上，发掘贯穿整个应用程序的潜在并行化需求。这个过程将会引导你去选择不同的架构或算法，以便更好地拓展程序在并行化方面的潜力。千万不要草率地界定性能瓶颈并贸然使其并行化，恰恰相反，我们应该通过改变程序的结构来并行化。

在通盘考虑数据结构和算法之前，不要急着界定问题的瓶颈所在。

分解、协调以及可扩展性共享技术之间是密切相关的，甚至是环环相扣的。因此，当我们在为特定的应用程序选择设计方案的时候，应该进行全方位的思考。

请尽量使用设计模式。

读完上面这些描述，你或许会觉得这太过形而上学，泛泛而谈了。究竟如何将问题分解成任务？到底该采用哪种协调技术，有更具体一点的回答吗？

答案是肯定的，这本书中所描述的设计模式就是最好的回答。它们就是解决这些问题真正的捷径。一旦你领会了这些设计模式幕后的设计动机，就会自然而然地培养出某种直觉，而这种直觉将会帮助你将这些设计模式及其变体应用到自己的程序中。接下来，我们将会在剩下的章节中详细地介绍这些模式，以供参考。

1.3　选择正确的设计模式

根据下面这张表，你可以为自己选择合适的设计模式：

此外，你也可以先阅读每章开头前两页的内容，熟悉一下这6个模式的大致情况，并了解一下它们的基本应用场景，然后再更深入地具体研究它们。

1.4　关于术语

在一般情况下，我们会认为“并行”和“并发”是同义词，但在这本书中它们是两个不同的概念。

在本书中，并发（concurrency）在更多时候是一个与多任务和异步输入输出（I/O）相关的概念。它实质上是一种执行方式，这种执行方式能在多线程轮番争夺时间片的情况下，确保每一个线程最终都能获得时间片。为了使程序能应对诸如用户输入、设备和传感器等外部中断，并发性是必要的。例如，操作系统和游戏天生就是具有并发性，即便在单核情况下也是如此。

而对于并行化（parallelism）而言，则更强调并发线程可以同时在多个处理器内核上执行的能力。并行编程则侧重于增加我们所占用的处理器资源，并且确保有多个处理器时不会被经常中断，以此来提升应用程序的性能。

而且，并发和并行想要达到的目标也并不相同。并发的主要目标是降低各个线程长期不受阻塞占有时间片的可能性，换句话说，并发就是要避免出现有线程被饿死的情况。

通常，并发在操作上往往是必要的。例如，在一个单核计算机的图形操作系统中，会有多个窗口需要同时刷新显示区域，这时候图形用户界面（GUI）就必须支持并发。相对而言，而并行化则与吞吐量有关，是一种性能的优化，而非功能性需求。它的目标是让处理器的所有内核保持最大的有效负荷。为此，它所使用的调度算法通常就不是具有抢占性的，正如处理队列（queue）或栈（stack）的算法所做的工作一样。

1.5　并行的局限

根据阿姆达尔定律（Amdahl's law） [1] 得出的结论，并行化所能带来的性能改善受到了应用程序中串行化处理所占分量的限制。是的，这乍看之下几乎有点儿违反直觉。

但你并没有看错。根据阿姆达尔定律的说法，无论我们有多少内核可用，程序所能得到的最大速度增长率也只能符合（1/串行化处理所消耗的时间）函数关系。图1-1对此做了说明。

图　1-1　当一个应用程序中的串行化处理占25%时的阿姆达定律

例如，当内核达到11个时，应用程序的运行速度只比完全串行化运行提高了三倍略多一些。即使在更少量内核的情况下，运行速率也不是线性增长的，图1-2对此做了说明。

图　1-2　一个拥有25%串行性的程序性能增长与内核数量增长之间的关系

如图1-2所示，随着内核数量（及整个应用程序的速度）的增加，应用程序的串行化部分所耗费时间所占的比例也会随即增加（注意，串行化处理时间本身的值并没有受到影响）。这几乎也说明了一个问题，或许我们应该对该程序能在四核计算机上得到两倍的速率增长感到满足。更重要的问题是，如何才能让应用程序维持这样的可扩展性，这取决于具体工作本身所拥有的天然串行性（inherently sequential）时间。

阿姆达尔定律给予我们的另一个提示是：或许应用程序中确实有可能会发现一些符合并行计算的特性，以至于我们想利用它为程序添加一些额外的功能。例如，电脑游戏开发员可能会发现，如果新型多核平台的并行硬件的支持，就可以画出更为精美复杂的图像，即使游戏的逻辑（人工智能引擎）并不适用于并行化执行。但是，对程序整体性能来说，最终发挥影响的可能还是应用程序中各功能模块的具体组合方式。

而就具体的实践来说，我们所能得到的速度增长率通常会比阿姆达尔定律所预计的情况还要糟糕一些。因为随着处理器内核数的增加。访问共享内存的成本也会跟着增加。同时，并行算法中包含的协调性成本，也不是相应串行方案中所必需的。对此，你可以求助于Visual Studio中的并发可视化工具，借助其中的分析工具来了解自己所采用的并行化方案的最终效率。

总而言之，因为应用程序是由并行化部分和一些必要的串行化部分共同组成的，所以，程序整体性能的增长率不太可能会与处理器的内核数形成某种线性关系，尽管在某些局部，程序确实能获得近似的线性增长。正因为如此，我们在分析性能需求时就有一个不能忽略的步骤，即我们在程序中发掘适于并行执行的部分，必须建立在充分理解应用程序结构及其算法的基础之上。

[1] 阿姆达尔定律（Amdahl's law）：一个计算机科学界的经验法则，因吉恩·阿姆达尔而得名。它代表了处理器并行运算之后效率提升的能力。——译者注

1.6　一些建议

总而言之，我们应该尽可能选择一些最简单而有效的方法，这里有一些最基本的建议：

·当你选择并行应用程序库或者架构时，应该尽可能地使用最高层的抽象逻辑。

·请尽可能地充分利用好应用程序服务器自身的并行化；例如Web服务或数据库本身就具备了一定的并行化。

·请使用API来实现并行化封装。例如并行模式库。这些程序库都是由专家所完成的，都经过了完备的测试。有了它们的帮助，你可以解决许多常见的并行编程问题。

·当你在构思一个并行化设计的时候，应该尽可能全面地考虑整个应用程序的架构。一般仅仅关注提高一些性能热点和焦点的性能。这种性能也确实可能得到一些改善，但通常不会获得最好的结果。

·请尽量使用设计模式来解决问题，例如本书中所介绍的设计模式。

·通常情况下，对算法进行重构（例如移除一些数据共享需求）是一个更好的选择，尤其要比你去修改那些原本就为串行化而设计的底层代码要好得多。

·除非非常必要，否则绝不要在并发性任务之间共享数据。如果非要进行共享数据，请使用API中所提供的容器，例如共享队列（shared queue）。

·除非万不得已，否则应该尽量避免使用类似线程和锁这样的低层次元素。同时，要尽可能地将应用程序中的抽象级别从线程提升到任务。

1.7　练习题

1）当你为问题选择分解粒度（即合并成更大的任务还是分解成更多的小任务）时，需要权衡的因素有哪些？

2）在内核数由1到4的增加过程中，如果程序中串行处理所占的时间约为10%，那么该程序能获得的最大速度增长率是多少？

3）并行与并发的不同之处有哪些？

1.8　更多资源

如果希望对文中所提到的术语有更好的理解，可以参考本书最后的术语表。

本书中所提到的设计模式都属于被业界用户广泛应用于开发的并行模式。在这些用户的概念中，这些模式被看做算法或可实现的模式。关于并行模式的分类，我们可以参考Mattson等人的书，还有Our Pattern Language（OPL）网站，我们尽可能地保证所有的术语都有据可查，对于无法做到的情况，文中将会给出相应的解释。

对于Microsoft Windows平台上的并行性，我们也可以在Duffy的书中找到更详细的讨论。

·Duffy, Joe. Concurrent Programming on Windows, Addison-Wesley，2008.

·Mattson, Timothy G.，Beverly A. Sanders, and Berna L.Massingill.Patterns for Parallel Programming.Addison-Wesley，2004.

·OPL, Our Pattern Language for Parallel Programming ver2. 0，2010.http://parlab.eecs.berkeley.edu/wiki/patterns.

第2章　并行循环

并行循环模式一般适用于执行一些具有独立性的迭代操作，例如遍历某集合（collection）中的元素或者执行一些限定次数的迭代。但前提是这些操作是彼此独立的，即循环步骤中不存在对本地内存或磁盘文件的共同写操作。

在语法方面，并行循环与我们所熟悉的for和for_each并无多大的区别，只不过它们在多核环境中往往会完成得更快而已。此外，另一个不同于串行循环的是，并行循环的执行顺序是不确定的，这意味着在并行条件下各步骤的操作通常会同步进行，有时候，甚至两个步骤的操作顺序会与它们在串行循环中的情况完全相反。这里唯一能保证的是：当循环完成时其中所有的步骤都会被执行到。

我们可以轻而易举地将一个串行循环（sequential loop）改为并行循环（parallel loop）。但是，你可千万不能就此认为简单就不会出错。毕竟，判断循环步骤之间是否具有独立性是一件非常困难的事。这需要在具体的实践中不断地学习并总结经验。有时候，如果我们不幸在一个具有某种依赖关系的循环中误用了并行循环模式，很可能就会导致某些不可预测的行为，程序有可能会就此停止响应，有时甚至还会出现那种运行100万次才出现一次的诡异bug。总而言之，“独立性”是我们能否使用并行循环模式的关键，而这也是本章接下来要介绍的重点内容。

并行循环模式一般用于处理基于多组数据的独立性操作。同时，这也是数据并行化（data parallelism）技术的一个标准应用。

对于并行循环来说，决定它并行度的通常不是代码，而是运行时环境（run-time environment）。也就是说，它取决于运行时环境能同时调用多少内核来完成这些循环步骤。然而，无论我们有多少内核可用，循环都应该能够始终正确地运行。即使在单核处理器上，只要该循环的每次迭代操作所执行的工作量不是太小，它的性能就理应与相应的串行代码相差无几（也许只相差几个百分点）；一旦有了多核处理器，性能就会立即得到改善。毕竟在多数情况下，性能与内核数之间还是会存在着一定的比例关系的。

2.1　基本用法

在并行模式库（PPL）中，我们已经为你内置了与for、for_each对应的并行化函数。其中，parallel_for函数可以被用来遍历整数区间，而parallel_for_each函数则可以用于遍历用户所提供的数据。

如果你想让带有独立性迭代的for和for_each循环在多核计算机上运行得更快，就应该去使用它们的并行版本。

2.1.1　并行版的for循环

现在，让我们来看一段标准C++版的for循环代码：

vector＜double＞results=……

int workload=……

size_t n=results.size();

for(size_t i=0;i＜n;++i)

{

results[i]=DoWork(i, workLoad);

}

接下来，为了体现出多核的性能优势，我们改用parallel_for函数来代替for关键字，并用lambda表达式（lambda expression） [1] 来替代原来的循环体。

如果你想使用并行循环，不要忘记循环各步骤间需要彼此独立，确保它们之间的通信不以写共享变量的方式来实现。

vector＜double＞results=……

int workload=……

size_t n=results.size();

parallel_for(0u, n,[＆results, workLoad](size_t i)

{

results[i]=DoWork(i, workLoad);

});

现在，只要条件允许，parallel_for函数就能利用多核资源来遍历上述代码中的索引区间。

在代码中，形如[captured variables]（args）{body}的语句实质上是一个lambda表达式。在继续深入阅读之前，你或许应该去复习一下C++中关于lambda表达式的语法内容。

另外，parallel_for函数还有一系列不同的重载（overload）版本。上述示例中所调用的版本声明如下。

template＜typename_Index_type, typename_Function＞

void parallel_for(_Index_type_First,_Index_type_Last,const_Function＆_Func);

在该函数中，前两个参数所表示的是该迭代的边界。第一个参数是循环的下界位；第二个参数则为循环的出界位，即上界位+1；而第三个参数则是一个作用于历次迭代的函数，迭代次数是该函数的参数之一。这意味着该循环的每次迭代操作都会调用这个函数。

关于parallel_for函数的另一个重载，我们将会在2.3节中再介绍。

和串行循环不同的是，parallel_for方法不能保证操作的顺序，即对较大索引值的操作可能排在较小索引值之前。

例子中类似于parallel_for第三参数那样的，形如[captured variables]（args）{body}的表达式，被称为lambda表达式。该表达式实质是一个能够捕获自己所在作用域中变量的函数对象。当然，_Func参数并不一定非得是lambda表达式，它也可以是一个函数指针 [2] （指向一个在别处声明的函数）。

如果你对lambda表达式的语法还不熟悉，可以参考2.7节中所推荐的文章。相信我，一旦你使用了lambda表达式，就会对它有一种相见恨晚的感觉。

[1] lambda表达式是C++11标准（有时候也叫C++0x）中的新特性，不熟悉的读者可暂时将其理解为一个即写即用的匿名函数。——译者注

[2] 此处的_Func在代码中是个引用（reference）参数，或许会引起读者的困惑。而实质上作者是指_Function模板参数也可以推导为一个指针（pointer）类型，与_Func参数本身是_Function＆类型并不矛盾。——译者注

2.1.2　parallel_for_each

下面是一个C++Standard Template Library（STL）版的for_each串行循环示例。

vector＜size_t＞inputs=……

int workload=……

for_each(inputs.cbegin(),inputs.cend(),

[workLoad](size_t i)

{

DoWork(i, workLoad);

});

同样，为了发挥出多核的性能优势，我们用parallel_for_each函数来替代for_each关键字 [1] 。

parallel_for_each会在循环体内处理容器中的每一个元素。

vector＜size_t＞inputs=……

int workload=……

parallel_for_each(inputs.cbegin(),inputs.cend(),

[workLoad](size_t i)

{

DoWork(i, workLoad);

});

在语法上，parallel_for_each函数与std::for_each基本相同。第一个参数是一个迭代器（iterator），指向操作区间内的第一个元素；第二个参数迭代器则指向该区间内最后一个元素的后一个位置；第三个参数则是一个作用于该区间内每一个元素的函数对象。

不要忘记，由于各迭代操作之间必须保持彼此独立，因此，每一个循环体只能更新那些传递给它的字段实体。

由于parallel_for_each方法的执行顺序是不确定的（这与for_each串行循环不同），因此这里的输入参数并非总能按照既定的顺序来处理。

[1] 原文如此，而实际上两者都是函数，并不存在关键字（keyword）。——译者注

2.1.3　期望为何

默认情况下，循环的并行度（即硬件能同时运行多少次迭代）取决于处理器中可用的内核数量。可用的内核越多循环便运行得越快，这种趋势可以一直持续到我们遇上阿姆达定律所预言的那个收益递减点。从那之后，循环究竟能运行得多快就取决于该循环本身的工作内容了（请参阅第1章关于阿姆达定律的讨论）。

内核的增加能使循环运行得更快，但这也总会有个上限。

为循环体选择正确的粒度是很有必要的，因为有太多过小的并行循环因其工作量过度分解而导致其多核优势被循环本身的开销抵消殆尽。

在parallel_for或parallel_for_each的执行过程中，如果某个迭代操作抛出了异常（exception），该异常将会被上抛至该函数所调用的线程处进行处理。在2.3节中，我们将会更详细地讲解并行循环中的异常处理问题。

强健的异常处理机制是并行循环处理的重要组成部分之一。

在用并行循环替换了串行循环之后，如果你发现程序不能按照自己所预期的方式执行，那么，最大的可能就是该循环步骤之间并非是彼此独立的。下面，我们将会介绍一些常见的依赖性循环体（dependent loop body） [1] ，以你供参考：

请仔细检查循环迭代之间是否存在某种依赖关系。因为到目前为止，对这种依赖性的疏忽是我们在使用并行循环时最常见错误。

·对共享变量执行写操作：如果循环体内对共享变量执行了写操作，那么该循环体就是具有依赖性的。这在我们需要执行某些聚合求值操作时很常见。下面这个示例中的total就是被所有迭代体共同操作的变量。

for(int i=1;i＜n;i++)

total+=data[i];

如果真的遇上了类似的情况，建议你参考第4章中的并行聚合模式。

当然，共享变量的形式是多样化的。任何在循环体外部声明的变量都有可能是共享变量。例如，如果我们对一些类似于类或数组（array）这样的类型对象使用某种共享性引用的话，这些对象中的所有字段、元素都有可能会被隐式地转换为共享变量；另外，通过引用或指针方式传递的函数参数也会导致变量共享；同样，在lambda表达式中使用引用方式捕捉的变量也一样会如此。

·使用对象模型的数据访问器（data accessor）：如果循环体是通过数据访问器来处理一个对象的话，我们就有必要了解一下这些访问器，看看其中是否涉及了某种共享状态抑或只是对象自身的状态。例如，下面有个名为GetParent的访问器方法很可能就涉及了某种全局状态（global state）：

在用访问器读取数据的时候，你必须非常谨慎。因为这些大型对象往往会以令人费解的方式共享它们的可变状态。

for(int i=0;i＜n;i++)

SomeObject[i].GetParent().Update();

这个例子中的循环迭代体就很有可能不具备独立性。因为对于该循环中的每一个i来说，SomeObject[i].GetParent()引用的都有可能是同一个对象实体。

·引用了非线程安全的数据类型和函数：如果在一个并行循环中，循环体所使用的数据类型或函数不是线程安全的话，就表示这些线程中已经存在着依赖关系了，因此我们也就可以断定该循环体中的操作是不具有独立性的。

·循环执行性依赖（Loop-carried dependence）：如果在parallell_for循环中，循环体中所执行的数值运算是基于循环索引变量的，那么出现循环执行性依赖的可能性就非常大了。例如在下面的代码中，循环体中同时引用了data[i]和data[i-1]，一旦这样的代码出现在parallel_for中，我们是无法确保data[i-1]能在处理data[i]之前就被更新完成的。

基于循环索引变量的数值运算，特别是加减法操作，通常都会导致循环执行性依赖。

for(int i=1;i＜N;i++)

data[i]=data[i]+data[i-1];

然而在某些时候，这种包含循环执行性依赖的并行算法也是可行的，但这已经超出了本书的讨论范围。对此，我们最好的建议是：要么考虑一下程序中别的并行化机会，要么分析一下你的算法能否匹配某些用于科学计算的高级并行模式，例如并行扫描和并行动态规划之类的。

当我们在寻找并行化机会的时候，对应用程序的性能进行详细分析，将会有助于我们了解程序的运行时间主要消耗在哪些地方。但是，这种分析终究不能代替你理解应用程序的算法和结构，例如，它不能替你判断循环体中是否存在依赖关系。

不要对代码的性能分析寄予过高的期望——它不能替代你分析算法，所以我们只能靠自己。

[1] 此处必然是循环体（loop body）而非循环（loop），因为循环的索引变量本身也是一种共享变量，而且循环必然要对它执行读写操作，这种读写操作不会影响循环体的独立性。——译者注

2.2　实例示范

接下来，我们将通过一个具体的实例向你演示并行循环模式的运用。Fabrikam航运公司为自己的商业用户增设了一项借贷业务，他们根据用户的信用度来鉴别哪些账户可能存在风险。并且在每个账户内都包含了一份结欠记录。因为Fabrikam公司从历史记录中发现了一个规律：在账户被判违约之前的数月内，那些违约客户的借贷余额都会呈现出稳定的增长趋势。

于是，为了鉴别风险账户，Fabrikam公司使用了统计学中的趋势分析法，据此估算出每个账户所能达到的最高负债额。如果分析程序预测某用户的账户将在三个月内超出他的负债限额，该账户就会被标记下来，并将面临Fabrikam公司信用分析师的人工审查。

在这个应用中，顶层循环将会遍历账户库中的所有用户。循环体会根据用户的负债记录制定出一条趋势线，据此推定其负债范围，然后用它来比对之前设定的负债限额。一旦发现该用户有超额负载的可能性，程序就会为该账户分配警告标志。

这个应用程序的一个重要特点是，每个用户的信用度是可以被独立估算的。这意味着一个用户的信用度并不依赖于另一个用户，这些操作是彼此独立执行的。因此，只要用并行循环替换掉其中的for_each串行循环，我们的信用分析程序就能运行得更快一些。

关于该程序的完整源代码，读者可以在http://parallelpatternscpp.codeplex.com中的Chapter2\CreditReview目录下找到。

2.2.1　串行版的Credit Review

下面是信用分析操作的串行化版本：

void UpdatePredictionsSequential(AccountRepository＆accounts)

{

for_each(accounts.begin(),accounts.end(),

[](AccountRepository::value_type＆record)

{

Account＆account=record.second;

Trend trend=Fit(account.Balances());

double prediction=PredictIntercept(trend,(account.Balances().size()+g_predictionWindow));

account.SeqPrediction()=prediction;

account.SeqWarning()=prediction＜account.GetOverdraft();

});

}

在这个程序中，UpdatePredictionsSequential方法被用来处理账户库中的每一个账户。而其中的Fit方法是一个工具函数，它通过统计学中的最小二乘法根据一组（用户）数据绘制出趋势线。此外，Fit方法是一个纯函数过程即它不会改变任何东西的状态。 [1]

该值是根据趋势得出的三个月内的预测。如果预测值越过了负债限制（账户系统中的负债额是个负值），该账户就会被标志为审查状态。

[1] 作者是在强调这个函数不会引入任何依赖关系。——译者注

2.2.2　parallel_for_each版的Credit Review

并行版的信用度分析程序与串行版本几乎相差无几：

void UpdatePredictionsParallel(AccountRepository＆accounts)

{

parallel_for_each(accounts.begin(),accounts.end(),

[]

(AccountRepository::value_type＆record)

{

Account＆account=record.second;

Trend trend=Fit(account.Balances());

double prediction=PredictIntercept(trend,(account.Balances().size()+g_predictionWindow));

account.ParPrediction()=prediction;

account.ParWarning()=prediction＜account.GetOverdraft();

});

}

如你所见，除了用parallel_for_each替代for_each外，UpdatePredictionsParallel方法的代码与UpdatePredictionsSequential几乎完全一致。

2.2.3　性能对比

根据在一台四核计算机上运行Credit Review的情况来看，parall_for_each版本相比于其串行版本快了近四倍。至于具体的时间变化情况，建议读者在自己的计算机上运行一下网上所提供代码来获得第一手的数据。

2.3　模式变体

上面的信用分析实例为我们展示了一个并行循环的典型用法，但它还可以有很多变体用法。本节将介绍其中最重要的几个。尽管这些变体被使用的机会并不会太多，但是你应该知道它们是可行的。

2.3.1　提前退出循环

在串行迭代中，退出循环的操作并不会令人感到陌生，而这类操作在并行循环中并不常见，但有时我们依然会用得着它。下面是一个串行情况下的做法：

int n=……

for(int i=0;i＜n;i++)

{

//……

if(/· stopping condition is true*/)

break;

}

相比较而言，并行循环的情况会更复杂一些。因为其中往往会有不止一个步骤在同时运行，而且各步骤的执行顺序完全无法预知。但是，我们倒是可以通过取消任务组的方式来实现从一个并行循环中提前退出的效果。关于任务组，我们将会在第3章中详细介绍。

使用task_group::cancel方法可以帮助你提前退出一个并行循环。

下面是一段如何提前跳出并行循环的示范代码：

vector＜double＞results=……

int workLoad=……

task_group tg;

size_t fillTo=results.size()-5;

fill(results.begin(),results.end(),-1.0);

task_group_status status=tg.run_and_wait([＆]

{

parallel_for(0u, results.size(),[＆](size_t i)

{

if(i＞fillTo)

tg.cancel();

else

results[i]=DoWork(i, workLoad);

});

});

如你所见，如果想要提前退出一个并行循环，我们需要先为该循环建立一个任务组对象，然后让该循环在任务组内执行。这时候，如果你想退出循环，只需要调用该任务组的cancel方法即可。

不要忘了并行循环是无序执行的，所以取消一个并行循环时并不能确保位置更靠后的迭代操作没有先被运行。

但是和串行循环不一样的是，我们应该始终牢记并行循环中的步骤是无序执行的。因此当你取消一个并行循环任务组的时候，并不能保证没有更靠后的迭代在cancel被执行之前已经运行完成。

2.3.2　异常处理

当一个并行循环中抛出未处理异常时，就不会再执行新的步骤了。但在默认情况下，异常抛出时正在运行的其他迭代操作仍将继续。一直到它们全部完成之后，该并行循环才会将异常上抛到调用它的线程中去。

抛出一个未处理异常可以阻止新的迭代操作开始。

由于循环运行在并行环境中，所以可能会发生不止一个异常。如果遇上这种情况，并行循环会随机抛出一个异常，而剩下的异常将不会被外界所察觉 [1] 。

下面的代码示范了如何在并行循环中执行异常处理操作。

vector＜double＞results=……

try

{

size_t n=results.size();

parallel_for(0u, n,[＆results](size_t i)

{

results[i]=DoWork(i,10);//throws exception

});

}

catch(ParallelForExampleException e)

{

printf("Exception caught as expected.\n");

}

[1] 即无论并行循环中有多少异常发生，在循环所在线程中能看到的只有一个，而且是随机的。——译者注

2.3.3　小型循环体的特殊处理

如果循环体中只执行了少量的工作，我们或许可以将这些迭代分配到更大的工作单元中去，以谋求更好的性能。这样做是有原因的。当执行一个循环时通常会引入两种类型的成本：管理工作线程的成本和调用函数对象的成本。大多数情况下这些成本是微不足道的，但是，当循环体很小时这些开销就不能忽略不计了。

如果你有很多迭代，而每次迭代的工作量又很小的话，请考虑使用分组策略。

parallel_for的其中一个重载版本允许我们指定索引值的步长（step size），当我们使用步长大于1的迭代时，就可以在并行循环中内嵌一个串行循环。这样一来，外（并行）循环的每一次迭代就由对逐个索引值进行处理转变为对一个索引值范围进行处理。这种将迭代分组的方法可以让我们避免一些并行循环固有的开销。具体的实例代码如下：

size_t size=results.size();

size_t rangeSize=size/(GetProcessorCount()*10);

rangeSize=max(1,rangeSize);

parallel_for(0u, size, rangeSize,

[＆results, size, rangeSize, workLoad](size_t i)

{

for(size_t j=0;(j＜rangeSize)＆＆(i+j＜size);++j)

results[i+j]=DoWork(i+j, workLoad);

});

相对于普通无分组的parallel_for函数来说，这种将数据分组的方式会导致更为复杂应用程序的逻辑。一旦遇上大工作量的迭代（或者当迭代区间的大小不均时），这个方法或许就无法帮助我们获得较好的性能了。所以通常只有在经过了深思熟虑之后，或者是在这种循环体极小而迭代次数很多的特定情况下，我们才会考虑采用这种更为复杂的语法。

至于分组的数量，则一般取决于计算机上可用的内核数。默认条件下，分组数量大约维持在内核数的3到10倍是比较理想的。

除此之外，另一种处理这种小型循环体的方法是，调用parallel_for_fixed和parallel_for_each_fixed。这两个函数可以在并发运行时相关的示例程序包中找到。默认情况下，parallel_for和parallel_for_each都具有动态负载平衡（dynamic load balancing）的功能。一旦循环体中的工作量过小，这种负载平衡所带来的开销会对程序产生很大的影响。该示例包中的parallel_for_fixed和parallel_for_each_fixed则不会执行这样的负载平衡，因此它们在小循环体中或许能比parallel_for和parallel_for_each运行得更快一些。

这两组函数的另一个不同之处是，parallel_for和parallel_for_each会检查每一次迭代的注销情况，相比之下，parallel_for_fixed和parallel_for_each_fixed并不会在它们的子区间内执行这种检查。

2.3.4　并行度控制

一般情况下，并行循环的迭代操作在内核上的具体分配是由系统自行管理的。但在某些特殊的时候，我们也需要取得更多的控制权。

在并行循环模式中，我们经常会在各种情况下用到这些模式变体。例如，为了模拟一些性能较差的硬件，我们需要通过降低程序的并行度来执行一些性能测试；而如果循环中各次迭代都要花费大量时间来等待I/O输入，就适于提高程序的并行度来提高执行线程的数量，使之大于内核数。

你可以控制一个并行循环中并发的活动线量的最大数量。

所谓并行度，实质上是指我们在迭代时占用的内核数量。一般来说，并行度都是交由系统的底层组件自行管理的，例如parallel_for和parallel_for_each的函数实现、并发运行时的任务调度，以及操作系统中的线程调度等。这些组件都为各种条件下优化系统的吞吐量做出了一定的贡献。因此，尽管无法直接控制并行度，但我们可以通过控制执行一个并行循环的线程数量来对其施加影响。而这需要我们去设置一下SchedulerPolicy类中MinConcurrency和MaxConcurrency策略。关于设置这些策略（policies）的更多信息，读者可以参考附录A中的相关内容。

2.4　反面模式

所谓反面模式（Anti-Patterns），通常指的是一些具有警示性的反面故事，它为我们凸显出了一些需要进行深思熟虑的事件和问题域。接下来，我们将会为你列举一些在使用并行循环时需要注意的问题。

2.4.1　隐性循环体依赖

错误分析了循环中的依赖关系往往是导致软件缺陷的主要根源之一。因此，我们要留意所有并行循环体中不可见的隐性依赖，这些是非常容易出错的地方。

举例来说，如果在非线程安全的情况下，我们在各个并行迭代操作之间共享了一个类实例的话，就很可能会遇上这种微妙的依赖性。同样，你也要留意在lambda表达式的封闭域中使用引用变量来共享状态的问题。

即使各循环体之间彼此并不完全独立，我们依然是可以使用并行循环的。只不过，在这种情况下，我们必须确保所有的共享变量是受保护的，并且是可以同步的。同时，我们还必须对自己所引入的任何同步化机制在性能影响方面有充分的认知。尽管这种机制的引入可能会极大地降低并发程序的性能，但如果你因此而忽略了它的必要性，同样会导致程序中出现灾难性的并且难以重现的bug。

如果循环体之间并不存在任何依赖关系——例如执行求和运算——你或许就可以考虑我们将在第4章中介绍的并行循环变体。

2.4.2　少量迭代的小循环体

如果我们只是在很小的并行循环体中处理一些很有限的数据元素，性能或许并不会得到多少改善，这种情况下并行循环本身的成本反而会成为主要开销。显然，如果仅仅是简单地把每个串行的for循环改为parallel_for循环，并不一定就能获得好的结果。

2.4.3　重复输入性枚举

在使用parallel_for_each的过程中，如果输入中有多个指针或引用变量指向了同一个对象实体，往往就意味着我们的代码中可能存在不安全竞争的情况。因为如果同一个对象引用（object reference，即指向同一个类实例的指针或引用）在循环输入中不止一次地出现，就很可能会产生两个并行线程同时更新一个对象的问题。

请不要在并行循环中反复使用同一个实体的副本，如果有一个对象不止一次地出现在某个循环的输入中，该对象就很有可能会被两个并行线程同时更新。

2.4.4　基于协同性阻塞的交叉调度

在一个并行循环中，如果它的每次迭代都要执行协同性阻塞（cooperative blocking） [1] ，就很可能会使任务调度器中所产生的线程数量多得超乎想象。因此，我们应该尽可能少地在此类循环中使用协同性阻塞操作。

[1] 关于协同性阻塞的内容请参考第3章中的内容。——译者注

2.5　相关模式

并行循环其实还是并行聚合模式的基础模式。而后者正是我们将要在第4章中介绍的内容。

2.6　练习题

1）在下面的这些问题中，有哪一个能适用于本章所介绍的并行循环技术？

a）对内存中的一个拥有100万个数字元素的数组进行排序。

b）读取一个文本文件，并按字母顺序输出每一行的单词。

c）对一个集合中的所有数字求和，并输出最终的运算结果。

d）将两个集合中的数字两两相加，并将每对数字的相加值输出到第三个集合中。

e）对一个文本文件集合中出现的每个单词执行统计计数。

f）找出一个文本文件集合中出现频率最高的单词。

2）从上题中选择一个你认为合适的问题，分别用串行循环和并行循环实现一个解决方案。

3）对CodePlex站点http://parallelpatternscpp.codeplex.com中的Credit Review实例执行性能分析。用命令行选项独立地变换迭代次数（即账户数）和每个迭代的工作量（即信用记录中的月份数）。并记录下该程序三个版本所有的执行时间报告。然后，变换账户数和月份数的各种不同组合在不同内核数，以及不同执行环境（从其他应用程序中载入）的各种计算机上进行反复测试。

2.7　补充阅读

在这本书中，几乎所有的实例都运用了Microsoft Visual C++中的功能特性和程序库，因此关于这些功能、程序库以及lambda表达式的详细资料，MSDN自然是首要的参考来源。此外，Mattson等人也在自己的书中介绍了一些关于并行编程的软件设计模式，值得一提的是，这些模式并不局限于特定的语言和程序库。最后，Messmer的文章也值得你看看，其中介绍了几个与本章相关的模式，以及一些关于如何在PPL中使用并行循环的建议。

·Mattson, T. G.，B.A.Sanders, and B.L.Massingill.Patterns for Parallel Programming.Addison-Wesley，2004.

·Mattson, T. G.，"Use and Abuse of Random Numbers"（video），Feb 14，2008，http://software.intel.com/en-us/videos/timmattson-use-and-abuse-of-random-numbers/.

·Messmer, B.，Parallel Patterns Library, Asynchronous Agents Library，＆Concurrency Runtime：Patterns and Practices，2010. http://www.microsoft.com/downloads/en/confirmation.aspx?displaylang=en＆FamilyID=0e70b21e-3f10-4635-9af2-e2f7bddba4ae.

·MSDN, Lambda Expressions in C++，http://msdn. microsoft.com/en-us/library/dd293608.aspx.

第3章　并行任务

并行任务是一种可以让我们在同一时间内执行的异步操作（asynchronous operations）。这种方法也被称为任务并行化。

第2章所介绍的并行循环模式，实质上是一种实现用单一操作处理多组数据的并行技术，有时也称为数据并行化（data parallelism）。第3章将要为你介绍的是，面对多个同时运行的异步操作时所应该选择的处理技术。在这种情况下，如果我们能实现任务的并行化执行，就可以在程序控制流中实现类似于fork [1] 这样的操作。这种做法通常也称为任务并行化（task parallelism）。正因为如此，并行任务模式有时候也会称为Fork/Join模式或者Master/Worker模式。

数据并行化和任务并行化就好像位于我们技术光谱的两端 [2] 。前者通常侧重于处理单一操作的多重输入；而后者则往往侧重于处理多个操作，并且每个操作中都有属于自己的输入。

在并行模式库（PPL）中，我们一般会通过task_group类的方法来启动并管理任务。你可以在ppl.h头文件中找到task_group类的相关声明。在该类中，run方法主要用于创建新的任务，并对其进行调度；而wait方法则主要用于使程序暂停下来，以等待某个任务组对象完成它所有的任务。从fork/join组操作角度来看，这里的run方法就相当于fork操作，而wait方法则基本等同于join。

在PPL中，并行任务是通过task_group类来管理的。

任务的调度是并行任务模式中的一个重点内容。这与线程不同，新任务启动之后通常并不会立即执行。而是先被移到一个工作队列中。等处理器有空闲资源时，再由相关的调度器将它们从工作队列中调出来运行。因此，只要我们确保任务足够多，并且这些任务之间不存在序列化依赖关系（serializing dependency），程序性能往往就能随着可用内核数的增多而改善。在这里，我们又有了一次体会潜在并行化概念的机会。还记得吗？这个概念在第一章中曾被我们反复提及。

此外，对基于任务模式的应用程序来说，另一个重点内容就是异常处理机制。在PPL中，一个在任务执行期间发生的未处理异常通常会被推迟发现。例如，某个异常通常会被推迟到task_group::wait方法被调用时自动发现。到那时，该异常会在当前所调用的wait方法中再次被抛出，这种方式让我们得以在并行程序中使用串行式的异常处理方法。

在PPL中，任务会推迟异常的上报，直至调用任务组方法wait时才会再次抛出。

3.1　基本用法

尽管每一个任务实际上都是一组串行化操作，但任务本身的执行是实现并行化的。请看下面这两行串行的代码：

DoLeft();

DoRight();

让我们先来假设一下，如果这里的DoLeft和DoRight都是可以独立执行的函数，即它们不会一个方法读另一个方法写同样的本地内存区域或者磁盘文件。我们就可以将它们并行化。为了实现这一点，我们需要调用Concurrency命名空间中的parallel_invoke函数。像下面这样：

parallel_invoke(

[](){DoLeft();},

[](){DoRight();}

);

Concurrency命名空间中的parallel_invoke函数能在系统中建立起一组并行任务，并会等到这些任务完成之后再继续。

parallel_invoke函数是并行任务模式中最简单的实现形式了。该函数会将参数列表中的每一个lambda表达式都视作一个新的并行任务，并为它们创建一个新的任务组来执行。并在所有任务完成之后返回。正因为如此，parallel_invoke函数参数列表中的那些函数也称为工作函数（work function）。

顺带一提，有些parallel_invoke函数的重载版本所支持的工作函数可以达到9个之多。

需要注意的是，在我们使用并行任务时，不能把设计思路建立在所有任务都会被立即执行的基础上。因为，并行任务的运行时机几乎完全取决于它当时所在的工作负载和系统配置，也就是说，任务调度器既有可能会依次调用它们，也有可能让它们同时运行。关于任务调度的细节问题，后面再详细介绍。

在parallel_invoke中，任务函数除了以正常形式完成调用外，也有可能会因抛出异常而提前结束。如果在parallel_invoke函数运行期间，某个工作函数抛出了异常，通常会推迟到所有任务执行完成之后再上抛。如果抛出异常的工作函数不止一个，运行时环境会随机选择其中的一个来上抛，而其余的异常将不会被外部发现 [3] 。关于异常处理的详细信息及其相关的代码示例，我们将会在3.3.3节中进一步说明。

从本质上说，parallel_invoke函数的主要作用就是新建一系列任务并等待这些任务完成执行。因而，我们完全可以自己建立一个任务组对象，并通过调用它的run和wait方法来实现同样的功能。例如：

task_group tg;

tg.run([](){DoLeft();});

tg.run([](){DoRight();});

tg.wait();

任务组对象的run方法主要用于创建一个任务并完成它的调度与执行；而wait方法则主要用于阻塞当前环境，以待任务组执行完所有的任务。

上例通过task_group类的run方法新建了一个任务，并完成了相关的执行调度。实际上，run方法的参数是一个供任务执行时所调用的函数，它可以是一个lambda表达式、一个函数指针或者一个函数对象。换句话说，该参数可以是任何支持形如void operator()()函数调用运算符的对象。task_group::run方法每新建一个任务，就会将该任务加入到一个工作队列中，以备最终执行。但是只有在任务调度器将其调出队列时才会执行它，而这种出队操作可以现在发生，也可以在将来的某个时间点上发生。

如果传递给task_group::run方法的参数引用了某个支持operator()接口的类实例。那么，我们就必须有效地管理该函数对象所用的内存，确保该对象在任务组的wait方法返回之后才被销毁。而lambda表达式或者指向静态函数的函数指针，并不需要显式销毁。从这点来说，这确实比functors类类型（class-type）要方便得多。

我们能通过调用任务组对象的wait方法来令其等待该任务组中的任务完成之后再返回。

或许有时候我们也可以将run和wait的操作步骤合二为一，代码如下所示：

task_group tg;

tg.run([](){DoLeft();});

tg.run_and_wait([](){DoRight();});

从线程使用效率上来说，直接调用任务组对象的run_and_wait方法显然要比之前的run/wait方式略胜一筹。因为该方法还有一种提示作用，可以用来建议任务调度器就在当前调用里执行新任务 [4] 。

到目前为止列举的这些例子都非常简单，但已经足以帮助你处理许多具体问题了。如果还想学习更多任务使用的方法，可以参阅3.3节中的内容。

[1] fork以及后面的join都是UNIX下典型的系统调用，如今也被列为posix中的标准操作。它们可以把当前进程中的某些代码复制到子进程中执行，实现一定程度上的异步调用。——译者注

[2] 这里强调的并非是对立关系，而是工作的侧重点不同。正如伽马射线和无线电波（它们位于全光谱的两端），两者可以共存，但作用完全不同。——译者注

[3] 此处的描述和第2章中的相关内容基本相同，即外部能看到异常可能是这些工作函数所抛出的异常中任何一个。——译者注

[4] 意即如果这种建议被系统采纳，能减少一些线程的使用开销，但这也仅仅是个建议。——译者注

3.2　实例示范

下面，为了示范任务并行化的用法，我们将通过一个图像处理程序来说明问题。这是一个分层处理的图像程序，它会分别对各个不同来源的图像执行单独处理，然后再用一种称为阿尔法混合（alpha blending） [1] 的技术将这些图像合并。这种方式可以通过叠加半透明图层的方式来形成一个图像。

由于将要被合并的源图像是不同的，因此基于这些源图像的操作也是不同的。这意味着图像处理程序必须分别对这些源图像执行单独操作，并且这些操作要在图像进行合并之前完成。接下来，为了简单起见，我们只使用两个源图像和一些简单的操作：变换图像的灰度和角度。但在现实中你往往必须要面对更多的源图像和更复杂的操作。

首先，我们先来看看该程序的串行化实现。你可以在http://parallelpatternscpp.codeplex.com的Chapter3\ImageBlender文件夹下找到完整的示例源码。

static void SequentialImageProcessing(

Bitmap*const source1,Bitmap*const source2,

Bitmap*const layer1,Bitmap*const layer2,

Graphics*const blender)

{

SetToGray(source1,layer1);

Rotate(source2,layer2);

Blend(layer1,layer2,blender);

}

在上面的代码中，参数source1和source2为原始源图像；而layer1和layer2则是最后合并操作提供的中间位图；最后的blender则是一个Graphics类型的实例引用，该实例用以存放合并完成之后的结果图像；而该函数体中的SetToGray、Rotate、Blend则都是来自Gdiplus命名空间中的图像处理方法。

其中，SetToGray和Rotate这两个方法是完全独立于彼此运行的，这意味着它们可以在独立的任务中各自执行。因此只要拥有多核环境的支持，它们就可以实现并行化运行。这样一来，该图像处理程序就有可能获得比上面的串行版本更高的效率。

接下来，我们就改用parallel_invoke函数来创建任务，并令其执行环境等待所有任务完成之后再执行后续处理。代码如下所示：

static void ParallelInvokeImageProcessing(

Bitmap*const source1,Bitmap*const source2,

Bitmap*layer1,Bitmap*layer2,Graphics*blender)

{

parallel_invoke(

[＆source1,＆layer1](){SetToGray(source1,layer1);},

[＆source2,＆layer2](){Rotate(source2,layer2);}

);

Blend(layer1,layer2,blender);

}

使用parallel_invoke函数可以使我们随时能在某个单独的作用域中定义一些任务。

在这一示例中，parallel_invoke函数通过自身的参数列表隐式地划分了任务，并确保所有任务完成之前不会返回。

当然，我们也可以显式地建立并行任务。像下面这样：

static void ParallelTaskGroupImageProcessing(

Bitmap*const source1,Bitmap*const source2,

Bitmap*layer1,Bitmap*layer2,Graphics*blender)

{

task_group tasks;

tasks.run(

[＆source1,＆layer1](){SetToGray(source1,layer1);}

);

tasks.run_and_wait(

[＆source2,＆layer2](){Rotate(source2,layer2);}

);

Blend(layer1,layer2,blender);

}

如你所见，这段代码在堆栈上分配了一个任务组对象，并调用任务组run方法新建并运行两个任务，各自执行SetToGray和Rotate这两个函数。其中，代码中还使用了run_and_wait方法来创建第二个任务，以便代码在执行图像合并处理之前等待所有任务完成。

[1] 阿尔法混合（Alpha Blending），也叫阿尔法通道融合技术，是一种按照"Alpha"混合向量的值来融合源像素和目标像素的图像处理技术。——译者注

3.3　模式变体

这一节将为你介绍一些由PPL实现的基于并行任务的模式变体。

3.3.1　基于协同性阻塞的任务协调

协同性阻塞为任务协调工作提供了一种灵活而又强健的编程机制。

在Concurrency命名空间中。我们通常将所有实现了任务协调功能的类和函数统称为协同性阻塞（cooperative blocking）。这种协同性阻塞能让程序自行挂起当前所执行的任务，并将控制权交还给任务调度器，直到满足某个特定条件时，任务才会继续执行。这种情况通常适用于当前任务的执行需要另一个任务先执行某个动作。例如，task_group::wait方法就是这种协同性阻塞的典型用例。如果一个任务在运行过程中调用了该方法，任务调度器就会令当前任务暂停执行，并且使其在指定任务组中的任务全部完成之前不再继续执行，总而言之，协同性阻塞为任务协调工作提供了一种灵活而又强健的可编程机制。

另外，协同性阻塞还有助于更充分地利用处理器资源，进而使并行应用程序的性能得到进一步改善。因为一个任务被阻塞往往意味着另一个任务可以得到从调度器那里分配到更多的处理器资源的机会。因而，如果你想更有效地使用PPL，就必须充分地理解协同性阻塞和任务调度器之间的相互作用。关于这些内容，我们将会在附录A中做进一步介绍。

我们可以通过调用协同性阻塞来提示任务调度器启动或恢复其他的工作任务。

当然，我们也可以在任务中使用操作系统中的任何同步化功能。这些操作系统的低层次的阻塞操作有时也统称为非协同性阻塞（noncooperative blocking）。如果你打算使用阻塞，那么就必须要在非协同性和协同性之间选择一个最适用的阻塞方式。一般而言，协同性阻塞与我们的任务调度器之间有更好的协调优势。

协同性阻塞有助于我们更充分地利用处理器资源，从而进一步改善并行应用程序的性能。

在下面的表格中，我们列出了一些最重要的运行时协同性阻塞操作，以供参考。另外需要特别提醒的是，我们并没有说这些操作每次都能成功地执行阻塞，而仅仅是说，在某些条件下它们有执行阻塞操作的可能性。另外顺带一提，表中所有的类都来自Concurrency命名空间。

3.3.2　取消一个任务组

使用task_group::cancel方法可以将一个任务组中的所有任务都设为取消状态。

通过调用一个任务组对象的cancel方法，我们可以将该对象标记为取消状态，如下所示：

task_group tg;

tg.run([](){DoLeft();});

tg.cancel();//could be called from any thread

wcout＜＜L"Is canceling:"＜＜tg.is_canceling()＜＜endl;

task_group_status status=tg.wait();

wcout＜＜L"Status:"＜＜status＜＜endl;

如你所见，通过任务组对象的cancel方法可以使该对象改变自身的状态。也就是说，该方法被调用后，这个对象的is_canceling方法就会返回true。在这种情况下，任务组对象就不会再启动那些尚未运行的任务了。并且在cancel方法调用之后，所有由run方法所加入的新任务也都将会被自动忽略。

但是，对于那些在任务组对象被取消之前就已经启动了的任务来说，仍将继续运行，但情况也会有一些改变。即如果在这之后，在被取消的任务组中有一个任务调用了任何一个Concurrency命名空间中的函数，都有可能会导致程序抛出异常。例如，如果一个任务组被取消之后，该组中的某个任务在运行期间调用了另一个任务组的wait方法，运行时环境就有可能抛出异常。至于具体是由Concurrency命名空间中的哪个函数抛出，或者具体是在什么情况下抛出，我们并没有明确的规定。因此，在操作处于取消状态的任务组时，我们的异常处理设计不能依赖于某个具体的库函数是否抛出异常。

你必须确保任务的工作函数是异常安全（exception-safe）的。因此请尽可能地使用RAII模式（资源获取即初始化模式，Resource Acquisition is Initialization）来执行自动清理工作。

你在一个被取消的任务组中混合使用阻塞操作而不是用task_group::wait的时候必须格外小心，这种情况下，你必须确保所有的代码路径中都不会存在死锁的可能。

在目前的PPL版本中，如果我们贸然取消一个带协同性（或非协同性）阻塞任务的任务组，是有可能会导致死锁的。我们来举个例子，考虑下面这种情况：假设你的任务组中有两个任务，而这两个任务共享了一个支持协同性阻塞的event类实例E。接着，你在其中一个任务中调用E的wait方法，同时又在另一个任务中调用E的signal方法。也就是说你让第一个任务处于阻塞状态以等待E事件，而第二个任务还没有开始执行，如果你此时调用这个任务组对象的cancel方法，就会导致第一个任务因其等待条件无法得到满足而永远处于阻塞状态（即死锁）。

在一些特定情况下，取消状态还会在任务组对象之间自动传递。例如，如果A任务组中的某个任务需要等到B任务组完成任务之后才能继续执行，那么这两个对象就会传递彼此的取消状态。在该情况下，如果任务组A的cancel方法在任务组B的wait方法执行完成之前被调用，任务组B的cancel方法也将会被运行时调用。这时候，任务组A中的任务因等待任务组B的完成而正处于阻塞状态，如果你在此时调用了任务组B的wait方法，就会导致该wait方法产生并抛出异常。（当然，如果这时任务组B执行得很快，那么它的wait方法很可能在任务组A还没有传递取消状态之前就已经正常返回了） [1]

对于一个正处于取消状态的任务组对象来说，如果该对象中有任一任务因调用另一个任务组对象的wait方法而进入阻塞状态，那么该对象的取消状态也会随之被传递过去。

需要注意的是，运行时环境只负责任务组关系树中最顶层的那个任务组对象，它的wait方法会返回一个枚举变量canceled来表示当前的取消状态值。而堆栈中的其他结构则都是通过一个内部异常来传导该状态的。

如果你从the task_group::wait方法中返回一个任务组对象，将会得到它的初始状态，即与它刚被创建时相同。

对于那些长时间运行的任务，我们可以通过is_canceling方法来实时检测其所在任务组的取消状态，以便它们在收到取消请求时实现自我关闭。此外，如果需要对一个正在被取消的任务执行本地化清理，is_canceling方法也能发挥一定的作用。因为一个任务组对象从wait方法返回时，它的状态会被重置。在那之后，is_canceling方法返回的就是false值了。

对于一个迭代次数很多并且每次迭代的工作量又很小的循环来说，执行取消检测就可能会给程序的性能带来负面的影响。但是话又要说回来，如果检测次数过少，也会给程序响应取消请求的操作带来某种令人不可接受的延时。例如，对于一个基于图形界面的交互应用来说，每秒钟检测一次以上或许是个不错的想法。而对于在后台运行的程序来说，或许每2到10秒检测一次就已经足够了。因此，这需要我们对应用程序进行具体分析，并收集相关的性能数据。只有这样，我们才能为应用程序找到最合适的响应取消请求方式。至于如何在代码的性能分析中找到最佳的检测频率，你可以参阅附录B的相关内容。

[1] 此处实际是列举了取消状态在任务组对象之间自动传递的几种情况，读者需要特别留意被调用的方法各自是谁的方法，是任务组方法还是任务组中任务的方法。——译者注

3.3.3　异常处理

一旦某个任务抛出了未处理异常，该任务所在的任务组就会被运行时环境自动取消。并且在其关闭过程中，任务组的is_canceling方法会返回true。

一般来说，如果任务的工作函数在执行期间抛出了一个未处理异常，你的应用程序是无法立即感知到的。因为该异常将会由运行时环境直接捕获，并记入到一个内部数据结构中，紧接着运行时环境会取消这个问题任务所在的任务组。关于取消任务组的相关细节，在3.3.2节中已经详细介绍过。同时，我们也对取消状态在某些条件下的自动传递问题发出了相应的警告。

需要注意的是，即便程序中有多个任务抛出异常，我们在task_group::wait方法也只能观察到这些异常当中的一个，而且具体上抛哪一个异常通常是不受人为控制的。

关于重拾被推迟的异常并将其上抛的过程，我们称之为“未处理任务的异常发现”。一旦任务组对象完成了它所有的任务，task_group::wait方法就会将问题任务中的异常上抛到调用wait方法的线程所在的运行时环境中。如果这时有多个异常抛出（意即有不止一个任务抛出了未处理异常），运行时环境只会随机选择一个上抛，而剩下的异常将不会被外部所发现。

如果觉得有必要记录所有的异常信息，你可以自行实现一个日志（logging）程序或对代码进行跟踪记录。

3.3.4　预测性执行

通常情况下，我们所谓的预测性执行（Speculative execution），实质上是一种基于特定的预测值而率先执行的操作。现在来举个例子，假设我们对第一步计算结果的预测是42，那么直接用42作为下一步计算的输入并立即开始执行。这样做的结果是：如果第一步计算的结果确为42，那么我们就成功地将后一个操作提前了一步，实现了这两步操作的并行化执行；而如果第一步计算的结果不为42，那我们也可以重新再进行第二步计算。

预测性执行的另一个典型应用是，如果我们要并行化执行多个异步操作，但事实上这些操作只需要其中一个完成就足够了。例如，如果三个搜索任务搜索的目标是相同的，当最快的那个任务找到目标之后，实际上我们已经不需要再等待另外两个任务完成了。在这种情况下，我们在等到最先的那个任务完成之后通常就会取消剩下的任务。但与此同时，我们应对其中任何一个任务可能抛出的异常保持应有的警觉。

如果我们想要让程序只完成一组任务中的一个，可以通过标志任务取消状态的方式达到目的。

我们可以用task_group::cancel方法来实现预测性执行，代码如下：

task_group tg;

tg.run([＆tg](){SearchLeft(tg);});

tg.run([＆tg](){SearchRight(tg);});

tg.run_and_wait([＆tg](){SearchCenter(tg);});

在上面的代码中，我们并行执行了三个搜索任务，并且只要完成其中任意一个函数，就可以继续后面的处理，因此我们无需等到它们全部完成。为了实现这一点，我们可以向该代码中的每个工作函数传递一个任务组对象的引用，然后分别在这些工作函数的末尾处加入取消任务组的操作语句，以便在它们工作完成时都能调用这一操作。例如，SearchLeft函数的代码可做如下改动：

void SearchLeft(task_group＆tg)

{

bool result=DoCpuIntensiveOperation(g_TaskMilliseconds/5,＆tg);

wcout＜＜L"Left search finished, completed="＜＜result＜＜endl;

tg.cancel();

}

作为一个长时间运行的函数，DoCpuIntensiveOperation会负责检测任务组对象的取消状态。代码如下：

bool DoCpuIntensiveOperation(DWORD milliseconds,task_group*tg=nullptr)

{

//……

int i=0;

DWORD checkInterval=……

while(true)

{

if((milliseconds==0)||(++i%checkInterval==0))

{

if(tg＆＆tg-＞is_canceling())

return false;

}

//……

}

}

如你所见，while循环会周期性检测任务组tg是否接收到了取消状态的请求。另外出于性能考虑，代码只在特定的迭代周期做这种检测操作。

3.4　反面模式

接下来，我们介绍一些在使用任务组的过程中需要留意的注意事项。

3.4.1　闭包中的变量捕获

在C++中，所谓的闭包（closure）通常指的是一些由匿名函数（例如像lambda表达式这样的）所创建的代码域。它允许我们引用其作用域之外的变量，例如在该闭包自身所在的域 [1] 中声明的局部变量。

对于一些程序员来说，C++中的闭包语义不但很不直观，而且非常容易出错。一旦我们错误地使用了闭包，对变量捕获就会与我们原先所期望的结果大相径庭。而这一点在并行程序中表现得尤为明显。

只要我们在引用变量时不小心忽略了其作用域关系，问题就会出现这种问题是很常见的，例如下面这种情况：

task_group tg;

for(int i=0;i＜4;i++)

{

//WARNING:BUGGY CODE, i has unexpected value

tg.run([＆i](){wcout＜＜i＜＜endl;});

}

tg.wait();

你可能认为上述代码会以任意顺序打印1、2、3、4，但事实并非如此。该程序的打印结果几乎完全取决于线程运行的具体情况，比如你甚至有可能会看到4、4、4、4这样的结果。问题的根源在于，在这段代码中，变量i是以引用的形式被捕获的，这使得for循环的每一步都在其闭包域中共享了同一个i值。这样一来，当任务开始执行时，该任务所引用的变量i可能早已和它创建时不相等了。 [2]

要解决这个问题，我们只需在相应的作用域内改用传值的方式捕获变量即可，如下所示：

task_group tg;

for(int i=0;i＜4;i++)

{

tg.run([i](){wcout＜＜i＜＜endl;});

}

tg.wait();

这个版本将会如我们原先预期地打印出1、2、3、4（以任意顺序）。原因在于我们将变量i的值传递了给闭包，也就是说，现在在for循环中，历次迭代所引用的变量i实际上都是一个新的实体 [3] 。

确保lambda表达式对变量执行的是显式捕获而非隐式捕获，这是一个很不错的做法。

[1] 即闭包域的外层域，例如函数域的外层域是命名空间域，或者全局域（如果没有定义命名空间的）。——译者注

[2] 这里也许会引起一些读者困惑，因为这里的for并非并行循环。但是循环中的tg.run()的执行顺序是未定义的，因而，尽管这些任务捕获i的顺序是固定的（即for循环的顺序），我们也无法确保当后一个任务捕获i时，前一个任务已经将i输出了。因此才有了后面的解决方法。——译者注

[3] 关于这点，读者可以类比形参和实参的不同之处。——译者注

3.4.2　计划外的取消状态传递

如果我们基于PPL实现了这样一个程序库，它会在自身的API被调用时创建一个内部的任务组对象。这就会引出一个问题，如果我们在一个程序中调用该库的API，就有可能会在无意间导致我们的任务组需要等待这个程序库中的任务组。根据3.3.2节中所做的描述，我们知道如果此时在程序中调用任务组对象的cancel方法，就有可能会导致取消状态被隐式传递到那个程序库所建立的任务组对象中去。而这种把取消状态对其他组件内部任务组的计划外传递通常不是我们愿意看到的。因为在多数情况下，我们可能更愿意该库函数继续完成它的工作，不用去管它的更高层组件是否正准备关闭。

我们要意识到隐性的从属关系会影响取消状态的行为以及异常处理，这点对于程序库尤为明显。

在这种情况下，我们可以通过一种中性的方法来避免这种在运行时环境中的计划外传递，即我们可以在非PPL的线程环境里使用那些依赖于任务组wait操作的库函数。例如，我们可以在某个轻量级的任务里调用这些库函数，该轻量级的任务是一个低层次的任务类型，它不会导致取消状态的相互传递。这一点我们到附录A里再做进一步的介绍。

3.4.3　同步化成本

在并行程序中，尽管类似锁这样的同步化操作有时候是必需的。但是，程序员们往往低估了这类串行操作对于程序性能的影响。

如果想避免性能瓶颈，你必须仔细检查自己所使用的同步化操作（包括锁）。

关于在程序设计的过程中，应该如何考虑同步化的问题，在1.2.3节中已经详细介绍过。就总体而言，设计良好的程序所需要的显式同步化操作往往要少得多。

3.5　设计注意事项

本节将讨论一些在创建并行模式库的过程中需要注意的设计事项，其中也包括一些编码方面的经验。

3.5.1　任务组调用约定

无论在何时，只要我们调用了任务组对象的run方法，都必须接着调用它的wait方法，并且还要确保在wait方法在任务组对象销毁之前返回。否则，运行时环境就会抛出一个"missing wait"异常。该异常一般只会出现在正常的程序流程中，如果是应用程序本身的异常导致的栈展开（unwind stack）并不会导致它的发生。也正因为如此，我们不需要对wait方法的调用进行RAII封装。

在task_group类中，所有方法都是并发安全的，因此我们可以采用多种方式来调用这些方法。但无论你选择哪一种方式来调用wait方法，都必须确保除了该任务组的析构函数之外，执行该调用的那个方法的返回语句必须是当前执行路径在正常情况下的最后一步（即在执行过程中没有异常被抛出）。

如果我们在多个执行环境中多次交叉调用同一任务组的wait方法和run方法的话，就必须小心这样做的后果，因为这有可能会导致代码每次运行的结果各不相同。例如，我们可能会在两个不同的环境并发地调用同一个任务组的wait方法。在这种情况下，如果该任务组中还有尚未执行的任务，就意味着这两个task_group::wait调用都要等到任务组完成所有任务之后再返回。这样的话，一旦在某任务执行期间该任务组被取消了的话，这段代码中就只有一个wait函数能返回其取消状态。而wait的另一个调用则会因这种交叉调用而返回正常状态。（即wait方法返回时会重置任务组的is_canceling状态。无论是哪一个wait先返回，它都会执行这个重置操作。）

3.5.2　任务与线程

通常情况下，只要我们在程序中启动一个任务，程序的任务调度器就会自动为该任务的工作函数选择合适的执行线程。

而任务在运行过程中并不会在线程之间迁移。因此，我们可以放心使用一些线程关联（thread-affine）的抽象机制（例如临界区段），不用担心会出现诸如EnterCriticalSection和LeaveCriticalSection不在同一线程执行这样的类似情况。

而且一般来说，新建任务操作所耗费的资源比新建线程要少得多。这样的话，即便某个应用程序创建了成百上千乃至于上百万个任务也能照常有效地运行。

根据我们在各自应用程序中使用任务的实践经验，可以对程序中的任务划分进行具体的分析。如果任务分得太细，程序反而会因其中的管理成本而导致性能下降。比如，对于一个执行单一算术运算的任务来说，无法考虑如何提高性能。但是，如果你把该任务分解成过多的小任务，也依然不能充分发挥程序中潜在的并行性。

3.5.3　如何调度任务

任务调度与线程调度在技术目标上有着本质上的区别。操作系统用最小化延迟的方法来调度线程。抢占的调度技术优先考虑的是如何让用户在与一个繁忙的系统交互时感觉不到明显的延迟，即便这是一个单核系统也是如此。

相对而言，任务调度则更侧重于使系统整体的吞吐量实现最大化，而不是去确保其中任何一个既定任务的低延迟性。换句话说，当我们将一个计算密集的操作分解成若干个可能并行运行的任务时，主要考虑的是如何尽可能地提升整体的运行速度，因此我们通常不会去关心某些任务可能会被延时调度的问题。对于像PPL这样基于任务的、并且运行于并发运行时之下的系统来说，成功取决于它最终的速度，即处理器资源的最优化利用。

基于上述原因，我们无需在基于PPL的任务调度中强调所谓“公平性”，相反，我们应该尽可能地多用技术来优化系统的吞吐量。并确保这些技术都主要致力于使内核保持负荷和有效地使用系统缓存。关于这些内容，我们将在附录A中为你做进一步的介绍。

此外，我们还拥有一系列可用于调度分配任务的控制选项，这些内容也会在附录A的“调度策略”中详细地介绍。

3.5.4　结构化任务组及任务处理

除了task_group类之外，PPL还为我们提供了一个较低层的接口类型：structed_task_group类，你可以在MSDN上查到该类的文档资料。

通常，结构化任务组的开销比一般的任务组要小一些。但是有一些条件限制了结构化任务组的使用。例如，结构化任务组必须使用基于栈结构的工作函数，而且必须保证其自身严格的嵌套结构。尽管在大多数应用程序设计中，我们推荐使用一般的任务组类型，但是结构化任务组对于实现某些并行化程序库依然有着重要的意义。例如，PPL中的parallel_invoke函数就是用结构化任务组来实现的。parallel_invoke一般都被用于一些严格嵌套的并行化执行，而且它的使用也比结构化任务组要简单许多。这使得我们无须去直接调用结构化任务组对象。

在PPL中，还有一种名为task_handle类的数据类型，它通常负责将某个工作函数封装到一个任务中去。该类型既可被task_group::run的某个重载函数当做参数使用，也可在make_task函数中被实例化调用。在大多数应用程序中，我们并不需要直接使用task_handle类。但是，如果我们在程序中使用了结构化任务组对象，就必须关注一下对该类型的使用情况，因为这种情况与调用lambda表达式不一样，我们有必要在程序中task_handle对象进行显式的内存管理。

3.5.5　轻量级任务

除了本章介绍的task_group对象，并发运行时中还提供了一系列低层次的、或许对某些程序员有用的API，尤其是针对那些创建了很多线程的应用程序。然而在通常情况下，我们还是建议程序员们应该尽量从并行模式库（PPL）或异步代理库所在的地方开始。关于轻量级任务的相关内容，我们还将在附录A中进一步介绍。

3.6　练习题

1）本章中的图像合成器使用了并行任务模式，即每个图层都用一个不同的任务来处理。而图像处理中数据并行的典型做法是：用同样的计算操作处理同一个图像的不同部分或者多张不同的图像。该图像合成器有没有可以使用数据并行的方法？如果有，该方法与这里讨论的任务并行相比，各有那些优势和劣势？

2）在图像合成器的实例中，图像处理方法SetToGray和Rotate都是void方法，即没有用返回值返回结果，但却在它们的第二个参数中保存了结果，为什么它们不能直接用返回值返回结果？

3）图像合成器实例所调用的task_group::run方法中，如果其中某个并行任务抛出了一个异常，会发生什么事？如果同样的事情发生在parallel_invoke函数内又会如何？

3.7　补充阅读

关于设计方面的一些注意事项，读者可以参阅Leijen等人的相关讨论，其中包括了基于任务调度的概念，以及工作窃取算法 [1] 的代码。

关于Concurrency Runtime和Parallel Pattern Library的代码样例包，读者可以在CodePlex网站上的ConcRTExtras项目中找到它。

·Leijen, D.，W. Schulte, and S.Burckhardt."The Design of a Task Parallel Library."S.Arora and G.T.Leavens, editors, OOPSLA 2009：Proceedings of the 24th Annual ACM SIGPLAN Conference on Object-Oriented Programming, Systems, Languages, and Applications, pages 227-242.ACM，2009.

·ConcRTExtras software."Code Samples for the Concurrency Runtime and Parallel Pattern Library in Visual Studio 2010."http://code. msdn.microsoft.com/concrtextras.

[1] 工作窃取算法（work stealing algorithms）会为程序的工作负载提供某种更高效的处理方法，在默认情况下，它会使用爬山试探法随时间动态地改变程序中的线程数，以便在运行时动态地找到最佳并行级别。——译者注

第4章　并行聚合

在第2章中，我们介绍了如何利用并行技术实现多个输入值上的统一性独立操作。然而，并不是所有的并行循环体都是可以独立执行的。例如，对于执行求和运算的串行循环而言，步骤之间就不可能具备这样的独立性。因为所有的步骤都要将计算结果累积到单一变量中去，而这个变量所表示的就是求和运算执行到该处的累计值。这样的累计运算通常也称为聚合操作（aggregation）。现在，如果我们不做任何改动地并行化这个循环的话，就很可能会导致这段代码无法得出我们所期望的结果。毕竟对单一变量的并行化读写很可能会污染变量的自身状态。

话虽如此，但如果我们非得要实现并行循环中的聚合操作，也不是完全不可能，我们还可以选择并行聚合模式（Parallel Aggregation）。

并行聚合模式让我们得以利用多核处理器的能力来执行求和运算，以及其他基于结合性操作的累计型运算。

尽管求和运算本身就是一个常见的聚合示例，但是，模式所面对的通常应该是些更一般的情况。这就要求它能处理任何二元的结合性运算。而且，有些并行聚合模式实现或许还会要求它的运算具有一定的可交换性。

在并行聚合模式中，我们将会创建多个非共享的局部变量，并用它们合并出最终的计算结果。因为使用这些非共享性局部变量来储存分区计算的局部结果，可以使循环步骤操作获得一定的独立性。由此里也可以看出，并行聚合模式体现了一个设计原则：即对于一个现存的算法来说，做些必要的改动总是要比简单地引入同步化机制要好一些。关于该模式在算法方面的更多内容，在4.4节中还会再做进一步的讨论。

实质上，并行聚合模式的工作就是将多个输入合并到一个单一的输出中去。因此，我们有时也称它为并行缩减（Parallel Reduction） [1] 模式。

4.1　基本用法

对于聚合操作来说，最常见的应用还是求和计算，下面就有它的一个串行版本：

vector＜int＞sequence=……

int count=0;

for(size_t i=0;i＜sequence.size();i++)

count+=IsPrime(sequence[i])?1:0;

return count;

如你所见，这是一个典型的串行化for循环。代码中有一个叫做IsPrime用户函数（它会在接下来的示例中反复出现），它的主要功能就是判断自己的参数是不是质数。其输出结果是某个输入序列中包含了多少个质数。（当然，你也可以在该示例中使用STL中的count_if操作）

那么，怎样实现这些串行计算的并行化呢？据第2章所做的说明，由于变量count为循环中所有的迭代所共享，这种情况下，如果简单地将for替换成parallel_for会导致该循环不能正常工作。对此，也许对你最有吸引力的做法是，用一个临界区段来封装变量count的增量操作，以此来控制历次并行迭代之间的读写冲突。但是，这种做法会给性能带来非常糟糕的影响，甚至比我们所要优化的那个串行版本还要糟糕。而且，这种开销是完全可以避免的。（实际上，程序员往往低估了同步化操作的开销给性能带来的影响。）

因此，并行编程中的许多经典案例解决类似问题的做法是：放弃在现存的串行算法中引入同步化机制，以便保证操作并行化之后的“安全性”。并转向算法的重新设计。在这里，我们将上面的算法重新设计分为两个阶段：首先，依据现有的可用内核数将目标问题分解成若干个任务，并分别在每个内核中计算出分结果；然后，一旦各个任务的结果计算完毕再将这些分结果聚集起来进行串行化合并，得出最终的累计结果。对于这种先分块缩减、再进行合并的处理过程，本章封面上的那张卡通画已经做了很形象的比喻了，你可以通过它来加深理解。

Combinable类能使我们很容易地实现分任务并行计算出各自的结果，并最终在一个串行步骤里将它们进行合并。

此外，PPL还提供了一种特殊的数据结构，以便我们在并行化过程中进行分任务计算并得出局部结果，然后最终在一个串行步骤里将这些任务结果合并。这个数据结构就是combinable类。在下面的代码中，我们示范了如何用combinable类来实现并行聚合模式：

vector＜int＞sequence=……

combinable＜int＞count([](){return 0;});

parallel_for_each(sequence.cbegin(),sequence.cend(),

[＆count](int i)

{

count.local()+=IsPrime(i)?1:0;

});

return count.combine(plus＜int＞());

如你所见，上面的变量count就是combinable类的一个对象。这个类会提供一个线程私有（thread-private）变量，它将会在一个独立的函数域（来自该类的构造函数形参）内完成相应的本地初始化。

接下来，parallel_for_each循环就会创建出许多任务（理想情况下，新建的任务数应该等于相关计算机中的内核数），然后，让这些循环体函数实现并行执行。各任务收集在各个内核上计算出的结果，然后分别放进由combinable对象的local方法为各任务分配的变量中去。

在默认情况下，combinable类会假设所有以参数形式传递给combine方法的操作都是具有交换性的。

循环所创建的任务数取决于当前环境的有效并发级。至于设置运行时环境并发级的方式，读者可以参阅附录A中的相关内容。除此之外，parallel_for_each循环中还使用了一种称为动态区间窃取 [2] 的技术，它也能帮助我们平衡各工作线程内的工作量。

待parallel_for_each循环完成之后，combinable对象的combine方法就会调用某个由用户指定的二元操作来聚合各个分任务所得出的结果值。例如本例中的合并操作就是整数加法运算。最后，combine方法返回了最终的聚合值。

在并发运行时的样例包中，还内置了几个符合STL样式的并行聚合函数。要理解这些函数的工作方法，最简单的办法就是对比一下STL中与之相对应的串行版本。

众所周知，STL中有一个非常简单易用的、基于迭代器的串行聚合方法，下面示范一下它的使用：

vector＜int＞sequence=……

return accumulate(sequence.cbegin(),sequence.cend(),0,IncrementIfPrime());

在这段代码中，STL函数accumulate对其内部的某个累计变量和序列的每个成员调用了一个二元函数，并在历次迭代中更新累计变量。这个函数的前两个参数是该函数所迭代的上下边界；第三个参数则是一个用于用初始化累计变量的值；第四个参数是一个二元缩减函数，该类函数必须要成功地作用于函数中的累计变量，以及历次迭代值。实际上，它的工作就是完成两个输入值的合并操作。下面的IncrementIfPrime函数对象就是此类缩减函数的一个典型实现示例：

struct IncrementIfPrime

{

int operator()(int total, int element)const

{

return total+(IsPrime(element)?1:0);

}

};

由此可以看出，STL函数accumulate在这里所执行的还是串行操作，性能与稍早前那个例子中的串行for循环基本相同。现在，我们要将该表达式转换成并行聚合操作。为此，我们可以调用并发运行时样例包中的parallel_reduce函数。代码如下：

如果你对STL算法的规则并不熟悉，那么在继续阅读本章之前最好先复习一下这些知识。你可以参考4.7节中所介绍的资料。

using namespace::Concurrency::samples;

vector＜int＞sequence=……

return parallel_reduce(sequence.cbegin(),sequence.cend(),0,CountPrimes(),plus＜int＞());

正如你所见，parallel_reduce函数拥有五个参数。前两个参数表示的是其迭代范围的上下界；第三个参数则是个缩减操作的单位元（identity element） [3] ，即如果该缩减操作是加法，该元素为0；如果是乘法缩减，则该元素为1；而对作用于聚合联合集这样的缩减操作而言，该单位元就应该是个空集。

函数的第四个参数是一个函数对象。主要用于为迭代器的子区间产生一个局部分聚合值。例如，这个例子中定义的函数器是一个CountPrimes类型的实例，该函数返回了并行聚合模式中第一阶段的结果：局部的分聚合值。

最后，第五个参数是一个缩减函数，它主要用户合并各个子区间各自计算的分结果。

关于函数器类型CountPrimes，实现代码如下：

struct CountPrimes

{

int operator()(vector＜int＞::const_iterator begin,vector＜int＞::const_iterator end,int right)const

{

return right+accumulate(begin, end,0,IncrementIfPrime());

}

};

parallel_reduce函数会负责将输入迭代器分配到若干个区间中，这样我们就会有足够的区间来补偿由于工作负载不均带来的影响。但是这些区间是不能用于控制计算本身的开销的，因此，我们所建立的区间数量将由PPL本身来决定。

在这个例子中，CountPrimes函数对象将会在每个区间里被调用一次，它将在这些子区间中执行串行化的累计操作并搜集所得的结果。

如果我们想在用PPL实现程序的过程中应用并行聚合模式，parallel_reduce函数通常会是我们的首选。其自然的声明方式使它比其他方法更不容易出错，并且在多核计算机上应用，其性能方面也更有竞争力。此外，用parallel_reduce实现并行聚合时不需要地对代码进行额外加锁，所有的同步化操作都会在其内部进行。当然，如果parallel_reduce不能满足你的需求，或者你更愿意使用那种缺少声明的编码样式，你也可以使用combinable类在用parallel_for或parallel_for_each来实现并行聚合。

只不过，当你使用parallel_for和parallel_for_each时，要小心某些类似取消请求和动态区间窃取这样的特性，他们可能会带来一些额外的开销。另外，在一个并行循环中调用combinable::local()方法也会在循环的每次迭代过程中引入哈希表查找的开销 [4] 。因此就一般而言，只有当这些迭代所执行的运算较为复杂时，并行聚合模式才能有效地改善程序的性能。

[1] 此处的术语Reduction，以及下面将出现的reduce一词在msdn文档中均被译为“缩减”，这里遵守这一术语约定。读者可参考“并行集合和对象”译文（将鼠标移到相关句子上会显示原文）：http://msdn.microsoft.com/zh-cn/library/dd504906.aspx。——译者注

[2] 原文是dynamic range stealing，译者没能找到相关的确定资料，从上下文来看，读者可以将其理解为上一章中所提到的“工作窃取算法”，即这是一种会根据具体的工作量自动平衡线程负载的算法。——译者注

[3] 单位元是集合论里最基本的概念之一。如果G是一个集合，而e是G中的元素，满足ex=xe=x，对任何x∈G成立，那么就称e是G中的单位元。具体资料读者可参考wikipedia上的相关文章：http://zh.wikipedia.org/wiki/单位元。——译者注

[4] 由于combinable类对象内会用一个哈希表来存储各个分计算的结果，所以，当我们引用这些结果时必然要进行哈希查找。——译者注

4.2　实例示范

事实上，聚合不仅只适用于数值运算，它也会经常出现在许多其他的应用环境中。例如在接下来要介绍的示例中，我们将会看到一种叫做映射/缩减（map/reduce）模式。该模式是并行聚合模式的一种变体，我们将用它来聚合非数值类型的数据。

该示例程序是一个社交网络服务，该服务的用户可以自定义他与其他用户之间的朋友关系。这个站点还能对用户朋友的朋友进行一些鉴别工作，并根据鉴别的结果为每个用户推荐新朋友。而为了限制推荐数，服务程序只向用户推荐那些拥有最多共同朋友的候选者。这其中，鉴别候选者的工作都可以是一些独立执行的并行操作，我们可以在聚合操作中对候选者进行分级和选择。

现在，我们来考虑一下这个推荐服务所应该采用的数据结构和算法。首先，我们使用一个整型数字的ID来标识每个用户，接着，我们还要为每个用户提供一个集合，以存储他所有的朋友ID。由于该集合中的每个元素（即该用户朋友的ID号）都是唯一的，并且是顺序无关的，所以这个集合可定义为set类型，例如，某个ID为0的用户有两个ID分别为1、2的朋友，我们可以这样表示：

0-＞{1，2}

接下来，该社交网络站点还为每个用户都创建了对应的名单库。因为，为了给用户推荐朋友，该服务程序必须综合考虑该用户的名单和他所有朋友的名单。例如，如果要为用户0推荐朋友，我们所需要考虑的相关名单如下：

0-＞{1，2}

1-＞{0，2，3}

2-＞{0，1，3，4}

正如你看到的，服务程序应该向用户0推荐用户3和4，因为他们都出现在用户1和2的朋友关系中，而后者跟用户0已经建立了朋友关系。另外，该服务程序还应该赋予用户3比4更高一些的级别，因为3是用户0朋友们的共同朋友，而4只和他们中的一个有朋友关系。关于这点，我们可以做如下表示：

{3（2），4（1）}

这意味着用户3与用户0共享了两位共同的朋友，而用户4只共享了其中一位。而这正是multiset集合的一个应用示例。在multiset类型中，每个元素（如例中的用户3和4）都有一个多重关联度，它用来表示元素在集合中出现的次数（分别是2和1）。因此，在multiset集合中，尽管元素实体只有一个，但我们依然可以表示元素的出现次数（或者更大的多重关联度）。当然，此类集合中的元素顺序是无关紧要的。

在本节的例子中，我们所使用的multiset并不是STL中所实现的那一个版本。

下面，我们将按照映射/缩减模式分三个阶段来实现这个推荐服务。

第一阶段是映射阶段，在这一阶段中，服务程序要建立一个朋友候选者的集合，这个集合要供我们遍历用户的朋友关系、并搜索用户朋友的朋友关系，然后从搜索结果中找出目前跟该用户没有建立朋友关系的人，并将其收集为用户的潜在朋友。

第二阶段是缩减阶段，在该阶段中，服务程序需要把上一阶段收集的潜在朋友集合聚合到一个multiset对象中去，并记录该multiset对象中的每一个候选者ID的多重关联度（共同好友的数量）。对于每一个候选朋友集合来说，reduce阶段的主要工作就是将它们进行合并，并且维护一份记录他们出现次数的计数统计。这个设计，我们将会通过实例化一个hash_map＜FriendID, int＞集合来实现。

在最终阶段，我们还要做些后续处理。服务程序需要根据候选者的多重关联度进行排序分级，并选择多重关联度最高的候选者。

映射/缩减模式的一个重点就是，我们在映射阶段中所收集的各项集合数据必须要为缩减阶段所用，而我们很清楚后者所使用的是一组multiset对象。基于这个原因，我们在映射阶段不会只产生一个候选者ID的list，而是一个multiset类对象的vector容器，并且，在该容器中，每个multiset对象为候选者们记录了各自的多重关联度。在本例中，映射阶段输出的就是一个包含了两个multiset对象的容器。这两个对象别代表了用户3和4。

{3（1）}，{3（1），4（1）}

在这里，第一个multiset对象所表示的是用户1的朋友关系，而第二个multiset则代表了用户2的朋友关系。

除此之外，映射/缩减模式的另一个重点是，即对于映射阶段所生成的每一个集合数据，我们都会在缩减阶段中对它们执行某种二元操作。具体到本例中，这就是multiset对象的联合操作，它会将那两个multiset对象中的元素合并、并将它们的关联度相加。结果，上述集合容器中的两个multiset就被合并成了这样：

{3（2），4（1）}

现在，我们的程序中就只有一个multiset对象了，缩减阶段的任务也就随即完成。通过这样反复在缩减阶段中执行multiset对象的联合操作，我们就能将任意大小的multiset容器聚合为单一的multiset对象。

在接下来的代码中，我们定义了一些程序中将会用到的主要数据类型：

typedef int SubscriberID;

typedef int FriendID;

typedef set＜FriendID＞FriendsSet;

typedef shared_ptr＜FriendsSet＞FriendsSetPtr;

typedef hash_map＜SubscriberID, FriendsSetPtr＞SubscriberMap;

class FriendMultiSet:public hash_map＜FriendID, int＞

{

//Multiset of potential friends.

//……

}

typedef shared_ptr＜FriendMultiSet＞FriendMultiSetPtr;

如你所见，上述代码中的FriendsSet类型是用STL set类来实现的。而FriendMultiSet则是一个自定义类型。在其他类型中，我们用有序列表（list）来实现用户潜在朋友的多重关联度降序排列。代码如下所示：

struct LessMultisetItem

{

bool operator()(const pair＜FriendID, int＞value1,const pair＜FriendID, int＞value2)cons

{

return(value1.second==value2.second)?(value1.first＞value2.first):(value1.second＞value2.second);

}

};

typedef public set＜pair＜FriendID, int＞,LessMultisetItem＞FriendOrderedMultiSet;

值得注意的是，STL也实现了一个std::multiset类型，但是那是一个可以存储多个相同键值元素的集合类型。并不符合我们期望的键/值对（key/value）的存储方式 [1] 。

终于，我们获得了这个朋友推荐程序的串行版算法的代码。如下所示：

FriendOrderedMultiSet

PotentialFriendsSequentialTransform(

const SubscriberMap＆subscribers,

SubscriberID id,

int maxCandidates)

{

//Map:

FriendsSetPtr friends=subscribers.find(id)-＞second;

vector＜FriendMultiSetPtr＞friendsOfFriends(friends-＞size());

transform(friends-＞cbegin(),friends-＞cend(),

friendsOfFriends.begin(),

[＆subscribers,＆friends,＆id](int friendID)-＞FriendMultiSetPtr

{

FriendsSetPtr theirFriends=

subscribers.find(friendID)-＞second;

FriendsSetPtr friendsOfFriend=make_shared＜FriendsSet＞();

set_difference(theirFriends-＞cbegin(),

theirFriends-＞cend(),

friends-＞cbegin(),friends-＞cend(),

inserter(*friendsOfFriend, friendsOfFriend-＞end()));

friendsOfFriend-＞erase(id);

return FriendMultiSetPtr(

new FriendMultiSet(friendsOfFriend));

});

//Reduce:

//The reduction does not use std:accumulate because this results in too much copying of intermediate

//FriendCountMap

FriendMultiSet candidates;

for_each(friendsOfFriends.cbegin(),friendsOfFriends.cend(),

[＆candidates](FriendMultiSetPtr set)

{

candidates.Union(set);

});

//Postprocess:

return candidates.MostNumerous(maxCandidates);

}

如你所见，在映射阶段的代码中，我们的串行循环遍历了用户的朋友关系，并且构建了一个集合来存储这些代表候选者的multiset对象。接着，再由缩减阶段代码中的串行循环遍历这些multiset对象，并执行multiset的联合操作将它们进行聚合，这一点我们将调用Union方法来实现。如果现在我们使用这段代码来处理本例中的少量用户，那么传入的id参数的数据分别就是0和subscribers，这时候find（id）-＞second就会返回{1，2}。如此一来，当映射阶段的操作完成时，变量friendsOfFriend中包含的内容应该就是{3（1）}，{3（1），4（1）}。而当缩减阶段的操作最终完成时，candidates对象的值应该就是{3（2），4（1）}。

严格来说，浮点运算既不具有可交换性，也不具有结合性。因此在程序运行过程中，执行单精度或双精度浮点运算的并行程序在最终结果上可能会存在一定的舍入误差。

通常情况下，multiset对象的联合操作是具有结合性的。如果你能反复地使用某种对值联合成功地将若干个multiset聚合为单一对象，那么，实际上最终的结果与联合操作的执行顺序是无关的。而且，multiset对象的联合操作通常也是可交换的，即它的结果也不依赖于其参数的位置。如果聚合函数不具有结合性，它就不能被并行化，否则就有可能会潜在地得到不同的结果；同样的，如果它是不可交换的，那么并行化的可能性也会大大地降低。

在接下来的代码中，我们将向你示范如何用并发运行时样例包中所提供的函数parallel_transform和parallel_reduce来实现基于映射/缩减模式的社交网络程序：

FriendOrderedMultiSet

PotentialFriendsParallel(const SubscriberMap＆subscribers,SubscriberID id,int maxCandidates)

{

//Map:

FriendsSetPtr friends=subscribers.find(id)-＞second;

vector＜FriendMultiSetPtr＞friendsOfFriends(friends-＞size());

parallel_transform(friends-＞cbegin(),friends-＞cend(),

friendsOfFriends.begin(),

[＆subscribers,＆friends,＆id](int friendID)-＞FriendMultiSetPtr

{

FriendsSetPtr theirFriends=subscribers.find(id)-＞second;

FriendsSetPtr friendsOfFriend=make_shared＜FriendsSet＞();

set_difference(

theirFriends-＞cbegin(),theirFriends-＞cend(),

friends-＞cbegin(),friends-＞cend(),

inserter(*friendsOfFriend, friendsOfFriend-＞end()));

friendsOfFriend-＞erase(id);

return FriendMultiSetPtr(

new FriendMultiSet(friendsOfFriend));

});

//Reduce:

FriendMultiSet candidates;

candidates=parallel_reduce(friendsOfFriends.cbegin(),

friendsOfFriends.cend(),

FriendMultiSet(),

[](vector＜FriendMultiSetPtr＞::const_iterator cbegin,

vector＜FriendMultiSetPtr＞::const_iterator cend,

const FriendMultiSet＆right)

{

return right.Union(cbegin, cend);

},

[](const FriendMultiSet＆left, const FriendMultiSet＆right)

{

return left.Union(right);

});

//Postprocess:

return candidates.MostNumerous(maxCandidates);

}

让我们再复习一下映射/缩减模式：我们通常是执行完一系列独立的并行操作（映射阶段）之后，紧接着再执行某种聚合操作（缩减阶段）。在映射阶段，并行操作会遍历用户0的所有朋友。在这一阶段中，我们可以通过parallel_transform函数来找出了用户的朋友们的所有朋友。接着，再由set_difference函数来消除其中重复用户并产生推荐项。如此一来，我们就得到了这一阶段的输出：存储着用户所有朋友的multiset对象的vector容器。

接着，我们在缩减阶段可以通过parallel_reduce函数来完成对每个候选者ID出现次数的统计。这里需要注意的是，代码中对FriendMultiSet函数的调用只会返回一个用以存放单位元的空multiset对象。Union方法所执行的才是上述那两个multiset对象的合并操作。

最后，代码中的返回语句执行了最终的后续处理步骤。并选出了多重关联度最高的那位候选者。

[1] 而且STL版的multiset会对元素自身（如本例中ID）进行自动排序，反而会增加该程序不必要的开销。——译者注

4.3　模式变体

本节将向你介绍一些常见的并行聚合模式变体。

4.3.1　基于小型循环体的考虑

如果执行并行聚合操作的循环体工作量过小，你有可能会发现并行聚合所花费的时间反而会比串行聚合更多。因此，当我们遇到此类小型循环体时，可以考虑应用2.3.3节中所介绍的技术。这些技术可以帮助我们将一些串行聚合操作移到一些较大的子区间内去执行 [1] 。

[1] 这样做有助于提高并行化执行的粒度。——译者注

4.3.2　Combinable对象的其他用处

尽管，Combinable类最常见的应用就是实现并行聚合模式。但是我们并不是只能用它来进行聚合操作。例如，我们也可以在某线程启动时，用Combinable对象来为该线程创建一个本地化变量。

4.4　设计注意事项

如果我们对比一下聚合模式的串行版和并行版，就会发现并行版算法设计中多了一个步骤，以用于合并分结果。接下来，我们用一张图（见图4-1）来具体说明一下该模式实现的两个阶段：

图　4-1　并行聚合

如图所示，为了避免对单一的共享变量执行累计运算，我们在并行循环中所使用的是非共享性的、分块存储结果的局部变量（这些分结果在图1中被称为“子结果”）。再由combinable类所提供的local方法去访问各线程中的非共享性存储。这样一来，每个工作线程会各自处理一部分的程序输入。而且在这种情况下，我们能分配的线程数只取决于程序的并行度，它需要有效使用计算机的可用内核。待所有的分结果都计算完成后，再由combinable对象的combine方法将它们进行合并，并得出最终的总结果。

这种方法能使程序变快的原因在于，它只需要很少量的同步化操作。因为，分任务计算的局部结果使用的是非共享性变量，我们无需加锁。而后面的合并操作所使用的是各自独立的串行操作，也不需要我们加锁。

我们不能期望通过简单地加锁来获得好的执行性能，这需要有针对性地思考算法。

到目前为止，我们以并行聚合模式的实现为例，向你解释了为什么将一个串行算法进行并行化转换时，做些适当的改动通常是必要的。

为了让问题更清楚一点，我们再来看一个在并行聚合模式中加锁的例子。也就是说，如果我们真的想要对一个现存串行算法进行简单的加锁处理的话，只需要把串行版中的for直接替换为parallel_for，然后加入相关的加锁语句即可，代码如下所示：

//WARNING:BUGGY CODE.Do not copy this example.

//It will run*much slower*than the sequential version.

//It is included to show what*not*to do.

vector＜int＞sequence=……

CRITICAL_SECTION cs;

InitializeCriticalSectionAndSpinCount(＆cs,0x80000400);

int count=0;

//BUG--Do not use parallel_for_each in this case

parallel_for_each(sequence.cbegin(),sequence.cend(),

[＆count,＆cs](int i)

{

//BUG--Do not use locking inside of a parallel aggregation

EnterCriticalSection(＆cs);

//BUG--Do not use shared variable for parallel aggregation

count+=IsPrime(i)?1:0;

LeaveCriticalSection(＆cs);

});

return count;

如你所见，如果我们在这样的代码中忘记了对临界区段执行出入操作，这段求和运算就不能在多核环境中计算出正确的结果。因此，如果你确定要引入这些同步化代码的话，就必须严格地遵守相关的串行化规则。只有这样，这段代码才有可能得到我们所期望的结果，但如此一来，我们在代码吞吐量优化方面的努力就完全失败了。在很多情况下，这段本希望被优化的代码的性能甚至会比它的串行版本还要慢很多倍。其中的根本原因就是同步化所带来的开销。

相比而言，我们在本章其他地方看到的那些并行聚合模式示例在多核环境中要比等效的串行版本快得多。并且它们对性能的改善程度还与内核数量存在着某些近似比例的关系。

在一个算法中引入一些额外的步骤反而会获得更好的性能？乍看之下这确实有点违反我们的直觉。但事实确实如此。如果在算法中引入的额外工作能有助于我们抑制并行任务中存在的数据依赖。往往就能在性能方面收获很好的效果。

4.5　相关模式

事实上，可用于数据集合的汇总操作的设计模式很多，我们的聚合模式（缩减也被认为）是其中之一。除此之外，这些模式中还包含了Scan模式和Pack模式 [1] 。Scan模式一般应用循环中每次迭代依赖于前次迭代运算结果。而Pack模式一般则用于某并行循环中的筛选元素操作。所以该模式的操作结果往往都是原始输入的一个子集。此外，我们也把这些模式合并起来，统称为Fold and Scan模式。关于这些模式的更多信息，请参阅4.7节中所列出的相关资料。

[1] 译者没有找到业界对这两个模式名（包括后面的Fold and Scan模式）明确的翻译，故而决定保留原名，以防误解。——译者注

4.6　练习题

1）请再分析一下我们的小型社交网络示例（只含用户0、1、2），程序中的数据之间存在着哪些约束？这些约束在我们的示例代码中是如何体现的？

2）在我们的社交网络示例中，有一个独立的操作步骤，它对候选者multiset对象进行了后续的处理，将这个无序集合中的元素按照其关联朋友的数量排序，并转换成一个有序序列以选出最顶层的第N号候选者。现在请考虑一下，我们可以把其中一些或全部后续的处理工作归到缩减步骤中去吗？

3）根据隐射/缩减模式的标准参考（见4.7节），我们应该会在映射阶段执行某个map函数，该函数会根据一些成对的输入生成一个与之相关的键/值对集合。然后，我们将所有的这些键对集合都传递给缩减阶段。而在缩减阶段，我们又会执行某个reduce函数将该集合中具有相同关联键的对值进行合并。如果我们要用函数声明来表现这个过程，可以类似于这样：map（k1，v1）-＞list（k2，v2）、reduce（k2，list（v2））-＞list（v2）。那么，现在请回答一个问题，对于我们的社交网络示例来说，k1、k2、v1和v2分别对应与哪些类型？map和reduce处理函数又分别对应于哪些操作？

4.7　补充阅读

在下面所罗列的阅读资料中，首先是Musser等人对涉及STL相关内容作出的解释；接着，Duffy在他的书中为我们介绍了一种能彻底处理同步化技术问题的方法；再接下来是McCool关于Stencil、Scan和Pack相关模式的讨论；以及Dean和Ghemawat写的关于map/reduce模式的标准参考论文；此外，Toub还为我们介绍一些其他带步骤依赖性的并行循环算法，这其中就包含了Fold-and-Scan模式和动态规划的内容。尽管Toub示例中所使用的是托管代码，但这些算法本身都是可以用等效的自然代码来实现；最后列出的wikipedia文章介绍的是本章代码中所使用的数学性multiset类型的相关内容。

·Dean, J.，and S. Ghemawat."MapReduce：Simplified Data Processing on Large Clusters."In OSDI'04：Sixth Symposium on Operating System Design and Implementation，137-150，2004.

·Duffy, J.，Concurrent Programming on Windows. Addison-Wesley，2008.

·McCool, M.，"Structured Patterns：An Overview."December 2009. http://www.ddj.com/go-parallel/article/showArticle.jhtml?articleID=223101515.

·Musser, D. R.，G.J.Derge, and A.Saini.STL Tutorial and Reference Guide：C++ Programming with the Standard Template Library，3rd edition.Addison-Wesley Professional, December 2009.

·Toub, S.，"Patterns of Parallel Programming：Understanding and Applying Parallel Patterns with the. NET Framework 4."2009.http://www.microsoft.com/downloads/details.aspx?FamilyID=86b3d32b-ad26-4bb8-a3ae-c1637026c3ee＆displaylang=en.

·Wikipedia."Multiset."http://en. wikipedia.org/wiki/Multiset.

第5章　Future

在第3章中，我们已经学会了如何通过并行任务模式来fork一个程序的控制流。而在本章中，我们将介绍如何通过Future模式 [1] 来整合一个程序的控制流和数据流。

首先，在本章我们所谓的future，通常指的是类似于某种计算占位符的函数对象，这些对象通常被用来代替某个晚些时候才能知晓的计算结果，而这个结果的计算过程往往是可以与其他运算一起并行化操作的。总而言之，Future模式的实质是，通过任务的并行化来重新整合参数与返回值之间那个为人们所熟知的关系。

所谓Future，实质上就是一些异步函数。

显然，Future模式也体现了第1章中所介绍的、挖掘潜在并行性的理念。即只要我们的硬件资源支持并行化执行，就可以通过future对象来分解程序中的串行操作，从而使程序运行得更快。但是，有时候内核资源也会被其他应用操作完全占用，如果你的程序有出现这种情况的可能性，就必须要对非并行化条件下的future对象有所评估。

请注意，不要将本章所介绍的、基于任务的Future模式实现与其他Future模式实现相混淆，例如STL中实现的std::future组件。后者是C++0x标准中新引入的STL组件。

在很多时候，我们都可以将future对象看成一个带返回值的任务对象。该对象可以让我们避免去显式地调用wait这样的方法来等待任务的完成。这样一来，在我们需要使用该任务的返回值时，只需简单地对该任务进行求值访问。如果这时候任务已经完成，就会立即返回结果值；而如果任务还在运行中，线程就会在求值处自动进入阻塞状态，直至任务返回有效的结果值（当线程处于阻塞状态期间，内核可以执行其他工作）；如果任务还尚未启动，我们就可以把它调到当前线程中来执行。

Future模式在PPL环境中的实现是很简单的。下面，我们通过一段简单的代码来示范一下它的工作方式：

template＜class T＞

class Future

{

private:

single_assignment＜T＞m_val;

task_group m_tg;

public:

template＜class Func＞

Future(Func f)

{

m_tg.run([f, this]()

{

send(m_val, f());

});

}

T Result()

{

m_tg.wait();

return receive(＆m_val);

}

};

在这个版本中，我们并没有实现Future类的所有特性，例如Result方法在被反复调用时具有抛出异常的能力。你可以在自己的程序里使用这个版本，但要清楚它没有完全体现Future模式的所有特性。

使用任务组可以帮助你轻松地实现Future。

在上述示例中，Future＜T＞类每实例化一次就会创建一个任务组对象，并通过该任务组的run方法添加新任务。而每个新任务的工作函数则都由该类的构造函数参数提供，并且这些工作函数的返回值类型都必须为T。

注意：single_assignment类在实现Future类的过程中起到了一个消息缓冲区的作用。它的send和receive方法能保证对某单一数值进行通信的并发安全。关于这一点，我们将在第7章中再做详细的介绍

在本章中，我们所讨论的Future模式和有时候也被称为任务图（task graph）的模式密切相关。特别地，当其中一些future对象提供的结果是另一些future对象所需要的输入时，这种关系就可以用定向图来表示。图的每个节点都代表一个任务对象，而每条弧线则代表每个任务各自的输入或输出值。

请注意，不要把Future和管道混淆起来。虽然，我们在第7章中将会遇到的管道任务（pipeline tasks）也可以用定向图的节点来表示。但这些节点之间的连接弧所表示的都是某个并发队列所传输的一组序列值，它们是类似于组装线或数据流那样的复杂结构。而相比之下，对于Future而言，任务图节点之间的连接弧所表示的都是函数参数和返回值这样的单值结构。

5.1　基本用法

只要稍稍回忆一下我们在第3章中介绍的并行任务模式，你就会明白在多数情况下，执行一个任务通常都是为了获得某个计算的结果。换句话说，我们的异步操作往往是通过一些带返回值的函数体现出来的。当然，任务也能做些别的事情，例如对数组重新排序之类的。但是，计算型任务实在是太常见了，值得我们专门为它设计一些工作模式。同时，这样做也可以使得这些纯函数操作变得更为纯粹，因为它们只负责返回计算结果，没有任何副作用。只要计算机中可用的内核数越多，这种简单化处理的作用就会越大。

Future

接下来，我们来看一段来自某个串行方法内的代码片段：

int a=22;

int b=F1(a);

int c=F2(a);

int d=F3(c);

int f=F4(b, d);

return f;

我们假设这里的F1、F2、F3和F4是处理器密集型函数。如你所见，这些函数为了避免在状态读写过程中使用共享变量，都改用了参数和返回值的方式来传递彼此的消息。

再继续假设，现在我们要根据系统中可用的内核数分配这些函数的工作量。但与此同时，我们还必须确保这段代码在任何数量的内核中都能得到正确的执行。如果你仔细观察代码中的输入和输出就会发现，F1是可以与F2、F3并行运行的，而F3则必须等到F2完成之后才能启动。你现在知道该怎么做了吗？我们可以先将函数可能的调用流程清晰地画出来。如图5-1所示。

图　5-1　计算f的任务图

如你所见，图中的各个节点都分别代表了F1、F2、F3、F4这四个函数。并且，在各个节点之间，进入节点的箭头代表了这些函数各自的输入请求，而从节点出去的箭头则代表了它们的输出值。如此一来，我们很容易就得出结论：F1与F2是可以同时运行的，而F3则必须跟在F2之后。

在接下来的代码中，我们将示范如何在上面的代码中创建Future。为了简单起见，我们假设这段代码所执行的依然是整数运算，并且变量a已经被赋予了给定的值（例如来自当前函数的参数值）。

int a=22;

Future＜int＞futureB([a](){return F1(a);});

int c=F2(a);

int d=F3(c);

int f=F4(futureB.Result(),d);

return f;

在这里，Result方法既可以立即返回一个已经计算好了的值，也能等到该值计算好了再返回。

如你所见，代码中创建了一个供F1（a）执行异步计算的future对象。该对象帮助我们实现了F1与当前线程在多核系统中的并行化执行。同时，这也意味着F2可以独立开始执行，而不必再等待F1。而对于F4来说，它只需要等待相关的数据到位就可以自动开始，而不必再去关心F1、F3具体谁先完成。因为这两个函数的返回值在F4可以被调用之前应该都已经到位了（记得吗？Result方法在future的值没有到位之前是不会返回的。）。值得提醒的是，这里不需要再将F2、F3和F4的调用封装成future对象了。因为我们只要实现其中一个异步操作 [2] ，就可以让这段代码完成并行化转换。

当然，你也可以像下面这样，选择将F2、F3封装成future对象。

int a=22;

Future＜int＞futureD([a](){return F3(F2(a));});

int b=F1(a);

int f=F4(b, futureD.Result());

return f;

事实上，无论你将任务图中的哪个分支设为异步计算，运行结果都是相同的。

对于这个例子来说，异常处理也是一个重点，在future执行期间产生的异常会由Result方法负责抛出。这会使得异常处理变得很容易，我们可以自己决定是返回结果还是抛出异常，这本质上和一般的C++工作函数很相似。下面就有一个异常处理的示例：

如果Futures是基于任务运行的，那么在运行期间产生的异常一般都会被推迟到Result方法被调用的时候再处理。

int a=22;

Future＜int＞futureD([a](){return F3(F2error(a));});

int b=F1(a);

try

{

int f=F4(b, futureD.Result());

printf("Result=%d\",f);

}

catch(exception＆e)

{

printf("Exception'%s'is caught as expected.\n",e.what());

}

如果F2或F3中抛出了异常，并且异常的类型是exception的话，那么异常就会被推迟，直到到futureD对象的Result方法被调用时才被重新抛出。在上面的代码中，异常在try块内调用Result方法时产生，续而在catch块中被一并处理。

[1] 该模式在业界并没有权威的翻译，并且大多数相关文件都保留了原词，所以这里也遵守这一惯例，以免给读者制造不必要的阅读障碍。——译者注

[2] 此处F2和F3被视为是同一个操作，意思是F1与（F2，F3）只要有一路被设为异步调用即可。——译者注

5.2　实例示范：Adatum金融仪表盘

接下来，我们将通过一个具体的实例来为你介绍如何在一个应用程序中使用Futures模式。并演示如何在一个图形界面（GUI）的应用程序中并行运行一系列的计算密集型操作。

Adatum是一家金融服务公司，它为自己的员工开发了一个制作金融仪表盘的应用程序，叫做Adatum仪表盘，员工们要用它来分析金融市场。于是该程序要运行在每个员工的桌面工作站上。此外，Adatum仪表盘所分析的是历史数据流，而不是实时的价格数据。因此尽管该分析是一种计算密集型操作，但也会因该服务的不同网络数据源的输入而存在一定程度的I/O延迟。

应用程序获得市场数据后，便会将数据集进行总体合并。该程序会按部就班地合并这些市场数据，并对它们进行分析。然后根据所完成的分析建立相应的市场模型。与此同时，程序也会从Fed储备系统（Federal Reserve System）中获得市场的历史数据。待市场的当前模型和历史模型都到位了之后，程序就会比对这两个模型。谈话根据比对结果为客户提供一份关于买入、卖出或者继续持有的投资建议。为了更具体一点，我们可以将这些步骤画下来，见图5-2：

图　5-2　Adatum仪表盘的任务图

如你所见，上图中所有的任务都是通过一些特定的业务对象类型来实现通信的，它们在Adatum仪表盘中都会以类的形式实现。

你可以在CodePlex网站：http://parallelpatternscpp.codeplex.com/下的Chapter5\A-Dash工程中下载到这个Adatum仪表盘程序的源代码。它主要由业务对象定义、分析引擎和用户界面三部分组成。

5.2.1　业务对象

在Adatum仪表盘中，我们使用的都是一些不可变数据类型。即这些类型的对象一旦被创建就不可以再修改，这能使它们能很好地适用于实现程序的并行化。

其中，StockDataCollection类代表了一组股票在一段特定时间内的收盘价序列。我们可以根据股票代码建立一个索引目录。从本质上来说，这些序列实际上就是一些存储了每个股票价格的数组。我们可以将这些StockDataCollection对象以股票代码不重叠的方式进行合并。合并操作会产生一个新的StockDataCollection对象，里面包含了该时间内所有的输入序列值。

此外，我们还将用StockAnalysisCollection类来表示程序在分析步骤中获得的结果。类似地，我们也将用MarketModel和MarketRecommendation这两个类来分别表示程序在建模阶段和比对阶段的输出。其中，MarketRecommendation类还提供了一个数据访问器，以便用来返回是买入、卖出还是持有的投资决议。

5.2.2　分析引擎

在Adatum仪表盘中，最终的投资建议将会由AnalysisEngine类根据它所接收的市场数据来完成。

下面，先展示串行版的实现代码，为了使代码看上去更清晰，这里省略了取消状态的相关处理，所以，细节上会与你下载的那份源码略有些不同。

MarketRecommendation

DoAnalysisSequential(AnalysisEngineState＆engineState)const

{

engineState.Reset();

engineState.IsRunning();

vector＜StockDataCollection＞stockDatasets;

vector＜MarketModel＞models;

//Current market data tasks

stockDatasets.push_back(LoadNyseData());

stockDatasets.push_back(LoadNasdaqData());

StockDataCollection mergedMarketData=MergeMarketData(stockDatasets);

StockDataCollection normalizedMarketData=NormalizeData(mergedMarketData);

StockAnalysisCollection analyzedStockData=AnalyzeData(normalizedMarketData);

models.push_back(RunModel(analyzedStockData));

//Historical data tasks

StockDataCollection fedHistoricalData=LoadFedHistoricalData();

StockDataCollection normalizedHistoricalData=NormalizeData(fedHistoricalData);

StockAnalysisCollection analyzedHistoricalData=AnalyzeData(normalizedHistoricalData);

models.push_back(RunModel(analyzedHistoricalData));

//Compare results

MarketRecommendation result=CompareModels(models);

engineState.SetMarketRecommendation(result.GetValue());

engineState.IsStopped();

return result;

}

如你所见，最终的计算结果返回的是一个MarketRecommendation对象。由于这个方法的每次调用返回的数都会成为调用操作所需要的输入。所以，如果我们采用这种方式来调用，必然会受到来自这种串行化执行所带来的限制。

接下来，我们将会在并行版的实现中，对各个操作步骤应用future。当然，同样是为了代码看上去更清晰，这里也省略了取消状态的相关处理，因此细节上还是会于你下载的代码略有些不同。

MarketRecommendation

DoAnalysisParallel(AnalysisEngineState＆engineState)const

{

engineState.Reset();

engineState.IsRunning();

//Current market data tasks

Future＜StockDataCollection＞future1(

[this,＆engineState]()-＞StockDataCollection

{

scoped_oversubcription_token oversubscribeForIO;

return LoadNyseData();

});

Future＜StockDataCollection＞future2(

[this,＆engineState]()-＞StockDataCollection

{

scoped_oversubcription_token oversubscribeForIO;

return LoadNasdaqData();

});

Future＜StockDataCollection＞future3(

[this,＆engineState,＆future1,＆future2]()-＞StockDataCollection

{

vector＜StockDataCollection＞stockDatasets;

stockDatasets.push_back(future1.Result());

stockDatasets.push_back(future2.Result());

return this-＞MergeMarketData(stockDatasets);

});

Future＜StockDataCollection＞future4(

[this,＆engineState,＆future3]()-＞StockDataCollection

{

return NormalizeData(future3.Result());

});

Future＜StockAnalysisCollection＞future5(

[this,＆engineState,＆future4]()-＞StockAnalysisCollection

{

return AnalyzeData(future4.Result());

});

Future＜MarketModel＞future6=Future＜MarketModel＞(

[this,＆engineState,＆future5]()-＞MarketModel

{

return RunModel(future5.Result());

});

//Historical data tasks

Future＜StockDataCollection＞future7(

[this,＆engineState]()-＞StockDataCollection

{

scoped_oversubcription_token oversubscribeForIO;

return LoadFedHistoricalData();

});

Future＜StockDataCollection＞future8(

[this,＆engineState,＆future7]()-＞StockDataCollection

{

return NormalizeData(future7.Result());

});

Future＜StockAnalysisCollection＞future9(

[this,＆engineState,＆future8]()-＞StockAnalysisCollection

{

return AnalyzeData(future8.Result());

});

Future＜MarketModel＞future10=Future＜MarketModel＞(

[this,＆engineState,＆future9]()-＞MarketModel

{

return RunModel(future9.Result());

});

//Compare results

vector＜MarketModel＞models;

models.push_back(future6.Result());

models.push_back(future10.Result());

MarketRecommendation result=CompareModels(models);

engineState.SetMarketRecommendation(result.GetValue());

engineState.IsStopped();

return result;

}

如你所见，在并行版本中，DoAnalysisParallel方法所提供的代码与串行版本并无多大差别，只是将所有的同步方法调用都替换成了相应的future对象。如此一来，即便在单核环境下，并行版的执行性能也能与串行版基本相同。而一旦到了多核环境里，所有的future对象都会实现并行化执行，这时候的程序性能将只受制于其自身之间存在的数据依赖。

此外，在这段代码中，有几个future对象所执行的任务是属于长期I/O密集型的，占用内核能力的比例很低，对于这些对象，我们将通过scoped_oversubscription_token类将其从代码中标识出来，以提示任务调度器分配相应的资源使该任务与其他任务一起并发执行。

5.3　模式变体

到目前为止，我们所看到的都是一些最常见的、运用future来创建任务的方法。接下来，我们将介绍一些别的future用法。

5.3.1　取消Future对象

在使用Futures模式的过程中，实现取消状态模型可以有几种不同的形式。

缺省情况下，如果我们在任务环境中调用了一个future对象的Result方法，然后又在这个调用返回之前取消了该任务所在的任务组，那么这个future对象内的任务组也会因此被隐式取消。关于任务组之间取消状态的传递，你可以参考3.3.2节的相关内容。

除了依靠任务取消状态的隐式传递，我们也可以通过消息缓冲区来实现显式取消future对象。对于这种基于消息缓冲区的取消方案，我们将会在第7章的ImagePipeline实例中加以示范。

5.3.2　消除瓶颈

对于项目管理来说，关键路径（critical path）是一种很常见的思路。这里所谓的“路径”，实质上是指程序从开始工作到最终得出结果期间所执行的任何一条任务序列。由此看来，任务图中所包含的路径往往都不会只有一条。例如从图5-2所展现的那个任务图中，我们就可以找出三条这样的路径。它们各自从“载入NYSE数据”、“载入Nasdaq数据”、“载入Fed历史数据”这三个任务开始，最终都结束于“对比”任务。

一个路径的持续时间等于该路径中所有任务执行时间的总和。而关键路径通常又是持续时间最长的那一个。因此，程序最终的耗时计算往往取决于关键路径。因为只有拥有足够的资源（即有足够的可用内核），其他那些非关键路径通常不会对整体执行时间产生影响。

这样一来，如果我们想让执行这个任务图的程序运行得更快，就只需要找到一种能缩短关键路径时间的方法即可。为此，我们可以对工作进行更有效地重组，将那个最慢的任务分解到其他任务中去，使其能实现执行的并行化。同样，也可以对某个特别耗时的任务进行一定的修改，使其可以通过本书中提到的任一模式来实现执行的并行化。

在之前的例子中，Adatum仪表盘程序并没有为我们提供将那些最慢的任务分解到其他任务中以实现并行化的机会，因为它所有的路径都是线性的。但是，我们依然可以在其中运用并行循环和并行聚合这样的模式，以挖掘出那些最耗时的任务中潜在的并行性。如此一来，即使不改变程序的任务图，我们也依然可以实现任务的并行化执行。至于并行循环和并行聚合这两个模式，我们已经在第2章和第4章中分别详细介绍过了，这里就不再重复。

5.3.3　在运行时修改任务图

在之前金融分析引擎的代码中，我们建立的是一个静态的任务图。也就是说，程序的任务依赖关系图会直接在代码中显现出来。这种情况下，只需要阅读该分析引擎的实现代码，我们就可以判断程序中有多少个任务和多少依赖关系集。

不过，我们也可以在future对象之间建立动态的依赖关系。例如，在之前完成的Adatum仪表盘程序中，如果我们希望在每个future对象完成之后，可以即时更新用户界面（UI）来反映程序的执行进度的话，可以为此再新建一些任务对象，用于等待Adatum仪表盘的任务图中所标识的那些future对象返回。换句话说，我们可以根据具体的需求多次调用Future类的Result方法，如此一来，该方法的每次调用都会导致调用环境被挂起，直至相关的值被计算出来。并且可以使我们在建立future对象以外的地方调用Result方法。

此外，动态建立任务也是实现类似于排序、搜索以及图遍历这种结构性算法的一种方式，相关的例子，读者可以参考第6章的相关内容。

5.4　设计注意事项

接下来，我们将与你分享几个Adatum仪表盘程序的幕后设计理念。

5.4.1　分解到future对象中去

第一个设计决定是最明显的，即借由future对象来实现Adatum仪表盘程序的并行化执行。我们之所以能这么做，是因为该问题空间可以分解的，而且分解之后输入和输出操作的界限很明确。

5.4.2　函数式风格

通常来说，任务之间同步数据的方式可以分为显式和隐式两种。在本章中，我们所采用的都是显式方法。即任务之间的数据传递都是通过参数来完成的，这会使数据传递操作明显地依赖于程序员。而另一方面，如果我们回顾一下第3章，就会发现那里所采用的就是隐式方法了。在第3章，任务之间的通信是依靠修改共享数据结构所产生的副作用来完成的。这种情况下，我们可以依靠任务中适当的阻塞操作来控制它们之间依赖关系。然而，一般而言，显式数据流出错的可能性要比隐式数据流低一些。

以此类推下去，我们就能明白。尽管原则上编程语言并非一定得支持带返回值的方法。程序员完全可以通过不带返回值的全局变量来更新共享信息，从而实现不同程序组件之间的信息传递。然而在实际运用中，考虑到使用返回值编程的低出错率，程序员自然会更倾向于认为全局变量比返回值更容易出错。

同理，对于任务通信来说，使用future对象（带返回值的任务）也比修改共享全局状态的方法更能有效地降低并行程序中出错的可能性。除此之外，与使用全局状态变量的任务相比，带返回值的任务对象所拥有的同步机制往往也会更少。

显然，future对象为我们提供了一种更为自然的数据隔离方式，这种方式可以被认为类似一种函数式编程（functional programming）。它依靠操作本身的输入输出来实现任务通信。由于函数式程序很容易适应多核环境。所以，future对象一般只依靠自身的返回值来实现与外界的通信。而且，这种对返回值采用不可变类型的做法，也是值得肯定的。

如果通过参数和返回值的方式来完成任务之间的通信，程序会更容易与内核数形成成比例的增长关系。

5.5　相关模式

在本节中，我们将介绍几个与Future模式相似的模式。但它们也有各自不同的侧重点，我们将会在这里进行一些简单的对比。

5.5.1　管道模式

管道模式是第7章将要介绍的内容。该模式与任务图有几个重要的不同之处。管道主要是依靠缓冲队列（即消息缓冲区）来实现数据流的，它不需要去关心任务之间的依赖关系。因此在一个管道中，同一个任务通常可以在多个数据项上运行。

5.5.2　Master/Worker模式

满足Master/Worker模式的任务之间会存在着某种类似父子的关系。具体地说，就是所有worker任务对象都是由master任务负责批量建立的。并且，后者还负责传递数据、等待结果返回，而且这些worker任务还可以针对不同的数据执行相同的计算操作，例如在PPL实现并行循环的过程中，内部就是应用了这种Master/Worker模式。

5.5.3　动态任务并行化模式

动态任务并行化模式也叫做分治模式（Divide and Conquer pattern），这是我们在第6章中将要讨论的话题。该模式会在程序运行的过程中通过一种类似递归的方式动态地创建程序的任务树。如果说Future对象中所使用的是异步函数。那么动态任务并行化所使用的则是异步调用的递归函数。

5.5.4　离散事件模式

离散事件模式（Discrete Event pattern）的侧重点在于任务之间的消息传送。它对任务中的所触发的事件数量或事件的触发时间并没有限制，这两方面都不会影响事件在任务之间的传递。另外，离散事件模式也没有先后依赖关系，通过设置一些额外的限制，我们就可以将它应用于某个任务图的实现。

5.6　练习题

1）下面有一段串行代码：

auto b=F1(a);auto d=F2(c);

auto e=F3(b, d);auto f=F4(e);

auto g=F5(e);auto h=F6(f, g);

请参照5.1节中的第一个例子的示范，用future对象并行化上面的代码。请画出相关的任务，然后思考一下，为了最大化程序的并发能力，你至少要创建多少个future对象？并且在这些对象中，最多可以同时运行几个？

2）修改你从CodePelx网站http://parallelpatternscpp.codeplex.com/中下载的BasicFutures代码包，迫使该代码中的某个future对象抛出异常。看看会发生些什么？请在修改完成之后观察代码的执行情况。

第6章　动态任务并行化

在动态任务并行化模式中，我们往往会借助一些相对简单的小问题来解决一个复杂的问题。例如，如果想统计出类似二叉树（binary tree）这样数据结构中有多少个节点，你可以先分别统计出左右子树中各自拥有的节点数，再将结果相加即可。从这一点我们也可以看出，使用递归算法的串行代码往往是很容易转换成动态任务并行化模式的。

也正因为如此，动态任务并行化模式有时也会被称为递归分解或者“分治”。

动态任务并行化模式与递归很相似。即任务会根据即时需要来创建子任务，以解决相应的子问题。

对于动态任务并行化模式来说，典型的用例是在那些使用类似树和图这样数据结构的程序中。当然，它有时候也会出现在一些地理学或几何学方面的应用中，用以解决一些空间划分方面的问题。总而言之，动态任务并行化模式不同于本书目前为止介绍过的所有技术，它的特别之处在于能使程序在执行过程中动态地往工作队列中添加任务。

6.1　基本用法

我们先来定义一棵二叉树。代码如下：

template＜typename T＞

struct TreeNode

{

private:

T m_data;

shared_ptr＜TreeNode＜T＞＞m_left;

shared_ptr＜TreeNode＜T＞＞m_right;

//……

};

现在，如果我们接下来想对树上的每个数据执行一个相同的动作，就需要去访问它的每一个节点。这种访问方式通常被称为树的遍历，这本质上就是一种递归操作。下面我们先用串行代码来做一个示范：

template＜typename Func＞

static void SequentialWalk(shared_ptr＜TreeNode＜T＞＞node,Func action)

{

if(nullptr==node)return;

action(node-＞Data());

SequentialWalk(node-＞Left(),action);

SequentialWalk(node-＞Right(),action);

}

如你所见，SequentialWalk函数是以深度优先的顺序对树中的每个节点调用了action函数。接下来，我们要改用并行任务来执行这个遍历，代码如下所示：

template＜typename Func＞

static void ParallelWalk(shared_ptr＜TreeNode＜T＞＞node,Func action)

{

if(nullptr==node)return;

parallel_invoke(

[＆node,＆action]{action(node-＞Data());},

[＆node,＆action]

{

Tree＜T＞::ParallelWalk(node-＞Left(),action);

},

[＆node,＆action]

{

Tree＜T＞::ParallelWalk(node-＞Right(),action);

}

);

}

在这里要注意的是，一旦使用动态任务并行化模式来执行树的遍历，我们就不再能确定节点的访问顺序。如果你需要指定一定顺序，例如以前序、中序或者后序的方式来执行遍历，可以考虑一下第七章中的管道模式。

与串行化的递归算法相比，动态任务并行化模式中的执行顺序会更难以预测。

还有一点值得注意，我们在上述代码中所创建的任务数是节点数的三倍。而在现实情况中，节点的数量本身就非常庞大。因此，我们在设计并行模式库（PPL）时对这一情况采取了一些相应的措施。有关这方面的内容，我们将在6.2节做相关的介绍。

6.2　实例示范

接下来，我们将要为你介绍一个实例。在该实例中，我们将使用动态任务并行化模式来实现一个类似于QuickSort这样的算法，以完成相关的列表排序。这个算法通常会先将未经排序的数组值划分为若干个子列表，然后再进行整理和重组。现在，我们先来演示该算法的串行版本：

static void SequentialQuickSort(VectorIter begin,VectorIter end,long threshold)

{

if(distance(begin, end)＜=threshold)

{

InsertionSort(begin, end);

}

else

{

VectorIter pivot=partition(begin+1,end,bind2nd(less＜int＞(),*begin));

iter_swap(begin, pivot-1);

SequentialQuickSort(begin, pivot-1,threshold);

SequentialQuickSort(pivot, end, threshold);

}

}

在这段代码中，VectorIter是一个typedef定义，它实质上是一个vector＜int＞::iterator类型 [1] 。使用迭代器有助于我们对vector＜int＞对象中的特定区间进行排序，这样就不用再去排序整个数组了。参数begin和end会负责界定数组中将要被排序的区段。此外，由于这段代码所使用的一些优化措施，即对小区段不使用递归算法。我们为此设置了一个全局变量threshold，如果被排序区段的长度小于或等于这个变量，代码就会调用非递归的InsertionSort方法。我们在QuickSort算法的并行版和串行版中都应用了这个优化措施。

排序是一种最能反映动态任务并行化优势的经典应用。

而如果区段长度大于threshold，递归算法就能发挥作用了。std::partition方法会以迭代器pivot所指的元素为基准，将数组中不大于该元素的所有元素移动到一个以pivot为结束位置的区段中。而剩下的元素则被移动到另一个以pivot开始位置的区段中（pivot自己也可能会随之移动）。然后，我们就会分别在这两个区段上递归调用SequentialQuickSort方法。

该例中所使用的迭代器规则均来自标准模板库（STL）。

现在，我们接着实现QuickSort算法的并行版本。代码如下：

static void ParallelQuickSort(VectorIter begin, VectorIter end,long threshold, int depthRemaining)

{

if(distance(begin, end)＜=threshold)

{

InsertionSort(begin, end);

}

else

{

VectorIter pivot=partition(begin+1,end,bind2nd(less＜int＞(),*begin));

iter_swap(begin, pivot-1);

if(depthRemaining＞0)

{

parallel_invoke(

[begin, end, pivot, depthRemaining, threshold]

{

Sort::ParallelQuickSort(begin, pivot-1,depthRemaining-1,threshold);

},

[＆pivot, begin, end, depthRemaining, threshold]

{

Sort::ParallelQuickSort(pivot, end,depthRemaining-1,threshold);

}

);

}

else

{

SequentialQuickSort(begin, pivot-1,threshold);

SequentialQuickSort(pivot, end, threshold);

}

}

}

如你所见，在这一版本中我们将通过parallel_invoke方法来完成递归调用，从而实现任务的并行化。如此一来，我们的任务对象将会在递归调用过程中中被动态地创建出来。而且我们所面对的数组越大，程序所创建的任务就越多。

除了对较小的子序列采用插入排序之外，我们在这段并行代码中还采用了另一种优化措施。由于一旦程序中的任务对象在数量上超过了它所能使用的内核，再继续创建新的任务就会失去意义。为了限制程序中的任务创建，我们在ParallelQuickSort方法中设置了一个depthRemaining参数，并设定每次递归调用该参数就会减1。这意味着，程序只有在该参数的值大于0时才能继续创建任务。在下面这段代码中，我们为你演示了如何根据可用的内核数计算出合适的递归深度（实际就是depthRemaining参数值）：

static void ParallelQuickSort(vector＜int＞＆a, long threshold)

{

const int maxTasks=CurrentScheduler::Get()-＞GetNumberOfVirtualProcessors();

ParallelQuickSort(a.begin(),a.end(),(int)LogN(float(maxTasks),2.0f)+4,threshold);

}

如你所见，还有一个因素也会影响程序中所能创建的任务数量，那就是任务运行时间预测准确度的问题。以QuickSort算法为例，由于该算法划分区段的基准点是依赖于待排序的数据的，这就有可能会导致各任务在持续时间上有很大的出入。并且，由这样的基准点所产生的区段在大小上往往都是互不相等的（事实上，差距往往很大）。而每个区段所需要的排序时间又往往取决于它们自身的大小。综合以上种种原因，如果我们真以这样的基准点来划分区段的话，你完全可以想象这些任务在持续时间上会有多不均衡。为了补救这种不均衡，我们在设定depthRemaining参数的初始值时，应该允许程序所创建的任务数大于内核数，并将其限制在大约内核数16倍左右的地方。之所以是16倍，是因为我们所允许的最大任务数应该不大于2^depthRemaining，而如果你将depthRemaining=log2（NCores）+4代入并简化这个表达式，立即就会得出任务数等于16 x NCores的结果（记得吗？对于一个任意值a，如果满足条件a=log2（b），2^a=b，2^（a+4）的值就等于16乘2^a）

当然，对于其他的算法，你也可以把depthRemaining参数的值设为2或者3，即将任务数分别限定在大约4 x NCores或者8 x NCores的位置上。总之，任务数的设置取决于你如何估计任务之间在持续时间上的不均衡程度。任务持续时间越不均衡，我们所需要的任务就越多。

注意：在本节中，我们以QuickSort算法为例来为你讲解动态任务并行化的设计原则。而作为一个排序算法来说，它也许并不能满足你的需要。在ConcRT Extras样例包中还包含了一些其他的并行排序算法。你可以为自己的程序选择一个更合适的算法。

这种通过递归深度来限制子任务数量的技术，对于确保引入适当的潜在性并行来说是非常重要的。毕竟如果一个程序中的任务过多就有可能会导致开销过大，而太少又会导致内核资源的浪费。

6.3　模式变体

接下来，我们将为你介绍几个动态任务并行化模式的变体。.

6.3.1　非空while循环体的并行化

到目前为止，我们在本章中所采用的并行化技术都是面向深度优先的串行化遍历操作的。而事实上，对于一些其他类型的遍历操作，我们同样也有一些相应的并行化技术。这些技术通常使用一些并行容器来跟进遍历过程中尚未完成的工作，就像下面这样：

template＜typename T, typename Func＞

void ParallelWhileNotEmpty1(

vector＜shared_ptr＜TreeNode＜T＞＞＞initialValues,Func body)

{

concurrent_vector＜shared_ptr＜TreeNode＜T＞＞＞

from(initialValues.size());

for(size_t i=0;i＜initialValues.size();i++)

from[i]=initialValues[i];

while(!from.empty())

{

concurrent_vector＜shared_ptr＜TreeNode＜T＞＞＞to;

function＜void(shared_ptr＜TreeNode＜T＞＞)＞addMethod=[＆to](shared_ptr＜TreeNode＜T＞＞n){to.push_back(n);};

parallel_for_each(from.cbegin(),from.cend(),[＆body,＆addMethod](shared_ptr＜TreeNode＜T＞＞item)

{

body(item, addMethod);

}

);

from=to;

}

}

在上面的ParallelWhileNotEmpty1方法中，我们向你演示了如何通过parallel_for_each方法来处理一个大小可能会随时间不断增长的集合。首先，我们会对该集合中的值进行初始化处理，然后就会发现新增了要处理的值，将它们复制到to队列中去进行相关的处理。待第一批值处理完之后，接着开始处理更多新增的值。如此重复下去，直至循环中不再有新的值产生。

其中，concurrent_vector类是一个由并发运行时提供的、支持并发安全的vector类型。

接下来，我们要在ParallelWalkWithWhileNotEmpty1方法中调用ParallelWhile-NotEmpty1方法，以实现二叉树的遍历，代码如下：

template＜typename T, typename Func＞

void ParallelWalkWithWhileNotEmpty1(shared_ptr＜TreeNode＜T＞＞node,Func action)

{

if(nullptr==node)

return;

vector＜shared_ptr＜TreeNode＜T＞＞＞nodes;

nodes.push_back(node);

ParallelWhileNotEmpty1(nodes,/*Func body*/[＆action](shared_ptr＜TreeNode＜T＞＞item,function＜void(shared_ptr＜TreeNode＜T＞＞)＞addMethod)

{

if(nullptr!=item-＞Left())addMethod(item-＞Left());

if(nullptr!=item-＞Right())addMethod(item-＞Right());

action(item-＞Data());

});

}

至于ParallelWalkWithWhileNotEmpty1方法的具体应用，我们可以通过一个网站工具来说明：假设调用该方法的程序是一个用来检查网站链接的工具，那么首先，该方法加载初始页面并搜索其所有链接。逐一检查链接后从列表中移除。然后将同一站点的那些未查页面的其他链接加入列表。如此周而复始，直到该列表中没有未被检查的链接或者程序终止为止。

6.3.2　在挂起等待环境中添加任务

通常，我们总是在先创建完所有任务之后，再调用任务组对象的wait方法。然而，在某些特殊情况下，我们也需要在调用wait方法之后，在现存任务的执行过程中再添加一些新的任务。类似的情况在我们遍历一个图的所有节点时经常会出现。

在下面的函数中，我们为你演示了如何通过一个单任务组的并行任务来完成树的遍历。

template＜typename T, typename Func＞

void ParallelSubtreeHandler(task_group＆tg,shared_ptr＜TreeNode＜T＞＞node,Func action)

{

while(nullptr!=node)

{

//Start up processing the left subtree in a new task

if(nullptr!=node-＞Left())

{

tg.run([＆tg, node, action](){

ParallelSubtreeHandler(tg, node-＞Left(),action);

});

}

//Process this node

tg.run([node, action](){action(node-＞Data());});

//Walk down the right side of the tree

node=node-＞Right();

}

}

接着，我们演示顶层函数应该如何调用ParallelSubtreeHandler方法。代码如下：

template＜typename T, typename Func＞

void ParallelTreeUnwinding(shared_ptr＜TreeNode＜T＞＞node,Func action)

{

if(nullptr==node)

return;

task_group tg;

ParallelSubtreeHandler(tg, node, action);

tg.wait();

}

ParallelTreeUnwinding函数会建立一个单任务组来处理树中的各个子树。下面的代码中，我们演示了这个函数的用法，其中的lambda表达式会负责记录所有节点的访问过程。

const Tree＜int＞＆tree=……

concurrent_vector＜int＞result;

ParallelTreeUnwinding(tree.Root(),

[＆result](int itemData)

{

DoCpuIntensiveOperation(Time);

result.push_back(itemData);

});

使用这种在任务组中动态添加任务的方法，我们就可以利用任务组对象来跟踪未处理的节点，而不需要再像ParallelWhileNotEmpty1化代码示例中所做的那样，再去额外定义一个concurrent_vector容器来完成这件事。并且这样做的话，我们就无需再用分离式数据结构来存储这些未处理节点了。而且这种方法也有助于我们提高代码的可读性。这样一来，完成上述的处理就不是需要清空concurrent_vector容器中的所有未处理节点的时间了，只要简单地判断一下目标程序中还有没有尚未完成的任务即可。

6.4　练习题

1）在本书CodePlex网站的样例代码中，区段长度参数threshold被赋予了一个特定的默认值。在该默认值的作用下，QuickSort方法调用的是非递归的插入排序算法。现在，请通过命令行环境给threshold参数赋予各种不同的值，并观察该程序的串行版本排序不同大小的数组时的具体情况。你预计会看到什么结果？是否符合你的观察结果？对你的系统环境来说，threshold参数的最佳值应该是多少？

2）请用命令行参数设定不同的数组大小，然后观察数组的大小分别对程序的串行版和并行版的执行时间产生了什么影响。是否符合你的预期？请解释你实际观察到的情况。3）请基于既定的内核数量，提出一些其他的限制任务数量的方案。

6.5　补充阅读

在下面的文献中，Heineman等人对QuickSort以及其他排序算法的各种变体做了相关的论述。读者可以自行参考。

·Heineman, George T.，Gary Pollice, and Stanley Selkow. Algorithms in a Nutshell.O'Reilly Media，2008.

第7章　管道

在某些情况下，我们往往会因为程序中存在着某种数据依赖而无法使用并行循环，这时候，管道模式就不失为是一种实现程序并行化的选择。这里所谓的管道，实质上是一组阶段性操作组成的线性序列，并且其中所有的阶段都是是严格按照生产者/使用者（producer/consumer）模式划分而成的。因而在这组操作序列中，每个阶段都必须依赖于前一阶段的输出。此外，管道模式也是数据流网络（dataflow network）模式的一种应用特例。后者能将一个计算分解成若干个可相互协作的组件，并且组件之间的通信是靠收发消息来完成的。

数据流网络是一个由异步组件组成的集合，该集合内部的通信是通过消息传递来建立的。

而数据管道则是一个由消息缓冲区连接而成的异步组件序列。它每个阶段的操作都必须依赖于前一阶段的输出。在我们因遇上数据依赖性而无法使用并行循环模式时，管道模式就不失为一种选择。

当然，实现管道模式的技术有很多。而在本章中，我们将讨论的管道模式是通过异步代理库中的线程消息块（messaging blocks）和异步代理机制来实现的。

通常情况下，数据流都是由管道的初始阶段进入，然后经由一系列的中间阶段到达最后阶段的。事实上，管道的每个阶段应该都是可以并发执行的，但与并行循环不同的是，从整体上看，管道对输入数据的处理顺序是固定的。因此，我们完全可以把软件中的管道理解为工厂里的组装线，各阶段的操作就相当于组装线上的各个项目，而工作就在这些项目之间一个接一个地传递执行。尽管它们在输出顺序上必须与输入保持一致，但许多操作本身却是可以同时进行的。

管道模式可以运用在许多应用程序中。你可以通过它来读取某个实时事件流中的数据，例如股票的实时价格记录、用户界面上的鼠标单击事件，以及从网络上发来的数据包等。你也可以用管道来处理数据流中的各个元素，例如完成压缩和加密、视频流中各帧的转换操作等。不过，上述的所有这些情况都有一个共同点，数据的处理顺序都是被串行化了的，不能对它们运用并行循环模式，因为并行循环不能保证这样的处理顺序。

在本章中，我们所提到的异步代理库函数一般都是用于改善程序在多核条件下的执行性能的，但它们还可以有一些其他的用处。即在更一般的情况下，这些异步通信代理还可以被用来实现并发，或者是作为一种应用程序的组织形式，以实现类似于仿真程序这样的应用。

7.1　消息块类型概述

在异步代理库中，我们内置了一系列可以用来应对各种情况的消息块类型，下面对它们做一个详细的说明：

·unbounded_buffer：并发队列，无容量限制。

·overwrite_buffer：可反复更新的单消息缓冲区。

·single_assignment：只能设置一次的消息单值。

·call：在向消息块中添加新消息时所要调用的函数。

·transformer：也是在向消息块中添加新消息时所要调用的函数，它的返回值会被添加到一个输出消息块中。

·choice：该方法会在消息源集合中选择一个符合条件的消息。

·join：调用该方法可以令程序在处理消息之前等待一个或多个指定的消息源。

·multitype_join：功能与join方法基本相同，只不过它可以应付多种消息类型的输入。

·timer：该方法可以使程序按照指定的时钟频率定期产生出新的消息值。

在本章中，我们将为你介绍四种消息块类型的应用方法。它们分别是unbounded_buffer＜T＞、overwrite_buffer＜T＞、transformer＜T, S＞以及call＜T＞。当然除此之外，你也可以实现属于自己的消息块类型。

不要将管道模式和并行循环模式混淆起来，管道往往被应用于一些并行循环无法发挥作用的地方。对于管道模式来说，数据是按照串行顺序处理的，第一个输入会被转换为第一个输出，第二个输入也会成为第二个输出，以此类推，直至完成。

7.2　基本用法

在异步代理库中，用于连接管道各个阶段的缓冲区往往是由一些消息块实例构成的，例如unbounded_buffer＜T＞类的实例。尽管这些缓冲区本身的容量是不受限制的，但管道可以通过自身的反馈机制来限制其中的挂起项目。而且，管道的每个阶段都能自行实现对agent类的实例化。

异步代理库为我们提供了消息块、agent类以及用于收发消息的函数。

现在，我们来一张四阶段管道的应用示意图（见图7-1）。在这个应用实例中，代码会先从某个数据源中读取一组词句，然后对其中的标点和大小写问题进行纠正，并把它们重新组合成新的词句，最后再将合成完的句子写入某个磁盘文件。

图　7-1　管道应用的样例

在本节中，我们所讨论的是一种基于agent类的管道技术，这种技术能根据管道各阶段的具体需求来创建专用的执行线程。这里的每个阶段实际上都是一个Agent对象。此外，你还可以参考7.4.1节中所介绍的重要模式变体，在那里，我们将会为你演示如何在管道中创建非阶段专用的线程。

在上面的管道中，每一个阶段都会从特定的输入源或数据源中读取数据，并将结果写入特定的输出或者其他的目标。例如在上图中，“大小写纠正”阶段就会将buffer1作为它的数据源，而把buffer2作为它的输出目标。由于图中的三个消息块缓冲区的输入输出之间不存在任何形式的共享，因此管道所有的阶段操作都可以同时执行的。只要我们手里有四个以上的处理器内核可用，这四个阶段就完全可以实现并行化。

另外，这里的每个管道阶段都是可以执行输入性阻塞（即输入性等待）的。输入性等待在其他编程环境中是很常见的——即如果有哪个枚举体或者数据流缺少了某个特定的值，它们的使用者就会暂时停止执行，以等待所需的值到位，或者出现EOF（end-of-file）的状况。但是现在管道阶段之间有了缓冲区，这意味着它能同时容纳多个消息值，这使得我们可以用它来平衡程序在消息处理上所耗费的时间。而且，缓冲区还能有助于我们实现多个管道阶段之间的异步操作。

注意：当你使用unbounded_buffer＜T＞类时，应该为自己定一个特殊的值作为EOF标志，这个标志有时候要作为哨位值（sentinel value）来使用。当然，当你使用哨位值时也要注意一件事，即要确保你所定义的EOF标志绝对不会作为一个常规消息出现。在本例中，用来表示消息序列末尾的值是由PhraseSource::FinishedSentinel()静态方法产生的。

而实际上，哨位值的选择工作要比它初看上去困难得多，因为我们还必须要考虑unbounded_buffer＜T＞类在实例化过程中所推导出的参数类型T的取值范围。例如，假设这里所推导出的T类型是字符串，或许你就可以用空字符串作为临时的哨位值，但是前提是你必须保证相关的管道不会把空字符串当做常规值来处理，否则这样做是不安全的。此外，在实践中，空指针也经常被用作哨位值。

在接下来的代码中，我们将为你演示管道的具体实现过程。在这一过程中，我们将运用unbounded_buffer类来实现缓冲区，而agent类则会被用来实现管道中的各个阶段。

unbounded_buffer＜wstring＞buffer1;

unbounded_buffer＜wstring＞buffer2;

unbounded_buffer＜wstring＞buffer3;

PipelineGovernor governor(g_sentencePipelineLimit);

ReadStringsAgent agent1(seed, g_sentenceMax, governor, buffer1);

CorrectCaseAgent agent2(buffer1,buffer2);

CreateSentencesAgent agent3(buffer2,buffer3);

WriteSentencesAgent agent4(g_targetSentence, g_pipelineResults,governor, buffer3);

agent1.start();

agent2.start();

agent3.start();

agent4.start();

agent*agents[4]={＆agent1,＆agent2,＆agent3,＆agent4};

agent::wait_for_all(4,agents);

如你所见，在第一阶段中，我们的代码会从输入源中读取一组字符串，并将它们放入buffer1；接着在第二阶段中完成相关字符串的大小写纠正工作；然后由第三阶段将这些字符串组合成相关的语句；最后再将这些语句写入指定的磁盘文件。这里需要特别注意的是，在这段代码中，输入元素（单词）与输出元素（语句）在数量上是不相等的，因为管道中的部分功能已经将单词合并成了相关的语句。

此外，缓冲区（输入或输出）内部的数据传递都是通过向每个agent的构造函数传递引用的方式来完成的。以第二阶段为例，CorrectCaseAgent类的实例agent2就以buffer1作为输入，而buffer2作为输出。关于这些agent类对象之间的具体连接情况，之前在图1中已经做了详细的说明，你可以自行回顾一下。

请尽量使用unbounded_buffer＜T＞对象来负责管道中各个阶段之间的连接工作。

在管道模式中，每个阶段在处理元素上所耗费的时间并不总是相同的。为了防止缓冲区中元素数量过多，管道运用一种控制机制，以便可以在任何指定的时间内将元素限制在一定数量之内。在当前的这个应用示例中，我们是通过PipelineGovernor类来实现这个机制的，你可以在相关的Visual Studio解决方案（出自本书的样例代码包）中找到这个类的定义，就存在于该解决方案下的Utilities文件夹下。一般情况下，我们只有在管道的首尾阶段中才需要这种管道调节器（governor）技术，即每当有操作项从管道中退出时，收尾阶段就会调用该调节器对象将运行时元素的计数器减1。同样的，在一个新元素进入管道之前，初始阶段也会检查是否没有超过运行时待执行元素的上限，如果一切正常，它就会调用调节器将运行时元素的计数器加1，并把该元素放入初始阶段的缓冲区。而如果此时管道已满，初始阶段就会停止操作，并等待调节器在管道有空闲时发回相应的信号。本质上说，这其实就是一种管道首尾阶段之间的同步化操作，只不过调节器是通过消息块类型实现了这一操作。

在创建完所有的agent对象，并将它们连上各自的数据源和目标后，我们就可以通过这些agent对象的start方法来启动它们了。

在这段代码的最后，我们调用了agent类的wait_for_all静态方法，以便让管道在所有阶段都处理完它们各自的输入之后再进行相关的清理工作。在这个代码示例中，所有agent对象的内存都是在当前执行环境的堆栈上分配的，因此，在这些对象完成它们的工作之前，我们是不能退出当前环境的。

此外，在上面代码中，管道的初始阶段是通过ReadStringAgent类来实现的，这个代理对象中包含了一个写输出缓冲区的串行循环。下面就是这个agent类的run方法的实现，在这段代码中，我们详细地定义了agent对象启动之后的工作内容。

class ReadStringsAgent:public agent

{

//……

void run()

{

PhraseSource source(m_seed, m_numberOfSentences);

wstring inputPhrase;

do

{

//……

inputPhrase=source.Next();

//Limit whole sentences in the pipeline not phrases.

if(phrase==L".")

m_governor.WaitForAvailablePipelineSlot();

asend(m_phraseOutput, inputPhrase);

}while(inputPhrase!=PhraseSource::FinishedSentinel());

done();

}

};

请尽量使用调节器对象来限制管道中运行时元素的数量。

在管道中，如果你不通过调节器对运行时元素加以限制，就又能会导致元素数量无上限增长。而一旦出现元素过多，程序就会有性能方面的问题，甚至还会出现内存溢出现象。当然，本例中所提供的调节器对象只是各种限制元素方式中的一种，你可以根据程序自身的需要设计出其他的限制方式。

agent类对象可以通过调用自身的start方法来启动运行。

通过wait_for_all方法，我们可以让代码在继续执行之前先处理完全部的agent对象。

agent对象会在启动时调用它的run方法，并在run方法调用done方法并退出时结束任务。

如你所见，我们让串行的do循环来负责往输出缓冲区中添加新值，之所以要用串行循环，主要是为了保证元素的处理顺序，这也是一个应用程序能使用管道模式的先决条件之一。此外，在这段代码中，从外部数据源读取新信息的动作是通过PhraseSource类来完成的，调用该对象的Next方法可以将该对象源中的序列值读取出来。

另外，这里的asend函数，即“异步发送”，是由异步代理库提供的，定义在头件agents.h中。该函数可以通过调度任务的方式将数据异步发送给指定的目标消息块。

应用管道模式的应用程序通常都要按照一定的顺序来处理元素，如果处理顺序不是问题的重点，你可以考虑应用别的模式来进行数据处理，例如并行循环模式。

当一个消息生产者对象想要向某个既定的目标消息块发送消息值时，它既可以调用send函数，也可以调用asend函数。只不过，send函数会在目标消息块收到或者退回消息之前，令当前环境处于阻塞状态，如果消息被确认接收的话就返回true，否则返回false。而asend函数则不需要在返回之前判断目标是接受了该消息，还是拒绝了。因此，如果你在继续当前工作之前，必须要确认消息目标的话，就应该调用send函数。而如果想采用“发送后不管” [1] 的消息传递风格，就可以考虑调用asend函数。

在向下一阶段的输入缓冲区发送数据时，请尽量使用send函数或asend函数。

在某些特定情况下，消息被目标消息块拒收是完全有可能发生的，例如，如果你给一个单参数的缓冲区发送多个消息的话，第二个消息就可能会被缓冲区拒收。此外，消息块的构造函数通常都会允许我们自定义一个过滤函数，以判断其中的消息是否会被相应的消息块所接收。

因此在这段代码中，我们在第一管道阶段执行添加新元素操作之前，先调用了调节器对象的WaitForAvailablePipelineSlot方法。这样一来，如果管道已经满了，调节器就会让程序进入阻塞状态，以待管道重新出现空闲再继续。

接下来进入第二阶段，如果需要的话，我们将进行单词的大小写转换操作。我们将会从数据源缓冲区中读取所有的元素，并进行转换，然后将转换结果输出到目标缓冲区。下面的CorrectCaseAgent类的run方法为你演示这一过程：

class CorrectCaseAgent:public agent

{

//……

void run()

{

wstring inputPhrase;

while(true)

{

inputPhrase=receive(m_phraseInput);

if(inputPhrase==PhraseSource::FinishedSentinel())

{

asend(m_phraseOutput, inputPhrase);

break;

}

//……transform phrase by possibly capitalizing it

asend(m_phraseOutput, outputPhrase);

}

done();

}

};

这段代码的重点在于，我们是直接在数据源消息块上调用receive函数的，这种方法可以让代理对象一直处于等待输入的状态，直到有新的消息产生。这段代码一直迭代下去，直至表示EOF的哨位值出现。

请尽量使用receive函数来等待消息块的输入，并用一个哨位值来表示输入结束。

顺带一提，有些消息块允许存在多个消息使用者和生产者。

某些消息块类型自身就包含了unbounded_buffer＜T＞类的实现，获得了该类的支持，这些消息块的实例就能同时面向多个消息生产者和使用者对象。

在第三管道阶段中，我们将通过CreateSentencesAgent类的run方法来读取之前的词组序列，并重新组合成相关的语句。在该方法中，代码每读取一组以标点字符结尾的词组，就会将它组合成相应的语句，并输出到目标消息缓冲区中。此外，CreateSentencesAgent类的实现也向我们说明了一个事实，即并不是所有的管道阶段都需要限制其中所产生的消息数量。

在管道中，每个阶段都可以对消息执行归并类操作。因此，它们的输入和输出未必总是一一对应的。

最后的第四阶段是通过WriteSentencesAgent类来实现的，在这里，我们依然会从前一阶段的输出中获得相关的消息值，但不会再产生新的消息值了，我们将会把接收到的消息直接输出到文件流中去。下面所实现的该代理对象的run方法：

class WriteSentencesAgent:public agent

{

//……

void run()

{

wofstream fout;

fout.open(m_outputPath);

wstring sentence;

while(true)

{

sentence=receive(m_sentenceInput);

if(sentence==PhraseSource::FinishedSentinel())

break;

if(sentence==m_targetSentence)

sentence.append(L" Success!");

fout＜＜m_currentSentenceCount++＜＜L""＜＜sentence.c_str()＜＜endl;

sentence.clear();

m_governor.FreePipelineSlot();

OutputProgress(m_currentSentenceCount);

}

fout.close();

done();

}

};

如你所见，该agent对象会将之前所合并的语句读进来，并与标准语句m_targetSentence进行对比。所有生成的语句都会被输出到一个指定的磁盘文件中，其中符合规定的语句都会被标志下来。

在这过程中，agent对象会通过管道调节器的FreePipelineSlot方法来标志管道中可用的空间。

在管道实现中，使用agent和消息块来读写数据是非常方便的，其原因之一就是，我们可以在这里使用传统的串行技术（例如迭代）。尽管整个过程会需要一些同步化操作，不过那些细节都被隐藏在unbounded_buffer＜T＞类的实现了。

（为了简单起见，我们在本例中省略了一些关于错误处理、取消操作及其一些性能相关的数据集合的处理细节。如果你想查看这部分代码的内容，可以参考本章后面将要详细介绍的ImagePipeline样例。）

[1] 这原本是军事术语，用来形容巡航导弹会自主寻找目标。——译者注

7.3　实例示范

在你下载的样例包中，有一个叫做ImagePipeline的应用程序。该程序会读取某个目录中的JPEG图形，然后产生相应的缩略图并对其加载某种图像增强的滤镜，以产生一组幻灯片。最后，程序会将这组幻灯片以文件名的字母顺序一一展示出来。

7.3.1　串行化的图形处理

在这里，每张图形的处理都分为四个阶段：首先，程序会从磁盘文件中载入大型的有色图形，再生成相应的小型缩略图，然后再往原图中掺入一些斑点效果，最后，处理完的图形就会被作为幻灯片显示出来。整个过程如下所示（见图7-2）：

图　7-2　串行化的图形处理

这个应用无法使用并行循环来实现，因为它是按照一定的顺序进行图形处理的，而并行循环在不能保证这样的处理顺序。

下面，我们先来看一下串行版的代码：

vector＜wstring＞filenames=……

int sequence=kFirstImage;

for_each_infinite(filenames.cbegin(),filenames.cend(),

[this,＆sequence, offset](wstring file)-＞bool

{

ImageInfoPtr pInfo=LoadImage(sequence++,file, offset);

ScaleImage(pInfo, m_imageDisplaySize);

FilterImage(pInfo, m_noiseLevel);

DisplayImage(pInfo);

return IsCancellationPending();

});

如你所见，通过LoadImage、ScaleImage、FilterImage以及DisplayImage这四个方法，我们实现了之前设计的四个步骤。当然，为了简单起见，我们省略了一些性能优化方面的处理细节，关于这些内容，你可以自己去查看相关的网站上代码样例。

此外，代码中的ImageInfoPtr类型是shared_ptr＜ImageInfo＞类的typedef定义，实质上是一个由STL提供的、指向一个ImageInfo实例的共享指针。而ImageInfo类则是一个存储待处理位图的数据类型。使用指针可以使我们在各管道阶段之间传递该类型数据时，免去位图拷贝的开销。并且，这种缓冲形式也可以确保这些“共享”数据的无锁化需要，因为该指针本身就保证了我们在同一时间内只能访问一个阶段内的ImageInfo对象。

for_each_infinite函数是一个在样例包代码中定义的辅助性函数，它会负责对目标序列中的每一个元素调用某个指定的函数（在这里是一个lambda表达式）。并且，每当该循环遍历到该序列的末端时，就会自动从头开始，一直到那个指定的函数返回true为止。例如在这段代码中，能让循环停止的方式只有两种，要么程序抛出异常，要么IsCancellationPending() [1] 返回true。关于这里所采用的取消模型，将在7.4节中详细介绍。

[1] 这段代码中，“指定的函数返回true”实质上是指lambda表达式，由于IsCancellationPending()的调用正好位于返回语句上，所以它的返回值等同于表达式返回值。希望读者不要误解这里存在的微妙差别。——译者注

7.3.2　图形管道

通常情况下，串行循环在同一时间里只能处理一张图形，也就是说，我们的每张图形都必须依次经历这四个阶段中的所有操作，否则就不能开始下一张图形。由此可以看出，这四个阶段之间的连接完全是串行化的。这使得这个应用看上去似乎有着非常顽固的串行性——因为程序的最上层循环的逻辑限定了这些图形的显示顺序（就像视频帧那样）。而且，该循环步骤内每个子步骤的操作都必须依赖前一个子步骤的输入，这意味着，我们不能在加载滤镜之前就将图形显示出来、不能在缩略图产生之前就对其加载滤镜、不能在原始图形载入之前就调整图形的大小。

但即便是在这样的强串行性应用中，管道模式也能引入一定的并行化操作。在该模式的作用下，我们所输入图形序列依然会经历这四个阶段的所有操作，但是，它们已经可以在同一时间内各自处理不同的图形了。下面，我们用一张图来解释一下这个过程，见图7-3：

图　7-3　图形管道

现在，我们演示这个应用的并行版，该代码可以在ImageAgentPipelineControlFlow.h头文件中找到。

unbounded_buffer＜ImageInfoPtr＞buffer1;

unbounded_buffer＜ImageInfoPtr＞buffer2;

unbounded_buffer＜ImageInfoPtr＞buffer3;

ImageScalerAgent imageScaler(……,buffer1,buffer2);

ImageFiltererAgent imageFilterer(……,buffer2,buffer3);

ImageDisplayAgent imageDisplayer(……,m_governor,……,buffer3);

imageScaler.start();

imageFilterer.start();

imageDisplayer.start();

vector＜wstring＞filenames=……

int sequence=kFirstImage;

for_each_infinite(filenames.cbegin(),filenames.cend(),

[this, offset,＆buffer1,＆sequence](wstring file)-＞bool

{

ImageInfoPtr pInfo=this-＞LoadImage(sequence++,file, offset);

if(nullptr==pInfo)

return true;

m_governor.WaitForAvailablePipelineSlot();

asend(buffer1,pInfo);

return IsCancellationPending();

});

m_governor.WaitForEmptyPipeline();

asend＜ImageInfoPtr＞(buffer1,nullptr);

agent*agents[3]={＆imageScaler,＆imageFilterer,＆imageDisplayer};

agent::wait_for_all(3,agents);

在这段代码中，我们定义了三个unbounded_buffer＜T＞类型的消息块，以作为各个管道阶段之间的缓冲区。并且，这里我们也是通过agent对象的start方法来启动各个管道阶段的。

在这里，代码会按照文件名的顺序进行遍历，并调用LoadImage方法将各个图形载入到内存中去。这一步骤的操作与之前的串行版基本相同，只不过，它可以与下一个操作各自处于彼此独立的进程中，而代码会在消息块buffer1中放置一个新载入图形的数据指针。这样一来，我们就可以把buffer1看做ImageScalerAgent对象的数据源，以便它接收图形并进行调整图形大小的相关处理。与此同时，循环又可以继续进行遍历，开始加载下一图形。

与本章前一节介绍的文本处理示例相似，这个图形处理程序也会用PipelineGovernor工具类来限制管道中运行时元素的最大数量。

（为了简单起见，这里省略了一些关于错误处理、取消操作及其性能相关的数据集合的处理细节。关于这些内容，你可以参考线上样例包中完整的代码实现。）

7.3.3　性能特征

如果我们想要充分理解上述两个版本（串行版和管道版）各自的性能特征，就要求助于一张下面这样的任务调度图，见图7-4：

图　7-4　图形管道中各阶段在速度相等时的表现

在图7-4中，我们所描述的是管道中各任务如何随时间执行。例如，图中的头一行描述的是阶段1中的情况，如图形1的处理于t0处开始，而图形2开始于t1处；而在阶段2中，图形1的处理则开始于t1处。如果假设这里的任务完全实现了负载平衡，也就是说，各个管道阶段所消耗的工作时间是相同的（我们将这个时间单位定义为T）。那么我们就可以这样描述图7-4中的情况：程序在t1处所消耗的时间是1个T，而在t2处则消耗了2个T等，以此类推。

要留意管道阶段之间的大型数据拷贝，例如在各阶段之间拷贝大型的位图数据，会造成大量不必要的内存消耗。建议采用数据结构指针传递的方式来代替这种数据拷贝。

这时候，如果有足够的内核可用，管道中的任务就完全可以实现并行化运行。这样一来，在图4所描述的管道中，处理6个图形所需的总时间预计会在9个T左右。相比之下，串行版则需要24个T左右的时间，因为程序每处理一个图形都必须经历所有的24个步骤，之后才能继续处理下一个。

并且，只要程序中处理的图形越多，其平均性能的改善就越明显。要理解之所以会这样的理由，我们可以再回头看看图7-4中的诠释，在管道启动任务被占据时，以及在关闭任务被释放时，都有一些内核空闲。当待处理图形数量很庞大时，任务启动和关闭所耗费的时间就显得微不足道，所以处理每个图形的平均时间也就更接近于单位时间T。

如果有足够的内核可用，并且管道中所有阶段所耗费的处理时间又相等的话，那么管道整体的执行时间与单一阶段所耗费的时间是基本相同的。

但是，这里面有个误导：即我们的前提是管道内所有阶段所需的处理时间是基本相同的，而这个假设并非总是成立的。例如在下面这张调度图（见图7-5）中，加载滤镜阶段所用的操作时间就是其他阶段的两倍。

图　7-5　图形管道中各阶段在所耗时间不相等时的表现

当管道中有一个阶段所耗费的时间是2个T，而其他阶段依然是T时，我们就会发现程序不能再确保所有内核的满负荷状态了。这时候，处理图形所需的平均时间（处理大量图形的）应该也在2个T左右。也就是说，当用于各个管道阶段的内核是足够多时，管道的平均速度大致上与其中执行最慢的阶段相等。

如果我们将这个ImagePipeline程序运行起来，就能通过该样例程序的用户界面（UI）观察到它的运行效果。该用户界面能以毫秒为单位地显示管道中各个阶段所消耗的平均时间。同时，它也会显示程序处理每个图形所消耗的平均时间。如果程序以串行模式运行（选择Sequential单选扭即可），你就会注意到处理每个图形所消耗的时间等于所有阶段所消耗的总和。而如果你选择以管道模式运行，每个图形的平均处理时间大致等于管道中最慢阶段的时长。并且当所有阶段的执行速度相等时，程序的性能达到最佳。尽管我们不是总能看到这样的效果，但这依然是一个值得努力的方向。

当一个管道中各阶段所消耗的时间不一致时，它的平均执行速度就基本与最慢阶段的速度相等。

7.4　模式变体

管道模式有几个不同的变体，下面为你一一介绍：

7.4.1　异步管道

到目前为止。我们讨论的都是同步管道。也就是说，这里的生产者和使用者都是一些需要长时间运行的任务对象（通过agent类来实现的），实质上就是一个串行循环从输入端中读取信息并再将信息写入输出端的过程。只不过，该串行循环包含在代理对象的run方法中。这种方法有时候也叫做控制流代理。这种控制流能为每个管道阶段申请一个专用的执行线程。而在事实上，如果根据等时长分配工作的实践经验来构建管道，你会发现这种按阶段分配线程的方法是有意义的。而且，如果在管道执行期间阶段之间的工作是等量分配的，程序就能确保各线程的有效负荷状态持续下去。关于这些分配工作的具体细节，在7.3.3节中已经做了详细介绍，这里就不再累述了。

当然，我们也可以选用异步管道技术。在异步管道中，我们的任务通常是在相关数据到位之后创建的。相对于之前提到的控制流方法而言，这种处理风格或许更倾向于在数据流上做文章。尽管，这两种方法的区别主要取决于我们在编码时所优先考虑的方向。但有时我们也会为了实现一些特定的功能和性能，必须要明确这两种方法各自的针对性。

异步管道通常是通过transformer和call这两个类来实现的，这两者都是异步代理库中的消息块类型，同时也都是用于存放相关数据的队列。当这些队列中的数据到位时，如果当前没有正在处理队列的任务，管道就会为其新建一个。并且，只要这些队列中一直存在待处理数据量任务就会一直处于活动状态，直至发现队列中已经没有数据时才会结束。而如果又有数据到达，管道又会再启动新的任务。换而言之，这里就是使用transformer和call这两个消息缓冲区来扮演agent类的角色。并且，它们会根据数据的处理需要来创建新的任务，这就使我们免去了一些专用的执行线程。

当我们面对一个多阶段管道时，如果不想为每个阶段都分配一个专用线程，异步管道技术就不失为一种很好的选择。而且，这种技术也有利于我们对经常出现的空管道情况采取某种应对措施（例如，在程序等待输入时）。在这种情况下，transformer消息块可以帮助我们更好地使用线程，进而改善应用程序的性能。

当然，异步管道也有一个缺点，即代码的编写和调试相对于本章早先提到的基于agent风格要更困难一些。而且，为了保证程序调度的低延迟性，异步管道可以根据具体的使用情况来申请任务调度器的。这也使得它只能执行一些拥有等量输入和输出的操作阶段。如果你想了解关于任务调度器的进一步信息，可以参考附录A的相关内容。

接下来，下面有一个使用异步管道的应用示例，它来自ImageAgentPipelineDataFlow类的相关实现。

transformer和call是两个与agent类作用相似的消息缓冲区，只不过它们不需要像agent类那样申请专用的线程。异步管道技术就是通过实例化这两个类的对象来实现的。

void Initialize()

{

m_scaler=unique_ptr＜transformer＜ImageInfoPtr, ImageInfoPtr＞＞(

new transformer＜ImageInfoPtr, ImageInfoPtr＞(

[this](ImageInfoPtr pInfo)-＞ImageInfoPtr

{

this-＞ScaleImage(pInfo, m_imageDisplaySize);

return pInfo;

},

……

));

m_filterer=

unique_ptr＜transformer＜ImageInfoPtr, ImageInfoPtr＞＞(

new transformer＜ImageInfoPtr, ImageInfoPtr＞(

[this](ImageInfoPtr pInfo)-＞ImageInfoPtr

{

this-＞FilterImage(pInfo, m_noiseLevel);

return pInfo;

},

……

));

m_displayer=unique_ptr＜call＜ImageInfoPtr＞＞(

new call＜ImageInfoPtr＞(

[this](ImageInfoPtr pInfo)

{

this-＞DisplayImage(pInfo);

m_governor.FreePipelineSlot();

},

……

));

m_scaler-＞link_target(m_filterer.get());

m_filterer-＞link_target(m_displayer.get());

}

如你所见，我们在这段代码中建立了一系列的transformer对象，用来接收和发送ImagePtr对象。同时，我们也为每个transformer对象都定义了一个lambda函数，这些lambda函数都会采用一个图形指针参数，并在该指针上执行某个指定操作。完成之后再将修改后的图形以指针的形式返回。在transformer对象中，消息的输入与输出是一对一的。也就是说，对于每一个输入的值，transformer函数都必须返回一个相应的单值作为输出。

在最后一个管道阶段中，我们定义的是一个call消息块对象。除了没有输出消息之外，call消息块的作用与transformer类基本相同。m_displayer变量中也包含了一个lambda函数，用来显示图形，并更新管道的调节器，只不过它不产生任何输出罢了。

除此之外，我们还要该程序提供一个转换函数，以便将相关的输入值转换成transformer类构造函数可用的参数。这也意味着，只要程序中一有可用的输入，系统就会自动调用这个转换函数。也正因为如此，我们在设计该转换函数时必须要留意其中的异常处理问题。

除了初始阶段之外，上述管道中所有阶段之间的通信都是由新建的transformer和call对象来完成的。此外，transformer的目标配置可以通过调用link_target方法来完成。而且我们也不需要为transformer和call设定相应的数据源，因为它们本身就是一种消息块类型，自身就拥有数据源。

接下来，我们来看看基于数据流的图形管道是如何实现的，它的run方法如下所示：

Initialize();

vector＜wstring＞filenames=……

int sequence=kFirstImage;

for_each_infinite(filenames.cbegin(),filenames.cend(),

[this, offset,＆sequence](wstring file)-＞bool

{

ImageInfoPtr pInfo=this-＞LoadImage(sequence++,file, offset);

if(nullptr==pInfo)

return true;

m_governor.WaitForAvailablePipelineSlot();

asend(m_scaler.get(),pInfo);

return IsCancellationPending();

});

//Allow subsequent stages to terminate

m_governor.WaitForEmptyPipeline();

done();

如果将这段代码与之前章节中提到的、基于agent的图形管道进行对比，你会发现这两个run方法有些类似。它们都通过串行循环载入图形，并发送图形给大小调整阶段的数据源。也都是通过调用调节器对象来控制运行时元素的数量。

transformer和call对象的目标是通过link_target方法来设置的。

运行时，我们也会观察到transformer和call之间的一些不同点。以异步管道的实现来说，无论何时，只要消息块接收到新的元素（来自send或者asend函数），transformer对象就会在管道中新建一个任务。然后，这个新任务会去调用相关的转换函数（通过该函数我们才能把数据传递给构造函数的首参数），待转换函数返回，再将返回值发送给转换器指定的目标消息块。

而call消息块的行为和transformer基本类似，只不过它最后不会返回一个对象。call块函数可以在任何新输入的元素上被调用，但不会返回任何值。

在运行时，transformer和call消息块会根据队列中的处理需求来新建任务，这样一来，任务就只需在处理元素时活动即可。

7.4.2　管道中的取消操作

在管道模式中，任务之间通常都是共同工作的，因此它们也必然要共同响应取消操作。对于管道中的各个阶段来说，最常见的做法是，在处理输入源循环的末尾处加入一些取消操作的检查。例如在之前的图形处理应用中，我们会在ImagePipelineDlg控制类中看到一个由用户界面所提供的overwrite_buffer＜bool＞对象，这个对象就是用来检查由用户界面所发出的取消请求的。管道中各个阶段都会定期执行这样缓冲区检查，以此来判断程序是否已经收到取消请求。

继续以图形处理的应用为例，我们在基于agent类实现的基类AgentBase中，定义了如下内容：

class AgentBase:public agent

{

private:

//……

ISource＜bool＞＆m_cancellationSource;

public:

//……

AgentBase(HWND dialog,ISource＜bool＞＆cancellationSource,……):

m_dialogWindow(dialog),

m_cancellationSource(cancellationSource),

……

{

//……

}

bool IsCancellationPending()const

{

return……||receive(m_cancellationSource);

}

//……

}

在这段代码中，我们演示了外部环境（这里就是来自应用程序的用户界面的请求）向管道发送信号，然后执行取消操作的处理过程。agent类的构造函数有一个ISource＜bool＞类型的参数，是被当做取消信号源来使用的。而该信号源则是通过实例化overwrite_buffer＜bool＞类来实现的。正常情况下，该对象的值为false，当管道收到来自用户的取消请求时，该值就会被改为true。除此之外，个别的管道操作还能通过调用IsCancellationPending()方法来实现有序的关闭效果。

另外，我们实现取消操作的方式可能会对应用程序的性能带来一定的影响。因此，要注意不要在紧凑的循环中做取消检查，但同时也要保持一定的检查频率，以便使用户感觉不到取消操作的执行延迟。一般来说，图形管道会在每个管道阶段启动时执行取消检查，通常每一百毫秒一个agent线程检查一次，当然，具体的检查频率取决于你计算机的速度。由此可见，对应用程序进行必要的概要分析，将会有助于你判断取消操作是否会给程序的性能带来损害。

当一个管道阶段中出现异常时，我们就应该取消其他阶段的操作，否则就有可能会发生死锁。与之相关的规则将会在7.4.3节中详细介绍。

7.4.3　管道中的异常处理

异常处理与取消操作基本相同。不同之处在于，某个管道阶段的内部产生异常信息时，而在其他没有发生异常的管道阶段中执行的任务是不会接收到该异常信息通知的。因此，如果不及时传递异常信息通知，就会在应用程序中留下若干导致死锁的隐患。

在图形处理的实例中，基类AgentBase会实例化一个overwrite_buffer＜bool＞类的对象，该对象可以在某个阶段发生异常时，将提示信息发送给所有的管道阶段。代码如下：

class AgentBase:public agent

{

private:

//……

mutable overwrite_buffer＜bool＞m_shutdownPending;

public:

//……

AgentBase(……)……

{

send(m_shutdownPending, false);

}

void ShutdownOnError(Phases phase, const wstring＆filePath,const exception＆e)const

{

wostringstream message;

message＜＜e.what();

SendError(phase, filePath, message.str());

}

void SendError(Phases phase, const wstring＆filePath,wstring message)const

{

//……

send(m_shutdownPending, true);

send(m_errorTarget, ErrorInfo(phase, filePath, message));

PostMessageW(m_dialogWindow, WM_REPORTERROR,0,0);

}

bool IsCancellationPending()const

{

return receive(m_shutdownPending)||receive(m_cancellationSource);

}

//……

}

如果管道中有阶段捕获到一个异常，就会自动调用应用程序的ShutdownOnError方法。由于管道中各个阶段是并发运行的，该shutdown方法的编写必须符合并发安全的规则，因此，这里选择了向缓冲区发送信息的方式，以此来代替共享变量的即时更新操作。

首先，ShutdownOnError方法会发送一个true值给覆写缓冲区m_shutdownPending，将管道中的其他agent对象标志为即将关闭状态。紧接着，该方法又会将包含错误报告的消息发送给无限缓冲区m_errorTarget。最后，再将自定义的windows消息WM REPORTERROR通报给需要执行错误处理的用户界面（UI）。当该界面所在线程接收到这个windows消息时，就会自动去调用应用程序中相关的回调方法（callback method），以便从m_errorTarget缓冲区中获得相关的信息，并将它们显示在对话框中。在这段信息中，应该包含了相关异常的文本描述，以及异常发生时程序正在处理的图形文件名。

而IsCancellationPending方法则会检查两种情况：其中一种情况是，检查是否出现了会导致程序关闭的异常，另一种则是检查是否存在用户发出的取消请求。之所以要分开使用这两种缓冲区，主要是因为：传递外部取消信号的消息机制在当前的实现中是不可重用的。尽管，管道阶段可以自动识别异常的发生。但取消操作依然只能通过用户的请求来完成。毕竟，操作的作用范围是一个需要考虑的因素，取消请求可能会影响管道以外的操作，而错误处理则只在管道内部执行。

7.4.4　多生产者作用下的负载平衡

unbounded_buffer＜T＞类允许我们从多个生产者那里读取信息。我们可以利用这一点在比其他管道阶段更耗时的管道阶段之间实现某种负载平衡。

有时候，通过增加任务的数量来实现特定管道阶段中的负载平衡是完全有可能的。

在稍早前所介绍的那个图形管道应用中，经过缩略处理的幻灯片需要按照输入文件的顺序进行逐一展示。这种约束对于多管道应用来说很常见，例如视频帧的处理过程就是如此。然而在这个应用示例中，在这些连续图形上加载滤镜的操作事实上是可以独立进行的。因此，我们可以考虑在管道中增加一项额外的任务，过程如图7-6所示。

图　7-6　使用来自多个生产者的信息

在图7-6中，我们演示了在添加额外的滤镜任务后所发生的情况。图中的数字所表示的是图形被处理的顺序。（还记得吗？本例中的图形处理必须按照一定的顺序进行。）并且，两边的滤镜任务都会接收到来自前一管道阶段的图形。但它们处理这些图形的顺序是不能完全确定的，尽管从加载滤镜这个任务的角度来看，图形确实是按顺序输入的。

每个滤镜加载阶段都有一个属于自己的目标缓冲区，用以暂时存放自身所产生的元素。而关于使用这些队列的组件，我们叫它多路转换器（multiplexer）。它会负责合并来自多个生产者的输入信息。在我们的样例代码中，这个转换器就是管道的显示阶段，它会按照既定的串行顺序接收图形。并且，这里的图形是不需要进行重新整理或排序的，因为前面的生产者队列自身就是有序的，这有助于多路转换器可以通过实时同步的方式从所有的生产者队列中有序地读取信息。

为了说明得更具体一点，我们再来看一个例子。假设我们通过某个数据访问器获得了一组图形。其中的每个图形具有唯一的序列号。与图7-6中所描述的情况一样，号码从1开始递增。在这种情况下，第一个滤镜任务所处理的分别是1、4、5号图形，而第二个滤镜任务则处理2、3、6和7号图形。处理完成之后，带负载平衡的滤镜任务就会将结果图形输出到它们各自的队列中去。这两个输出队列都是按既定顺序排列的（也就是说，队列中不会有号码高的图形出现在低号码图形之前）。但是，号码序列之间还是存在间隔的。例如，从第一个滤镜任务中输出的图形依次是1号、4号和5号，而2号和3号图形则由第二个滤镜任务输出。

类似的间隔会带来一个问题。因为在下一阶段中，显示任务需要以无间隔的顺序将图形显示出来。这时候，就需要多路转换器发挥作用了，这种转换器会同时接收来自这两个滤镜阶段的生产者队列中的信息。而且，当它接收到相关图形时，会检查图形的号码是否符合顺序，如果是，转换器就会将图形直接传递给显示图形的阶段处理。如果不符合当前的号码顺序，转换器就会将该图形暂存到一个内部的预备（look-ahead）队列中去，然后继续检查下一个图形，直到这两个输入队列中再也找不到属于预备图形的信息为止。有了这种算法，我们可以让多路转换器同时从不断增加元素的生产者队列中获取输入，并且，在不进行排序的情况下确保输入信息的顺序。

依图7-7所示，当管道中加载滤镜操作所所需的时间是其他阶段的两倍时，将滤镜阶段的任务数量翻倍，可以在一定程度上改善程序的性能。

图　7-7　带负载平衡的图形管道

在上面的管道中，如果除了加载滤镜阶段外，其他阶段处理一个图形所需的时间为单位T，而滤镜阶段所需的时间就应为两个T。这种情况下，我们可以在该管道中使用双滤镜阶段，并在生产者队列之间实现负载平衡。这样或许就能从整体上改善该应用的平均速度，而且这样一来，只要管道中图形数量越多，处理单个图形所用的平均时间就越接近单位T。如果你想亲眼查看这个优化效果，只需在运行这个ImagePipeline应用样例时选上"Load Balanced"单选钮即可。总而言之，管道的执行速度（当所处理的图形达到一个合适数量时）基本上接近于单实体阶段的平均速度，或者比加载滤镜操作所需的平均时间的一半略大一些。

在ImagePipeline样例中，队列Queue 3在等待操作上所用的时间会在用户界面（UI）中显示出来，以记录程序在使用多重生产者队列时，在等待操作上的开销。这也说明了一个事实，即在并行计算中，如果进行某种增加开销的改动可以使多核处理器得到更有效地利用，那么这样做反而会提升程序的整体速度。

7.4.5　管道与流的关系

你或许已经注意到了，消息缓冲区与流之间存在着某些相似之处。消息缓冲区有时可以直接采用流的形式来代替，反之亦然。例如我们可以在一个读写流的库方法中应用管道模式。再假设一下，当你需要对文件执行并压缩加密操作时，尽管这些操作都有现成的程序库支持，但不幸的是，那些库函数的参数列表通常都只能接受流类型，无法接受消息块类型。这时候，就需要在代理对象和消息块所实现的现有类型外面，再实现一个相关的流类型 [1] ，这是完全可行的。

[1] 即用Stream类型对当前的管道类型进行再封装，这在STL应用中非常普遍。——译者注

7.5　反面模式

在我们实现管道的过程中，有几件事需要小心对待。

7.5.1　在管道各阶段之间进行大量的数据拷贝

如果你要传递一个大型的数据结构，请尽量使用指针来完成，而不是在管道中直接拷贝数据本身。而且在使用指针时，我们还能借助“资源获取即初始化（RAII）”模式来确保程序的正确性。这一点在你面对带多个终点的非线性数据流网络时更是如此。只有在面对小型数据项时，我们才会考虑按值传递。

7.5.2　管道阶段中的工作量过小

请尽量不要在管道中使用过小的工作项，否则它产生的管理成本将会抵消掉并行化所带来的优势。

7.5.3　在消息传递时忘记使用隔离技术

agent对象之间的数据共享请尽量不要使用类似锁和信号装置这样的共享性数据结构，应该以消息传递的方式来完成。

7.5.4　无限期的等待

通常情况下，只要有任务在管道中捕获到了一个异常或者终止信号，便会停止继续从消息块中接收输入。有时候，可能由于某种既定的管道逻辑，你会遇上一些无限期的阻塞处理。这时候，通过使用7.4.3节中介绍的技术，可以使你避免这种尴尬的局面。

7.5.5　无限制的队列增长

你应该留意管道缓冲区中的元素数量，并在一定的时间范围内对它们加以限制。关于这一点，你可以利用在前面几节中学习到的控制技术，参考样例代码中的Pipeline-Governor类是如何限制运行时的元素数量的。

7.5.6　更多信息

如果想得到更多的指南信息，读者可以参考MSDN上的文章：《异步代理库中的最佳实践》，链接是：http://msdn.microsoft.com/en-us/library/ff601928.aspx。 [1]

[1] 相应的中文链接是：http://msdn.microsoft.com/zh-cn/library/ff601928.aspx。——译者注

7.6　设计注意事项

当我们使用管道模式分解一个问题时，需要考虑一件事，即你会将管道划分为几个阶段？这个问题主要取决于我们在实现应用程序时，如何评估程序在运行时可用的内核数。因为管道模式与这本书中的其他大部分模式都不同，它无法自动调整自身所用的内核数量，这也是它的一个局限所在。（当然，某些特定的情况下还是可以在其中引入额外的并行性的。）

通常来说，管道中的阶段分得越多，工作就越顺利。除非将元素添加或删除元素缓冲区中的开销变成了当前主要的性能瓶颈。但这个问题通常只会发生在阶段自身工作量过小时。

如果我们想要实现高度的并行化，就还需要要注意一下管道中的所有阶段能否以某个相近的执行速度工作。如果不能实现这一点，我们的管道就很可能会受制于自身最慢的组件。

在管道中，运行时元素的数量也会是影响程序整体性能的重要因素。如果我们将元素数量限制得过小，就有可能无法保持所有管道阶段的工作负荷状态。而如果这时处理数据元素的时间是一个变化值的话，问题就会显得更为突出。因此，我们应该允许缓冲区在运行时持有更多的元素，以便应对在元素处理时间上的变化。而所允许的具体范围应该取决于被处理元素的对象大小，例如对于大型位图对象来说，所需的内存空间是比较大的。

一般情况下，管道流本身就有足够的缓冲能力以应对这种变化，但它也无法应对更多的情况。对此，我们可以使用Visual Studio中的并发可视化工具（Concurrency Visualization），以便即时地观察与管道吞吐量有关的特性，并根据需要修改相关的管道容量参数，调整各管道阶段在I/O阻塞上所等待的最小时间。

7.7　关联模式

管道模式的应用很常见，例如操作系统实现的过滤器和通道（pipes）。而且管道也与流的概念有一定的关联。

同时，管道模式也是生产者/消费者模式的一种特殊的表现形式。实际上，它就是由一系列的生产者/消费者组件构成的，管道中的每个组件都依赖于前一组件的输出。

7.8　练习题

1）请修改7.1节中的实例，并在此基础上实现属于你自己的管道程序。

2）请在并发可视化工具的视图中执行本章的代码示例，并尝试合理地说明你的观察结果。

7.9　补充阅读

关于从多个生产者队列获得多重输入的内容，Campbell等人在他们的文章中做了全面的论述；而从Buschmann等人的说明文献中，你也可以找到关于操作系统命令行环境（command shell）中的通道与过滤器模式的相关说明。

·Buschmann, F.，R. Meunier, H.Rohnert, P.Sommerlad, and M.Stal.Pattern-Oriented Software Architecture Volume 1：A System of Patterns.Wiley，1996.

·Campbell, C.，M. Veanes, J.Huo, and A.Petrenko."Multiplexing of Partially Ordered Events."TestCom 2005，Springer Verlag, June 2005.http://research.microsoft.com/apps/pubs/default.aspx?id=77808.

附录A　任务调度器与资源管理器

我们所实现的并行模式库与异步代理库，都必须依赖于一些共同的特性，而这些特性均来自一个叫做并发运行时的低层组件。该组件主要包含了两个重要组件：任务调度器与资源管理器。尽管，它们在MSDN上都已经有了各自的文档资料。但在这篇附录中，我们还是会带你概览一遍这些组件的主要功能，并初步了解一下它们的设计初衷。

并发运行时的存在，有助于我们发掘出自己程序中潜在的并行性。因为它的设计不仅着眼于当前的硬件体系结构中的并行能力，同时也会考虑到这些体系结构未来的发展潜力。换而言之，并发运行时致力于让我们的应用程序能更有效地应对硬件平台的发展。它的任务调度器可以自行决定应该于何时何地运行应用程序中的任务，并通过一定的协同调度技术来实现内核之间的负载平衡；而资源管理器则可以帮助你引导应用程序的不同组件之间，以及所调用的程序库之间对于并行资源的竞争。并且，这也可以帮助你找到使用这些硬件资源（如硬件缓存器）的最佳方式。总而言之，并发运行时的作用是非常重要的，通过其中一系列的高级算法和实践经验，我们可以更有效地优化多核体系结构下的工作。

在下图中（见图A-1），我们展示了Concurrency命名空间中的各个组件，以及它们之间的关系。（这些组件都以蓝色阴影显示。）

图　A-1　各程序库、运行时组件之间的关系

在下面的表格中，我们列出了并发运行时中与各组件相关的头文件，透过这些头文件，你可以快速了解这些组件的功能信息。

由于并发运行时拥有用户模式（user-mode）级权限，它处于操作系统的顶层。因此它有能力管理大量的内核，这是一些应用程序自身无做到的事情。此外，并发运行时还是Visual Studio 2010开发系统所自带的C++运行时的一部分，这意味着我们在使用它时，不需要在程序中引入额外的程序库。

并发运行时对操作系统中管理处理器的API进行了某种程度的抽象，这使它能自动适应各个版本的操作系统 [1] 特性。例如，在64位的Windows 7操作系统上，如果我们能扩展自己的应用程序，使它能在256个内核之间自动分配任务，并在其中运用非均匀性内存架构（Non-Uniform Memory Architecture, NUMA）加以规范。（这对程序员来说并不容易。）那么我们就能在支持64个内核的Windows Vista上运行同样的应用程序。

注意：在这篇附录中，除非有特别说明，否则这里涉及的所有类名与全局函数名一律都来自命名空间Concurrency。

A.1　资源管理器

资源管理器的主要任务是为应用程序中的各个任务调度器分配处理器内核，以便任务尽可能以“本地化”的方式执行。所谓的本地化执行，实际上包含了一系列与NUMA节点、硬件缓存器有关的内存访问技术。此外，需要注意的是，这里的资源管理器实际上是ResourceManager类的一个单实体（singleton instance）对象 [2] 。

通常情况下，大多数程序员不会去直接使用资源管理器，但这对于我们理解资源管理器的工作方式还是很有帮助的。

资源管理器是ResourceManager类的一个单实体对象，它主要负责将处理器资源分配给应用程序中的任务调度器，并辅助实现任务的“本地化”运行。

A.1.1　为什么需要资源管理器

当我们在单一的应用程序中面对多个调度器实例时，资源管理器的作用就会显得尤为重要。在类似的应用场景中，资源管理器可以让这些任务调度器组件（包括Scheduler类和其他第三方组件）在内核竞争的过程中共存下来。例如，如果我们的应用程序中有两个调度器，并且采用的都是默认调度策略的话，资源管理器会在初始化时将计算机上可用的内核资源平均分配它们。当然，如果必要的话，资源管理器也会运用NUMA节点边界、处理器包等技术来完成内核资源的分配。

另外，当我们需要使用来自多个开发商的并行程序库时，资源管理器也能提供一些帮助。它可以使这些程序库实现资源的合作共享。Microsoft公司一贯鼓励并发程序库的开发商，期待他们能基于并发运行时开发出自己的组件，并在其中尽可能地发挥出并发运行时中所有的特性。

资源管理器还提供了一些动态资源管理的能力，它能根据内核实际的使用效率自行调整相关组件的并发访问级别。

[1] 虽然理论上可以适用于任何操作系统，但目前仅限于各个版本的windows。——译者注

[2] 关于如何确保一个类的单实例化，读者可以参看《设计模式》一书中的“单件模式”。——译者注

A.1.2　资源管理器的工作方式

资源管理器主要的抽象逻辑是一个由IVirtualProcessorRoot类提供的虚拟处理器对象。我们可以把该对象当做一个令牌，它可以授权调度器启动（或者恢复）一个线程的运行。程序会根据虚拟处理器对象指定的处理器关联码（processor affinity mask）来选择执行该线程的内核。所谓的处理器关联码，实质上是一种位码，主要用于为操作系统中特定的线程分配执行内核。当然，这里所谓的内核是泛指硬件中所有支持执行的资源，包括支持实时多线程（即所谓的“超线程”）的硬件体系。

实际上，我们可以把虚拟处理器对象看做一张音乐会的入场券，凭借这种入场券，我们可以获得某一场音乐会的入场资格，但上面并未指定具体的座位号码。这意味着，当我们拿着这张入场券进门时，可以在指定场次的音乐会中随意找一个位置坐下来。同样，这里的虚拟处理器对象也是一种“入场券”，它将允许一个工作线程根据具体的处理器关联码信息来选择自己的执行内核。

注意：为了便于线程管理，资源管理器提供了一个IThreadProxy类的实例。该类的对象是一个调度组件，一般用于关联继承了IExecutionContext接口的对象。

虚拟处理器对象可以授权任务调度器启动（或者恢复）一个线程，而选择该线程的执行内核取决于相应的处理器关联码。

下面，我们用一张图来演示一下这种工作方式。

在图A-2中，我们演示了计算机内核按照处理器包和NUMA节点规则进行分组的过程。资源管理器明白计算机中NUMA节点和处理器包是如何设计的。为了某个特定的处理操作，通常会有若干个调度器对象向资源管理器申请指定数量的虚拟处理器对象，以用于它们的工作线程。而在这些调度器中，有些只是想要获得更高的并发度，而有些想要的则是获得尽可能多的并发能力。 [1]

图　A-2　虚拟处理器对象

所谓处理器节点，实质上是由资源管理器根据NUMA节点、处理器包或者其他（潜在的）分组规则而建立的一种抽象机制。

通常情况下，资源管理器会尽量满足所有调度器对象的请求，但计算机上的内核是有限的。为了弥补内核的不足，最终资源管理器不得不为每个调度器配给若干个虚拟处理器对象。然而，这会使虚拟处理器对象丧失应对特定内核分配（例如为特定线程指定内核ID）的能力。而如果这里改用处理器节点来处理的话，由于这是资源管理器根据NUMA节点、处理器包以及其他分组规则建立的一种抽象机制，因此，每个虚拟处理器对象在任何时候都有权让一个工作线程运行在合适的处理器节点中。这种情况下，即便再遇上指定内核分配的情况，也可以通过延迟分配方式来解决。

而且，资源管理器会在处理器节点与内核集之间建立某种映射关系。在这种情况下，即便调度器已经接收到了虚拟处理器对象，工作线程也不会知道它要调用的是哪一个虚拟处理器对象。直到它被运行的时候，调度器才会让该工作线程与特定的虚拟处理器对象建立（或断开）联系。这就使得每个工作线程在任何指定的时间内都能申请到一个虚拟处理器对象。而虚拟处理器对象则会负责将处理器关联码传递给操作系统，由操作系统来完成内核的选择。最终，工作线程将会在操作系统指定的内核中完成它的执行。

一旦线程开始运行，虚拟处理器对象就无法再被其他线程重用了，除非运行过程中发生了某种协同性环境切换（cooperative context switch）操作。这里所谓的协同性环境切换，主要是指某个工作线程因为某种协同性阻塞操作而进入阻塞状态时所发生的情况。在这种情况下，被阻塞的线程会断开与虚拟处理器对象的联系，而另一个工作线程就会连接上该虚拟处理器对象，并获得运行许可。此外，如果某个调度器想同时运行更多的线程，就必须向资源管理器申请额外的虚拟处理器对象。通常，这些对象的数量取决于相关调度器的并发级别，或者等于在同一时间段内运行的工作线程数。尽管，任务调度器可以创建任意数量的线程，但它在指定时间内能运行的线程数却取决于虚拟处理器对象的数量。

总而言之，调度器与虚拟处理器对象之间的相互作用，将最终决定该调度器的并发级别，以及它为具体线程指定执行内核的方式。

[1] 由于翻译的原因，这里的“并发度”和“并发能力”的区别并不明显，实质上，前者指的是程序被预先设定的并发级别，而后者更多的是指程序在运行过程中所能发挥出来的并发运行能力。——译者注

A.1.3　动态资源管理

资源管理器可以运用动态资源管理技术来辅助调度器，实现内核之间的协同调用。这使资源管理器能在运行时动态地监控执行资源的调用。这样一来，它就能及时了解虚拟处理器对象当前的状态是空闲还是忙碌。如果资源管理器在监控过程中中发现，当前所使用的模式未能使内核得到充分的利用，资源管理器就会为该内核重新分配调度器。当然，这也说明了动态资源管理只能出现在拥有多个调度器的应用中。

由于操作内核再分配这种技术的允许，资源管理器才可以将执行资源分配到最需要它们的地方去。也正因为如此，我们才会觉得虚拟处理器对象的分配是随时变化的。

A.1.4　超额预订内核

如果在一个内核集合中，虚拟处理器对象在数量上超过了内核（在这里，所有的硬件线程都可以被当做内核），我们就说内核被超额预订（oversubscribe）了。通常情况下，默认的调度策略只会根据可用的内核分配资源，而不会让其中虚拟处理器对象数量超过计算机的实际内核数。换句话说，资源管理器通常会避免超额预订。尤其是当内核忙于一些密集型计算操作时，超额预订会导致系统缓存器的紧张，进而使造成系统吞吐量下降。然而，这条规则也存在着一些例外。

例如在一个应用程序中，如果所有的调度器所需的最小并发数总合超过了当前所能调用的最大内核数的话，资源管理器就会有意识地去使用超额预订策略。除此之外，另一种情况是，如果我们将调度选项TargetOversubscriptionFactor的值设置为大于1，那么，就等于在调度策略上允许调度器对内核进行超额预订了。

第三个例外情况是：在程序运行过程中，如果有调度器向资源管理器申请了额外的并发资源（这意味着更多的虚拟处理器对象）。那么。最终有可能会出现一种特殊情况，即当资源管理器的动态资源管理部分认为某个调度器没有充分利用它的内核资源时，就可能会暂时将这些资源设置为超额预订状态，以协助其他调度器完成它们的工作，而不是直接将相关的虚拟处理器对象从早先的调度器中移除。

A.1.5　查询环境

尽管在编程过程中，不太可能需要直接调用资源管理器。但在头文件concrtrm.h中，有一些函数还是会对我们有所帮助的，它们能反馈给我们一些关于操作环境的信息。其中，GetProcessorCount和GetProcessorNodeCount这两个函数的作用尤为明显。它们能返回当前计算机所拥有的内核数量（包括所有被当做内核的硬件线程），以及处理器节点的数量。如果返回的节点数大于1，我们就可以认为自己当前所在的机器上存在着某些本地化内核的概念，例如，这是一台带NUMA规则或者多重处理器包的机器。毕竟从严格意义上来说，“处理器节点”这个概念是由资源管理器和具体的计算机类型共同决定的。

A.2　任务的种类

在讨论任务调度器之前，有件事非常重要，即我们必须要了解并发运行时中存在着两种不同类型的任务。由任务调度器所提供的任务，我们称之为轻量级任务。这与PPL所提供的任务完全不同。

A.2.1　轻量级任务

轻量级任务的主要接口主要是由ScheduleGroup类的ScheduleTask方法提供的。该方法允许我们在指定的调度组队列中添加一个新的、被挂起的轻量级任务。当然，如果你想让运行时自动为你选择调度组，你也可以调用CurrentScheduler::ScheduleTask静态方法。关于调度器与调度组的相关内容，将在A.3节中详细讨论。

当我们不需要应用程序中执行取消操作、异常处理或者PPL任务的任务等待功能时，就可以选择轻量级任务。但在使用的过程中，如果你需要实现对某个轻量级任务的等待操作话，就必须借助最原始的同步化机制了。如果你还想进行异常处理，就要去调用STL的exception_ptr类，然后确保它在你选定的时间内抛出异常，并予以捕获。

请不要将由并发运行时提供的轻量级任务与PPL中的任务混淆起来，绝大多数程序员通常都不会直接创建轻量级任务。

并发运行时中的轻量级任务接口与Windows的线程池很相似，里面只是调用了相关工作函数的函数指针。只不过，并发运行时的样例包为我们提供了一组便捷的方法，其中包含了一组基于仿函数（functor-based）的、用于调度轻量级任务的封装类，它们分别是task_scheduler, schedule_group。相比之下，如果你真的需要使用轻量级任务，可能还是样例包中所提供的函数显得更为方便一些。

对于大多数程序员来说，他们通常更愿意使用PPL来创建任务，而不是去选择轻量级任务。尽管如此，轻量级任务对于一些具有新型控制结构的程序设计来说。还是能有所作为的。也就是说，我们可以通过轻量级任务，将一些线程迁移到另一些任务中去。例如，如果我们的应用程序是通过Windows API来创建线程的，但是我们希望该程序改用比线程池更好的任务调度器，这时候，我们或许就需要去研究一下轻量级任务的应用了。但总而言之，基于PPL任务的应用程序依然是绝大多数程序员的首选。

此外，我们在第七章中曾经介绍过，异步代理库中的消息块与agent类，都是通过轻量级任务来说实现的。

在异步代理库中，消息块与agent类的内部是使用轻量级任务来实现的。

A.2.2　基于PPL创建的任务

通常，只要我们调用了PPL中的特性，就会有任务随之被创建。例如parallel_for函数、parallel_for_each函数、parallel_invoke函数、task_group::run方法、structured_task_group::run方法等。总之，在我们在这里创建的任务类型与轻量级任务有着显著的不同，这篇附录中的任务通常都泛指基于PPL的任务。

A.3　任务调度器

并发运行时中的任务调度器与线程池很相似。实际上，我们可以把任务调度器看做是一种善于优化大量精细化工作的特殊线程池。它的实现方式与许多线程池不同，在当前所有线程都处于繁忙状态时，它不会收到请求就立即新建线程。相反，它的会将接收到的任务放进任务队列中，以待处理器资源出现空闲时再执行。这样做就可以最小化操作系统中环境切换次数，并且最大化硬件缓存器的使用率，以便更有效地维持处理器内核的负荷状态。

任务调度器可以被看做一种特殊类型的线程池，它有利于我们进行精细任务的优化工作。

任务调度是通过Schedulerk类来实现的。每当我们开始运行一个工作线程时，该类的对象会负责将该线程关联到由资源管理器分配的虚拟处理器对象上去。这样一来，资源管理器就能确保每个虚拟处理器对象最多只能运行一个工作线程。

在一个调度器中，线程通常是以Context类的实例形式存在的，同时，这些线程中的数据结构也都是由调度器自行维护的。

A.3.1　管理任务调度器

通常情况下，并发运行时会为每个进程分配一个默认的调度器，这个调度器会在并发运行时首次被调用时被创建。当然，我们也可以根据当前的环境创建新的调度器实例，以便重新设置调度策略，并将其加载到现存的线程去。

虽然，每个进程都会配有一个默认的调度器，但是你依然可以根据每个执行环境的具体情况再创建额外的调度器，也可以重新设置默认调度器的调度策略。

或者，我们也可以选择在程序启动时、默认调度器被建立之前，通过Scheduler类的SetDefaultSchedulerPolicy静态方法重新设定默认调度器的工作方式。

如果我们在应用程序中用到了多个调度器对象，或许就应该在程序启动时就完成所有调度器的构造，并将它们加载到程序中去。这样的话，我们从一开始就能通过分配调度器避免一些不必要的开销，而这些开销通常是因为程序在工作时需要创建调度器而产生，其中包含了系统资源再分配，以及线程与处理器节点之间重复的关联操作。例如，如果我们手头有一个用于调用parallel_for循环的调度器，默认情况下，它会调用当前机器上所有的内核资源。现在，如果我们想要在parallel_for操作进行到一半的时候，增加一个基于agent类对象而实现的调度器。那么，早先的那个调度器就必须要降低它的运行并发性。由此可以看得出，在程序启动时就完成这两个调度器之间的内核资源分配，运行效果会更好一些。

1.任务调度器的创建与加载

通常情况下，我们可以通过工厂方法Scheduler::Create来创建一个调度器对象的实例。该方法会接受一个SchedulerPolicy对象的引用作为参数，其中包含了我们所建调度器的所有配置参数。在完成调度器创建之后，我们就可以将它加载到当前环境中去，令其发挥作用。下面，我们将用一段代码来为你演示这一过程。

#include＜concrt.h＞

#include＜concrtrm.h＞

#include＜stdio.h＞

#include＜windows.h＞

#include＜iostream＞

using namespace::Concurrency;

using namespace::std;

int main()

{

SchedulerPolicy myPolicy(2,MinConcurrency,2,MaxConcurrency,2);

Scheduler*myScheduler=Scheduler::Create(myPolicy);

cout＜＜"My scheduler ID:"＜＜myScheduler-＞Id()＜＜endl;

cout＜＜"Default scheduler ID:"＜＜CurrentScheduler::Get()-＞Id()＜＜endl;

myScheduler-＞Attach();

cout＜＜"Current scheduler ID:"＜＜CurrentScheduler::Get()-＞Id()＜＜endl;

如你所见，由Scheduler::Attach方法所设置的调度器将会被作用于当前的执行环境。在上面的代码中，新建的调度器将资源管理器的并发级别设置为2级。在该级别下，当我们想要进行调度器替换时，就必须要到该调度器所在的线程中，通过它的Attach方法来实现。此外，通过CurrentScheduler类的Get方法，我们可以获得作用于当前环境的调度器对象，或者默认的调度器（如果用户没有为当前环境指定调度器的话）。

当然，如果为了更方便一点，你也可以通过CurrentScheduler类的Create方法来实例化一个新调度器对象，并通过一个单独的调用将他加载到当前环境中去。

2.卸载任务调度器

通过CurrentScheduler类的Detach方法，我们可以撤销最近一次调用Scheduler::Attach方法带来的影响。由于这里的加载与卸载都属于栈上操作，因此当你调用Detach方法时，当前的调度器就会从堆栈中弹出来，并将最近的调度器恢复为当前调度器。在下面的代码中，我们演示了这一过程：

CurrentScheduler::Detach();

cout＜＜"Current scheduler ID:"＜＜Concurrency::CurrentScheduler::Get()-＞Id()＜＜endl;

3.销毁任务调度器

调度器对象所表现的是一种独立于加载/卸载（attach/detach操作的）引用/解除（reference/release）语义。加载/卸载操作在逻辑上本身就已经包含了引用/解除操作对的内容。

尽管，我们在任何时候都能卸载掉最近加载的调度器。但是，运行时环境并不会立即销毁卸载掉的调度器，而是要等到该调度器的所有引用都被解除，并且与其关联的任务全部完成为止。如果想要在该调度器被销毁时得到通知。我们需要将这个调度器注册为一个Windows事件。在下面的代码中，我们演示了关闭调度器的正确操作序列：

HANDLE schedulerShutdownEvent=CreateEvent(NULL, TRUE, FALSE, L"Shutdown Scheduler");

myScheduler-＞RegisterShutdownEvent(schedulerShutdownEvent);

myScheduler-＞Release();

WaitForSingleObject(schedulerShutdownEvent, INFINITE);

这段代码会使当前环境进入阻塞状态，以等待该调度器的所有用户都完成他们的操作，在有些实例中，也有可能必须要等到调度器关闭之后才能卸载相关的DLL。

4.多任务调度器的使用情况

多任务调度器有助于保证我们的服务质量。即便是在面对一些长时间运行的并行应用时，它也能确保程序在操作响应方面能有较好的用户体验。例如，我们可以为一些具有高优先级的操作（例如音频处理）指定特定的调度器实例，来为其预定内核资源。

此外，消息传递也是这种高优先级操作的另一个示例。如果我们的计算机内核在很忙的情况下，消息传递操作不具备高优先级的话，那些依赖于从消息块中接收数据的任务或许就无法运行了，这往往会导致程序中出现一些bug。要解决这个问题，我们可以为“消息传递”操作创建一个专用调度器，然后通过它来发送消息。例如，如果我们想要确保那一个取消请求能得到及时处理，就为它单独创建一个调度器。

多任务调度器使用的另一种情况往往出现在服务器群中。如果要在服务器群中运行一个多节点的主机程序，出于性能考虑，我们肯定不想让程序中的并行循环横跨多个处理器节点运作，这时候，我们就可以使用多个调度器，将并行循环限制在某个单一的处理器节点中。

多任务调度器是可以独立于用户界面（UI）之外运作的，它可以在后台完成相关的处理操作。这样一来，我们可以选择在用户界面中创建一个前台调度器，来处理类似单击按钮这样的操作。同时，再另建一个后台调度器来完成一些需要持续进行的工作。

5.实现自定义的调度组件

Scheduler类是不可扩展的，因此它不能被当做基类来用。这意味着我们在用PPL和异步代理库进行调度作业时，必须通过运行时环境的Scheduler类本身来完成。

但如果你想为自己设计一个并行程序库的话，可以通过实现IScheduler与IExecutionContext这两个接口，来创建出属于自己的调度组件。

A.3.2　调度算法

通常，Scheduler类中的任务是基于队列运行的。任务被创建之后往往都会先被放在队列中，以等待调度器协同资源管理器与操作系统一起为它分配运行所需的处理资源。在这里，我们主要通过牺牲个体任务上的分配“公平性”，来换取程序吞吐量的整体优化。这种交换思路有利于我们将一个规模较大的问题分解为一系列彼此相关的任务。这样一来，即便有些任务的等待时间比其他任务运行时间长，但主要目的是用最快的方法解决全部问题。

我们可以通过牺牲个体任务在分配上的“公平性”，以此来换取程序吞吐量的整体优化措施。

在这里，你可以对比一下基于队列调度的方法与多任务优先之间的区别。在多任务优先的方法下，所有运行中线程都会在既定时间段内分片执行。多任务所强调的是各个线程的响应能力。

本节中的资料可以帮助我们很好地理解在使用运行时的Scheduler类过程中所观察到的性能特性。另外，要强调的是，这里所描述的调度算法只是一种实现选择。或许在并发运行时未来的版本中，我们在任务执行上会获得更好的优化效果。

在本节中，我们所描述的幕后措施只适用于Visual Studio 2010 SP1环境，并不能确保运行时将来发布的版本中不会出现更有效的措施。

1.调度组

通常，一个调度器对象中都会有一个以上的任务队列，用以存放被挂起的、尚未开始运行的任务。在内部，Scheduler类通常会为这些队列创建一些ScheduleGroup类实例来辅助管理。例如，调度器实例会按照内核分区的方式，将这些队列分配到不同的处理器节点中去。

当我们将某个任务添加到调度器中时，就等于授权调度器为该任务选择了一个调度组。

通常情况下，调度器对象都会为它的每一个调度组都同时维护着几个不同类型的队列。具体如图A-3所示：

图　A-3　调度组中的数据结构

如你所见，每个调度组中都包含了一个轻量级任务（LWTs）队列，以及多个用于存放被挂起的PPL任务的工作窃取队列。并且，在每个调度组的执行环境中，也含有一个相同的队列。所谓工作窃取队列，实质上是一个可变性列表（mutable list），它的私有端可以对某个单一的执行环境进行快速访问，而公有端则可以被多个执行环境同时访问。这样一来，工作窃取队列中的任务项既可以以最小的同步性成本从队列的私有端（或本地端）添加或删除，也可以以较高的同步性成本从队列的公有端（或共享端）添加或删除。但是，只有调度组所在的执行环境才能连接工作窃取队列，并从私有端中添加或移除相关的项目。

此外，在每个调度组中，还包含了一个当前可运行环境的主队列，以及这个主队列中的各个虚拟处理器对象缓存器。这里所谓的可运行环境，实质上是一个含有预置中断操作的线程，而其中的预置中断则是一种协同性阻塞操作，只不过这种阻塞不会立即执行，而是被设定为需要时随时可以执行。

2.添加任务

通常情况下，轻量级任务是在进行消息块操作或者启动一个agent类对象时自行创建的，当然，我们也能通过Scheduler类、CurrentScheduler类，以及ScheduleGroup类的ScheduleTask方法来创建轻量级任务。当一个轻量级任务被创建时，该任务就会被添加到由我们指定的，或者调度器自行选择的调度组中。并且，该新任务被添加到的位置正是该调度组的轻量级任务队列的末尾处。

而当一个PPL任务被创建时，新任务就会被添加到属于当前环境的工作窃取队列的私有端中。该当前环境可以是任何一个不属于调度器的工作线程。在这种情况下，调度器会负责将工作窃取队列与它的其中一个调度组所在的当前环境关联起来。

而且，我们可以通过A.3.1节所提到的Attach和Detach方法，来控制将哪一个调度器设定为既定执行环境的当前调度器。

3.运行任务

有两种方法可将虚拟处理器对象设为可用状态：其中一种方法是，对当前运行在虚拟处理器对象上的线程执行协同性阻塞操作；而另一种方法则是让线程完成它当前的任务。一旦处理器对象的状态变为准备接受新任务时，调度器就能根据优先级的启示，继续下一步可能的操作。

在一般规则下，调度器往往会先从恢复与该任务相关的可运行环境开始，优先于启动新挂起的任务。该环境应该是一个之前被某个协同性阻塞操作中断并挂起的工作线程，只不过，现在它可以解除阻塞状态恢复运行了。在调度器选择可运行环境时，总是先以后进先出（LIFO）的顺序查找虚拟处理器对象的可运行环境的本地缓存，然后，再以先进先出（FIFO）的顺序查找当前调度组的可运行环境队列。有时候还会到其他虚拟处理器对象的缓存和调度组中去查找。通过在后进先出（LIFO）的可运行环境缓存中的查找，我们可以增加运行环境与硬件缓存器中数据关联的可能性。这种可能性可以帮助我们首先恢复最近的、处于非阻塞状态的运行环境，并且让它运行在相同的虚拟处理器对象中，以维持自身的非阻塞状态。除此之外，我们还可以通过修改调度策略变量LocalContextCacheSize的键值来配置非阻塞任务缓存器的大小。

接下来，如果调度器没有找到可用的可运行环境，就会转而去查找调度组中轻量级任务队列中的任务，并且以先进先出（FIFO）的顺序选择可用的轻量级任务。由于轻量级任务通常被用于消息传递的实现，因此，它的优先级往往要高于PPL任务。

如果在当前调度组中也没有轻量级任务被挂起，调度器就会继续前往当前调度组的工作窃取队，在该队列的公有端中查找任务。并且，在查找工作窃取队列的过程中，调度器总是先考虑与当前处理器节点有关联的队列，然后再进行跨处理器节点查找。

此外，如果某个线程向task_group::wait方法或者structured_task_group::wait方法输入了某些信息，就会出现一种特殊的情况。一般来说，wait方法只是一种协同性阻塞操作而已。但是，如果当前线程所调用的wait方法等待对象是一个被挂起的任务，并且该任务还存在于当前环境的工作窃取队列中的话，就会使得当前环境无法立即进入阻塞状态。取而代之的是，PPL会利用一种叫做内联执行（inline execution）的优化技术，来实现对当前环境的重用，使我们能运行本地队列中的任务。这样一来，被挂起的任务将会从当前环境的工作窃取队列的私有端中，以后进先出（LIFO）的顺序被提出。

但是，调度器本身却并不知道进行了内联执行，因为进行内联执行的那个线程并不会被wait方法协同性阻塞，并且这种情况会一直持续下去，直到那个满足等待条件的、本地化的工作窃取队列中没有更多的任务为止。当然，如果该内联执行本身就满足等待条件的话，那么wait方法就会立即返回，并且，当前线程将会继续执行它的顶层任务。关于内联的更多信息，这篇附录将会在之后的“内联运行的任务”中再做说明。

Scheduler类为它的调度算法实现了两种变体：一种方法是通过设置变量SchedulingProtocol的键值来选择调度策略；而另一种调度方法则是运用区域增强模式（enhanced locality mode，即默认模式）与前进模式（forward progress mode）。

4.区域增强模式

在区域增强模式中，调度器总是会试图批量执行相关的任务。也就是说，在第一个调度组中的任务没有处理完之前，调度器会一直试图处理其中所有被挂起的任务。处理完之后，调度器才会接着处理下一个调度组中的被挂起任务，以此类推下去，直到它重新回到第一个调度组为止。当然，这种顺序并不是严格限定的。因为在这过程中，如果系统中出现了一个任务，而它需要取消当前任务的阻塞状态的话，系统就会拒绝执行该任务，这就有可能会导致线程饥饿现象。因此，还是需要有一些启发性技术来避免这种线程饥饿的现象。这种技术会定期地给予其他调度组中的任务一些优先权。这在当前调度组中有很多任务被挂起时，是很有必要的。

区域增强式调度方法通常会批量性地执行调度组中相关的任务处理。

在区域增强模式中，运行时环境会假设同一个调度组的各任务之间是共享内存的。因此，为了让硬件缓存资源得到最大程度利用，调度器会尽可能地按时间顺序来执行同一个调度组中的任务，即便可能有些其他调度组中的任务是更早被添加进来的，也会被延迟。例如，假设我们创建了三个任务：task1、task 2、task 3，以及两个调度组：A和B。现在，我们将这三个任务按照A、B、A的任务组顺序依次分配。接下来，如果调度器先开始处理的是调度组A，我们就可能会看到task3会先于task2运行，尽管task3被创建的时间晚于task2，换而言之，调度器总是会先执行完调度组A中的所有任务（包括task1和task3），再去处理调度组B（含有task2）。

由于处理器节点对任务的分区方式与调度组类似，因此处于同一组的任务在空间上也是相互靠拢的。于是，当接下来调度组最终被轮到时，组中所有的任务都会以同样的方式执行。

5.前进模式

在前进模式中，调度器会循环式地访问各个调度组，并从每次访问到组中选择一个被挂起的任务来执行。与区域增强式调度不同的是，在这里，各个虚拟处理器对象的可运行环境中没有缓存。只不过，这些环境能按照既定的优先级来遍历这些被挂起的任务。

前进式调度会轮流访问每一个调度组，并从各次访问到的组中选择一个任务来执行。

对于前进式调度而言，运行时环境会假定各个调度组轮流所拖延的时间远远多于对相关的任务进行批处理的时间。这种情况是有可能的。例如，在某个离散事件的模拟案例中，我们可能总会希望所有调度组中的操作都能领先图形界面更新一步。当然，前进式调度在基于任务的应用程序中，并不是一个常用的调度方法。

6.任务的执行顺序

调度器并不能保证队列中任务的执行顺序，我们在程序中也不应该对特定任务的执行时间做任何事先的假设。在目前的实现中，我们只能确定被挂起的任务是以先进先出（FIFO）的顺序被处理的（除非这些任务是内联执行的，那又另当别论）。然而，即便我们将被挂起任务所在队列的选项变量设定为循环处理或者批量处理，程序还是不可能预知它们的执行顺序。如果非要指定执行的顺序，我们就应该使用一种本书所介绍的任务协同技术了。例如，可以调用task_group::wait方法，或者消息块的receive函数。

7.内联运行的任务

我们在之前的“运行任务”中曾经提到过，调度器是可以将一个任务运行在其他的任务的线程环境里，而后者正在等待前者完成。例如，如果我们在一个任务环境里调用某个任务组的wait方法，运行时环境就会知道当前环境空闲，直到该任务组总的任务完成。这样一来，运行时环境就能通过对阻塞线程的重用，执行一个或多个被挂起的任务，从而实现对工作线程的优化利用。此外，对于潜在性并行来说，内联执行也是一种很好的示范，当一系列相互关联的任务运行同一台多核计算机上时，它们是并行运行的，而当计算机上只有一个内核可用时，它们又可以像串行函数那样被调用。

另外，内联执行还能帮助我们避免因线程饥饿而导致的死锁现象。与其他协同性阻塞操作不同的是，这里的task_group::wait函数只是给调度器一个建议：被挂起的任务在当前环境下应该被解除阻塞状态。对于一个将要给别的任务解除阻塞状态的任务来说，内联执行是它提高自身优先级的一个好方法。

需要注意的是，只有在当前环境中所创建的任务，它的任务组的wait方法被调用时，该任务才能具备内联执行的资格。

当然，我们也可以提供一个显式的内联提示，即调用task_group::run_and_wait方法。该方法会创建一个新任务，并立即对它执行内联处理。

A.3.3　使用Context类实现和调度器的通信

Context类可以帮助我们实现与任务调度器通信，通常来说，Context对象总是与当前正在执行的线程是相对应的，我们可以通过静态方法Context::CurrentContext将它们关联起来。

如果你获得了一个Context对象，而它所对应的线程不属于调度器工作线程的话，就必须要留意一下某些具有环境依赖的操作，它们的行为可能会与调度器工作线程关联时的情况有所不同。

CurrentContext方法的调用既可以在应用程序线程中进行，也可以在调度器的工作线程中进行。但是，由此产生的Context对象在行为上可能是不一样的，这取决于当前的Context对象所对应的是工作线程还是应用程序线程。例如，如果我们在某个应用程序线程（这意味着该线程不属于当前调度器的工作线程）中调用某个协同性阻塞操作，该线程就会进入阻塞状态。当然，有一种情况除外，即当工作线程也处于阻塞状态时，它必须允许调度器的可运行环境能对它进行恢复操作。

1.调试信息

我们可以从执行环境中获得有用的调试信息，比如当前环境对象的整数ID、调度组的ID，以及虚拟处理器对象的ID。这些信息都可以通过Context类的静态方法Id、ScheduleGroupId、以及VirtualProcessorId来获得。

2.取消状态查询

通过Context类的静态方法IsCurrentTaskCollectionCanceling，即便是在没有引用相关任务组对象的情况下，我们也依然能检查到任务的取消状态。如果需要运行一个长时间运行的任务，取消检查对我们来说是很有帮助的。

3.执行协同性阻塞的接口

任务可以通过任何一种协同性阻塞操作来实现与调度器的通信，这第3章已经做了详细的讨论。读者可以参考3.3.1节中的内置操作列表。

此外，Context类的Block与Unblock方法也提供了一种额外的、低层次的协同性阻塞接口。PPL和异步代理库正是通过这两个方法实现它们所有的协同性阻塞操作的。

当我们需要自定义一些同步化基元（primitive）时，也可以用Block与Ublock这两个方法来协调这些基元中的任务。不过，如果你在为自己编写同步化并行基元的过程中使用了Block与Ublock方法，就必须要非常小心，千万不要干扰了运行时环境本身对这些操作的调用。尽管Block与Ublock都不属于嵌套性操作，但是它们的操作顺序是很灵活的。如果有某个Ublock在一个Block操作之前被调用，该Block操作就会被忽略掉。这样一来，如果我们无序地调用Ublock操作，可能就会在无意中干扰了PPL内部的某个操作。例如，在无序地调用了某个Ublock操作之后，紧接着调用了PPL的critical_section::block方法，在某种特定的交互情况中，有可能会导致该临界区段出现在当前域中不可见的问题。

对于大多数程序员来说，应该使用的是PPL中高层次的内置任务协调机制与消息块，而不是自己实现的同步化基元。

当然，Block与Ublock毕竟都属于低层次的方法，多数程序员都应该更倾向于使用高层抽象协同性阻塞操作。

如果你调用了Block与Ublock方法，就必须得非常小心，千万不要在无意中干扰了PPL对它们的调用。对于大多数程序员来说，通常是不需要通过Block与Ublock来实现他们自己的同步化基元的。

4.等待操作

在命名空间Concurrency中，有一个全局的wait函数，通过该函数我们可以挂起当前的执行环境，并且确保在其参数所指定的时间段内，其中被挂起的任务不会被重启。这是完全有可能的，因为任务调度器中使用的都是面向队列的调度方法，这会使得任务被挂起的时间往往会比指定的等待时间要更长一些。

通过调用Context类的Yield方法，我们可以运行某个被挂起的轻量级任务，或者在轻量级任务不存在的情况下，它也能使某个可运行环境恢复运行。如果满足上诉这两种情况中的任何一种，该方法就会将当前工作线程添加到可运行环境的队列中去，否则，Yield方法就是一个空指令（no-op）。除此之外，如果在一个不属于调度器工作线程的线程中调用Yield方法，它也会成为一个空指令。

为了对某些情况进行监测，而在一个紧凑的循环中调用Yield方法，可能会对性能产生负面影响。因此，我们建议你用事件等待的方式来替换这类调用。

5.缓存子分配器

我们可以在当前执行环境的内存缓存中分配内存。当任务要在堆（heap）上创建一些小的临时对象，或多个任务需创建相同大小的对象时，这种方式很有用。

对于并行程序来说，来自全局内存池中的，在协调内存分配与再分配的过程中所产生的同步化开销可能是它最主要的开销来源。因此，并发运行时提供了一种线程私有的缓存子分配器，该分配器允许我们在一个任务中对一些临时的工作存储单元进行分配与再分配。并且，这种分配方法相对于使用全局内存池来说，可以让我们减少对锁或其他的内存隔离技术的使用。因为在这种情况下，分配请求所产生的同步化开销只会出现在运行开始处，那时候程序必须向缓存中添加新的内存。

缓存子分配器主要针对的是任务中频繁的临时内存分配。所以，我们只能在处于运行状态的任务中调用它的函数。此外，缓存子分配器也不是在所有情况下都能改善程序的性能，而且，它并不会自行释放内存。关于该分配器的使用，读者可以参考MSDN中的相关资料。

6.长时间运行的I/O任务

默认情况下，调度器都会假定它的任务都是计算密集型的。尽管动态资源管理器也会在某种程度上给予不属于计算密集型的I/O密集型任务一些补偿，但是如果你提前知道自己的任务是一个需要长时间运行的I/O密集型任务，而它所占用的只是处理器资源的一小部分时，就有必要给调度器一个提示，即该任务可以通过一个额外的运行线程对当前所用的处理器节点进行超额预订。

为了做到这一点，我们需要以true为参数去调用静态方法Context::Oversubscribe。该调用会告诉调度器，它应该向资源管理器请求一个额外的虚拟处理器对象，它使用的处理器关联码和当前线程用的相同，即它可以在调度器所用的内核数量没有增加的情况下，提升调度器的并发层级。

在长时间执行一个I/O密集型任务的过程中，如果我们以false为参数调用Oversubscribe方法，就会重设原先的并发层级值。我们必须确保Oversubscribe方法是成对调用的，并且还要考虑可能会出现的异常情况。本书的样例包中有一个叫做scoped_oversubcription_token的辅助类，它会自动确保Oversubscribe方法的成对调用。并且，我们应该尽量将Oversubscribe方法的调用放在那个长时间运行的I/O任务的工作函数体内。

A.3.4　设置调度策略

有几个可控的设置值可以帮助我们控制调度器的工作方式。MSDN文档中有关于PolicyElementKey枚举值的详细说明（这也可以在头文件concrt.h中找到），但是下面还是摘录了一些最重要的设置值、关于设置这些策略的具体例子，读者可以回头去参考A.3.1节中的相关内容。

A.4　反面模式

接下来，我们将介绍一些需要注意的事项。

A.4.1　多重资源管理器

通常情况下，资源管理器都是单独作用于一个进程的。它并不会协调多个操作系统进程之间的处理器资源。如果需要在程序中并发地使用多个进程，我们就必须将并行级别降到更适合于各个进程的一个有效位置。

这是有可能实现的，在某些情况下我们确实会在一个单独的进程中创建多个资源管理器实例。但是，一旦我们真的这样做了，就会导致这些资源管理器实例之间的资源争夺。这种情况往往会出现在我们使用静态连接库的时候。这将使每个C++运行时的静态连接实例中都会有一个资源管理器。

因此，如果我们在同一个应用程序中使用了多个版本的C++运行时，拥有多个资源管理器实例也是完全有可能的。这种情况下，每个版本的运行时都会各自拥有一个资源管理器。总而言之，我们应该尽量避免程序中出现多个资源管理器实例并存的情况。

请尽量避免静态连接你的并行应用程序。

A.4.2　资源管理的开销

资源管理器会通过动态调整应用程序中调度器的并发级别，以实现处理器资源的优化利用。但是，在某些特定的情况下，你会发现动态资源管理器并不能提供优化的结果。在这些情况下，我们可以将调度器并发级别的最大值和最小值设置为相同的值，从而有效地实现动态资源管理器的禁用。但是，在这么做之前，你必须要确保自己充分了解了应用程序的性能特征。毕竟在大多数情况下，动态资源管理器对应用程序吞吐量的整体优化效果还是比较好的。

A.4.3　来自内联任务的非计划性超额预订

在A.3.2节的“运行任务”中，我们已经详细地介绍了调度器重用线程的过程。即线程可以暂停下来，等待某个任务组的任务完成一项或多项其他被挂起的任务，这种方法被叫做内联。

内联执行通常出现在一个调度器对象的工作线程中，并且能不受调度器并发级别的影响。因为无论是否进行内联运行，在同一时间段内，可以运行的工作线程数都会受到调度器中虚拟处理器对象数目的限制，而后者则取决于资源管理器的分配。

与工作线程不同的是，应用程序线程是不受任务调度器管理的。也就是说，应用程序线程（指的是任何不属于调度器工作线程的线程）是不会被计入资源管理器所限定的并行运行线程数的。因此，除非收到来自操作系统本身的阻塞指令，否则应用程序线程就会一直处于运行状态。

如果我们在一个应用程序线程中先调用了task_group::run方法，然后紧接着在相同线程的任务组中调用了wait方法，那么我们所创建的任务就会以内联的方式在该应用程序线程中执行。而由于应用程序线程会忽略调度器对工作线程数的限制，该任务将一定会被允许运行。这就意味着，在这个应用程序中，内联执行可以通过一个正在运行当中的线程来提升并行操作整体的并发级别。即如果调度器所设定的最大并发级别是四个虚拟处理器对象的话，那么现在程序中可能就有五个任务在同时运行：四个调度器的工作线程，外加一个由wait方法引入的应用程序线程。

另外，只有在线程（由wait方法引入的）本地的工作窃取队列中驻留时，被挂起的PPL任务才能实现内联化。因此，如果我们想避免在应用程序线程中执行内联任务，可以将原本在应用程序线程中创建PPL任务放到一个轻量级任务中去，由轻量级任务来负责创建该任务。这样一来，被挂起的PPL任务就被放进了该任务所在的工作窃取队列，因而失去了在应用程序线程中内联执行的资格。

本节关于内联的论述也可以被运用到parallel_for、parallel_for_each、parallel_invoke等方法中去，过程和在结构化任务组中创建任务一致。此外，我们在调用task_group::run_and_wait方法时，也可以实现内联化执行。

A.4.4　因线程饥饿而导致的死锁

想必读者还记得之前提到过，我们有两种类型的阻塞操作：一种是由并发运行时提供的协同性阻塞操作；另一种则是有操作系统本身提供的“非协同性”阻塞操作。

当一个工作线程因为某个协同性阻塞操作而进入阻塞状态时，调度器就会重启一个可运行环境或一个被挂起的任务。请注意，如果这时没有空闲的工作线程可以运行被挂起的任务，程序就会为此新建一个工作线程。在这里，创建额外的线程并不会引入额外的并发性。因为在任何既定的时间段内，由操作系统所调度的工作线程在数量上都不可能会超过资源管理器分配的虚拟处理器对象。

在任务被协同性阻塞时，允许运行额外的被挂起任务会增加该任务解除阻塞状态的可能性。在拥有许多任务依赖的、并且并发级别固定的操作系统中有助于避免程序中出现因线程饥饿而导致的死锁现象。这是很可能的。

与协同性阻塞相比，非协同性的或者操作系统级的阻塞对PPL和并发运行时来说是不透明的，除非这些阻塞操作发生在一个支持用户模型调度（UMS）的线程中（UMS线程不在本书的讨论范围内）。当某个工作线程因操作系统级的阻塞操作而进入阻塞状态时，调度器仍然会把它看做一个正在运行的线程。因此，调度器在响应操作系统级阻塞操作时也不会去恢复某个可运行环境，或者启动某个被挂起的任务。这样就很有可能会使程序出现因线程饥饿而导致的死锁现象。例如，如果在某种极端情况下，调度器的所有工作线程都因非协同性阻塞操作而处于阻塞状态，调度器就会因无法解除被挂起任务的阻塞状态而出现死锁。

正因为如此，我们建议你运用协同性阻塞操作来实现程序中存在的任务依赖机制。

调度器中的工作线程数不等于它的并发级别。因为在任何既定的时间段内，由操作系统执行的线程在数量上都不会超过虚拟处理器对象，而后者则是由资源管理器负责分配给调度器的。

A.4.5　忽略进程关联码

在Windows 7中，我们可以通过进程关联码的设置来限制应用程序中线程在哪一个内核上运行。但是作为Visual Studio 2010 SP1的一部分，目前所发布的并发运行时版本并不能识别用户选择的进程关联码。相反，当各调度器将并发级别设定为最大值时，程序就可以使用所有的可用内核资源。

目前来说，我们只能期待这个问题能在将来的并发运行时版本中得到解决。

A.5　引用资料

·对于有关缓存子分配器的更多信息，读者可以参考MSDN上关于Alloc函数的具体说明：http://msdn.microsoft.com/en-us/library/dd492420.aspx。

·对于有关NUMA的全面性概述，读者可以参考MSDN期刊博客上的关于NUMA的详细介绍：http://blogs.msdn.com/b/msdnmagazine/archive/2010/05/06/10009393.aspx。

·对于有关PolicyElementKey枚举体的更多信息，读者可以参考MSDN上的相关条目：http://msdn.microsoft.com/en-us/library/dd492562。

附录B　并行应用程序的调试与分析

在Microsoft Visual Studio 2010开发系统中，有了两个用于辅助并行编程的窗口，它们分别是并行堆栈窗口与并行任务窗口。除此之外，在Visual Studio 2010的高级版与旗舰版中，还有一套与之相关的分析工具。在这篇附录中，我们将通过几个具体的实例来介绍这些窗口的使用，以及如何使用可视化分析器分析我们的并行程序，并证明代码确实是按照我们所期望的方式运行的。掌握了这些基础知识之后，你只需要再积累一些经验，就能独立运用这些工具来识别并定位问题了。

B.1　并行任务窗口与并行堆栈窗口

现在，请在Visual Studio中打开本书样例包的解决方案，并将第5章中讨论过的ADash项目设置为启动项目。接着，再打开AnalysisEngine.h文件，并找到AnalysisEngine::DoAnalysisParallel方法，这是一个用于声明和配置ADash项目工作流（workflow）的方法。这里的每个future对象都会各自在不同的工作流阶段中执行并返回结果，然后将结果依次传递给工作流中的下一个future对象。接下来，我们要在lambda表达式future5的声明处插入一个断点（breakpoint）。

设完断点之后，我们就可以按F5，或者打开Debug（调试）菜单并单击Start Debugging（启动调试）进入调试过程 [1] 。接下来，ADash程序就会启动运行并显示它的图形界面。然后在该图形界面中，我们勾选Parallel复选框并单击Calculate按钮。这样一来，当程序运行到断点处时，所有的任务都会停止运行，并弹出调用堆栈窗口。现在，我们再打开Debug菜单下的Windows（窗口）子菜单，并单击Parallel Tasks（并行任务）。于是当程序首次运行至断点处时，我们就从之前调出的并行任务窗口中观察到，有一个任务已经与被加到工作流中的各个future对象取得了关联。

接下来，我们来详细分析一下图B-1中所显示的多任务运行的具体情况。首先要记住的是，一般情况下，每个任务都是独自运行在一个线程中的。并行任务窗口中所演示的正是为任务分配线程的过程，ID列中标识的任务所对应的就是Thread Assignment（线程分配）列中所显示的线程。但如果这些任务中有内联任务的话，就会出现多个任务运行在同一个线程中的情况。由此可见，运行的任务数量超过所执行的线程数是完全有可能的。而Status（状态）列所代表的则是任务的状态，可能的状态包括running（正在运行）、scheduled（已计划）以及waiting（正在等待）。在某些情况下，调试器或许无法检测任务的waiting状态，这些任务对象的状态就会显示为running。接下来是Location（位置）列，它列出的是当前被调用方法的名字。如果我们将光标放在该字段上，就能在弹出的窗口中观察到当前栈帧对应的是哪一部分的用户代码，并且如果在上面双击，我们就能快速切换到该代码处。

图　B-1　并行任务窗口

通过双击Task（任务）列中的某个任务，我们能把调试器直接切换到该任务处。这时候，如果我们再打开Debug菜单中的Windows子菜单，并单击Call Stack（调用堆栈）项，就能将堆栈中正在执行任务的所有线程都完全显示出来。

接着，我们再打开Debug菜单的Windows子菜单，并选择Parallel Stacks（并行堆栈）。然后在弹出并行堆栈窗口中，单击左上角下拉菜单中的task（任务）选项，窗口中就会显示出调用堆栈中所有正在运行或等待的任务，如图B-2所示。

图　B-2　并行堆栈窗口

如果想获得关于并行堆栈窗口的更详细信息，我们可以去查看B.5节中所列出的参考资料。

如果我们在DoAnalysisParallel方法中，为其他几个future对象定义再增设几个额外的断点，并重新按F5启动调试，并行任务与并行堆栈会自动根据ADash程序工作流处理数据的过程作出相应的改变。这是应用程序应有的行为。但是，这些窗口有时也会出现一些意外的行为，这些行为能帮助我们识别并定位一些性能问题和同步化错误。例如，通过并行任务和并行堆栈这两个窗口，我们可以识别出类似于死锁的同步化问题。在下面的代码中，我们在ProfilerExamples样例中演示了这一行为：

void Deadlock()

{

reader_writer_lock lock1;

reader_writer_lock lock2;

parallel_invoke(

[＆lock1,＆lock2]()

{

for(int i=0;i++)

{

lock1.lock();

printf("Got lock 1 at%d\n",i);

lock2.lock();

printf("Got lock 2 at%d\n",i);

}

},

[＆lock1,＆lock2]()

{

for(int i=0;i++)

{

lock2.lock();

printf("Got lock 2 at%d\n",i);

lock1.lock();

printf("Got lock 1 at%d\n",i);

}

);

}

如你所见，这是一段典型的死锁代码实例。这里的每一个任务都在试图申请到锁，如此无休止地申请最终必然会导致死锁。到了那时，应用程序就会停止处理，也不会再有任何控制台输出。一旦真的出现了这种情况，我们只需要单击Debug菜单中的Break All（全部中断）菜单项，然后，就会在打开的并行任务窗口中看到如图B-3所示的情况。请注意其中的任务状态，它们都由原来的running变成了waiting。与此同时，Visual Studio也在会弹出的警告对话框中显示："The process appears to be deadlocked（or is not running any user-mode code）.All threads have been stopped." [2] 。通过并行任务窗口，我们可以仔细研究每一个死锁任务，以及它的调用堆栈，以便深入地理解应用程序发生死锁的原因。此外，如果把光标放在相关任务的status列上，我们就能看到该任务在等待的事件。我们也可以通过研究调用堆栈、锁识别以及其他的等待条件，从而找出导致问题的根源所在。

图　B-3　并行任务窗口中的死锁现象

[1] 这篇附录中的截图以VS2010的英文版为准，为了保证图文对应，译者决定保留这里相关菜单的原名，并以括号的形式附上对应的译名（以简体中文版为准），以供读者参考。——译者注

B.2　断点与内存分配

众所周知，过多的内存拷贝会导致严重的性能问题。如果我们能利用调试器在类的复制构造函数和赋值操作符的定义代码中设置一些断点，就能很容易地找出那些不在我们计划内的拷贝和赋值操作。

例如，在ImagePipeline程序样例中，我们更愿意通过shared_ptr＜ImageInfo＞类型的指针来传递对象，而不是直接在管道中拷贝ImageInfo对象本身。因为拷贝这些大型位图的开销实在是太高昂了。不过，ImageInfo中有一个ImagePerformanceData类的对象，它在每一次拷贝图形的过程中都会生成。根据这一特点，我们就可以利用调试器来验证程序中有没有额外的拷贝操作，方法如下：

首先，我们要找到ImagePerformanceData.h中ImagePerformanceData类的赋值操作符定义，然后在其中设置一个断点，并启动ImagePipeline程序。之后，我们将会看到，在完成管道中的传递后，ImagePerformanceData对象只执行过一次赋值操作。这次赋值发生在管道的显示阶段，ImagePipelineDlg::OnPaint方法中需要创建一个ImagePerformanceData对象的最终拷贝，用于图形界面（GUI）线程中以显示最新可用的数据。

此外，我们也可以利用调试器的Hit Count（命中次数）功能来统计赋值操作的次数。在断点所在的窗口中，右键单击该断点，然后在弹出的快捷菜单中单击Hit Count，接着，在随后弹出的Hit Count对话框中打开When the breakpoint is hit（命中断点时）下拉列表，选择break when the hit count is equal to（中断，条件是命中次数等于）选项，并将后面的命中次数设置为100。然后我们启动该程序，调试器将会在处理第100张图形时于断点处停住。由于命中次数与被处理的图形数相等，我们就能据此推定每张图形只有一次拷贝。而如果这其中存在不在计划内的拷贝操作，当程序被中断时，我们就会发现被处理的图形数量比原先的少了一些。

我们也可以将拷贝构造函数和赋值操作符声明为private，以此来避免一些些计划外的对象拷贝。事实上，ImageInfo对象正是基于这个理由，才将它的拷贝构造函数和赋值操作符设为私有成员的。

B.3　并发可视化工具

在Visual Studio 2010高级版和旗舰版的编辑器中，内置了一套并发可视化工具，其中还包括一系列的程序分析器。这套工具会向我们展示并行代码在运行时使用资源的具体细节，例如它使用的内核数、内核中线程的分配方式，以及各个线程的活动状态等。这些信息都有助于我们验证代码的执行过程是否符合我们的预期，同时，这些信息也能为我们诊断程序的性能问题提供一定的帮助。接下来，我们将借助并发可视化工具，具体分析一下来自第7章的ImagePipeline样例程序在一台8核计算机上的运行情况。

并发可视化工具的工作主要分为两个阶段：即数据的收集与具现化。在数据收集阶段，我们需要先收集尽可能多地可用数据，然后运行我们的应用程序，在数据的具现化阶段中验证这些收集到数据。

现在，我们先进行数据收集阶段。为了做到这一点，我们必须以管理员的身份打开Visual Studio，因为我们需要收集的数据中有一部分是属于内核层逻辑的。在Visual Studio中打开样例的解决方案之后，单击Debug菜单中的Start Performance Analysis（启动性能分析）菜单项。接着在弹出的性能向导中选择Concurrency单选项，并同时勾选Visualize the behavior of a multithreaded application（可视化多线程应用程序的行为）复选项。单击next之后，向导窗口中列出了Visual Studio中当前打开的所有解决方案，这里，我们选中要进行分析的ImagePipeline项目，并单击next继续。在向导的最后一页中，程序会问你是否要在向导完成之后启动分析。该选项默认情况下是被选中的。因此我们直接单击Finish按钮即可。随后，Visual Studio就会打开分析工具窗口，并导入当前设定的分析程序。接着，ImagePipeline样例程序就会启动运行，并打开它的图形界面（GUI）窗口。为了使处理器的效率最大化，我们需要选中Load Balanced选项，然后单击Start按钮开始运行程序。为了让可视化工具收集足够的数据，我们至少要等到图形计数器到达100以上，然后在Visual Studio的分析窗口中单击Stop Profiling（停止分析）。

在收集数据的过程中，性能分析器会反复地记录并行代码在运行过程中的状态数据（也叫做快照）。同时，分析器也会了利用Windows事件追踪技术（Event Tracing for Windows, ETW）来收集执行期间所有的环境切换以及其他相关的事件。每次数据收集都会同时写入几个不同的文件，其中包括一个.vsp文件。并且，一次收集大约能写入100兆字节的数据。另外，在相同程序的各次独立运行之间，数据收集可能是不相同的，毕竟总是会存在一些不可控因素的，例如同一台计算机上其他进程的运行状况。

接下来，如果上述数据文件都可用的话，我们就可以进入可视化阶段了。这时候，不需要再以管理员身份运行Visual Studio了。我们有几种不同的方法可以进入可视化界面。除非我们改变了默认选项，否则可视化界面在数据收集完成之后会自动出现。当然除此之外，我们也能在Visual Studio中单独打开任意的.vsp文件。如果使用了第一种方式，我们在数据收集与分析之后立即就会获得一份汇总报告。其中显示了几个不同的可用视图，包括一个线程视图、一个CPU使用率视图，以及一个内核视图。

图B-4中所显示的就是线程视图，每个任务都各自运行在一个线程中。可视化并行工具向我们显示了每一个任务所在的线程（当然不要忘了，由于内联任务的存在，任务数量或许要多于线程数量）。

图　B-4　并发可视化工具中的线程视图

在这本书的截图中，并发可视化工具上的许多细节无法显示，其中包括一些大小和颜色信息。如果想获得相应的有色截图，你可以到CodePlex网站（http://parallelpatternscpp.codeplex.com/）上去下载。

另外，线程视图中还内置了一组有用的功能。例如，通过单击屏幕上半部分中某个单独线程时间线上的不同片段，我们可以看到当前堆栈中的活动片段。这有助于你将具体的代码与这些活动片段联系起来分析。此外，通过单击该分析报告左边底部的Visible Timeline Profile（可见时间线分析），选择不同活动类型，会显示分析报告。可以帮助我们分析在时间线上那些被选中的、各类型的活动片段中，我们的应用程序分别调用的是哪些函数。最后，我们也可以通过单击Demystify（释义）功能来获得更多的关于这些不同颜色的意义信息，以及涉及该报告中其他功能的帮助信息。

下面，我们来看CPU使用率视图（见图B-5）。该视图所展示的是，在一定的时间函数内，整个应用程序（包括所有任务）所使用的逻辑内核数。例如，从上图中我们可以看出，这里所使用的计算机上有8个逻辑内核。此外，在该视图中也显示了与应用程序无关的其他进程，它们在汇总报告上的标签名为Other Processes（其他进程）。而对于应用程序进程来说，这些图形显示了程序在各个时间点上所使用的逻辑内核数。为了使进程更易于识别，不同的进程区域被赋予了不同的颜色（有些颜色无法在这张图中显示出来）。另外，有些数据点显示的只是一个百分比，而不是像0、1、2这样的整数，毕竟我们计算的是样例在执行过程中在各点上的平均值。

图　B-5　CPU使用率视图中的细节

接下来是内核视图（见图6）。内核视图显示的是应用程序所使用的可用内核数。在这里，每个内核都有一条自己的时间线，也用了各种有色段来表示各个线程的运行时间（用不同的颜色来表示不同的线程）。在这个实例中，在0到2.5的时间刻度之间，应用程序是处于空闲状态的，内核的工作量很少或没有。而到了2.5至12期间，管道中就被工作填满了，而且，被允许运行的任务显然要多于内核数。因此，我们能看到每个内核中都有几个线程在轮换，并且在该表下面的图中还显示了它们各自的跨内核环境切换的次数。

图　B-6　内核视图中的细节

最后，我们再细看一下线程视图（见图B-7）。该视图显示了各个线程所耗费的时间。视图的上半部分是一个时间线，其中用一系列的颜色段代表了线程中各自不同类型的活动状态。例如，红色表示的是处于同步状态时（即等待状态）的线程。在这个实例中，线程视图显示最初启动的只有主线程和工作线程4708。当管道开始处理图形之后，才有更多的线程被加入到线程池中。当然，并不是所有的线程都必须在可视化图形中显示出来的（你可以该视图上右键单击某一个线程并选择Hide（隐藏），来隐藏指定的线程）。最后，该视图也向我们展示了该程序主要线程的活动情况：如青绿色代表了用户接口的活动情况、绿色则显示了其他线程正在执行的状态、红色则代表了同步状态，以及用黄色表示的优先线程。

图　B-7　线程视图中的细节

在时间线上，2.5个单位之后管道中某些线程的执行开始变得频繁起来了，而与此同时，其他线程的执行也没有减少。于是管道中的线程终归要多于内核，这使得这些线程必须要按照优先级顺序进行轮换执行。

Scenario标识器

我们可以通过Scenario库来标识某些复杂应用程序中的不同阶段。示范代码如下（Scenario库可以到MSDN Code Gallery网站上免费下载，关于该库的更多资料，读者也可以参考MSDN上的"Scenario Marker Support"一文：http://msdn.microsoft.com/en-us/library/dd984115.aspx [1] ）：

#include"Scenario.h"

//……

shared_ptr＜Scenario＞myScenario=shared_ptr＜Scenario＞(new Scenario());

myScenario-＞Begin(0,L"Main Calculation");

//Main Calculation Phase……

myScenario-＞End();

这些标识器将会显示在上述那三个视图中，并且以横条的形式在这些时间线视图的顶部和底部出现。只要将鼠标放在这些横条上，就能看到这些标识器的名字。我们可以在后面的图B-11中看到相关的例子。然而要注意的是，不要过多地使用标识器，因为这样做很容易使可视化界面变得拥挤不堪，从而导致可读性下降。该工具允许隐藏一些标识器以改善可视化条件。我们可以利用缩放功能来放大察看特定视图中隐藏的标识器。

[1] 相应的中文链接是http://msdn.microsoft.com/zh-cn/library/dd984115.aspx。——译者注

B.4　可视化模式

在本书中，我们所讨论的模式主要集中在体现潜在性并行的问题上。然而，在并行开发的过程中，其他类型的模式也会有对我们有所帮助的。例如人类的思维就是一种很好的可视化模式，而我们的并发可视化工具正是利用了这一点。我们可以学习识别一些常见的可视化模式，以便用于解决一些应用程序中特定的性能问题。下面，我们将通过三个具体问题（即超额预订、锁竞争以及负载平衡）的讨论，来为你演示如何借助可视化模式来识别并定位问题。

此外，MSDN上还有更多的常见模式实例，这些模式都有可能会导致并行应用程序的表现不佳。关于这些内容，读者可以参考这篇附录最后“补充阅读”中所列出的信息。当然，我们也可以通过访问线程视图下面的Hints（提示）标签中的相关链接来获得相同的信息。该标签和链接你可以在前面的图B-4中找到。

B.4.1　超额预订

通常情况下，当一个程序中运行的线程数超过了它们所用的逻辑处理器时，就会产生超额预订的问题。超额预定之所以会带来性能不佳的问题，主要是因为，这样做往往会产生大量的环境切换操作，而每次切换都会浪费一定的处理时间和内存空间。

在并发可视化工具中，识别超额预订问题是很容易的，因为它会在分析追踪器中呈现出大量的黄色区块。而一个线程被标志成黄色意味着它具有了某种优先权（该线程将会被切换出去）。这样一来，当我们分析下面代码时，将会获得一个非常典型的超额预订案例。

void Oversubscription()

{

task_group tasks;

for(unsigned int p=0;p＜(GetProcessorCount()*4);p++)

{

tasks.run([]()

{

//Oversubscribe in an exception safe manner

scoped_oversubcription_token oversubscribe;

//Do work

delay(1000000000);

});

}

tasks.wait();

}

我们从运行上述函数的线程视图（见图B-8）中可以看出，该函数在一个具有8个逻辑内核系统上的运行。这是一个与以往截然不同的模式。

图　B-8　线程视图中的超额预订

B.4.2　锁的竞争与串行化

当一个程序中的某个线程企图获得另一个线程所持有的锁时，就会产生锁竞争的问题。在多数情况下，这个问题会导致这两个线程同时被阻塞，直到该锁被释放出来。在并发可视化工具的线程视图中，阻塞状态被标记为红色。这往往被视为性能下降的信号。极端情况下，应用程序有可能会被一两个这样的锁操作完全串行化，即便它使用了再多的线程也无济于事。我们可以从图B-9中观察到这一现象，那些狭窄的浅绿色区域说明了在同一时间段内，该程序中只有一个工作线程在运行。在这种情况下，应用程序的性能甚至有可能比传统的串行版本还要糟糕。

运行下面的LockContention方法，将会产生一种叫做锁保护的现象，这会导致更为明显的锁竞争问题，以及程序的串行化（即使使用了多线程也无济于事）。所谓的锁保护现象，实际上是一种因多个线程频繁争夺同一共享资源而产生的性能问题。

void LockContention()

{

task_group tasks;

reader_writer_lock lock;

for(unsigned int p=0;p＜GetProcessorCount();p++)

{

tasks.run([＆lock]()

{

for(int i=0;i＜10;i++)

{

//Do work

delay(100000);

//Do protected work

lock.lock();

delay(100000000);

lock.unlock();

}

});

}

tasks.wait();

}

从下面的线程视图（见图B-9）中，我们可以观察到上述代码在并发可视化工具中所产生的可视化效果。

图　B-9　线程视图中的锁保护现象

如果单击上图中的某个同步区块（红色的），我们就能将它高亮显示，以便查看处于阻塞状态的究竟是哪一个线程。同时，我们也可以通过单击下面的Unblocking stack（取消阻塞堆栈）标签来查看取消阻塞状态线程的堆栈。另外，我们也可以查看那些用来取消被阻塞线程的线程，以及它的调用堆栈。例如在这个例子中，线程1428中的褐红区块显示，它目前正在等待的是线程7400，而该线程正在调用ThreadProxy::SwitchTo方法。完成调用之后，线程1428就会取消阻塞状态继续执行并改由绿色显示。总而言之，我们可以在线程视图中单击任何一个红色的同步区块来查看它们的取消阻塞堆栈。如果其中还有可用的线程，就能继续查看它的调用堆栈。当然，与此同时要记住的是，我们还可以直接在相关的栈帧上双击，以便快速追溯它的源头。这样做有助于我们将线程视图中的区段与正在该区段中执行的代码联系起来。

B.4.3　负载平衡

如果并行操作涉及的所有线程之间的工作分布是不均衡的，就会产生负载不均的问题。负载不均意味着系统的性能没有得到充分地发挥，这通常是因为某些线程或内核在完成处理操作无空闲引起。在并发可视化工具的几个视图中，可视化模式都为我们提供了一些识别负载平衡的方法。现在，我们先来创建一个负载平衡的案例，代码如下：

void LoadImbalance()

{

const int loadFactor=20;

parallel_for_fixed(0,100000,[loadFactor](int i)

{

//Do work

delay(i, loadFactor);

});

}

虽然，在PPL中，大部分支持并行的组件都是通过动态分区来完成任务池中的工作分配的，但是，这里所调用的parallel_for_fixed方法（该方法来自头文件concrt_extras.h，读者可以从http://code.msdn.microsoft.com/concrtextras处获得该文件）使用的则是固定的迭代器区间（除非该区间是被窃取来的 [1] ）。因此，当上述代码在一个具有8个逻辑内核的系统上运行时，区间[0，12499]中的元素就会在一个相同的任务中被处理，而区间[12500，24999]中的元素则交由下一个任务处理，以此类推下去，直至处理完整个区间。这样一来，执行工作负载的主体就始终在0到当前索引值的范围内进行迭代，这意味着它的工作量会直接与区间的索引形成一定的比例关系。这将会使低位区间中的工作量远低于高位区间，从而导致每个子区间完成任务所需的时间各不相同。在这种情况下，对处理器使用负载平衡是无效的，关于这一点，我们从并发可视化工具的CPU使用率视图中（见图B-10）可以得到很好的解释。

图　B-10　CPU视图中的负载平衡

现在，让我们来观察一下LoadImbalance方法的执行，执行开始时，系统启用了所有的8个逻辑内核。而过了一段时间之后，等这些内核都陆续完成了各自的工作，相应的工作线程就会陆续下线，这时的系统效益就会呈现出逐级递减的模式。这一现象在分析器的线程视图中得了充分地体现，如图B-11所示：

图　B-11　线程试图中的负载平衡

我们可以从上面的线程视图中观察到，工作线程完成各自的工作之后，就会纷纷进入空闲状态，以等待主线程7136完成剩余的工作。因为，这个例子是一个控制台应用程序，这种情况下，运行时会让主线程独立负责一个任务的运行。

[1] 这里特指之前提到过的工作窃取技术。——译者注

B.5　补充阅读

在下面列出的参考资料中，有一个专注于并行性能分析的MSDN博客，在那里，你可以找到大量相关的代码分析、技术讨论，以及实例示范。此外，MSDN上也提供了一些关于Scenario库的详细信息，以供参考。

·Parallel Development in Visual Studio 2010 blog on MSDN：http://blogs. msdn.com/b/visualizeparallel/.

·"Concurrency Visualizer"on MSDN：http://msdn. microsoft.com/en-us/library/dd537632.aspx.

·"Performance Tuning with the Concurrency Visualizer in Visual Studio 2010"on MSDN：http://msdn. microsoft.com/en-us/magazine/ee336027.aspx.

·Scenario Home Page on MSDN：http://code. msdn.microsoft.com/scenario.

·The Parallel Tasks and Parallel Stacks windows, and other features of the debugger to support parallel programming：http://www. danielmoth.com/Blog/Parallel-Debugging.aspx http://msdn.microsoft.com/en-us/library/ms164746.aspx.

·"Debugging Task-Based Parallel Applications in Visual Studio 2010"on MSDN:http://msdn. microsoft.com/en-us/magazine/ee410778.aspx.

·Common Patterns for Poorly-Behaved Multithreaded Applications：http://msdn. microsoft.com/en-us/library/ee329530.aspx.

·Improve Debugging And Performance Tuning With ETW：http://msdn. microsoft.com/en-us/magazine/cc163437.aspx.

附录C　技术总览

在附录C中，我们将介绍一些本书内容之外的，但也是出自Microsoft公司的并行计算资源。在后面的“补充阅读”一节中，我们将会列出详细的资源列表，以供你参考，但现在，先让我们通过下面这张图（见图C-1）来了解一下这些不同的组件，以及它们之间的关系吧。

图　C-1　Microsoft的并行编程资源关系图

如你所见，本书只涉及上述组件中的并行模式与异步代理，以及实现这两个库所需的本地并发运行时 [1] 。而并发运行时中所包含的任务调度器和资源管理器，正是并行程序运行在多核体系上的关键性支持。

与Microsoft Visual Studio 2010开发系统一起发布的编程语言还包括Visual C#、Visual Basic，以及并行化的LINQ（PLINQ）等，它们都通过任务并行化程序库（Task Parallel Library, TPL）完成了对并行化的支持。除此之外，F#语言也是Visual Studio开发系统的一部分。它比其他语言拥有更多的并行手段，并且还加强了对不可变数据类型的支持。但是，F#的运行时程序库同样是构建在TPL的基础之上的，而且该语言中支持并行的Power-Pack控件也是构建在PLINQ基础之上的。关于如何使用Microsoft.NET Framework 4、TPL，以及PLINQ这些组件来开发并行应用程序的，是这本书的姐妹篇《Parallel Programming with Microsoft.NET》一书中的内容。最后还有一个库：Reactive Extensions（Rx）。通过它，我们可以借助一些可观察的集合来对比某个程序中的异步处理与事件处理。

另外，Accelerator组件在它的API中（该组件没有在图中显示出来）也提供了一个函数式编程的模型，以便用于实现一些处理数组的操作。Accelerator组件处理并行计算中的全部细节，以及在选定的目标处理器上所运行的计算操作的全部细节，其中包括图形处理单元和多核CPU。因此，Accelerator的API可以被当做一个独立的大型处理器组件来使用，我们只需要稍加改动，就能使一段相同的数组处理代码运行在任何Accelerator组件支持的处理器上了。

在Visual Studio 2010中，还提供了几个用于调试与分析并行应用程序的工具。这些工具的使用实例已经在附录B中做了详细的介绍。当然，你也可以使用由Microsoft研究院所提供的CHESS工具来检查自己并行代码中存在的bug。

CHESS和Accelerator都是属于孵化期或研究性质的项目，而且Microsoft公司也并没有对它们进行公开发行。但即便如此，这些项目中还是出现了许多新的想法与手段，如果你这对本书中的内容不太感冒，也许它们能引起你的兴趣。我们鼓励你去下载这些项目、评估它们，并将你的结果反馈给它们对应的团队。

最后要介绍的DryadLINQ，它实际上是一种编程环境，通常用于编写一些具有大规模数据的、并且运行在一些高性能计算（High Performance Computing, HPC）集群上的应用程序。总而言之，DryadLINQ上集成了两项重要的Microsoft技术，即Dryad分布式执行引擎和LINQ（.NET Language Integrated Query）。

除了DryadLINQ之外，上述的所有技术都能大量使用于单机环境中的并行计算，而对于Windows HPC服务器集群和其他支持跨多计算机的服务来说，尽管这些技术与集群计算很不相同，但在这本书中所涉及的一些基本模式（例如并行任务和并行聚合）仍然是适用的。

补充阅读

·在MSDN的并行计算开发中心，我们提供了关于托管和本地两个版本的并发运行时，以及支持编写并行程序的Visual Studio 2010开发工具的详细资料：http://msdn.microsoft.com/concurrency。

·如果想获得F#语言的参考手册以及相关的演示实例方面，我们可以去Microsoft的F#开发中心看看：http://msdn.microsoft.com/fsharp。

·如果想获取Windows HPC Server 2008的产品信息以及相关的开发资源，我们可以访问Windows HPC Server网站：http://www.microsoft.com/hpc。

·如果想获得DryadLINQ的CTP（Community Technology Preview）版本，我们可以到下面的网站中去下载：http://technet.microsoft.com/library/ee815854（WS.10）.aspx。

·如果想了解Accelerator项目的更多细节，我们可以参考：http://research.microsoft.com/accelerator。

·如果想获取Rx项目的详细资料与下载，我们可以访问下面的网站：http://msdn.microsoft.com/en-us/data/gg577609。

·CHESS属于Microsoft公司的DevLabs项目工程，我们可以从下面网站中获取相关资源：http://msdn.microsoft.com/devlabs。

·如果想更深入地了解一下Visual Studio开发环境在并行编程方面的发展趋势，我们可以参考一下Stephen Toub在TechEd Europe 2010大会上的演讲：http://www.msteched.com/2010/Europe/DEV208。

[1] 这里的“本地”是相对于托管代码而言，指非托管版本的并发运行时。——译者注

术语表 [1]

·代理（agent）：参考“异步代理”。

·聚合（aggregation）：将各自不同的数据汇聚成单一的结果集。

·阿尔法混合（alpha blending）：一种图片叠加技术，可以将多个半透明的图层合并为一幅完整的图形。

·结合性操作（associative operation）：一种二元操作特性，在这种特性作用下，操作结果不受操作顺序的影响。例如（a+b）+c=a+（b+c）。

·异步化（asynchronous）：一种操作类型，该类型的操作通常不会造成当前控制线程阻塞。

·异步代理（asynchronous agent）：一种软件组件，当我们执行较大规模的操作时，这些组件模块之间可以执行异步化操作。通常我们简称这种组件为“代理”。

·异步管道（asynchronous pipeline）：在这类管道中，只有当数据处于可用状态时任务才会被创建。

·后台线程（background thread）：与“前台线程”相对。一般情况下，当一个进程被关闭时其中线程也会随着终止。但是如果你运行的是一个后台线程，就不能使进程的运行状态保持同步了。线程池中的线程就是后台线程。

·边界（barrier）：一个预先设置的同步点，它能在所有的线程都到达该点之前，使已经运行到该处的线程都停下来等待。

·阻塞（block）：暂停执行当前操作，以等待某些特定的事件或条件。

·捕获变量（captured variable）：该变量通常定义在lambda表达式之外，而在表达式内会被引用。Lambda表达式可能会更新变量的值。

·集群（cluster）：一种由多台计算机通过网络连接而构成的并行运算系统，并非物理上的多核组成的单个处理器。

·闭包（closure）：一个lambda表达式，它可以捕获封闭词法范围内的变量。

·交换性操作（commutative operation）：一种满足以下条件的二元运算：交换运算符两边的变量，运算结果不变。例如加法运算和乘法运算都满足交换性：a+b=b+a。

·并发（concurrency）：与“并行”相对。同时进行多个不同活动。并发可以让程序和外部的交互响应更为敏捷，从而减少延迟。并发可以通过异步操作或者多线程来实现，一般情况下，线程会轮换使用处理器。

·并发安全（concurrency safe）：一段代码同时在多核上运行时不会引发错误即为并发安全。

·并发可视化工具（Concurrency Visualizer）：Visual Studio中的性能分析器插件，它可以收集并显示并发程序的执行状况和性能指标。

·环境切换（context switch）：即处理器上一个线程停止执行，另一个线程恢复。暂停当前进程的执行，并切换到另一个不同进程继续运行。如果处理器超额预订，就会导致过度频繁地环境切换，影响程序的运行效率。

·控制流（control flow）：一种协调形式，它能根据既定的算法来协调不同任务的执行情况，使这些任务看上去就像是并行执行的。

·控制流代理（control-flow agent）：该代理所对应的方法包含了有序的循环，它能处理来自消息块的值。

·协同性阻塞（cooperative blocking）：常用术语，与“非协同性阻塞”相对。特指当前程序在启动之前，某些资源必须变得可用，或者必须等待某个信号的出现。也就是说，当我们执行协同性阻塞时，必须通知任务调度器，由它来启动相应的任务。

·协同性取消（cooperative cancellation）：常用术语，表示如果我们使用协同性阻塞来执行某些操作，可以在操作完成之前取消它们。

·协同性环境切换（cooperative context switch）：因协同性阻塞而导致的工作线程阻塞时发生。参阅“协同性阻塞”。

·协调（coordination）：管理不同的任务，使它们能够协同工作并产生出正确的结果。这种协调操作可以基于目标程序的数据流或控制流来进行。

·内核（core）：物理处理器用于处理指令的部件。目前情况下，处理器通常都拥有不止一个内核，因此可以用它们来并行化处理任务。

·数据流（data flow）：一种协调形式，与“控制流”相对。特指在保持数据有效的情况下，协调管道或者任务列表中所有任务的运行状态。

·数据并行化（data parallelism）：一种程序并行处理形式，与“任务并行化”相对。应用于不同部分数据的并行化运算。并行数据在并行模式库（PPL）中可以通过parallel_for和parallel_for_each这两个函数来实现。

·数据分块（data partitioning）：将数据集分割成不同部分，以便实现数据并行化。

·数据竞争（data race）：在同一时间内，有多个并发线程同步读取或更新某个变量。

·死锁（deadlock）：一种线程状态，与“活锁”相对。特指程序因等待某一永远不会发生的事件而始终处于阻塞状态的情况。当一个程序中的某个线程持有另一个线程所需要的资源时，就有可能会出现死锁问题。

·分解（decomposition）：将一个较为复杂的问题分割成若干个相对简单的部分。在并行化处理中，这种分割可以根据数据来进行，也可以根据任务来进行。

·并行度（degree of parallelism）：程序在一段时间内所能允许的最大并发任务数量。

·依赖性（dependency）：与“独立性”相对。特指程序中一个操作需要用到另一个操作所产生的结果时所存在的关联性。一旦操作之间有了依赖关联，它们就不能执行并行化了。

·双重检查锁定（double-checked locking）：测试某个条件，如果条件满足就申请锁定，然后再次测试该条件，如果依然满足条件就允许更新共享数据。这个小技巧使用频率很高，它可以有效减少获取锁失败时大量操作所带来的开销。

·动态分块（dynamic partitioning）：根据并行任务的执行情况来决定如何分割数据。与“静态分块”相对。

·区域增强模式（enhanced locality mode）：一种程序调度模式，倾向于批量执行相关任务。

·前台线程（foreground thread）：保持与进程同步的线程。当所有的前台线程停止时，进程也会关闭。和“后台线程”相对。

·fork/join模式：一种使用了任务并行化的并行运算模式。当任务开始运行时，使用的是"fork"；当它们结束后，使用的则是"join"。

·前进模式（forward progress mode）：一种程序调度模式，倾向于从当前挂起的任务开始以轮询的方式选择下一个运行的任务

·future对象：带返回值的任务对象。

·函数对象（function object）：参阅“仿函数”。

·仿函数（functor）：一种语言特性，它可以让一个类对象像函数那样被调用。在C++中，仿函数是特指那些定义了operator()接口的类。

·粒度（granularity）：特指一个数据分块中的数据量或者一个任务中的工作数量，通常也指数据块的数量或任务的数量。粒度分得粗糙则意味着分块或者任务少而大，反之就意味着分块或者任务较多而细小。

·硬件线程（hardware thread）：一种在内核中执行的管道。通常情况下，并发性多线程（有时也称为超线程）允许单核运行多个硬件线程。每一个硬件线程都被视作一个单独的逻辑上的处理器。

·超线程（hyperthreading）：参考“并发多线程”。

·不可变性（immutable）：一种数据属性，具有该属性的数据一旦被创建就无法再进行修改。例如某些程序库中，字符串就是不可变的。和“可变值”相对。

·不可变类型（immutable type）：该类型的所有实例都是不可变值。它们都是纯函数数据结构。

·独立性（independent）：与“依赖性”相对，表示一个操作与其他操作的结果无关。通常具有独立性的操作更容易实现并行化。

·内核模式（kernel mode）：一种程序运行模式，与“用户模式”相对。在该模式下，程序将进入Windows内核的运行状态，它对所有的资源都拥有完全控制权。

·lambda表达式（lambda expression）：一种匿名函数类型，它可以从自己所在的代码域外获取变量。

·活锁（livelock）：运行还在继续，但是还没有实现最终目标。和“死锁”相对。

·负载平衡（load balancing）：一种任务运行描述，与“负载不均”相对。当不同任务的工作负载量比较接近时，可用处理器可以更高效地运转。

·负载不均（load imbalance）：一种任务运行描述，与“负载平衡”相对。当不同任务的工作负载量差异悬殊时，可用处理器就会因大量的任务闲置而导致利用率低下。

·锁（lock）：一种同步化机制，它能确保某段特定的程序在同一段时间内只被一个线程访问。

·锁保护（lock convoy）：当程序中有多个任务重复竞争同一个锁时出现。如果频繁地出现竞争失败的情况，则会大大降低性能。

·众核（manycore）：多核的一种，通常含有八个以上逻辑处理器。

·映射（map）：一种并行计算形式，这种形式可以分任务对不同数据执行相同的变换操作。它是数据并行化的一个典型实例。

·映射/缩减（map/reduce）：一种并行编程的模式，它通常会在数据并行阶段（映射）操作之后紧紧接着执行聚合阶段（缩减）的操作。

·内存边界（memory barrier）：一种机器指令，它迫使内存操作有序进行。边界之前的内存操作将会比边界之后的操作拥有更高的先行权。

·多核（multicore）：拥有多个内核，可以并行执行任务。最新的物理处理器都是多核的。

·重复数（multiplicity）：某个元素在多重集合中出现的次数。

·多重集合（multiset）：一个未排序的拥有多个重复元素的集合。每一个元素都有相应的重复数来记录它到底出现了几次。和“集合”相对。

·多重集合联合（multiset union）：一种将多重集合合并的操作，新多重集合中的元素是原多重集合元素的并集，重复数就是原多重集合中相同元素的重复数之和。

·可变性（mutable）：一种数据属性，具有该属性的对象在创建之后还可以被修改。但是请注意，千万不要与C++中的mutable关键字混淆。与“不变值”相对。

·可变类型（mutable type）：具有可变性的类型，即该类型的实例是可变的。

·嵌套并行化（nested parallelism）：通常指一个并行计算内嵌于另一个并行计算的情况。

·节点（node）：计算机集群网络中的某台计算机。

·非阻塞性算法（nonblocking algorithm）：这种算法允许我们不执行互相阻塞的情况下同时运行多个任务。

·非协同性阻塞（noncooperative blocking）：一种由操作系统提供的低级别阻塞的操作。任务调度器对非协同阻塞是无法察觉的，对于调度器它们和正在运行的任务基本没有区别。参考“协同性阻塞”。

·NUMA（non-uniform memory access）：一种计算机内存架构，内存访问时间与内存在处理器中的位置相关。

·对象图（object graph）：一种数据结构，它由一组相互引用的对象组成。它经常共享一些可变数据，这会使并行计算变得复杂。

·I/O重叠（overlapped I/O）：某些I/O操作在别的任务运行时处理或等待。

·超额预订（oversubscription）：特指在程序中让执行线程数超过处理器极限的做法。这有可能会导致程序效率非常低下，因为这种操作需要花费大量时间来进行环境切换。

·并行化（parallelism）：一种多线程编程方式，与“并发”相对。它能让多个处理器（或内核）同时执行某项工作，以提高性能。

·分块（partitioning）：将数据进行分割以实现数据并行化。

·物理处理器（physical processor）：一块处理器芯片，也可以认为是一套组件包或者接口。最新的物理处理器拥有多个内核，每个内核又有多个逻辑处理器。

·管道（pipeline）：一组满足生产者/消费者关系的系列任务，其中每个消费者的输入都必须出自该系列任务中的前一个的输出。

·优先级倒转（priority inversion）：低优先级线程运行而高优先级线程处于等待状态。当低优先级线程持有高优先级线程所需要的资源时可能出现这种情况。

·进程（process）：应用程序的运行实体。不同进程可以同时运行而保持相互之间的独立性（一般来说它们不会共享数据）。一个进程包括一个或多个线程或者任务。可和“线程”、“任务”相比较。

·处理器关联码（processor affinity mask）：一种位码，通过它我们可以向调度器指定执行某个特定线程所用的处理器。

·分析器（profiler）：一种收集并显示性能分析的工具，并发可视化工具就是一种用于分析并发和并行程序的分析器。

·纯函数（pure function）：通常指某个函数（或方法或操作）中没有任何的副作用（即不更新任何数据，也不产生任何类型的输出），而且只返回一个值。

·纯函数数据结构（purely functional data structure）：一种数据结构，它只能被纯函数访问。这是不可变类型的一个实例。

·竞争（race）：运算的输出结果取决于哪个表达式先被执行，同时运行的顺序又是不可控及无法同步化的。

·竞争条件（race condition）：影响并行操作执行顺序的因素，它会使程序有非常明显的表象。竞争通常会产生错误，良好的程序应该避免这种情况。

·递归分解（recursive decomposition）：一种分解方式，特指在我们在并行编程中让任务自行重复分解自身的做法 [2] 。

·缩减（reduce）：一种聚合操作，它通常会通过某种相关联的操作将数据进行合并，这可能会降低并行化程度。

·轮换（round robin）：一种调度算法。在该算法中，每一个线程采用固定的顺序轮流运行，周而复始。所以每循环一周，每个线程运行一次。

·可运行环境（runnable context）：某个线程先前被协同阻塞操作打断，现在阻塞已经解除，该线程继续运行。

·可扩展性（scalable）：如果某并行运算在增加处理器之后其性能得到了提升，那么就可以称为具有了扩展性。

·调度组（schedule group）：一种供调度器使用的内部数据结构，它实际上是一组队列，存储着挂起的任务。

·信号量（semaphore）：一种同步机制，用来确保只有一定量的线程可以同时执行某段代码。和“锁”相对。

·串行化（serialize）：使程序串行化执行，不支持并行化。

·集合（set）：一个未排序的没有重复的数据集。和“多重集合”相对。

·共享数据（shared data）：由多个线程共同使用的数据。对可变数据的读写操作需要同步。

·单线程数据类型（single-threaded data type）：非线程安全的类型。它不能被多线程共同访问，除非在用户代码中增加额外的同步代码。

·同步多线程（SMT）（simultaneous multithreading）：一种在单核上运行多个线程的技术。

·嵌入接口（Socket）：物理处理器。

·纯理论性执行（speculative execution）：即使任务的结果并不是必须的，仍然执行它。

·静态分割（static partitioning）：一种数据切割方式，在程序运行前分割方式就已经确定。与“动态分割”相对。

·哨位值（sentinel value）：一个由用户定义的值，它标志着文件内容的结束。

·同步（synchronization）：协调线程的运行顺序和方式以保证结果的正确性。锁就是同步机制的一种具体实现。

·任务（task）：并行的工作组合单元，与“线程”，“进程”相对。虽然任务也在线程中执行，但与线程相比，它更高的抽象层次。因此，任务最好能在支持并行编程的环境中执行，例如PPL，异步代理库等。

·任务图（task graph）：它可以用图的形式来直观地表现任务的输入输出。通常，其中的一些输出正好是另一些任务的输入。节点表示任务，而曲线则表示任务的输入输出。

·任务组（task group）：一种任务集合，通常在一个任务组内，所有任务必须状态一致，即要么一起处于等待状态，要么一起被取消。task_group类是线程安全的。

·任务内联（task inlining）：同一时间内，在同一个线程中并发地运行多个任务，这种情况发生的原因往往是线程中一个任务的运行必须由另一个任务的运行来支持。

·任务并行化（task parallelism）：并行化处理的一种表现形式，能并行化地处理不同的数据。与“数据并行化”相对。

·线程（thread）：一组按顺序执行的代码，与“进程”，“任务”相对。通常，一个进程中可以有若干个线程。并且，这些线程之间并不是孤立的，同一进程下的线程可以共享数据。尽管线程是运行任务的载体，但它和任务相比，是一种较低层次的抽象。

·线程关联（thread affinity）：为一些操作指定专用执行线程的过程。

·线程池（thread pool）：一种由操作系统控制线程集合，它可以使我们避免反复地创建及销毁线程。

·线程安全（thread safe）：参考“并发安全”。

·线程饥饿（thread starvation）：一种任务状态，通常特指因为长期得不到调度器为其分配的虚拟处理器，导致任务出现始终无法运行状态。

·线程本地状态（thread-local state）：一种状态变量，通常只能被单个进程访问，因此不需要锁或者其他同步方式来保证线程安全。例如，combinable＜T＞类就可以用在PPL，异步代理库中，以充当线程的本地状态变量。

·线程安全类型（thread-safe）：一种类型属性，与“单线程数据类型”相对。该类型的对象可以在多线程并发环境下运行，而又不需要引入额外的同步化机制。类型的线程安全保证了它的对象数据在同一时间内只能由一个线程访问，在多线程环境下它的操作都是具有原子性的。

·分块读取（torn read）：一种读取形式，与“分块写入”对应。该形式在读取某个变量时需要依靠多个机器指令来完成，而且在此期间允许其他个任务修改该变量的值。

·分区写入（torn write）：一种写入形式，与“分块读取”对应。该形式在写某个变量时需要依靠多个机器指令来完成，而且在此期间允许其他任务读取该变量的值。

·二步舞蹈（two-step dance）：一种形象比喻，特指如下情况：即当某个事件持有锁时，另一个刚唤醒的进程也需要获取该锁，结果它会发现能做的事情只有继续等待。这种情况通常引起频繁的环境切换，从而严重影响程序的性能。

·低负荷运转（undersubscription）：一种运行状态描述，通常指系统中的任务数量大大低于系统极限，处理器处于空闲状态。

·用户模式（user mode）：一种用户应用程序运行的模式，此模式下对于资源的访问是受限的。与“内核模式”相对。

·虚拟内核（virtual core）：指逻辑处理器。

·volatile：C++关键字，它可以提示C++编译器，告诉它会有哪些域将可能被多线程、操作系统或者其他硬件修改。

·工作函数（work functions）：特指parallel_invoke或task_group::run中的函数类型参数。

·工作窃取（work stealing）：一种线程技术，通常指我们为了保持现有线程的有效负荷，而在该线程中再启动一个实际上为其他线程服务的任务。于是，这就相对于其他线程窃取“窃取”了现有线程的运行资源。

[1] 本术语表中的词汇按英文单词的字母顺序排列。此外，由于考虑具体词汇的上下文环境，正文中的相应词汇会有些词性方面的调整，请读者参照理解。——译者注

[2] 类似快排算法中的数组分解。——译者注