论道 WP(六 ):任务并行库

阅读数:2039 2012 年 12 月 6 日 07:19

页面加载很卡

我的一个应用程序有一个用来管理原材料库的页面,如图 1 所示,这是一个 Pivot 页面,每个 Pivot 项列出一类原材料。整个 Pivot 页面绑到一个 ManageIngredientsViewModel 对象,每个 Pivot 项绑到一个 IngredientGroupViewModel 对象,这些 IngredientGroupViewModel 对象是在运行时根据原材料库的数据创建的。

论道WP(六 ):任务并行库

图 1

目前的做法是在 ManageIngredientsViewModel 的构造函数里通过 LINQ to SQL 加载数据,然后创建相应的 IngredientGroupViewModel 对象,如代码 1 所示。这种同步加载数据的做法很常见,也很直观,不过,如果数据比较多,并且伴随磁盘或者网络的访问,就有可能导致页面加载很卡。

论道WP(六 ):任务并行库

代码 1

我希望异步加载数据,并且只在用户查看某个 Pivot 项时才加载它的数据,这样可以确保页面保持响应,同时又能避免加载多余的数据。在这篇文章里,我们将会以这个应用程序为背景探讨如何通过任务并行库(Task Parallel Library,TPL)实现这些效果。

启动任务

首先,我不希望一开始就加载所有数据,因此把前面的代码 1 换成下面的代码 2,新的代码负责创建一组空的 IngredientGroupViewModel 对象。由于 Pivot 控件的 ItemsSource 属性和 ManageIngredientsViewModel 对象的 IngredientGroups 属性绑定,Pivot 控件会自动创建一组空的 Pivot 项。

论道WP(六 ):任务并行库

代码 2

接着,为了实现按需加载,我需要知道当前显示的 Pivot 项是哪个。这点很容易办到,我们可以让 Pivot 控件的 SelectedItem 属性和 ManageIngredientsViewModel 对象的 CurrentIngredientGroup 属性双向绑定,这样的话,每次用户切换 Pivot 项时,我们就可以通过 CurrentIngredientGroup 属性访问当前显示的 Pivot 项对应的 IngredientGroupViewModel 对象了。

当 CurrentIngredientGroup 属性的值发生改变时,我们将会调用 LoadIngredientsAsync 方法加载数据,如代码 3 所示。当然,这里不是调用 LoadIngredientsAsync 方法唯一选择,你也可以在 CurrentIngredientGroup 属性的 set 访问器里调用,因为加载数据的代码是异步执行的,所以不必担心对属性的返回造成阻塞。此外,你也可以订阅 Pivot 控件的 LoadingPivotItem SelectionChanged 事件,在它的事件处理程序里执行加载数据的代码。

论道WP(六 ):任务并行库

代码 3

当用户第一次切换到某个 Pivot 项时,将会调用 LoadIngredientsAsync 方法加载数据,为了避免阻塞,这个方法会在启动加载数据的任务之后马上返回,任务会以异步的方式执行,此时用户可以自由切换到其他 Pivot 项。当用户从其他 Pivot 项切换回来时,将会再次调用 LoadIngredientsAsync 方法,为了避免重复启动加载数据的任务,我们需要一个布尔字段来表示任务是否已经开始,如代码 4 所示,仅当任务还没开始才会启动任务。

论道WP(六 ):任务并行库

代码 4

启动任务的代码非常简单,如代码 5 所示, StartNew 方法会用我们传给它的 Lambda 创建一个 Task 对象,然后启动并返回它。StartNew 方法的类型参数和 Lambda 的返回值的类型对应,你可以通过 Task 对象的 Result 属性访问这个返回值,访问的时候,如果任务已经完成,将会马上得到结果,如果任务还没完成,将会阻塞当前线程。对于没有返回值的 Lambda,可以使用非泛型的 StartNew 方法创建 Task 对象。

论道WP(六 ):任务并行库

