在此之前我们接触了线程,线程池的一部分使用方法,同时也了解到异步编程模型APM和基于事件的异步模式EAM。但是,使用线程池并不轻松。我们需要自定义方式来获取结果,当异常发生时,也需要将异常正确地传播到初始线程中。使用APM模式组合多个异步操作也需要大量工作。为了解决这些问题,.NET Framework4.0引入了一个新的关于异步操作的API,称为任务并行库TPL。TPL被认为是线程池之上的又一个抽象层,其对程序员隐藏了与线程池交互的底层代码,并提供了更方便的细粒度的API。
这节大量参考了《C#并行编程高级教程》上的内容。《C#并行编程高级教程》Gastopn C. Hillar【美】清华大学出版社
一 Parallel静态类
Task类是TPL的主类,但创建并行代码并不一定要直接使用Task类的实例。有时候,最好创建并行循环或者region,这种情况下可以使用Parallel静态类。
Parallel.Invoke()方法允许并行允许一些方法,它的特点是各方法之间没有特定的顺序,例如,当我们运行Parallel.Invoke(()=>A, ()=>B, ()=>C, ()=>D))这四个方法时,我们需要至少四个逻辑内核(硬件线程)才足以让这4个方法并发运行。而如果一个或多个内核处于繁忙状态,底层的调度逻辑可能就会延迟某些方法的执行。
此外,Parallel.Invoke适用于加载的方法耗时相近的情况,如果这些方法时间迥异,那么就需要最长的时间才能返回控制。
循环并行化
Parallel还提供了一个For和一个ForEach方法来处理易并行计算任务的循环并行化工作。我们常常遇到这样的循环:它一个迭代一个迭代地运行,并对每一个数据都执行着同样的操作,这时候就可以对循环进行重构,从而并行地运行这些操作。
书中给出的例子是计算一些MD5哈希码的工作:
先看看原始串行执行的版本:
注:ConvertToHexString主要将字符数组转化为十六进制表示,因为不重要这里不给出函数体。
再看看Parallel.For的并行化版本:
Parallel.For有多种重载,但它的参数中,fromInclusive是迭代范围的第一个数,toExclusive是迭代停止的前一个数,Body表示要被调用的委托,ParallelOptions参数可以指定并行度,这些是共性。
在处理并行循环时,我们一定要做好充分的准备,因为并行化循环的执行顺序是无法预测的,一个循环可能会分化为多个并行的迭代,换言之,迭代2和迭代50的先后是未知且可变的。
Parallel.ForEach提供了另一种并行处理一组数据的机制。可以利用一个范围的整数作为一组数据,然后通过一个自定义的分区器(注,书中给出的译法,即Partitioner)将这个范围转换为一组数据块。每一块数据都通过循环的方式进行处理,而这些循环是并行执行的。
再来看看GenerateMD5Hashes使用Parallel.ForEach的版本:
我们通过Partitioner.Create()方法划分了一个[1, 800000)的迭代范围,同时为了便于观察,在每个小循环开始前打印出循环的范围和开始时间。如下图所示,在我的机器上,这个分区器划分了13个范围,默认的创建范围数是根据逻辑内核数,分区大小等因素来决定的。同时由于我的机器只有4个可用内核,因此这13个小循环并不会同时启动:
并行化的循环和会试图根据可用的硬件资源对执行进行负载均衡,这给我们一个启示,可对生成的分区数进行人为调整,使得工作负载能够更好地均衡。例如我的机器可用逻辑内核数为4,那么可以通过Partitioner.Create(1, 800000, 200000)语句设置每个分区的大小为200000使其匹配,这时多次运行并比较可以看出,运行时间有了一定的改善:
此外,如果现有的循环是对公开了IEnumerable接口的集合进行遍历的foreach循环,那么Parallel.ForEach也能被用来对这种循环进行重构。之前我们直接给定分区集合1~800000,这一次我们使用Enumerable.Range()方法来生成一个1~800000的整数序列,通过它来重构我们的循环。
这是ForEach的第二个版本,使用了Parallel.ForEach<>(IEnumerable<> source, Action<> body)重载:
从并行循环中退出
普通的串行循环可以通过break语句退出,然而在并行循环的时候,退出委托的主体方法对并行循环的执行没有任何影响,因为委托主体方法与传统的循环结构没有关系,只是在每次迭代的时候被调用。
再回到上面给出的第二个ForEach,这次我们设置当运行时间超过3s时通知并行循环尽快停止,关注我们的ParalellLoopState局部变量:
ParallelLoopState提供了从内部请求取消并行循环的可能性(外部取消请求可以用之前提到过的CancellationToken和CancellationTokenSource,后续还会提及),它有两个方法Stop()和Break()可以用于停止Parallel.For和Parallel.ForEach的执行,其区别在于:Stop()会立即退出循环,而Break()则告知并行循环在完成当前迭代之后尽快停止循环。
直观点说,由于并行,迭代500~750已启动,而迭代250~500还没有启动,而我们设置的退出并行循环条件是大于500,那么Stop()直接退出循环,而Break()可以保证迭代250运行完毕再退出。Stop()相当于串行循环的break,而Break()相当于串行循环的continue。
Parallel.For和Parallel.ForEach并非是没有返回结果的,其返回一个ParallelLoopResult对象,可以通过其只读属性IsCompleted和LowestBreakIteration来获知并行循环的运行情况。
IsComplete = true,循环执行完成
IsComplete = false && LowestBreakIteration.HasValue = false,Stop终止
IsComplete = false && LowestBreakIteration.HasValue = true,Break终止