上一篇文章的示例中,我们的例子是并行循环,它特点是共享索引,各个任务之间没有任何的关系,既不互相依赖,也不会互相更改其使用的变量值。这当然是并行任务最理想的情况,我们使用Parallel.For()方法使之并行化。这次,我们介绍另外的方法,Parallel.Do()和 System.Threading.Tasks.Task 类。
Parallel.Do()的方法签名为:
public static void Do(Action[] actions, TaskManager manager, TaskCreationOptions options);
TaskManager是任务管理器(和系统的那个任务管理器可不是同一个东西),可以对并行的线程数,线程堆栈大小等作设定。
TaskCreationOptions是一个枚举,它的作用我还没全弄明白。
一般情况下,我们不需要设置TaskManager和TaskCreationOptions ,他们的默认值已经工作的很好了。
同样,首先要设计一个看起来不是那么理想的运算,它拥有一点依赖性,很典型的我们使用与MSDN中示例相同的数据结构:二叉树
任务是:对树的每一个叶子执行一个比较耗时的运算作为其值,对每一个非叶子节点,将对左右子节点的值相加得到的和做同一运算作为其值。
首先,写出非并行计算方法:
{
if (root.Depth == 1) return Compute(((TreeLeaf<int>)root).value);
double d = NormalCalculateTree(root.Left) + NormalCalculateTree(root.Right);
return Compute(d);
}
很明显,这个计算的各个部分是依赖型的:每个节点的值依赖于其子节点的计算,考虑到节点的左右子树的计算是相互独立的,于是我们可以用Parallel.Do()方法来执行,算法可以改造为:
{
if (root.Depth == 1) return Compute(((TreeLeaf<int>)root).value);
if (root.Depth < 10) return NormalCalculateTree(root);
double l = 0, r = 0;
Parallel.Do(() => l = ParallelCaculateTreeUseDo(root.Left), () => r = ParallelCaculateTreeUseDo(root.Right));
return Compute(l + r);
}
如果计算过程中抛出了异常,那么Parallel.Do会在所有任务都结束时重新抛出一个AggregateException类型的异常,里面有一个列表,包含了执行过程中所有线程抛出的异常。
到此,我们的计算任务都比较完美,并行任务相互之间么什么关系,但是对于一般的运算,他们之间可能会更改同一个内存区域,也会相互依赖。对于共享内存的问题,我们可以通过加锁,或者创建本地副本再最后整合的办法解决。但是对于相互依赖的关系,我们只有自己去同步各个线程,这显然会使得程序变得很复杂。
还好,不用我们自己动手,在任务并行库对这种[派生-联结并行性]也有相当程度的支持,可以用System.Threading.Tasks.Task 类来实现。还是上面的计算,首先给出改造后的算法,然后再讲解。
上例中,我们在两处使用了Task,首先是创建,Task类一经创建,便会马上加入待执行的队列,如果CPU空闲,便会马上执行。同时,当前线程开始计算右子树,完成后,调用了Task.Wait()方法等待并发任务左子树计算,如果左子树已经计算完成,那么便会立即返回,否则,当前线程便会暂时阻塞。
ParallelCaculateTreeUseTask函数每被调用一次,便会分裂出一个并行任务,最终只有一个叶子会在当前线程完成计算。
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有上百个核心。