代码 5

值得提醒的是,StartNew 方法不一定马上执行任务,它会对任务进行排期,然后等待空闲的线程来执行。TPL 的 TaskScheduler 支持通过工作窃取实现负载平衡,因此,如果多个线程同时执行任务,先完成的线程会自动分摊其他线程的任务。

延续任务

加载数据完毕之后,我们需要在页面上显示出来。要在一个任务完成之后执行另一个任务,我们可以在第一个任务上调用 ContinueWith 方法,并以 Lambda 的方式向它传递第二个任务,如代码 6 所示。Lambda 的参数是第一个任务,我们可以通过它访问任务的状态和结果。

论道WP(六 ):任务并行库

代码 6

因为 Pivot 项的 ListBox 控件和 IngredientGroupViewModel 对象的 Ingredients 属性绑定,所以我们只需把数据添加到 Ingredients 属性,ListBox 控件就会自动更新了。但是,由于这个任务(间接)涉及到 UI 上的控件,必须切换到 UI 线程上执行,常见的做法是通过 Lambda 包装需要执行的代码,然后交给 Dispatcher 对象的 BeginInvoke 方法执行,如代码 7 所示。

论道WP(六 ):任务并行库

代码 7

TPL 默认在工作线程上排期和执行任务,如果我们想换另一种方式或者另一个地方排期和执行任务,我们可以向 ContinueWith 方法传递其他 TaskScheduler 对象。TaskScheduler 类有一个 FromCurrentSynchronizationContext 静态方法,可以用来获取与当前同步上下文关联的 TaskScheduler 对象。我们在 UI 线程上调用这个方法,获取与 UI 同步上下文关联的 TaskScheduler 对象,再把它传给 ContinueWith 方法,如代码 8 所示,这样就能在 UI 线程上排期和执行这个任务了。

论道WP(六 ):任务并行库

代码 8

ContinueWith 方法是有返回值的,它会返回第二个任务,如果有需要的话,我们可以在第二个任务上调用 ContinueWith 方法创建第三个任务,如此类推,这意味着我们可以通过 ContinueWith 方法创建任意长度的延续链(continuation chain)。

取消任务

当一个任务已经开始但尚未结束时,我们可以取消这个任务。取消一个任务并不像杀掉一个进程这么简单直接,取消任务的过程是一个协同过程,任务的取消可以看作调用方和被调用方达成一致共识的结果,取消任务的标准流程如图 2 所示。接下来,我们将会详细看看每个步骤是如何实现的。

论道WP(六 ):任务并行库

图 2

首先,我们需要创建一个 CancellationTokenSource 对象,并通过它的 Token 属性获取一个 CancellationToken 对象。我们可以把它们声明为私有字段,并在构造函数里初始化,如代码 9 所示。

论道WP(六 ):任务并行库

代码 9

然后,添加一个 _completed 布尔字段,用来标记任务已经完成的状态,并添加一个 CancelLoading 方法,如代码 10 所示。在 CancelLoading 方法里,我们会检查任务是否已经开始但尚未结束,如果是,就调用 CancellationTokenSource 对象的 Cancel 方法发送取消请求。

论道WP(六 ):任务并行库

代码 10

接着,把 LoadIngredientsAsync 方法的代码改成代码 11 所示的那样。这段代码有三个改动,第一个是修改任务的启动条件,并在任务完成的时候设置任务的状态。随着逻辑的发展,可能会出现更多的状态,这个时候,我们可以考虑通过一个枚举字段而不是一组布尔字段组合表示状态。第二个改动是在 foreach 语句里调用 CancellationToken 对象的 ThrowIfCancellationRequested 方法,这个方法会检查调用方是否发送了取消请求,如果是,就抛出 OperationCanceledException 异常取消任务。从这里不难看出,调用方可以发送取消请求,但是否接受请求并取消任务是由被调用方决定,如果被调用方认为任务不宜取消,可以忽略请求并继续执行。最后一个改动是把 CancellationToken 对象传给 ContinueWith 方法,这样做是因为任务不一定马上启动,如果调用方在任务启动之前发送取消请求,TPL 将会直接跳过这个任务,而不必先启动已经取消的任务再调用 ThrowIfCancellationRequested 方法取消任务。

