15.3 Task 异常
1. 在等待时拆包异常
在等待任务时,任务出错或取消都将抛出异常,但并不是 AggregateException 。大多情 况下为方便起见,抛出的是 AggregateException 中的第一个异常,往往这就是我们想要的。 异步特性就是像编写同步代码那样编写异步代码,如下所示:
1 async Task<string> FetchFirstSuccessfulAsync(IEnumerable<string> urls) 2 { 3 //验证url 4 foreach (string url in urls) 5 { 6 try 7 { 8 using (var client=new HttpClient()) 9 { 10 return await client.GetStringAsync(url); 11 } 12 } 13 catch (System.Net.WebException) 14 { 15 //记录日志 16 } 17 } 18 throw new System.Net.WebException("No urls Success"); 19 }
目前,先不要在意损失所有的原始异常,以及按顺序获取所有页面。我想说明的是,我们希 望在这里捕获 WebException 。执行一个使用 HttpClient 的异步操作,失败后可抛出 WebException 。我们想捕获并处理它,对吧?但 GetStringAsync() 方法不能为服务器超时等 错误抛出 WebException ,因为方法仅仅启动了操作。它只能返回一个包含 WebException 的任 务 。 如 果 简 单 地 调 用 该 任 务 的 Wait() 方 法 , 将 会 抛 出 一 个 包 含 WebException 的 AggregateException 。任务awaiter的 GetResult 方法将抛出 WebException ,并被以上代码 所捕获。
当然,这样会丢失信息。如果错误的任务中包含多个异常,则 GetResult 只能抛出其中的一 个异常(即第一个)。你可能需要重写以上代码,这样在发生错误时,调用者就可捕获 AggregateException 并检查所有失败的原因。重要的是,一些框架方法(如 Task.WhenAll() ) 也可以实现这一点。 WhenAll() 方法可异步等待(方法调用中指定的)多个任务的完成。如果其 中有失败的,则结果即为失败,并包含所有错误任务中的异常。但如果只是等待(await) WhenAll() 返回的任务,则只能看到第一个异常。
幸好,要解决这个问题并不需要太多的工作。我们可以使用可等待模式的知识,编写一个 Task<T> 的扩展方法,从而创建一个可从任务中抛出原始 AggregateException 的特殊可等待 模式成员。以下:
1 public static partial class TaskExtensions 2 { 3 [Description("Listing 15.03")] 4 public static AggregatedExceptionAwaitable WithAggregatedExceptions(this Task task) 5 { 6 if (task == null) 7 { 8 throw new ArgumentNullException("task"); 9 } 10 11 return new AggregatedExceptionAwaitable(task); 12 } 13 14 public struct AggregatedExceptionAwaitable 15 { 16 private readonly Task task; 17 18 internal AggregatedExceptionAwaitable(Task task) 19 { 20 this.task = task; 21 } 22 23 public AggregatedExceptionAwaiter GetAwaiter() 24 { 25 return new AggregatedExceptionAwaiter(task); 26 } 27 } 28 29 public struct AggregatedExceptionAwaiter : ICriticalNotifyCompletion 30 { 31 private readonly Task task; 32 33 internal AggregatedExceptionAwaiter(Task task) 34 { 35 this.task = task; 36 } 37 38 // Delegate most members to the task's awaiter 39 public bool IsCompleted { get { return task.GetAwaiter().IsCompleted; } } 40 41 public void UnsafeOnCompleted(Action continuation) 42 { 43 task.GetAwaiter().UnsafeOnCompleted(continuation); 44 } 45 46 public void OnCompleted(Action continuation) 47 { 48 task.GetAwaiter().OnCompleted(continuation); 49 } 50 51 public void GetResult() 52 { 53 // This will throw AggregateException directly on failure, 54 // unlike task.GetAwaiter().GetResult() 55 task.Wait(); 56 } 57 } 58 }
2. 在抛出异常时进行包装
你可能已经猜到我要说什么了,没错,就是异步方法在调用时永远不会直接抛出异常。异常方 法会返回 Task 或 Task<T> ,方法内抛出的任何异常(包括从其他同步或异步操作中传播过来的异 常)都将简单地传递给任务,就像前面介绍的那样。如果调用者直接等待任务,则可得到一个包 含真正异常的 AggregateException ;但如果调用者使用 await ,异常则会从任务中解包。返回 void 的异步方法可向原始的 SynchronizationContext 报告异常,如何处理将取决于上下文 。
除非你真的在乎为特定的上下文包装和解包异常,否则只需捕获嵌套的异步方法所抛出的异常即可。
1 private async static void MainSync() 2 { 3 Task<string> task = ReadFileAsync("fileName"); 4 try 5 { 6 string text = await task; 7 Console.WriteLine("file content {0}", text); 8 9 } 10 catch (System.IO.IOException ex) 11 { 12 Console.WriteLine("caught exception {0}", ex.Message); 13 } 14 } 15 private async static Task<string> ReadFileAsync(string fileName) 16 { 17 using (var reader = System.IO.File.OpenText(fileName)) 18 { 19 return await reader.ReadToEndAsync(); 20 } 21 }
调用 File.OpenText 时可抛出一个 IOException (除非创建了一个名为“garbage file” 的文件),但如果 ReadToEndAsync 返回的任务失败了,也会出现同样的执行路径。在 MainAsync 中, ReadFileAsync 的调用 发生在进入 try 块之前,但只有在等待任务时 ,调用者才能看到 异常并在 catch 块中捕获 ,就像前面的 WebException 示例一样。同样,除异常发生的时机以 外,其行为我们也非常熟悉。
与迭代器块类似,参数验证会有些麻烦。假设我们在验证完参数不含有空值后,想在异步方 法里做一些处理。如果像在同步代码中那样验证参数,那么在等待任务之前,调用者不会得到任 何错误提示。代码清单15-5给出了一个这样的例子。
1 static void Main(string[] args) 2 { 3 MainAsync().Wait(); 4 5 Console.ReadKey(); 6 } 7 private async static Task MainAsync() 8 { 9 Task<int> task = ComputeLengthAsync(null); 10 Console.WriteLine("fetch the task"); 11 int length = await task; 12 Console.WriteLine("length {0}", length); 13 } 14 private async static Task<int> ComputeLengthAsync(string text) 15 { 16 if (text == null) 17 { 18 throw new ArgumentNullException("text"); 19 } 20 await Task.Delay(500); 21 return text.Length; 22 }
代码清单15-5在失败前会先输出 Fetched the task 。实际上,在输出这条结果之前,异常 就已经同步地抛出了,这是因为在验证语句之前并不存在 await 表达式 。但调用代码直到等待 返回的任务时 ,才能看到这个异常。一般来说,参数验证无须耗时太久(或导致其他异步操作)。 最好能在系统陷入更大的麻烦以前,立即报告失败的存在。例如,如果对 HttpClient. GetStringAsync 传递一个空引用,则其可立即抛出异常。
在C# 5中,有两种方式可以迫使异常立即抛出。第一种方式是将参数验证和实现分离,这与 代码清单6-9中处理迭代器块的情形相同。以下代码清单展示了 ComputeLengthAsync 的固定 版本。
代码清单15-6 将参数验证从异步实现中分离出来
1 private static Task<int> ComputeLengthAsync(string text) 2 { 3 if (text == null) 4 { 5 throw new ArgumentNullException("text"); 6 } 7 return ComputeLengthAsyncImpl(text); 8 } 9 private async static Task<int> ComputeLengthAsyncImpl(string text) 10 { 11 await Task.Delay(500); //模拟真正的异步工作 12 return text.Length; 13 }
在代码清单15-6中,就语言形式而言, ComputeLengthAsync 本身并不是一个异步方法,因 为它没有 async 修饰符。该方法执行时使用的是正常的执行流,因此如果方法开始处的参数验证 抛出异常,就真的会抛出异常。而如果通过验证,则返回 ComputeLengthAsyncImpl 方法(工 作真正发生的地方)创建的任务。在更现实的场景中, ComputeLengthAsync 可以为公共或内 部(internal)方法,而 ComputeLengthAsyncImpl 应该为私有方法,因为它假设参数验证已经 执行过了。
另一个及早(eager)验证的方法是使用异步匿名函数,15.4节再来讨论这个示例。
异步方法中还有一种异常,其处理方式与其他异常不同,这个异常就是:取消(cancellation)。
3. 处理取消
任务并行库(TPL)利用 CancellationTokenSource 和 CancellationToken 两种类型 向.NET 4中引入了一套统一的取消模型。该模型的理念是,创建一个 CancellationToken Source ,然后向其请求一个 CancellationToken ,并传递给异步操作。可在source上只执行取 消操作,但该操作会反映到token上。(这意味着你可以向多个操作传递相同的token,而不用担心 它们之间会相互干扰。)取消token有很多种方式,最常用的是调用 ThrowIfCancellation Requested ,如果取消了token,并且没有其他操作,则会抛出 OperationCanceledException 。 如果在同步调用(如 Task.Wait )中执行了取消操作,则可抛出同样的异常。
C# 5规范中并没有说明取消操作如何与异步方法交互。根据规范,如果异步方法体抛出任何 异常,该方法返回的任务则将处于错误状态。“错误”的确切含义因实现而异,但实际上, 如 果 异 步 方 法 抛 出 OperationCanceledException ( 或 其 派 生 类 , 如 TaskCanceled Exception ),则返回的任务最终状态为 Canceled 。以下代码清单证实了导致任务取消的原因 确实是一个异常。
1 static void Main(string[] args) 2 { 3 Task task = ThrowCancellationException(); 4 Console.WriteLine(task.Status); 5 Console.ReadKey(); 6 } 7 private async static Task ThrowCancellationException() 8 { 9 throw new OperationCanceledException(); 10 }
这段代码的输出为 Canceld ,而不是 Faulted 。如果在任务上执行 Wait() ,或请求其结果 (针对 Task<T> ),则 AggregateException 内还是会抛出异常,所以没有必要在每次使用任务 时都显式检查是否有取消操作。
重要的是,等待一个取消了的操作,将抛出原始的 OperationCanceledException 。这意 味着如果不采取一些直接的行动,从异步方法返回的任务同样会被取消,因为取消操作具有可传 播性。 代码清单15-8给出了一个有关任务取消操作的更为实际的例子。
1 static void Main(string[] args) 2 { 3 var source = new CancellationTokenSource(); 4 var task = DelayFor30Seconds(source.Token); 5 source.CancelAfter(TimeSpan.FromSeconds(1)); 6 Console.WriteLine("initial status {0}", task.Status); 7 try 8 { 9 task.Wait(); 10 } 11 catch (AggregateException ex) 12 { 13 Console.WriteLine("caught {0}", ex.InnerExceptions[0]); 14 } 15 Console.WriteLine("final status {0}", task.Status); 16 Console.ReadKey(); 17 /* waiting for 30 seconds... 18 initial status WaitingForActivation 19 caught System.Threading.Tasks.TaskCanceledException: 已取消一个任务。 20 final status Canceled */ 21 } 22 static async Task DelayFor30Seconds(CancellationToken token) 23 { 24 Console.WriteLine("waiting for 30 seconds..."); 25 await Task.Delay(TimeSpan.FromSeconds(30), token); 26 }
上述代码中启动了一个异步操作 ,该操作调用 Task.Delay 模拟真正的工作 ,并提供了 一个 CancellationToken 。这一次,我们的确涉及了多个线程:到达 await 表达式时,控制返 回到调用方法,这时要求 CancellationToken 在1秒后取消 。然后(同步地)等待任务完成 , 并期望在最终得到一个异常。最后展示任务的状态 。
可认为取消操作默认是可传递的:如果A操作等待B操作,而B操作被取消了,那么我们认为 A操作也被取消了。 当然,你不必这么做。你可以在 DelayFor30Seconds 方法中捕获 OperationCanceled Exception ,然后或继续做其他事情,或立即返回,或干脆抛出一个其他类型的异常。异步特性 不会移除控制,它只是提供了一种有用的默认行为而已。
【说明】 小心使用该代码 代码清单15-8在控制台程序中或从线程池线程调用时,均可运作良好。 但如果在Windows Forms UI线程(或其他单线程同步上下文)上执行这段代码,则会造成 死锁。能看出原因吗?想想在延迟任务完成时, DelayFor30Seconds 方法会试图返回到 哪个线程上?再想想 task.Wait() 调用运行在哪个线程上?这是个相对简单的例子,但 一些程序员在初次接触异步代码时往往会犯同样的错误。从根本上来说,问题在于调用 了 Wait() 方法或 Result 属性。在相关任务完成前,二者均可阻塞线程。我并不是说不能 使用它们,但在每次使用时必须考虑清楚。我们应该总是使用 await ,来异步地等待任务 的结果。