详解.NET 4.0平行任务库中必须注意的三种关系

      随着.NET Framework v4.0的正式发布,  一个新的.NET程序世界开始徐徐拉开帷幕. 为应对迅速发展的硬件结构更新, 和各种软件开发模式/思路/潮流的变化, 平行任务库(Task Parallel Library, 简称TPL)成为.NET Framework v4.0各种新特性中最为闪耀的明星; 从社区的动向能明显体现出对这一新特性的关注. 这个新特性的核心是Task, 底层支持是.NET 4.0 线程池的重新实现.

      在前几个版本中, 微软为.NET框架设计了线程池和最简单的工作线程接口QueueUserWorkItem. 微软认为这个API保持简单易用, 是保证它能被广泛使用的前提; 在.NET 4.0中, API的接口改为使用任务(Task)并为平行计算做出了调整, 但是这个API的使用依然保持了简单这一准则, 如下图带有get_前缀get_XXX的方法实为只读属性XXX(上图可以通过VS2010打开您的项目后, 依次点击"Architecture" - "Create Dependency Graph", 选择关系的组织方式, 就可以看到).:

TaskClassView

上一版本线程池API的重要问题

      软件计算最终要解决的是现实生活中的实际问题; 问题域不仅包含被计算的实体或者数据, 而且还包含这些实体和数据之间的关系. 计算或者算法, 不仅需要处理问题域中的实体和数据, 还要通过计算过程隐含或明确地正确反映实体和数据之间的关系. 处理这些关系会最终体现在计算操作的关系上, 譬如, 时间先后, 因果相关, 层列组合, 整体局部等.

      在上一个版本的线程池应用程序接口(QueueUserWorkItem)中, 工作任务以独立的工作项的形式存在; 即应用程序接口假定程序开发人员投入到线程池中的都是独立的执行单元, 线程池应用程序接口不提供任何辅助手段处理各个工作项之间的关系. 微软是出于什么样的考虑在线程池应用程序接口中如此保守我们不得而知, 但是这样的限制带来的明显问题有:

      1. 程序开发人员责任加重. 毫无疑问, 正确处理数据及实体间的关系是算法正确性的要求. 所以当线程池不提供处理关系的应用程序接口, 而多个工作项之间确实存在需要处理的关系(比如某些操作要在另一些操作完成之后才能启动)时, 开发人员只能在工作项执行体中加入同步代码才能达到这一目的. 这样, 开发人员的责任加重, 程序的可读性下降, 当大量这样的代码出现时, 系统的可维护性和质量就受到威胁;

      2. 工作项执行的总体效率降低. 工作项的有效载荷是工作项需要完成的任务, 为执行工作项而进行线程切换所花费的时间和空间是工作项的"无效载荷". 我们当然期望有效载荷率越大越好, 但是当工作项执行体非常小的时候, "无效载荷"对最终性能带来的影响非常明显. 在多核处理器架构下, 如果工作项的总体数量很多, 远远超过处理器核心数-因为线程切换频繁-的时候 实验证明线程池的效能明显降低. 在上一个版本中, 线程池应用接口认为工作项之间是相互独立的, 所以就无法隐含/显式被告知或主动推断工作项之间的关系从而合并某些工作逻辑,也就达到降低无效载荷的目的.

      3. 开发方式受到影响. 由于API的这种限制, 开发人员更倾向于将"发射后不管"的任务放到线程池中执行, 而对于有同步或次序要求的任务, 更倾向于在独立的线程中执行.

父子关系

      .NET Framework 4.0 新版本的线程池中, 使用平行任务库作为主要的API. 平行任务库带来的第一个改变, 就是引入了父子关系. 父子关系简单来讲是一种组合关系, 又是一种依赖关系. parentchild 组合是指某些任务可以附加到某个任务上;这某个任务就成为父任务, 某些任务就成为和父任务关联的子任务. 任务的这种父子关系本身不隐含任务大小划分的粒度, 而在实践中我们往往倾向于按照业务逻辑的组合关系将一个较大的任务划分成许多可以单独执行的子任务.

      依赖关系则是在父子关系中实实在在存在的. 在平行任务库中, 每个任务有如下几个状态来描述:

  •           o Created
              o WaitingForActivation
              o WaitingToRun
              o Running
              o WaitingForChildrenToComplete
              o RanToCompletion
              o Canceled
              o Faulted

      其中最后三个是一个任务执行的终结状态: RanToCompletion表示成功完成,Canceled表示任务被取消, Faulted表示该任务或其子任务抛出了异常. 在平行任务库中, 因为多个子任务及父任务的异常可能有多个, 所以.NET 4.0中特别引入了新的异常类型AggregateException表示多个异常的聚合. Task类型的Exception属性(如上图)就是一个AggregateException类型的实例. 父任务对子任务的依赖关系, 就体现在: 1). 只有当所有的子任务都执行完毕, 父任务才能到达终结状态; 2). 只有到达终结状态, 父任务的Exception属性才会有具体的值(在之前一直保持null).使用如下的代码段将某个任务注册为父任务的子任务:

Create child task
  1.  Task parent = Task.Factory.StartNew(() =>
  2. {
  3.      Task child = Task.Factory.StartNew(() =>
  4.     {
  5.          //Child task execution body
  6.     }, TaskCreationOptions.AttachedToParent);
  7. });

      重点在StartNew方法的第二个参数上, 使用TaskCreationOptions.AttachedToParent告诉平行任务库这个任务是创建为包含它的任务的子任务. 这里特别需要注意的是:

      1. 虽然平行任务库允许我们在某个任务中创建一个子任务并在另外一个任务中显式地启动这个子任务, 但是任务间的父子关系是在创建伊始就决定了的, 而不是在启动时决定;
      2. 平行任务库是如何为我们决定谁是父任务呢? 在创建伊始, 平行任务库会在当前线程(注意!)中查找正在执行的任务, 并将它作为父任务. 被创建的子任务会附加到这个父任务上.

      其实AggregateException本身也是特意为这种父子/组合的关系中出现的异常所设计的, 现在只用在平行任务库及PLINQ中. 查看MSDN中的AggregateException Class.