论道WP(六 ):任务并行库

代码 11

在我们的示例里,CancellationTokenSource、CancellationToken 和 Task 这三个对象是一一对应的,但是,这不是必须的,事实上,如果你想同时取消多个任务,可以在多个任务里使用相同的 CancellationToken 对象,这样的话,调用方只需调用一个 CancellationTokenSource 对象的 Cancel 方法就可以取消这些任务了。

异常处理

处理任务抛出的异常非常简单,你只需在 try 块里调用 Wait 方法或者访问 Result 属性,然后在 catch 块里处理 AggregateException 异常就行了,如代码 12 所示。AggregateException 异常有一个 InnerExceptions 属性,你可以通过它访问同时执行的多个任务抛出的一个或多个异常。

论道WP(六 ):任务并行库

代码 12

不过,这种做法并不适用于我们的场景,因为调用 Wait 方法会阻塞当前线程,这正是我们极力避免的。想要避免阻塞,又要确保会在任务出错时执行,我们可以通过 ContinueWith 方法创建一个专门处理异常的任务,如代码 13 所示,TaskContinuationOptions.OnlyOnFaulted 用来指定这个任务只在前面的任务出错时才执行。相应地,我们要把代码 11 的 ContinueWith 方法的 TaskContinuationOptions.None 改为 TaskContinuationOptions.OnlyOnRanToCompletion,确保这个任务只在前面的任务完成时才执行。在传给 ContinueWith 方法的 Lambda 里,我们通过 Exception 属性访问前面的任务抛出的异常,因为它是一个 AggregateException 异常,所以需要通过 InnerExceptions 属性访问实际抛出的异常。

论道WP(六 ):任务并行库

代码 13

细心观察前面的代码,你会发现那条延续链已经演变成一颗延续树了,如图 3 所示。延续链上的每个任务抛出的异常都需要处理,如果不同的异常有不同的处理方式,那么延续树能够提供最大的灵活性,代价是代码的逻辑会因此变得晦涩。

论道WP(六 ):任务并行库论道WP(六 ):任务并行库

图 3

如果你想统一处理延续链上的多个任务,可以考虑通过 Task.Factory.ContinueWhenAll 方法为它们创建一个处理异常的任务,如代码 14 所示。在处理异常之前,你必须确保 Exception 属性不为 null,因为完成或者取消的任务是没有异常的。

论道WP(六 ):任务并行库

代码 14

如何获取 TPL?

最后一个问题,也是最重要的一个问题,如何获取 TPL?如果你正在使用 Windows Phone SDK 8.0 开发 Windows Phone OS 8.0 的应用程序,那么你只需在代码顶部添加 using System.Threading; 和 using System.Threading.Tasks; 就行了,因为 Windows Phone 8 本身就支持 TPL。

如果你正在开发 Windows Phone OS 7.1 的应用程序,可以通过 NuGet 在 Visual Studio 里添加 TPL 的引用,方法是在 Manage NuGet Packages 对话框里搜索 Microsoft.Bcl,然后安装 BCL Portability Pack for .NET Framework 4, Silverlight 4 and 5, and Windows Phone 7.5 ,如图 4 所示。

论道WP(六 ):任务并行库

图 4

TPL 只适用于托管应用程序,如果你正在使用 C++ 开发 Windows Phone Direct3D 应用程序或者组件 / 类库,你可以考虑并行模式库(Parallel Patterns Library,PPL),详细的用法可以参见《遇见C++ PPL:C++ 的并行和异步》


感谢贾国清对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

评论

发布