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 ,来异步地等待任务 的结果。

 

posted @ 2018-12-15 21:47  一只桔子2233  阅读(360)  评论(0编辑  收藏  举报