.NET4.0新特性之并行计算

随着多核CPU的普及和互联网的迅速发展,计算已经进入并行的时代,这种并行计算有两种主要的形式,一种着眼于充分挖掘单台计算机的硬件潜力,通常以多线程协作的方式完成指定的工作任务;另一种着眼于利用互联的计算机所共同拥有的计算能力,将一个工作任务分发到多台计算机上同时处理,通过多台计算机的相互协作完成单台计算机所无法完成的工作任务。

第一种计算形式在过去一直都是使用线程来实现的,而在.NET 4.0中,又在线程的基础上向软件工程师提供了一个"并行扩展(Parallel Extensions)",从一个更高的抽象层次简化多线程应用程序的开发,这也是本章要介绍的主要内容。

第二种计算形式依赖于多台计算机的相互协作,本质上是一种分布式的软件系统,在.NET平台上,WCF是开发这类型软件系统的强大工具。

 

并行计算引例

请大家仔细查看一下示例程序SequentialvsParalled的源码。此程序完成了一个非常典型的数据处理工作:递增一个整数数组的每个元素值。 示例程序将数组大小设定为1000000,然后对数组中的每个元素进行100次操作,每次操作都将元素值加1,因此,完成整个数据处理工作需要108次操作。

以下是串行代码:

//依次给一个数组中指定部分的元素执行OperationCounterPerDataItem次操作

static void IncreaseNumberInSquence(int[] arr,int startIndex,int counter)

{

for (int i = 0; i <counter; i++)

for (int j = 0; j < OperationCounterPerDataItem; j++)

arr[startIndex+i]++;

}

上述代码在T400笔记本电脑上执行时花费了776毫秒。

现在,使用.NET 4.0所提供的任务并行库让上述操作并行执行:

//将任务划分为TaskCount个子任务,然后并行执行

static void IncreaseNumberInParallel(int[] arr)

{

int counter = DataSize / TaskCount;

Parallel.For(0, TaskCount, i =>

{

int startIndex = i * counter;

IncreaseNumberInSquence(arr, startIndex, counter);

}

);

}

测试结果为419毫秒,并行加速系数约为1.85

再改算法,将对每个元素的每个操作设定为一个任务,然后再并行执行:

static void IncreaseNumberInParallel2(int[] arr)

{

//为每个数据项创建一个任务

Parallel.For(0, arr.Length, i =>

{

Parallel.For(0, OperationCounterPerDataItem, j => arr[i]++);

}

);

}

测试结果为10057毫秒,并行加速系数为0.08,比串行算法慢多了!

 

并行计算带来的复杂性

上面所介绍的例子非常清晰地展示出并行程序设计的特殊性,并不是"并行"总比"串行"快的,到底怎样才能获得最大的并行加速系数,需要仔细地设计并行算法,并且应该在多个典型的软硬件环境中进行对比测试,最终才能得到理想的并行设计方案。 开发并行程序的关键在于要找到一个合适的任务分解方案,并行总要付出一定的代价,比如线程同步、线程通讯、同步缓冲数据等都是开发并行程序必须认真考虑的问题。

下表对比了并行程序与串行程序的主要差别:

项目

串行程序

并行程序

程序行为特性

可以预期的,相同运行环境下总可以得到相同的结果

如果没有提供特定的同步手段,则程序执行的结果无法预期

内存访问

独占访问内存单元,数据可靠

有可能因多线程同时存取同一内存单元而引发数据存取错误

不需要

必须为共享资源加锁

死锁

不可能出现

可能出现,需要仔细考虑程序中可能出现的种种情况予以避免

测试

使用代码覆盖的测试方法可以检测出绝大多数BUG

由于多个线程同时并行,仅使用代码覆盖的测试方法无法检测出程序中隐藏的BUG,并行程序的测试变得很复杂

调试

相对简单,可以随时停止程序运行,单步跟踪定位到每条语句和每个变量的值

由于多个线程同时运行,当你暂停一个线程进行调试时,其他线程可能还在运行中,因此无法保证调试环境的一致性,并行程序的调试非常困难。

正因为并行程序开发、测试和调试都比串行程序要困难,所以一般都是先编写程序的串行版本,等其工作正常之后再将其升级替换为并行版本。

 

何时使用"并行计算"?

根据前面的介绍,大家一定对"并行计算"有了一个总体的认识,由于"并行"需要付出代价,因此,不是所有的程序都需要转换为并行的,当要处理的数据量很大,或者要执行的数据处理任务繁重,并且这些任务本身就可以分解为互不相关的子任务时,使用并行计算是合适的。 对于哪些规模较小的数据处理任务,比如你要编写一个"通讯簿"小程序来保存和检索好友信息,就不必考虑并行处理了,因为要处理数据量不会很大,串行算法的性能就可以满足需求,还用"并行处理"就显得是"牛刀杀鸡"。除了增加程序开发难度之外没有什么好处。

 

