命令式数据并行
Visual C# 2010和.NETFramework4.0提供了很多令人激动的新特性,这些特性是为应对多核处理器和多处理器的复杂性设计的。然而,因为他们包括了完整的新的特性,开发人员和架构师必须学习一种新的编程模型。
这一章是一些新的类、结构体和枚举类型,你可以使用这里来处理数据并行的场景。这章将为你展示怎样创建并行代码和描述与每个场景相关的新概念,而不是关注并发编程中的最复杂的问题。这样你将可以更加充分的理解性能改进。
开始并行任务
使用先前版本的.NET Framework,开发可以充分利用多核微处理器的并行能力的应用程序是很难的。使用那些可以控制并行的复杂结构来开始、控制、管理和同步多线程是必要的,但是这对现代的多核系统并不十分有效。
.NET 4引入了新的任务并行库(TPL),其产生在多核时代并且就是第一章中展示的轻量级并发编程模型。
为了支持数据并行、任务并行和管道,TPL提供了一个轻量级的框架,可以帮助开发人员应对不同的并行场景,实现基于任务模型的设计,而不是使用重量级复杂的线程进行工作。这些场景包括
数据并行
这里有很多的数据,并且每条数据都必须施加相同的操作。如图2-1,使用256位键的AES算法加密100个unicode字符串。
图 2-1
任务并行
这里有很多不同的操作可以并行执行,充分的利用并行的有力。例如,产生文件的哈希编码,加密unicode字符串,创建图片的缩略图。如图2-2,
图 2-2
流水线
这里混杂了任务并行和数据并行。这是最复杂的场景,因为它总是需要协调多个特定的并发任务。例如,以使用256位键的AES算法加密100个unicode字符串,然后为每个加密的字符串产生一个哈希值。这个管道可以实现同时运行两个并发执行加密和产生哈希代码两个任务。每一个加密的unicode字符串为了使用哈希编码算法进行处理而放入队列中。如图2-3,
图 2-3
当然也存在混合了前边的所有情况的复杂场景。理解怎样使用并行任务进行工作的最容易的方式就是使用他们。接下来的章节将会使用详细的例子覆盖这些最普遍的场景。
TPL引入了一个新的命名空间,System.Threading.Tasks。通过这个命名空间可以访问.NET4引入的新的类、结构体、枚举类型。所以,无论什么时候你想用TPL,使用这个命名空间是个不错的主意。
Using System.Threading.Tasks;
这样你可以避免大量的引用。例如,你可以使用Parallel.Invoke,而不是使用System.Threading.Tasks.Parallel.Invoke.
其中主要的类是Task,它代表一个异步的并发操作。然而,没有必要为了创建并行代码直接使用Task的实例。有时,最好的选择是创建并行的循环和区域。在这些场景中,你可以使用静态类Parallel提供的方法进行工作,而不是使用更底层的Task实例。
-
Parallel.For---为固定数目的独立For循环迭代提供负载均衡的潜在的并行执行。
-
Parallel.ForEach---为固定数目的独立For Each循环迭代提供负载均衡的潜在的并行执行。这种方法支持自定义分区类,这是你可以完全控制数据的分布。
-
Parallel.Invok---对独立的任务提供潜在的并行执行。
负载均衡的执行会尝试将工作分发在不同的任务中,这样所有的任务在大部分的时间内都可以保持繁忙。负载均衡总是试图减少任务的闲置时间。
当重构已经存在的代码来充分利用潜在的并发优势时,这些方法是很有用的。然而,理解这些并不是使用Parallel.For取代for那样简单是很重要的。
Parallel.Invoke
如果试图将很多方法并行执行最简单的方法就是使用Parallel提供的Invoke方法。以下是.NET中Parallel的Invoke函数原型。Invoke接受没有返回值(或者说返回值是void)Action[]参数数组,对操作尽可能的并行化。
public static void Invoke(params Action[] actions); /// <summary> /// 执行所提供的每个操作,而且尽可能并行运行,除非用户取消了操作。 /// </summary>
有以下四个函数:
static void First() { System.Console.WriteLine("1--First"); } static void Second() { System.Console.WriteLine("2--Second"); } static void Third() { System.Console.WriteLine("3--Third"); } static void Fourth() { System.Console.WriteLine("4--Fourth"); }
使用Parallel的Invoke函数,并行执行以上四个函数。
Parallel.Invoke( () => First(), () => Second(), () => Third(), () => Fourth());
以上调用()=>First()的形式是Lambda表达式。也可以使用匿名委托来执行以上代码,如下代码所示。
Parallel.Invoke( delegate { First(); }, delegate { Second(); }, delegate { Third(); }, delegate { Fourth(); });
或者直接传入函数参数,如下代码所示,但是使用Lambda表达式和匿名委托的一大优势:可以定义需要并行执行的多行方法,而不需要创建额外的方法。
Parallel.Invoke(
First,
Second,
Third,
Fourth);
输出结果是:
第一次执行:3-1-2-4
第二次执行:1-2-3-4
第三次执行:2-3-4-1
使用Parallel的Invoke函数时需要特别注意以下量两点:1、函数执行不要求特定的顺序。2、函数执行必须独立的,不存在特定的依赖关系。
优势和权衡
使用Parallel.Invoke的关键优势在于,这是运行并行方法的简单方式,而不用考虑任务和线程问题。然而Invoke并不适合所有情形。需要权衡一下几个方面:
-
如果使用Parallel.Invoke加载的方法运行时间不同,那么就需要很长的时间才能返回控制。这样很多逻辑内核就处于闲置状态。因此,使用时,一定要测量运行的结果、实现的加速比,以及逻辑内核的使用率,这是很重要的。
-
在并行的可扩展方面具有局限性。因为Parallel.Invoke调用的是固定数目的委托。
-
每次调用Parallel.Invoke都要产生一些额外开销。
-
与其他的并行代码一样,不同方法的任何相关性或不可控的交互会导致难以检测的bug以及意想不到的副作用。
-
对方法的执行顺序没有要求时,可以考虑Parallel.Invoke方法。需要特定执行顺序的复杂算法不适合使用parallel.Invoke
-
使用parallel.Invoke并行运行方法中,要考虑异常处理问题。
《C#并行编程高级教程》