19.3.4任务并行库原理初探
在上一小节中,我们看到只需简单地调用Parallel类中的一些静态方法,就可以让代码并行执行。您一定会对任务并行库的强大功能有了很深的印象,一些喜欢刨根问底的读者可能会问:
任务并行库怎样实现代码的并行执行?
任务并行库的底层技术细节很复杂,要介绍它超出了本书的范畴,然而,对其工作原理作一个介绍是可能的,了解这些知识,对于开发并行程序而言是很有益的。
1 并行指令的生成
![](http://img1.51cto.com/attachment/200910/200910091255056871926.jpg)
软件工程师使用Paralllel类编写的并行算法,经过编译器的处理,会全部转换为对Task类相应方法和属性的调用指令,这些指令被保存到编译好的程序集中。
Task类的实例代表一个可以被并行执行的任务,任务(而不是线程!)是TPL实现并行计算的基本单位。
2 任务并行库的工作原理
任务由线程负责执行,为了获取较高的性能,TPL使用线程池中的线程,并且使用了一个与线程池直接集成的“任务调度器(Task Scheduler)”来负责分派工作任务给线程,这个调度器使用的任务分派策略称为“Work-stealing”。
![](http://img1.51cto.com/attachment/200910/200910091255056940133.jpg)
如图 19‑16所示,线程池中的每个线程都拥有一个专有的(本地的)任务队列,当线程创建任务(即Task类的实例)时,默认设置下,这些任务被放入了线程本地工作队列中。
如果任务本身是通过调用ThreadPool.QueueUserWorkItem()添加的,则此任务会被添加到一个全局队列(global queue)中,这一全局队列就是图 19‑16中所示的“线程池任务队列”。
以下是任务调度器实现任务调度的基本过程:
当任务调度器开始分派任务时,它先检查一下创建此任务的线程是不是线程池中的线程(这种线程拥有一个本地的任务队列),如果不是,此任务被加入到线程池全局任务队列中,如果是,任务调度器检查此任务是否设置了TaskCreationOptions.PreferFairness标记,如果设置了,则此任务被加入到线程池全局任务队列中,否则,还是被放入到线程的本地队列中。
当一个线程开始执行时,它优先搜索自己的专有任务队列,当此队列为空时,它才会去搜索全局任务队列。由此可见,这种调度策略实际上是其于优先级的,本地工作队列比全局队列拥有更高的优先级。
上述这种默认的调度策略适用于绝大多数情况,但不可能是所有的情况,如果需要对线程本地队列和线程池全局队列中的任务一视同仁,在不改变调度策略的情况下(这个策略是由.NET为线程池所提供的默认调度器实现的,不可改),可以通过将需要“一视同仁”的Task任务直接放到线程池全局队列而不是线程本地队列中实现,其具体的实现方法就是在创建任务时,设置它的 TaskCreationOptions.PreferFairness标记。
提示:
如果并行执行是通过Parallel类的Invoke、For和ForEach方法启动的,则不能为其指定TaskCreationOptions.PreferFairness标记,只有在显式创建Task类的代码中可以设置此标记。下一小节将介绍如何直接使用Task类进行基于“任务”的并行编程。
下面对任务并行库的工作原理作一个小结。
简单地说:线程就是“工人”,它负责执行“任务”,任务由任务调度器负责分配。
任务调度器具有很强的智能性,它能自动协调各个任务的分配,不让“忙”的线程“忙死”,“闲”的线程“闲死”。从线程的角度看,由于有任务调度器的公平管理,所有线程都是“团结互助”的“雷锋”。
将线程之间合作的工作从线程自身的职责中“剥离”出来,交由任务调度器来统一协调管理,这是.NET 4.0并行计算任务库设计的一个关键点。如果让线程自身来负责处理工作任务的合理分配,必然会在线程函数内增加同步的代码,这会让整个软件系统变得复杂和难于调试。
我们可以适当地将TPL的这种设计思想引申到社会生活领域:如果将线程比喻为“政府官员”,那么,任务调度器就可以看成是一种“制度”,正是在“制度”的制约之下,“官员”才可能廉洁公正。
在现实社会中,指望贪官他们“良心”发现而自己“金盆洗手”是不现实的,必须建立起一种有效的制度,让所有官员都置于强有力的监督之下,“贪污”的行为自然会受到极大的制约。这是题外话了。
在下一小节中,我们将开始深入地了解Task类。
19.3.5 任务的创建与任务的状态
1 创建任务
在19.3.3节中,我们介绍了使用Parallel类的几个静态方法(如Invoke和For)进行并行编程的基本方法,在19.3.4节中,我们又知道了实际上Parallel类的功能是通过Task类实现的,因此,如果我们需要对任务的执行方式有更多的控制,可以直接基于Task对象编程而非使用Parallel类的静态方法。
进行并行编程的第一步,是创建一个任务对象。最简单的方法就是直接使用new关键字创建Task对象。Task类的构造函数有多个重载形式,我们逐个介绍其含义和用途:
public Task(Action action);
上述构造函数创建一个Task对象,并且让其关联一个任务函数(由action参数引用),当Task对象被线程执行时,此函数被调用。
public Task(Action<object> action, object state);
这一构造函数的第2个参数用于向任务函数传送附加信息,这些附加信息其实就是任务函数调用时的实参。
public Task(Action action, TaskCreationOptions creationOptions);
这一构造函数多了一个TaskCreationOptions类型的参数,此参数用于设置任务的属性标记,上一小节说过,默认情况下新建的任务会放在创建它的线程[1]的本地队列中,如果希望将任务放入线程池的全局队列中,可以向此构造函数传入“TaskCreationOptions.PreferFairness”值。
public Task(Action<object> action, object state,
TaskCreationOptions creationOptions);
这一构造函数是前3个构造函数的“集大成者”,各参数的含义不再赘述。
总结一下,每个任务一定关联有一个任务函数。这是Task对象的本质特征。
创建好以后,并不会自动运行,必须显示调用它的Start()方法。只有此方法被调用之后,此任务才会被插入到线程(或线程池)所关联的任务队列中,并在任务调度器的管理下得到执行。
Task t = new Task(() =>
{
… // 任务函数代码
});
… //任务对象创建完毕,但还未加入到任务队列中
t.Start(); // 将任务追加到相应的任务队列中调度执行。
创建任务的第2种方法是使用TaskFactory类,顾名思义,此类是一个“任务创建工厂”,它提供了“一堆”的公有方法可用于创建任务对象。
Task类有一个静态属性Factory可用于引用一个TaskFactory对象。
比如,上述创建并启动一个任务的代码可以简化为:
Task t = Task.Factory.StartNew(() =>
{
… //任务函数代码
});
在深入了解Task类的基础之上,TaskFactory类的使用就没有任何奇特之处,请读者自行查询MSDN了解TaskFactory类提供的另外一些方法的用法。
2 了解任务的状态
“风萧萧兮易水寒,壮士一去兮不复还”,与线程对象一样,每一个Task对象都会经历一个生命周期,在这个生命周期的每个特定阶段,对象处于一个特定的状态,并且不可能由后一个状态“回转”到前一个状态。简单地说,Task对象的生命是一条单行线,一旦上路,就只能往前走,直到生命的终结,期间绝无走回头路的可能。
![](http://img1.51cto.com/attachment/200910/200910091255057026394.jpg)
如图 19‑17所示,Task对象拥有8个状态,这些状态之间可以相互转换。
其中,Created是起始状态,而Canceled、Faulted和RanToCompletion是3个终止状态,其余状态都是中间状态。
通过对Task类特定的方法的调用,Task对象会自动进行状态的转换。通常情况下软件工程师无需考虑这一转换过程,因为它们是由TPL基础架构直接管理的。
Task类提供了一个Status属性来表明当前对象所处的状态,但出于使用方便考虑,Task类另外还提供了3个相关属性用于确定对象是否处理3个终止状态之一:IsCanceled、IsFaulted和IsCompleted。
==========================================================
从下一讲开始,将介绍在实际开发中针对各种典型开发场景使用Task实现并行计算的基本技术方案。
下一篇:.NET4.0并行计算技术基础(8) 上一篇:.NET4.0并行计算技术基础(6)