19.1.3 并行计算所带来的挑战
与串行执行的程序相比,开发并行程序需要软件工程师具备一个“多线程”的大脑。我们先来看一个引例,初步体会一下如何使用.NET 4.0所提供的任务并行库设计并行程序。
1 并行计算引例
请读者仔细查看一下本节示例程序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]++;
}
上述代码在笔者的双核笔记本电脑上执行时花费了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,比串行算法慢多了!
2 并行计算带来的复杂性
上面所介绍的例子非常清晰地展示出并行程序设计的特殊性,并不是“并行”总比“串行”快的,到底怎样才能获得最大的并行加速系数,需要仔细地设计并行算法,并且应该在多个典型的软硬件环境中进行对比测试,最终才能得到理想的并行设计方案。
开发并行程序的关键在于要找到一个合适的任务分解方案,并行总要付出一定的代价,比如线程同步、线程通讯、同步缓冲数据等都是开发并行程序必须认真考虑的问题。
下表对比了并行程序与串行程序的主要差别:
项目
|
串行程序
|
并行程序
|
程序行为特性
|
可以预期的,相同运行环境下总可以得到相同的结果
|
如果没有提供特定的同步手段,则程序执行的结果无法预期
|
内存访问
|
独占访问内存单元,数据可靠
|
有可能因多线程同时存取同一内存单元而引发数据存取错误
|
锁
|
不需要
|
必须为共享资源加锁
|
死锁
|
不可能出现
|
可能出现,需要仔细考虑程序中可能出现的种种情况予以避免
|
测试
|
使用代码覆盖的测试方法可以检测出绝大多数
BUG
|
由于多个线程同时并行,仅使用代码覆盖的测试方法无法检测出程序中隐藏的
BUG
,并行程序的测试变得很复杂
|
调试
|
相对简单,可以随时停止程序运行,单步跟踪定位到每条语句和每个变量的值
|
由于多个线程同时运行,当你暂停一个线程进行调试时,其他线程可能还在运行中,因此无法保证调试环境的一致性,并行程序的调试非常困难。
|
正因为并行程序开发、测试和调试都比串行程序要困难,所以一般都是先编写程序的串行版本,等其工作正常之后再将其升级替换为并行版本。
3 何时使用“并行计算”?
根据前面的介绍,读者一定对“并行计算”有了个总体的认识,由于“并行”需要付出代价,因此,不是所有的程序都需要转换为并行的,当要处理的数据量很大,或者要执行的数据处理任务繁重,并且这些任务本身就可以分解为互不相关的子任务时,使用并行计算是合适的。
对于哪些规模较小的数据处理任务,比如你要编写一个“通讯簿”小程序来保存和检索好友信息,就不必考虑并行处理了,因为要处理数据量不会很大,串行算法的性能就可以满足需求,还用“并行处理”就显得是“牛刀杀鸡”。除了增加程序开发难度之外没有什么好处。
19.2 .NET 4.0中的并行计算组件
由于并行计算是将一个工作任务进行分解以并发执行,因此,任何一个支持并行计算的软件开发与运行平台都必须解决这些并发执行的子任务之间的相互协作问题,比如:
l
一个子任务需要等待其它子任务的完成,多个子任务完成之后才允许执行下一个子任务(即所谓fork-join),
l
一个子任务结束后自动启动多个下级子任务的执行
l
允许一个任务中途取消
l
……
.NET 4.0通过对已有的基类库进行扩充和增强(图 19‑7),满足了上述需求。
如图 19‑7所示,.NET 4.0给 “System.Threading” 命名空间增加了一些新的类(,比如在第17章介绍过的Barrier等几个新的线程同步类),同时对部分已有类也进行了调整和优化。另外,针对中途取消线程或作务执行这一实际开发中非常普遍的需求,提供了一个统一取消模型(本书第16章介绍了此模型)。最大的变化是.NET为基类库提供了多个与并行计算密切相关的类,并将它们统一称之为“并行扩展(Parallel Extensions)”。
如
图
19‑8
所示,
NET 4.0
“并行扩展”的主要包括以下几个部分:
1
并行语言集成查询(PLINQ,Parallel Language Integrated Query
),这是
.NET 3.0
引入的
LINQ to Object
(本书第
24
章介绍)的换代“产品”,让查询操作可以并行执行。
2
任务并行库(TPL,Task Parallel Library
):将开发并行程序的抽象级别从“线程(
thread
)”提升到“任务(
Task
)”,只需规定好计算机要执行的任务,然后由
.NET
去管理线程的创建和同步等问题。
3
同步的数据结构(CDS,Coordination Data Structures
):包括一组线程安全的常用数据结构,比如线程安全的队列、堆栈等,在并行程序中访问这些数据结构,可以不需要显式地使用
lock
。
4
任务调度器(Task Scheduler
):负责任务的创建、执行、暂停等管理工作。
5
线程池:
.NET 4.0
对原有的托管线程池功能进行了大幅度的增强,通过给其集成一个任务调度器,线程池中的线程可以高效地并行执行各种任务。
上述五个组成部分当中,
PLINQ是建立在TPL之上的,而Task Scheduler是并行计算的核心,是一个Runtime,它与线程池相集成,负责将任务分派给线程池中的各个线程执行。
下面几个小节中,我们就整个
.NET 4.0
并行扩展中与软件工程师关联最紧密的两个主要组成部分——任务并行库
TPL
和并行语言集成查询
PLINQ
的内部机理进行剖析,然后在此基础上详细介绍使用
TPL
和
PLINQ
开发并行程序的基本技巧。