BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

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

| 作者 李永伦 关注 0 他的粉丝 发布于 2012年12月7日. 估计阅读时间: 15 分钟 | QCon上海2018 关注大数据平台技术选型、搭建、系统迁移和优化的经验。

页面加载很卡

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

图 1

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

代码 1

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

启动任务

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

代码 2

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

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

代码 3

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

代码 4

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

代码 5

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

延续任务

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

代码 6

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

代码 7

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

代码 8

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

取消任务

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

图 2

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

代码 9

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

代码 10

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

代码 11

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

异常处理

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

代码 12

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

代码 13

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

图 3

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

代码 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所示。

图 4

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


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

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

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT