这是本系列文章中的最后一篇,与前11讲一起,构成了一个对“.NET 4.0并行计算”技术领域的完整介绍。
 
微软10月22日刚向公众提供了Visual Studio 2010与.NET 4.0 BETA2的下载链接,而我正在下载当中。BETA2已与正式版非常接近了,在安装完VS2010 BETA2后,所有新旧实例均会转移到此新版本中,我再写的新文章也会针对BETA2。
 
相信大家都会非常关注VS2010与.NET 4.0,我过几天会发布一篇《迎接新一轮的技术浪潮》作为本系列文章的结束语,谈谈我对.NET 4.0新技术的观点,并介绍我的新著的相关情况。
 
金旭亮
 
2009.10.22
 
附注:由于51CTO对博客的字数限制,所以这一部分不得不分为三部分发出。
 
============================================
 
.NET4.0并行计算技术基础(12)——上
 
================================================

3自定义的聚合函数

         所谓“聚合函数(Aggregate Function”,其实就是对数据集合进行某种处理后得到的单一结果,比如统计一批数值型数据的平均值、最大值、最小值等。在PLINQ中,我们可以使用ParallelEnumerable类的扩展方法Aggregate()自定义一个聚合函数。
         ParallelEnumerable. Aggregate()有好几个重载形式,我们来看一个最复杂的:
 
public static TResult Aggregate<TSource, TAccumulate, TResult>(
    this ParallelQuery<TSource> source,   //指明此扩展方法适用的数据类型
    TAccumulate seed,   //聚合变量的初始值
    //用于更新聚合变量的函数,此函数将对每个数据分区中的每个数据项调用一次
    Func<TAccumulate, TSource, TAccumulate> updateAccumulatorFunc,
    //用于更新聚合变量的函数,此函数将对每个数据分区调用一次
    Func<TAccumulate, TAccumulate, TAccumulate> combineAccumulatorsFunc,
    //用于获取最终结果的函数,在所有工作任务完成时调用
    Func<TAccumulate, TResult> resultSelector
);
 
         这个函数声明拥有5个参数,看上去有些吓人,但只要耐下心来分析一下,还是可以理解的。
         首先,第一个参数的this关键字表明可以对任何一个ParallelQuery<TSource>类型的变量调用Aggregate()方法,请注意ParallelEnumerable. AsParallel< TSource >()方法的声明:
 
ParallelQuery<TSource> AsParallel<TSource>(
    this IEnumerable<TSource> source);
 
         这意味着任何一个实现了IEnumerable<TSource>接口的对象都可以很方便地转换为ParallelQuery<TSource>类型的对象。所以,我们可以使用以下公式来调用自定义聚合函数:
 
实现了IEnumerable<TSource>接口的对象.AsParall<TSource>().Aggregate< U,T,V>(…);
 
         另外,请牢记所有聚合函数返回单一值,因此,会有一个值在Aggregate()函数的剩余几个参数间“传递”,这个值不妨称之为“聚合变量”。聚合变量的类型由Aggregate()函数的类型参数TAccumulate指定。
         Aggregate()函数的第2个参数Seed给聚合变量指定一个初始值。
         Aggregate()函数的后面几个参数都是处理并修改聚合变量的。这里有一个背景知识:您必须知道PLINQ是如何执行查询的。
         19.3.3小节介绍Parallel.ForParallel.ForEach时,曾介绍过数据“分区”的概念。不妨重述如下:
       当有一批数据需要处理时,TPL会将这些数据按照内置的分区算法(或者你可以自定义一个分区算法)将数据划分为多个不相交的子集,然后,从线程池中选择线程并行地处理这些数据子集,每个线程只负责处理一个数据子集。
         回到针对“自定义聚合函数”的讨论中来,在这里,TPL会将指定的数据处理函数应用于每个数据子集中的每个元素,然后,再把每个数据子集的处理结果(由“聚合变量”所保存)组合为最终的处理结果。
         现在我们可以讨论Aggregate()函数的剩余几个参数的含义了。
         Aggregate()函数的第3个参数updateAccumulatorFunc用于引用一个数据处理函数,针对每个数据分区中的每个数据项,此函数都会调用一次。请注意这个被多次调用的函数接收两个参数,一个是聚合变量,另一个则是数据分区中的每个数据项,函数返回值将作为聚合变量的“新值”。另外,要注意对于每个数据分区都关联着一个不同的聚合变量,而对于每个数据分区而言,是以“串行”方式对每个数据项调用数据处理函数的,因此,在数据处理函数内部不需要给聚合变量加锁就可以安全地访问它。
         当所有数据分区的数据处理工作完成以后,每个数据分区会产生一个结果,此结果由本分区关联的“聚合变量”保存,由此得到了另一个数据集合:
 
{ 分区1的处理结果,分区2的处理结果,……,分区n的处理结果 }
 
         Aggregate()函数的第4个参数combineAccumulatorsFunc引用另一个数据处理函数对此“数据集合”进行处理。此数据处理函数的第一个参数也是“聚合变量”,第二个参数代表上述数据集合中的每个数据项,此数据处理函数的返回值将成为“聚合变量”的新值。
         现在开始介绍Aggregate()函数的最后一个参数resultSelector,同样地,此参数也引用一个数据处理函数,这个函数只有一个参数,其值就是前面两个数据处理函数被执行之后所得到的“聚合变量”的最终值。resultSelector引用的函数可以对这个“聚合变量”进行最后的“加工”,得到整个Aggregate()函数的最终处理结果。
         相信上述文字可能会让读者“头大”了,通过一个实例可能更好理解。我们在第19.3.2节中介绍过使用TPL计算数据的总体方差,为方便起见,这里将求方差的公式重新列出:
 
请看示例项目UseAggregateFunc,它使用聚合函数来计算方差,为简化起见,数据集合为5个随机生成的1~10间的整数。某次运行结果如下:
 
 
分析图 1922,我们可以发现:
         TPL将数据分为两个“区”,一个区包含2个数据,由线程9负责处理,另一个区包含3个数据,由线程6负责处理。
         请注意每个线程刚开始执行时,聚合变量aggValue值都为初始值0,每次执行数据处理函数updateAccumulatorFunc时,其返回值都成为aggValue的新值。
         等每个分区数据处理完成时,得到一个新的“数据集合”,其成员为两个分区的“聚合变量”的当前值:
 
{144}
 
         这时另一个数据处理函数combineAccumulatorsFunc被调用,将两个分区的处理结果累加起来。在示例中,只有两个数据分区,所以只需调用一次数据处理函数即可。如果有多个分区结果需要组合,此数据处理函数可能会调用多次。
         以下列出这个示例程序中的聚合函数代码片断,请读者仔细阅读注释:
 
            //生成测试数据放到整型数组source...(代码略)
            //计算平均值
            double mean = source.AsParallel().Average();
            Console.WriteLine("总体数据平均值={0}", mean);
            //并行执行的聚合函数
            double VariantOfPopulation = source.AsParallel().Aggregate(
                0.0,   //聚合变量初始值
                //针对每个分区的每个数据项调用此函数
                (aggValue, item) => {
                    double result = aggValue + Math.Pow((item - mean), 2);
                    Console.WriteLine(……);
                    return result;
                },
                //针对分区处理结果调用此函数
                (aggValue, thisDataPartition) =>
                {
                    double result = aggValue + thisDataPartition;
                    Console.WriteLine(……);
                    return result;
                },
                //得到最终结果
                (result) => result / source.Length
                );
    //输出最终结果
    Console.WriteLine("数据的方差为:{0}", VariantOfPopulation);
 
         使用聚合函数比较繁琐,不易理解,但代码量较小,程序执行性能也不错,更关键的是它不需要考虑线程同步问题,还是比直接使用线程和Task更方便,因此,还是值得花费点时间弄明白它的用法。

4中途取消PLINQ操作

         PLINQ采用统一线程取消模型来中途取消查询操作,只需在查询中使用WithCancellation()扩展方法就行了。
         以下是示例代码:
 
    CancellationTokenSource cs = new CancellationTokenSource();
    //……
    var query=from data in DataSource.AsParallel().WithCancellation(cs.Token)
    select data;
    //……
 
         CancellationToken置于signaled状态时PLINQ会抛出一个OperationCanceledException异常只需捕获此异常即可响应外界发出的“取消”请求。
         示例PLINQCancel展示了如何中途取消PLINQ操作,请读者自行阅读源代码。
 
      提示:
       由于PLINQ在底层使用TPL,所以,PLINQ的异常处理机制与TPL的一致。
======================================