在上一篇文章 NET 并行扩展(ParallelFX) 试用(上) 中,我对任务并行库做了一个简单的介绍,对Parallel.For() 方法作了几个实验,对其线程的分配和异常的处理有了一定的了解。虽然只是针对这一个方法,但是它其实在某种程度上反映了任务并行库一些内部的处理方法,有一定的普遍性。

    上一篇文章的示例中,我们的例子是并行循环,它特点是共享索引,各个任务之间没有任何的关系,既不互相依赖,也不会互相更改其使用的变量值。这当然是并行任务最理想的情况,我们使用Parallel.For()方法使之并行化。这次,我们介绍另外的方法,Parallel.Do()和 System.Threading.Tasks.Task 类。
  Parallel.Do()的方法签名为:
       public static void Do(params Action[] actions);
       
public static void Do(Action[] actions, TaskManager manager, TaskCreationOptions options);
    其中,Action 是执行方法体,要求其不能有参数,也不能有返回值。
    TaskManager是任务管理器(和系统的那个任务管理器可不是同一个东西),可以对并行的线程数,线程堆栈大小等作设定。
    TaskCreationOptions是一个枚举,它的作用我还没全弄明白。

    一般情况下,我们不需要设置TaskManager和TaskCreationOptions ,他们的默认值已经工作的很好了。
    同样,首先要设计一个看起来不是那么理想的运算,它拥有一点依赖性,很典型的我们使用与MSDN中示例相同的数据结构:二叉树
二叉树
    
    任务是:对树的每一个叶子执行一个比较耗时的运算作为其值,对每一个非叶子节点,将对左右子节点的值相加得到的和做同一运算作为其值。
    
比较耗时的计算

    首先,写出非并行计算方法:
        private static double NormalCalculateTree(TreeNode root)
        
{
            
if (root.Depth == 1return Compute(((TreeLeaf<int>)root).value);
            
double d = NormalCalculateTree(root.Left) + NormalCalculateTree(root.Right);
            
return Compute(d);
        }

    很明显,这个计算的各个部分是依赖型的:每个节点的值依赖于其子节点的计算,考虑到节点的左右子树的计算是相互独立的,于是我们可以用Parallel.Do()方法来执行,算法可以改造为:
用Parallel.Do()改造的算法
    这确实非常有效,传入的是两个无参的Lambda表达式,程序运行时,将会启动两个线程分别执行左右子树的计算任务。但是如果是4核或更多核的CPU,就有点不好了。当然了,我们可以改造NormalCalculateTree(),使它内部的计算也并行化,例如:
        private static double ParallelCaculateTreeUseDo(TreeNode root)
        
{
            
if (root.Depth == 1return Compute(((TreeLeaf<int>)root).value);
            
if (root.Depth < 10return NormalCalculateTree(root);
            
double l = 0, r = 0;
            Parallel.Do(() 
=> l = ParallelCaculateTreeUseDo(root.Left), () => r = ParallelCaculateTreeUseDo(root.Right));
            
return Compute(l + r);
        }
    "if (root.Depth < 10) return NormalCalculateTree(root)" 为了避免过度公开并行而带来的性能损失。

    如果计算过程中抛出了异常,那么Parallel.Do会在所有任务都结束时重新抛出一个AggregateException类型的异常,里面有一个列表,包含了执行过程中所有线程抛出的异常。    

    到此,我们的计算任务都比较完美,并行任务相互之间么什么关系,但是对于一般的运算,他们之间可能会更改同一个内存区域,也会相互依赖。对于共享内存的问题,我们可以通过加锁,或者创建本地副本再最后整合的办法解决。但是对于相互依赖的关系,我们只有自己去同步各个线程,这显然会使得程序变得很复杂。
    还好,不用我们自己动手,在任务并行库对这种[派生-联结并行性]也有相当程度的支持,可以用System.Threading.Tasks.Task 类来实现。还是上面的计算,首先给出改造后的算法,然后再讲解。
    
使用Task改造的算法

    
    上例中,我们在两处使用了Task,首先是创建,Task类一经创建,便会马上加入待执行的队列,如果CPU空闲,便会马上执行。同时,当前线程开始计算右子树,完成后,调用了Task.Wait()方法等待并发任务左子树计算,如果左子树已经计算完成,那么便会立即返回,否则,当前线程便会暂时阻塞。
    ParallelCaculateTreeUseTask函数每被调用一次,便会分裂出一个并行任务,最终只有一个叶子会在当前线程完成计算。

    Task的构造函数签名为

Task构造函数

    Action<object> 是一个可以接受一个object类型值作为参数,没有返回值的委托。

    过程已经明白了,现在我们进一步看看Task
    1. Task 一经创建便会加入执行队列,如果可能便会立即执行。
    2. Task 执行过程中如果产生异常,那么会被保存起来在一个AggregateException的InnerExceptions中,并在第一次调用Wait()方法时重新抛出该AggregateException类型的异常。其InnerExceptions是一个ReadOnlyCollection<Exception>集合
    3. 多个Task的并发由TaskManager统一管理,TaskManager运行在另外一个线程上。
    4. 和Parallel类的各个方法相同,所有并发的任务共享一个默认的TaskManager。
    5. 如果有特殊需要,可以在Task的构造函数中指定用户创建的TaskManager。
    6. 可以调用Task.Cancel()取消任务的执行,这会使执行线程抛出TaskCanceledException异常。直到Wait方法被调用时重新抛出AggregateException异常。     
    由以上可见,Task类要比Parallel类提供的方法灵活得多,可以很容易的把具有潜在并行性的算法改造成并行算法。我们不用担心不同线程的同步问题,只要在合时的地方调用Wait()方法就可以了。

    最后,要注意的是避免过度公开并行,举个例子:如果我们把 private static double Compute(double num) 方法内的循环改成并行的,那么结果只会造成性能下降,除非你的CPU有上百个核心。

posted on 2007-12-21 17:38  yujiasw  阅读(2181)  评论(2编辑  收藏  举报