.NET 4.0中的并行计算组件

由于并行计算是将一个工作任务进行分解以并发执行,因此,任何一个支持并行计算的软件开发与运行平台都必须解决这些并发执行的子任务之间的相互协作问题,比如:

  • 一个子任务需要等待其它子任务的完成,多个子任务完成之后才允许执行下一个子任务(即所谓fork-join)。
  • 一个子任务结束后自动启动多个下级子任务的执行。
  • 允许一个任务中途取消。
  • ""

.NET 4.0通过对已有的基类库进行扩充和增强,满足了上述需求。

如图所示,.NET 4.0给 "System.Threading" 命名空间增加了一些新的类,同时对部分已有类也进行了调整和优化。另外,针对中途取消线程或作务执行这一实际开发中非常普遍的需求,提供了一个线程统一取消模型最大的变化是.NET为基类库提供了多个与并行计算密切相关的类,并将它们统一称之为"并行扩展(Parallel Extensions)"。

如图,NET 4.0"并行扩展"的主要包括以下几个部分:

1、并行语言集成查询(PLINQParallel Language Integrated Query),这是.NET 3.0引入的LINQ to Object的换代"产品",让查询操作可以并行执行。

2、任务并行库(TPLTask Parallel Library):将开发并行程序的抽象级别从"线程(thread"提升到"任务(Task",只需规定好计算机要执行的任务,然后由.NET去管理线程的创建和同步等问题。

3、同步的数据结构(CDSCoordination Data Structures):包括一组线程安全的常用数据结构,比如线程安全的队列、堆栈等,在并行程序中访问这些数据结构,可以不需要显式地使用lock

4、任务调度器(Task Scheduler):负责任务的创建、执行、暂停等管理工作。

5、线程池:.NET 4.0对原有的托管线程池功能进行了大幅度的增强,通过给其集成一个任务调度器,线程池中的线程可以高效地并行执行各种任务。

上述五个组成部分当中,PLINQ是建立在TPL之上的,而Task Scheduler是并行计算的核心,是一个Runtime,它与线程池相集成,负责将任务分派给线程池中的各个线程执行。

任务并行库原理及应用

任务并行库(TPLTask Parallel Library)是.NET 4.0为帮助软件工程师开发并行程序而提供的一组类,位于System.ThreadingSystem.Threading.Tasks这两个命名空间中,驻留在3.NET核心程序集mscorlib.dllSystem.dllSystem.Core.dll里。使用这些类,可以让软件工程师在开发并行程序时,将精力更关注于问题本身,而不是诸如线程的创建、取消和同步等繁琐的技术细节。

使用TPL开发并行程序,考虑的着眼点是"任务(task"而非"线程"

一个任务是一个Task类的实例,它代表某个需要计算机执行的数据处理工作,其特殊之处在于:

在TPL中,任务通常代表一个可以被计算机并行执行的工作。 任务可以由任何一个线程执行,特定的任务与特定的线程之间没有绑定关系。在目前的版本中,TPL使用.NET线程池中的线程来执行任务。 负责将任务"分派"到线程的工作则由"任务调度器(Task Scheduler"负责。任务调度器集成于线程池中。 换言之,对于应用软件开发工程师而言,使用TPL开发并行程序,在编程方式上没有任务变化,只不过是编程时多了几个类可用,并且处理数据时需要使用并行算法。

 

使用任务并行库实现并行处理

上面介绍了基于线程编码实现并行处理的技术要点,可以看到还是比较繁琐的。但使用.NET 4.0的并行库可以简化开发工作。我们略微详细一点地介绍一下示例程序中是如何使用任务并行库实现并行计算的。

其中的一个关键函数是ForRange()函数,先来看看它的声明:

public static ParallelLoopResult ForRange(

int fromInclusive, int toExclusive, Action<int, int> body);

前两个参数代表要计算的数据在数组中的起始和结束索引,第3个参数是一个Action委托,它引用一个将被并行执行的处理函数。 在并行计算程序中,任务的分解方式是一个需要仔细考虑的问题,有一种常用的方案就是依据本机所包容的处理器个数来决定并行处理的任务数,可以直接调用.NET基类库中的类来获取这一信息。

int numberOfPartitions = System.Environment.ProcessorCount;

确定了要分解的任务数,就可以算出每个子任务负责处理的数据项数:

// 获取要计算的数据范围

int range = toExclusive - fromInclusive;

//计算出每个并行任务要计算的数据个数

int stride = range / numberOfPartitions;

if (range == 0) numberOfPartitions = 0;

