.NET4.0并行计算技术基础(8)
有些朋友来邮件询问本系列文章的示例源码,由于我没有发现CSDN博客直接提供了上传附件的功能,所以打算等本章所有文章贴完以后,再将所有源码打包上传到CSDN下载频道。
另外,要想看懂本系列文章,需要您对.NET多线程开发有基本的了解。我在新书中花了近200页的篇幅来介绍.NET多线程开发技术,可帮助大家循序渐进地掌握技术,呵呵,在此先作个广告。
今天贴出第8讲 “任务的同步”,本章内容过半了。
金旭亮
2009.10.12
==================================
前几讲的链接:
=====================================
在并行计算应用程序中,通常会创建多个 Task 对象以执行不同的工作任务,而依据具体应用场景,这些工作任务对象之间又会有着相互协作的需求,比如可能要求某个工作任务完成以后自动启动一个或多个新的 Task 对象执行后继处理工作,或者某个正在执行的工作任务中途需要等待另一个工作任务执行完毕才能执行,这就是任务的同步问题。
Task 类提供了 ContinueWith 和 Wait 系列方法,在“任务”的层次(而不是线程的层次)实现任务的同步。
例如,以下代码在 task1 完成之后自动运行 task2:
Task task1=new Task(()=>
{
DoStep1();
});
Task task2 = task1.ContinueWith ((PrevTask) =>
{
DoStep2();
});
task1.Start();
上述代码中的 PrevTask 参数代表已完成的“前辈” Task 对象, TPL 会将此对象传给后继的 Task 对象,因此,在后继对象中可以通过此参数获取前一个任务的相关信息。
ContinueWith() 方法有多个重载形式,其中很有用的是返回一个 Task<TResult> 的重载形式:
public Task<TResult> ContinueWith<TResult>(
Func<Task, Task<TResult>> continuationFunction);
当任务需要返回一个唯一值时,可以使用这个重载形式。
有的 ContinueWith() 方法重载形式接收一个 TaskContinuationOptions 类型的参数:
public Task ContinueWith(
Action<Task> continuationAction,
TaskContinuationOptions continuationOptions );
TaskContinuationOptions 是一个枚举,可以使用它来指定“在何种情况下”才执行后继的工作任务。例如,以下代码指定只有 task1 中有未捕获的异常时,才运行 task2 :
Task task2 = task1.ContinueWith((PrevTask) =>
{
DoWithException();
},TaskContinuationOptions.OnlyOnFaulted);
另一个被广泛使用的任务同步手段是 Task 类的 Wait 系列方法,此系列方法可分为 3 类,每类方法又有着多个重载形式。
1. Wait(): 等待单个任务的完成
2. WaitAll(): 等待一组任务的全部完成
3. WaitAny() :等待一组任务的任何一个任务完成
当并行程序调用上述方法时,会在调用线程上阻塞等待任务完成。
以下代码等待单个任务的完成:
Task t = Task.Create(...);
...
t.Wait();
以下代码等待多个任务的完成:
Task t1 = Task.Create(...);
Task t2 = Task.Create(...);
Task t3 = Task.Create(...);
...
Task.WaitAll(t1, t2, t3);
将上述代码中的 WaitAll 改为 WaitAny ,则 t1,t2,t3 中任何一个完成时,当前任务都会结束等待状态而继续执行。
当一个任务会创建另一个任务时,称此任务为“父任务”,被创建的为“子任务”。使用任务之间的“父子关系”,可以实现类似于 Task.ContinueWith 的功能:
Task tskParent = new Task(() =>
{
// 父任务完成的工作
// 创建后继子任务并自动启动
Task.Factory.StartNew(() => MethodA());
Task.Factory.StartNew(() => MethodB());
Task.Factory.StartNew(() => MethodC());
});
// 启动父任务
tskParent.Start();
// 等待整个任务的完成
tskParent.Wait();
与使用 Task.ContinueWith 相比,使用父子任务的好处在于可以减少应用程序需要跟踪状态的任务对象个数。
不管是使用 ContinueWith 还是 Wait 系列方法,调用这些方法的线程都会阻塞等待。如果不希望阻塞当前线程,可以通过轮询 Task 对象的 IsCompleted 属性来了解其是否完成,以下是框架代码:
while (!task1.IsCompleted)
{
Thread .SpinWait(10000000); // 让当前线程时刻盯着前一任务的完成状态
// 可以安排进行其它工作
}
//task1 已完成,进行后继工作……
本节介绍的任务同步的方法都很简单,但通过灵活的组合,可以实现复杂的并行计算任务,而同样的功能,如果直接使用线程来处理,其工作量会增加很多。这也是使用任务并行库优越性的表现之一。
任何一个并行计算程序都需要处理一定量的数据,因此,需要解决如何将数据在任务中传送并在合适的时候取回处理结果的问题。
我们使用 Task 对象来代表一个并行处理任务,并调用其 Start() 方法启动并行处理过程。从 Task 类的构造函数可以看到,每个 Task 类关联的是一个 Action 或 Action<T> 委托,其所引用的函数其返回值为 void ,很明显此函数无法直接将处理结果返回给任务的启动者。
然而,由于任务的执行是由线程负责的,所以,可以在任务函数中直接访问程序中的用于保存处理结果的共享资源(比如某个对象的公有属性,或者类的静态成员等)。但这时,必须使用一种线程同步手段来通知任务启动者线程本线程工作结束,从而启动任务的线程可以从共享资源中取回处理结果。
请看示例程序 GetResultFromTask 。这个程序在内部使用了一个 ManualResetEvent 对象,主线程在启动处理工作后等待此对象变为 Signaled 状态。示例程序通过 Task 对象启动一个数组求和计算任务,在其任务函数中将处理结果保存到一个共享的静态字段中,然后 ManualResetEvent.Set() 方法通知主线程可以取出处理结果。
这种方法虽然可行,但本质上还是基于“线程”的开发, TPL 的优势没有显示出来。
我们完全可以不用“原始”的线程同步对象,而直接使用 TPL 所提供的任务同步机制达到同样的目的。
请看示例项目 GetResultFromTaskWithoutThreadSync 。它与前一个示例项目 GetResultFromTask 功能完成一样,但它利用到了 Task 对象的 ContinueWith() 方法:
public Task ContinueWith(Action<Task> continuationAction);
注意这是一个实例方法,对它的调用将返回一个新的 Task 对象,这个对象引用的任务函数由方法参数提供。
这一方法的功能是:
当前 Task 对象的任务函数执行完毕后,自动启动其 ContinueWith() 方法所创建的“后继” Task 对象。当前“已完成”的 Task 对象将作为“后继” Task 对象任务函数的参数传入。
下面列出示例的代码框架,详细代码请直接看项目源码:
static void Main(string[] args)
{
// 需要并行执行的数据处理函数
Action<object> ProcessData = delegate(object end)
{
// 编写代码完成数据处理工作 ...
// 保存处理结果 ...
};
// 用于取回处理结果的函数
Action<Task> GetResult=delegate(Task finishedTask)
{
if(finishedTask.IsFaulted)
Console.WriteLine(" 任务在执行时发生异常: ",
finishedTask.Exception.Message);
else
Console.Write(" 程序运行结果为 {0}", Program.result);
};
// 创建并行处理数据的任务对象
Task tskProcess = new Task(ProcessData, 1000000);
// 当数据处理结束时,自动启动下一个工作任务,取回上一任务的处理结果
Task tskGetResult = tskProcess.ContinueWith(GetResult);
// 开始并行处理数据……
tskProcess.Start();
Console.ReadKey();
}
上述代码非常简明,值得注意的是在后继的任务中可以通过检测“已完成的” Task 对象的 IsFaulted 属性得知是否在其处理过程出现了异常。
通过连续使用 ContinueWith() 方法,我们可以启动一连串的“首尾”相接的任务,因此特别适合并行执行多个任务,每个任务内部又包容着一系列需要顺序执行的子任务。
有关任务之间相互协作与配合(称为“任务同步”)的问题,后面章节中还有介绍。
与 Task 不一样, Task<TResult> 类直接关联一个可以有返回值的函数。以下是它的一个构造函数:
public Task(Func<object, TResult> function, object state);
从这个构造函数可以看出, Task<TResult> 对象关联着一个任务函数(由 function 参数引用此函数),构造函数中的 object 参数将成为任务函数的实参。
Task<TResult> 类提供了一个 Result 属性用于取回任务函数的执行结果:
public TResult Result { get; internal set; }
可以直接使用 new 关键字创建 Task<TResult> 类的实例,然后手动调用其 Start() 方法启动,也可以通过工厂类 TaskFactory<TResult> 的 StartNew() 方法一步完成创建任务对象和启动运行的工作。 Task<TResult> 类提供了以下静态属性引用工厂类:
public static TaskFactory<TResult> Factory { get; }
示例程序 GetResultFromTaskTResult 展示了 Task<TResult> 类的用法,其代码框架如下:
static void Main(string[] args)
{
// 完成数据处理工作,结果将作为函数返回值
Func<object,long> del = delegate(object end)
{
long result= 0;
//... 数据处理代码略
return result;
};
Task<long> tsk = new Task<long>(del, 1000000);
// 启动运行
tsk.Start();
// 取回结果
Console.Write(" 程序运行结果为 {0}", tsk.Result );
Console.ReadKey();
}
这里比较有趣的是整个代码中没有一句线程同步代码。您可能会奇怪,主线程与工作线程的同步是怎么实现的?主线程如何知道工作线程承担的工作任务已处理结束?
答案就在 Task<TResult> 类的 Result 属性中。请注意, Task<TResult> 类派生自 Task 类,而此 Task 类提供了一个 Wait() 方法用于等待工作任务的执行结束,所以,在访问 Task<TResult> 类的 Result 属性时,如果工作任务还未执行完毕,则尝试取回结果的线程会阻塞等待,因为 Result 属性的 get 访问器函数一直没有返回,请看以下框架代码:
public TResult Result
{
get
{
if (!base.IsCompleted)
{
base.Wait(); // 阻塞等待任务的执行结束
}
//……
}
internal set
{
//……
}
}
上述代码另一个要注意的地方是 Result 属性的 Set 访问器是 internal 的,这意味着应用程序不能直接设置其值。
本小节介绍了 3 种典型的取回任务处理结果的基本方法,很明显,第一种方式需要显式地使用线程同步手段,不是一个好的方案,第二种方式使用 Task<TResult> ,使用简单,最适合于取回单个 工作任务的单个 处理结果,对于内部包含多个并行执行子任务的复杂工作任务,使用 Task.ContinueWith() 最合适,它可以直接处理多个数据处理结果,并且在很大程度上减少了显式编码锁定共享资源的需求。
=====================================
下一讲,介绍如何取消任务
请看《.NET 4.0并行计算技术基础(9) 》