呵呵,越到国庆反而越忙,好多天没更新了,工作第一天,贴出一篇新文。
金旭亮
2009.10.9
=======================================
.NET4.0并行计算技术基础(7)
前几讲的链接:
=========================================
19.3.4 任务并行库原理初探
在上一小节中,我们看到只需简单地调用
Parallel
类中的一些静态方法,就可以让代码并行执行。您一定会对任务并行库的强大功能有了很深的印象,一些喜欢刨根问底的读者可能会问:
任务并行库怎样实现代码的并行执行?
任务并行库的底层技术细节很复杂,要介绍它超出了本书的范畴,然而,对其工作原理作一个介绍是可能的,了解这些知识,对于开发并行程序而言是很有益的。
1 并行指令的生成
软件工程师使用
Paralllel
类编写的并行算法,经过编译器的处理,会全部转换为对
Task
类相应方法和属性的调用指令,这些指令被保存到编译好的程序集中。
Task
类的实例代表一个可以被并行执行的任务,
任务(而不是线程!)是
TPL
实现并行计算的基本单位。
2 任务并行库的工作原理
任务由线程负责执行,为了获取较高的性能,
TPL
使用线程池中的线程,并且使用了一个与线程池直接集成的“
任务调度器(
Task Scheduler
)
”来负责分派工作任务给线程,
这个调度器使用的任务分派策略称为“
Work-stealing
”。
如
图
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
”值。
[1]
假设此线程是线程池中的线程
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
对象的生命是一条单行线,一旦上路,就只能往前走,直到生命的终结,期间绝无走回头路的可能。
如
图
19
‑
17
所示,
Task
对象拥有
8
个状态,这些状态之间可以相互转换。
其中,
Created
是起始状态,而
Canceled
、
Faulted
和
RanToCompletion
是
3
个终止状态,其余状态都是中间状态。
通过对
Task
类特定的方法的调用,
Task
对象会自动进行状态的转换。通常情况下软件工程师无需考虑这一转换过程,因为它们是由
TPL
基础架构直接管理的。
Task
类提供了一个
Status
属性来表明当前对象所处的状态,但出于使用方便考虑,
Task
类另外还提供了
3
个相关属性用于确定对象是否处理
3
个终止状态之一:
IsCanceled
、
IsFaulted
和
IsCompleted
。
==========================================================
从下一讲开始,将介绍在实际开发中针对各种典型开发场景使用Task实现并行计算的基本技术方案。
请看《
.NET 4.0并行计算技术基础(8
)》