我的一个应用程序有一个用来管理原材料库的页面,如图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控件的LoadingPivotItem或SelectionChanged事件,在它的事件处理程序里执行加载数据的代码。
代码 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?如果你正在使用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++ 的并行和异步》。