现在到了关键的部分,我们不是使用线程来执行每个子任务,而是直接调用.NET 4.0任务并行库中的Parallel类来完成这一个工作:

return Parallel.For(0, numberOfPartitions, i =>

{

int start = i * stride;

int end = (i == numberOfPartitions - 1) ? toExclusive : start + stride;

body(start, end);

}

);

Parallel.For()是一个静态方法,它的第3个参数是类型为Action<int>的委托,在这里,我们直接使用Lambda表达式来将一个函数直接"内联"作为For()方法的参数。 For()方法有一个ParallelLoopResult类型的返回值,可以通过此返回值的IsCompleted属性了解For()方法启动的所有任务是否运行结束。

 

让查询执行得更快——Parallel LINQ

LINQ的出现对于.NET平台而言是一件大事,它使用一种统一的模式查询数据,并且可以紧密地与具体编程语言直接集成。LINQ语句的编写方式是"动态组合""递归"的,这与函数式编程语言(如F#)类似,这种编写方式的优点在于代码量小,通过动态组合一些典型的查询运算符,可以实现相当复杂的数据处理逻辑,而同样的功能如果采用传统的编码方式实现,将耗费不少的力气写代码。

.NET 4.0引入的PLINQLINQ"升级换代"技术,它允许以并行方式执行LINQ查询。 使用PLINQ技术的最大好处之一是当计算机处理器个数增加时,不需要修改(或仅需少量修改)源代码,程序性能就可以得到相应的提升。

PLINQ概述

PLINQ主要用于并行执行数据查询,而它本身又是.NET 4.0所引入的并行扩展的有机组成部分,因此,它与LINQTPL都有着密切的联系。

LINQ,是英文词组"Language-Integrated Query" 的缩写,中文译为"语言集成的查询",分为LINQ to ObjectLINQ to SQLLINQ to XMLLINQ to DataSet等几个有机组成部分。

在目前的版本中,PLINQ只实现了LINQ to Object的并行执行,换句话说,PLINQ实现了对"内存"中的数据进行并行查询。如果数据来自于数据库或文件,您需要将这些数据加载到内存中才能使用PLINQ

标准的LINQ查询运算符是由"System.Linq.Enumerable"类所封装的扩展方法实现的,类似地,PLINQ也为所有标准的LINQ查询运算符(如whereselect等)提供了并行版本,这些并行的PLINQ查询运算符实现为.NET 4.0新增的"System.Linq.ParallelEnumerable"类的扩展方法。

LINQ查询转换为PLINQ非常简单,在许多情况下只需简单地添加一个AsParallel子句就行了,例如,以下代码将把整数集合中的偶数挑出来:

//创建一个100个元素的整数集合,保存从1100的整数.

var source = Enumerable.Range(1, 100);

var evenNums = from num in source.AsParallel()

where num % 2==0

select num;

可以看到,PLINQ查询除了多一个AsParallel子句之外,与标准LINQ的查询并没有什么不同,原有的绝大多数LINQ编程方法仍然继续适用。 当.NET语言编译器"看到"一个查询中包含AsParallel子句代码时,它会在编译期间引System.Concurrency.dll程序集,将相应的标准LINQ查询运算符替换为对ParallelEnumerable类相应静态方法的调用,同时"悄悄地"将查询的返回值修改为相应的并行版本(比如许多PLINQ查询返回一个ParallelQuery<T>类型的数据集合)。由于ParallelQuery<T>派生自IEnumerable<T>,而后者是许多标准LINQ查询运算符的返回数据类型,因此,PLINQ利用多态性保证了它与原有LINQ代码的最大兼容性。LINQ类似,PLINQ也具有"延迟执行"的特性,只有对查询集合调用foreach迭代、或者调用ToList之类方法时,PLINQ查询才会真正执行。

设计者在设计PLINQ,追求的一个目标是:PLINQ绝不能比它的前辈--LINQ to Object运行得更慢!如果在某个地方做不到,它就采用串行方式执行。

在真实的应用程序中,要确定到底性能有无提升,请直接运行LINQPLINQ的两个版本进行对比测试以决定取舍。

一般来说,对于小数据量的数据集而言,优先选择LINQ而不是PLINQ

 

并行计算的未来之路

当前计算机中普遍装备了"双核"CPU,一些新购置的计算机更是装备了"四核"CPU,随着CPU"多核化"之路上越走越远,并行计算已成为软件技术确定无疑的发展方向。

CPU多核化趋抛同时出现的是计算机网络的"无孔不入",由此可知,分布式的软件系统也将成为软件技术发展的另一个方向,而分布式的软件系统"天生"就是"并行"的,因此,未来的软件系统一定同时兼具有"并行""分布"两大特点

posted @ 2011-05-12 13:22  藏积  阅读(521)  评论(0编辑  收藏  举报