基于Task的异步模式(TAP),我们很容易创建处理Tasks的工具方法。所有的TAP方法都会返回一个Task,因此我们针对一个TAP方法写的所有特殊行为,都可以在其它方法上进行重用。在本章中,我们将探讨一些用于处理Task的工具方法,包括:
- 那些看起来像TAP的方法,但是却具有一些特殊行为,并且自身不会被异步调用
- 用来处理Tasks的组合器方法(Combinators),基于它会生成有用的新Tasks
- 在异步操作期间可以取消操作、显示进度的工具
尽管已经有很多这类工具方法了,但是将来你可能需要定义类似的工具(.NET framework未提供时),所以看看如何自己实现这样的工具方法还是很必要的。
延迟一段时间
你想执行的最简单的耗时操作可能就是在一定的时间内什么也不做。这等同于同步世界中的 Thread.Sleep 。事实上,你可以使用 Thread.Sleep 结合Task.Run 来进行实现
cs
await Task.Run(() => Thread.Sleep(100));
但这种方式是一种浪费。一个线程仅仅被用来阻塞一段时间,就是一种浪费。在.NET 中已经存在一种方式在不使用任何线程的情况下,在一段时间后调用你的代码------ System.Threading.Timer 类。更有效的方式是设置一个 Timer,然后使用 TaskCompletionSource 去创建Task,当 Timer 被触发时设置Task 为完成:
cs
private static Task Delay(int millis)
{
TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();
Timer timer = new Timer(_ => tcs.SetResult(null), null, millis, Timeout.Infinite);
tcs.Task.ContinueWith(delegate { timer.Dispose(); });
return tcs.Task;
}
当然了,在 framework 中已经提供了一个有用的小工具------ Task.Delay,并且 framework 提供的版本更强大、健壮,可能比我上面写得方法更高效。
等待一组Tasks
正如我们在第四章 Task和await 所见,通过依次调用异步操作,然后依次等待它们,很容易以并行方式运行多个异步操作。我们在 第九章 中将会发现:我们需要 await 每个启动的Task,这一点很重要,否则异常将会丢失。
这个问题的解决方案是使用 Task.WhenAll,它可以接收多个Tasks 然后生成一个聚合的 Task,这个聚合的Task将在所有输入的Task完成后才完成。下面是最简单版本的 WhenAll,它也包含针对Task<T> 的重载方法:
cs
Task WhenAll(IEnumerable<Task> tasks)
使用 WhenAll 与你自己写代码等待多个 tasks的核心区别是: 当抛出异常时 WhenAll 能正确处理相关行为。基于这个原因,你应该一直使用 WhenAll。
泛型版本的 WhenAll 会返给你包含你提供的单个Task 的结果的数组。但这不是必须的,仅是为了使用方便,因为你仍然可以访问原始的Tasks,因此你可以使用它们的 Result 属性(因为它们必然已经执行完成)。
让我们修改 favicon browser 来作为示例。记住我们现在有一个调用 async void 方法的版本开始依次下载每一个图标。当下载结束时这个方法会把图标添加到窗体中。这种方式非常有效,因为所有的下载都是并行进行的,但存在两个问题:
- 图标会以下载结束的先后顺序显示在窗体中
- 因为每个图标都在自己的 async void 方法中下载,任何里面抛出的异常都被重新抛到UI线程,这种情况下处理异常会很困难
因此让我们进行重构,把遍历所有图标的方法变为async。这意味着我们可以把这些异步操作作为一个组。从这次重构之后的版本开始,我们将依次处理每个图标:
cs
private async void GetButton_OnClick(object sender, RoutedEventArgs e)
{
foreach (string domain in s_Domains)
{
Image image = await GetFavicon(domain);
AddAFavicon(image);
}
}
现在我们来修正代码,使它不但以并行的方式进行所有的下载工作,还能按照顺序显示图标。我们通过调用 GetFavicon 来开始所有的下载操作,并且将Tasks存放到列表中。
cs
List<Task<Image>> tasks = new List<Task<Image>>();
foreach (string domain in s_Domains)
{
tasks.Add(GetFavicon(domain));
}
如果你喜欢使用LINQ,这样写会更好
cs
IEnumerable<Task<Image>> tasks = s_Domains.Select(GetFavicon);
// The IEnumerable from Select is lazy, so evaluate it to start the tasks
tasks = tasks.ToList();
一旦我们有了Tasks列表,就可以将它赋给 Task.WhenAll,它会返回一个 Task 对象,这个Task 会在所有下载任务都完成后完成,带着所有的结果
cs
Task<Image[]> allTask = Task.WhenAll(tasks);
然后,我们要做的就是await allTask,使用它的返回结果
cs
Image[] images = await allTask;
foreach (Image eachImage in images)
{
AddAFavicon(eachImage);
}
如此这般,我们成功地以短短几行代码就写出了复杂的并行逻辑。最终的代码在whenAll 分支上可以获取到。
等待集合中的任何一个 Task
在使用多个 Tasks 时另一个可能需要的工具方法是等待第一个Task结束。可能的使用场景是:你正在从各种各样的资源中请求一个资源,不管哪个资源最先返回我们就使用哪个。
完成这个工作的工具方法是 Task.WhenAny。下面是一个泛型版本。同样,也有很多重载方法,但是下面这个很有意思。
cs
Task<Task<T>> WhenAny(IEnumerable<Task<T>> tasks)
WhenAny 的方法签名比 WhenAll 稍微难理解一点, 但这自有其道理。当可能发生异常时,则需要小心使用 WhenAny。如果你想找出程序中发生的所有异常,你需要确保每一个 Task 都被 await了,否则异常可能会丢失。使用 WhenAny 并且简单地忘掉其它的 Tasks 等同于捕获了所有异常并进行忽略,这不是好的做法,往往会在后期显现为隐蔽的缺陷和无效状态。
WhenAny 的返回值是 Task<Task<T>>。这意味着当你await 它之后,你将得到一个 Task<T>,这是原来的Tasks 中最先完成的那个,因此当你得到它时它一定是已经完成了的。之所以返回Task 而不只是结果T,是因为这样你就可以知道到底原来Tasks中的哪个Task先完成了,从而可以取消或者await其余的Tasks。
cs
Task<Task<Image>> anyTask = Task.WhenAny(tasks);
Task<Image> winner = await anyTask;
Image image = await winner; // This always completes synchronously
AddAFavicon(image);
foreach (Task<Image> eachTask in tasks)
{
if (eachTask != winner) // <-- 可以用来判断task
{
await eachTask;
}
}
winner Task刚刚结束就用它更新UI是没有问题的,但当这个操作完成后,你应该像我代码所写的那样 await 所有剩下的 Tasks,这些额外的代码对你的程序并没有影响,但愿他们都能成功完成。但是如果剩下的 Tasks中有一个失败了,你需要找出它并修复这个问题。
创建自己的组合器(Combinators)
我们称 WhenAll 和 WhenAny 为异步组合器。尽管它们会返回Tasks,但他们自身却不是异步方法,而是以有用的方式组合了其它Tasks。如果需要,你也可以编写自己的组合器,这样你就会拥有一个包含可重用并行行为的工具包,为你所用。
让我们来写一个组合器作为例子。或许我们想在任何Task上添加超时机制。尽管我们可以很容易地从头编写这个功能,但我想用它作为使用 Delay 和 WhenAny 的良好示例。一般来说,组合器使用async进行实现是最容易的,就像在这个例子里一样,但是有时你也可以不这么做。
cs
private static async Task<T> WithTimeout<T>(Task<T> task, int time)
{
Task delayTask = Task.Delay(time);
Task firstToFinish = await Task.WhenAny(task, delayTask);
if (firstToFinish == delayTask)
{
// The delay finished first - deal with any exception
task.ContinueWith(HandleException);
throw new TimeoutException();
}
return await task; // If we reach here, the original task already finished
}
我的方式是创建一个使用了 Delay 方法的Task,它会在超过指定时间后结束。然后我使用 WhenAny 来控制 这个 delayTask 和原来的 Task,不管哪个先完成我都继续执行操作,或者是操作结束,或者是超时结束。然后,匹配看是哪个Task先结束了,或者抛一个 TimeoutException 或者返回结果。
注意,我很小心地处理了超时情况下的异常。我使用 ContinueWith 来继续原来的 Task,如果存在异常就会进行处理。我知道 Task.Delay 永远也不会抛出异常,因此我无需对它进行处理。HandleException 方法的实现大致如下:
cs
private static void HandleException<T>(Task<T> task)
{
if (task.Exception != null)
{
logging.LogException(task.Exception);
}
}
很明显,此处如何做取决于你对异常的处理策略。通过使用 ContinueWith 关联 HandleException 方法,我确保无论原来的Task何时结束(无论在将来的什么时间完成),检查异常的逻辑都会被执行。重要的是,这不会阻碍程序的主执行流程,因为当超时结束的时候,它已经做了它需要做的事情。
《未完待续》