15.2 思考异步编程
15.2.1 异步执行的基础
实际上, C#编译器会对所有await都构建一个后续操作。这个理念表述起来非常简单,显然是为了可读性和开发者的健康。
实际上基于任务的异步模式要稍有不同。它并不会将后续操作传递给异步操作,而是在异步操作开始时返回一个token
,我们可以用这个token
在稍后提供后续操作。它表示正在进行的操作,在返回调用代码前可能已经完成,也可能正在处理。token
用于表达这样的想法:在这个操作完成之前,不能进行下一步处理。 token
的形式通常为Task
或Task<TResult>
,但这并不是必须的。
在C# 5中,异步方法的执行流通常遵守下列流程。
(1) 执行某些操作。
(2) 开始异步操作,并记住返回的token
。
(3) 可能会执行其他操作。(在异步操作完成前,往往不能进行任何操作,此时忽略该步骤。)
(4) 等待异步操作完成(通过token)。
(5) 执行其他操作。
(6) 完成。
同步上下文
之前我提到过, UI代码的金科玉律之一是,除非在正确的线程中,否则不要更新用户界面。
在“检查页面长度”的示例中(代码清单15-1) ,我们需要确保await表达式之后的代码在UI线程上的执行。异步函数能够回到正确的线程中,是因为使用了SynchronizationContext类 。 该 类 早 在 .NET 2.0 中 就 已 存 在 , 用 以 供 BackgroundWorker 等 其 他 组 件 使 用 。SynchronizationContext涵盖了“在适当的线程上”执行委托这一理念。其Post(异步)和Send(同步)消息的方法,与Windows Forms中的Control.BeginInvoke和Control.Invoke异曲同工。
不同的执行环境使用不同的上下文。例如,某个上下文可能会从线程池中取出一个线程并执行给定的行为。除了同步上下文以外还有很多上下文信息,但如果你想知道异步方法是如何在正确的位置上执行的,就要牢记同步上下文。
要了解更多关于SynchronizationContext的信息,请阅读Stephen Cleary在MSDN杂志上关于该话题的文章(http://mng.bz/5cDw) 。如果你是ASP.NET开发者的话,应尤其注意:
ASP.NET上下文会让看上去没问题的代码死锁,轻易地让粗心的开发者掉进陷阱。
15.2.2 异步方法
我们主要感兴趣的是异步方法本身,但也包含了其他方法,这样就能看到它们是如何交互的。
特别是,你一定要了解方法边界处的有效类型。
15.3 语法和语义
15.3.1 声明异步方法
异步方法的声明语法与其他方法完全一样,只是要包含async上下文关键字。async可以出现在返回类型之前的任何位置。以下这些都是有效的:
public static async Task<int> FooAsync() { ... }
public async static Task<int> FooAsync() { ... }
async public Task<int> FooAsync() { ... }
public async virtual Task<int> FooAsync() { ... }
async
上下文关键字有一个不为人知的秘密:对语言设计者来说,方法签名中有没有该关键字都无所谓。3
async
修饰符在生成的代码中没有作用,这个事实是非常重要的。对调用方法来说,它只是一个可能会返回任务的普通方法。你可以将一个(具有适当签名的)已有方法改成使用async,反之亦然。对于源代码和二进制来说,这都是一个兼容的转换。
15.3.2 异步方法的返回类型
调用者和异步方法之间是通过返回值来通信的。异步函数的返回类型只能为:
- void;
- Task;
Task<TResult>
(某些类型的TResult,其自身即可为类型参数)。
1.在某种意义上,你可以认为Task
就是Task<void>
类型,如果这么写合法的话。
2.对于一个异步方法,只有在作为事件订阅者时才应该返回void。在其他不需要特定返回值的情况下,最好将方法声明为返回Task。这样,调用者可以等待操作完成,以及探测失败情况等。
3.还有一个关于异步方法签名的约束:所有参数都不能使用out或ref修饰符。因为这些修饰符是用于将通信信息返回给调用代码的;而且在控制返回给调用者时,某些异步方法可能还没有开始执行,因此引用参数可能还没有赋值。
15.3.3 可等待模式
await表达式非常简单,只是在其他表达式前面加了一个await。当然,对于能等待的东西是有限制的。需要提醒的是,我们正在谈论图15-1的第二个边界,即异步方法如何与其他异步操作交互。一般来说,我们只能等待(await)一个异步操作。换句话说,是包含以下含义的操作:
告知是否已经完成;
如未完成可附加后续操作;
获取结果,该结果可能为返回值,但至少可以指明成功或失败。
15.3.6 异常
1.在等待时拆包异常
在等待时拆包异常awaiter
的GetResult
方法可获取返回值(如果存在的话) ;同样地,如果存在异常,它还负责将异常从异步操作传递回方法中。听上去简单做起来难,因为在异步世界里,单个Task可表示多个操作,并导致多个失败。尽管还存在其他的可等待模式实现,但有必要专门介绍Task,因为在大多数情况下,我们等待的都是这个类型。
Task有多种方式可以表示异常
当异步操作失败时,任务的
Status
变为Faulted
(并且IsFaulted
返回true)。Exception属性返回一个
AggregateException
,该AggregateException包含所有(可能有多个)造成任务失败的异常;如果任务没有错误,则返回null。如果任务的最终状态为错误,则
Wait()
方法将抛出一个AggregateException。
Task<T>
的Result
属性(同样等待完成)也将抛出AggregateException。
取消操作
此外,任务还支持取消操作,可通过CancellationTokenSource
和CancellationToken
来实现这一点。如果任务取消了,Wait()方法和Result属性都将抛出包含OperationCanceledException
的AggregateException(实际上是一个TaskCanceledException
,它继承自OperationCanceledException
),但状态将变为Canceled
,而不是Faulted
。
抛出第一个异常
在等待任务时,任务出错或取消都将抛出异常,但并不是AggregateException
。大多情况下为方便起见,抛出的是AggregateException
中的第一个异常,往往这就是我们想要的。
异步特性就是像编写同步代码那样编写异步代码,如下所示:
async Task<string> FetchFirstSuccessfulAsync(IEnumerable urls)
{
// TODO:验证是否获取到了URL
foreach (string url in urls)
{
try
{
using (var client = new HttpClient())
{
return await client.GetStringAsync(url);
}
}
catch (WebException exception)
{
// TODO:记录日志、更新统计信息等
}
}
throw new WebException("No URLs succeeded");
}
但GetStringAsync()
方法不能为服务器超时等错误抛出WebException
,因为方法仅仅启动了操作。它只能返回一个包含WebException的任务 。 如 果 简 单 地 调 用 该 任 务 的 Wait() 方 法 , 将 会 抛 出 一 个 包 含 WebException 的AggregateException。任务awaiter的GetResult方法将抛出WebException,并被以上代码所捕获。
当然,这样会丢失信息。如果错误的任务中包含多个异常,则GetResult只能抛出其中的一个异常(即第一个)。你可能需要重写以上代码,这样在发生错误时,调用者就可捕获AggregateException并检查所有失败的原因。重要的是,一些框架方法(如Task.WhenAll())也可以实现这一点。 WhenAll()方法可异步等待(方法调用中指定的)多个任务的完成。如果其中有失败的,则结果即为失败,并包含所有错误任务中的异常。但如果只是等待(await)WhenAll()返回的任务,则只能看到第一个异常。
幸好,要解决这个问题并不需要太多的工作。我们可以使用可等待模式的知识,编写一个Task
public static partial class TaskExtensions
{
public static AggregatedExceptionAwaitable WithAggregatedExceptions(this Task task)
{
if (task == null)
{
throw new ArgumentNullException("task");
}
return new AggregatedExceptionAwaitable(task);
}
public struct AggregatedExceptionAwaitable
{
private readonly Task task;
internal AggregatedExceptionAwaitable(Task task)
{
this.task = task;
}
public AggregatedExceptionAwaiter GetAwaiter()
{
return new AggregatedExceptionAwaiter(task);
}
}
public struct AggregatedExceptionAwaiter : ICriticalNotifyCompletion
{
private readonly Task task;
internal AggregatedExceptionAwaiter(Task task)
{
this.task = task;
}
// Delegate most members to the task's awaiter
public bool IsCompleted { get { return task.GetAwaiter().IsCompleted; } }
public void UnsafeOnCompleted(Action continuation)
{
task.GetAwaiter().UnsafeOnCompleted(continuation); //❶ 委托给任务awaiter
}
public void OnCompleted(Action continuation)
{
task.GetAwaiter().OnCompleted(continuation); //❶ 委托给任务awaiter
}
public void GetResult()
{
// This will throw AggregateException directly on failure,
// unlike task.GetAwaiter().GetResult()
task.Wait(); //❷ 发生错误时,直接抛出AggregateException
}
}
}
class AggregatedExceptions
{
static void Main()
{
MainAsync().Wait();
Console.ReadKey();
}
private async static Task MainAsync()
{
Task task1 = Task.Run(() => { throw new Exception("Message 1"); });
Task task2 = Task.Run(() => { throw new Exception("Message 2"); });
try
{
await Task.WhenAll(task1, task2);
}
catch (Exception e)
{
Console.WriteLine("Caught {0}", e.Message);
}
try
{
await Task.WhenAll(task1, task2).WithAggregatedExceptions();
}
catch (AggregateException e)
{
Console.WriteLine("Caught {0} exceptions: {1}", e.InnerExceptions.Count,
string.Join(", ", e.InnerExceptions.Select(x => x.Message)));
}
}
}
//output:
//Caught Message 1
//Caught 2 exceptions: Message 1, Message 2
Task<T>
也需要一个类似的方法,即在GetResult()
中使用return task.Result
,而不是调用Wait()
。重点在于,我们把自己不想处理的部分委托给了任务的awaiter
❶ ,而回避了GetResult()
的常规行为,即对异常进行拆包。在调用GetResult
时,我们知道该任务处于即将结束的状态,因此Wait()
调用❷可立即返回,这并不妨碍我们要实现的异步性。
WithAggregateException()
返回自定义的可等待模式成员,而后者的GetAwaiter()
又提供自定义的awaiter
,并支持C#编译器所需要的操作来等待结果。注意,也可将可等待模式成员和awaiter
合并,并没有要求二者必须是不同类型,但分开的话会感觉更清晰一些。
2.在抛出异常时进行包装
异步方法在调用时永远不会直接抛出异常。
异常方法会返回Task
或Task<T>
,方法内抛出的任何异常(包括从其他同步或异步操作中传播过来的异常)都将简单地传递给任务,就像前面介绍的那样。如果调用者直接等待①任务,则可得到一个包含真正异常的AggregateException
;但如果调用者使用await
,异常则会从任务中解包。返回void
的异步方法可向原始的SynchronizationContext
报告异常,如何处理将取决于上下文②。
//以熟悉的方式处理异步的异常
static async Task MainAsync()
{
Task<string> task = ReadFileAsync("garbage file"); //❶ 开始异步读取
try
{
//任务中解包
string text = await task; //❷ 等待内容
Console.WriteLine("File contents: {0}", text);
}
catch (IOException e) //❸ 处理IO失败
{
Console.WriteLine("Caught IOException: {0}", e.Message);
}
}
static async Task<string> ReadFileAsync(string filename)
{
using (var reader = File.OpenText(filename)) //❹ 同步打开文件
{
return await reader.ReadToEndAsync();
}
}
调用File.OpenText时可抛出一个IOException❹ (除非创建了一个名为“ garbage file”的文件), 但如果ReadToEndAsync返回的任务失败了,也会出现同样的执行路径。在MainAsync中, ReadFileAsync的调用❶ 发生在进入try块之前,但只有在等待任务时 ❷,调用者才能看到异常并在catch块中捕获 ❸,就像前面的WebException示例一样。同样,除异常发生的时机以外,其行为我们也非常熟悉。
来看AggregateException
的情况:
//
static void Main()
{
MainAsync().Wait();
}
static async Task MainAsync()
{
Task<string> task = ReadFileAsync("garbage file");
try
{
// task.Wait() 或者 task.Result
string text = task.Result;
Console.WriteLine("File contents: {0}", text);
}
catch (AggregateException e)
{
Console.WriteLine("Caught {0} exceptions: {1}", e.InnerExceptions.Count,
string.Join(", ", e.InnerExceptions.Select(x => x.Message))); //执行这里
}
catch (IOException e)
{
Console.WriteLine("Caught IOException: {0}", e.Message);
}
}
static async Task<string> ReadFileAsync(string filename)
{
using (var reader = File.OpenText(filename))
{
return await reader.ReadToEndAsync();
}
}
//Caught 1 exceptions: 未能找到文件“E:OtherChaptersChapter15inDebuggarbage file”。
迭代器块类似,参数验证会有些麻烦。假设我们在验证完参数不含有空值后,想在异步方法里做一些处理。如果像在同步代码中那样验证参数,那么在等待任务之前,调用者不会得到任何错误提示。
//异步方法中失效的参数验证
static async Task MainAsync()
{
Task<int> task = ComputeLengthAsync(null); //故意传入错误的参数
Console.WriteLine("Fetched the task");
int length = await task; //❶ 等待结果
Console.WriteLine("Length: {0}", length);
}
static async Task<int> ComputeLengthAsync(string text)
{
if (text == null)
{
throw new ArgumentNullException("text"); // ❷ 立即抛出异常
}
await Task.Delay(500); //模拟真实的异步工作
return text.Length;
}
实际上,在输出这条结果之前,异常就已经同步地抛出了,这是因为在验证语句之前并不存在await表达式 。但调用代码直到等待返回的任务时 ,才能看到这个异常。
在C# 5中,有两种方式可以迫使异常立即抛出。
//将参数验证从异步实现中分离出来
static Task<int> ComputeLengthAsync(string text)
{
if (text == null)
{
throw new ArgumentNullException("text");
}
return ComputeLengthAsyncImpl(text);
}
static async Task<int> ComputeLengthAsyncImpl(string text)
{
await Task.Delay(500); // 模拟真正的异步工作
return text.Length;
}
3.取消处理
任务并行库(TPL)利用CancellationTokenSource
和CancellationToken
两种类型向.NET 4中引入了一套统一的取消模型。该模型的理念是,创建一个CancellationTokenSource
,然后向其请求一个CancellationToken
,并传递给异步操作。可在source
上只执行取消操作,但该操作会反映到token
上。(这意味着你可以向多个操作传递相同的token
,而不用担心它们之间会相互干扰。)取消token有很多种方式,最常用的是调用ThrowIfCancellationRequested
,如果取消了token,并且没有其他操作,则会抛出OperationCanceledException
。如果在同步调用(如Task.Wait)中执行了取消操作,则可抛出同样的异常。
//通过抛出OperationCanceledException来创建一个取消的任务
static void Main()
{
Task task = ThrowCancellationException();
Console.WriteLine(task.Status);
}
static async Task ThrowCancellationException()
{
throw new OperationCanceledException();
}
//output:
//Canceled
这段代码的输出为Canceld
,而不是Faulted
。如果在任务上执行Wait(),或请求其结果(针对Task<T>
) ,则AggregateException
内还是会抛出异常,所以没有必要在每次使用任务时都显式检查是否有取消操作。
重要的是,等待一个取消了的操作,将抛出原始的OperationCanceledException
。这意味着如果不采取一些直接的行动,从异步方法返回的任务同样会被取消,因为取消操作具有可传播性。
//通过一个取消的延迟操作来取消异步方法
static void Main()
{
var source = new CancellationTokenSource();
var task = DelayFor30Seconds(source.Token);
source.CancelAfter(TimeSpan.FromSeconds(1));
Console.WriteLine("Initial status: {0}", task.Status);
try
{
task.Wait();
}
catch (AggregateException e)
{
Console.WriteLine("Caught {0}", e.InnerExceptions[0]);
}
Console.WriteLine("Final status: {0}", task.Status);
}
static async Task DelayFor30Seconds(CancellationToken token)
{
Console.WriteLine("Waiting for 30 seconds...");
await Task.Delay(TimeSpan.FromSeconds(30), token);
}
//Waiting for 30 seconds...
//Initial status: WaitingForActivation
//Caught System.Threading.Tasks.TaskCanceledException: 已取消一个任务。
//Final status: Canceled
代码中启动了一个异步操作 ,该操作调用Task.Delay模拟真正的工作 ,并提供了一个CancellationToken。这一次,我们的确涉及了多个线程:到达await表达式时,控制返回到调用方法,这时要求CancellationToken在1秒后取消 。然后(同步地)等待任务完成 ,并期望在最终得到一个异常。最后展示任务的状态。
可认为取消操作默认是可传递的:如果A操作等待B操作,而B操作被取消了,那么我们认为A操作也被取消了。
当然,你不必这么做。你可以在DelayFor30Seconds方法中捕获OperationCanceledException,然后或继续做其他事情,或立即返回,或干脆抛出一个其他类型的异常。异步特性不会移除控制,它只是提供了一种有用的默认行为而已。