顺序关系

      顺序关系是为化简一些简单的同步操作而引入平行任务库的重要改变. 实际计算问题中, 一定存在这样的需求, 即某个问题的解决结果, 是开始对其他问题计算的先决条件(譬如某些算法要求复用前一步的计算中间结果). 针对这样的需求, 平行任务库中存在动静两种方式, 来定义任务间的顺序关系:静态方式在创建任务时使用ContinueWith; 动态方式显式使用Wait(); 下面的代码集中示例这两种方式引入的顺序关系:

Task Sequence
  1.  private static void TaskSequenceDemo()
  2. {
  3.      Task parent = Task.Factory.StartNew(() =>
  4.     {
  5.          Task child1 = null, child2 = null, child3 = null, child4 = null;
  6.  
  7.         child1 = Task.Factory.StartNew(() =>
  8.         {
  9.             Console.WriteLine("The child task 1 says hello!");
  10.         }, TaskCreationOptions.AttachedToParent);
  11.  
  12.         child2 = child1.ContinueWith((t) =>
  13.         {
  14.             Console.WriteLine("The child task 2 says hello!");
  15.         }, TaskContinuationOptions.AttachedToParent);
  16.  
  17.         child3 = Task.Factory.StartNew(() =>
  18.         {
  19.             Task.WaitAll(new Task[2] { child4, child2 });
  20.             Console.WriteLine("The child task 3 says hello!");
  21.         }, TaskCreationOptions.AttachedToParent);
  22.  
  23.         child4 = Task.Factory.StartNew((t) =>
  24.         {
  25.             Console.WriteLine("The child task 4 says hello!");
  26.         }, TaskContinuationOptions.AttachedToParent);
  27.     });
  28. }

      除了WaitAll方法外, 另一个重要的方法是Wait(), 接受Timeout设置, 或者接受CancellationToken参数. 假设A是某个任务实例, A.Wait()方法表示调用者挂起并期待任务A执行完毕. 这就引出了.NET 4.0对于原有的线程池和应用接口的一个重大改进.

inline      从Wait看平行任务库对于线程池应用接口的改进. 我们知道在上一个版本的线程池应用接口中, 我们需要自己处理同步需求. 譬如有两个工作项A和B, 在执行过程中都需要使用资源R1和R2. 如果我们的同步措施不当, 可能A占有R1等待R2, B占有R2等待R1, 从而工作项A和B产生死锁. 在最坏的情况下, 线程池中的所有线程可能都处于死锁的状态下而无法执行.

      在新的应用接口中, 平行任务库为我们提供了Wait()方法. 这个方法绝不是简单的类似于设置同步事件. 从各种文档来看, A.Wait()方法最大的特性, 是检查任务A的状态. 如果A正在运行, 则等待A的完成; 如果A尚未启动, 则调用A.Wait()方法的任务会将任务A引入到当前任务的执行线程中展开执行. 这在相当大程度上避免了因等待而产生死锁的可能.

      各位可以通过简单的试验程序Wait()的task运行的ManagedThreadId和ThreadId来验证这一Inline过程。

逻辑重叠关系

      这里要解释一下什么是逻辑重叠关系, 因为这个词是我杜撰出来的(囧). 所谓逻辑重叠, 即多个相同的执行体, 只是输入和产生的输出不同而已, 说白了就是ForXXX等类循环执行. NET 4.0特意为逻辑重叠关系作出了优化, 提供了Parallel.For(), Parallel.For<>()和Parallel.ForEach<>()方法.

      相对于已有的for和foreach只在一个线程中运行的方式, 和将集合中的每个元素都单独创建任务执行计算的方式, 一定需要一个平衡点和一种标准来衡量效率. 在前文提到, 先对于要完成的任务, 线程创建和切换是无效载荷. 引发线程切换的最大原因是windows的CPU调度和时间片分配方式. 所以CPU核心数就是这个标准和平衡点. 为了减少线程的切换, 一"堆"输入值最好按照CPU的数目划分开来并分配到CPU核心数个线程上,每个CPU核心运行一个线程. 理想状态下, 计算的整个过程将没有线程切换. Parallel.ForEach就可以智能的完成这一过程:

ForEach
  1. Parallel.ForEach<int>(source, item =>
  2.     {
  3.         // execution body here
  4.     }
  5. )

我们可以做一个实验来验证, 在foreach方式, Parallel.ForEach<>方式, 每个元素一个单独的计算线程的方式下, 三者的效率对比. 不过由于关于这个关系的论述文章比较多, 相对的实验也比较多. 在此处不再赘述.

对新线程池的担忧

      新的线程池实现了lock-free的任务提取和存入算法, 使用了平行任务库作为应用接口并着手处理任务之间的关系, 这一些都是显著的改善; 新的任务被定义或期待被定义为细粒度能快速执行完成的执行单元, 这样没有问题. 但是在这样的改善之后, 会不会出现线程池的误用(比如依旧使用粗粒度任务长时间在线程池中活动), 过度使用和依赖, 我们拭目以待.

作者:Jeffrey Sun
出处:http://sun.cnblogs.com/
本文以“现状”提供且没有任何担保,同时也没有授予任何权利。本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

原文地址:https://www.cnblogs.com/sun/p/1717620.html