● Async Patterns(异步模式) ● Foundations(async和await关键字) ● ErrorHandling(异步方法的错误处理)
使用异步编程,方法调用是在后台运行(通常在线程或任务的帮助下),并且不会阻塞调用线程。 本章将学习3种不同模式的异步编程:异步模式、基于事件的异步模式和基于任务的异步模式(Task-based Asynchronous Pattern, TAP)。TAP是利用async和await关键字来实现的。通过这里的比较,将认识到异步编程新模式的真正优势。
如果应用程序没有立刻响应用户的请求,会让用户反感。用鼠标操作,我们习惯了出现延迟,过去几十年都是这样操作的。有了触摸UI,应用程序要求立刻响应用户的请求。否则,用户就会不断重复同一个动作。
因为在旧版本的.NET Framework中用异步编程非常不方便,所以并没有总是这样做。Visual Studio旧版本是经常阻塞UI线程的应用程序之一。例如,在Visual Studio 2010中,打开一个包含数百个项目的解决方案,这意味可能需要等待很长的时间。自从Visual Studio 2012以来,情况就不一样了,因为项目都是在后台异步加载的,并且选中的项目会优先加载。Visual Studio 2015的一个最新改进是NuGet包管理器不再实现为模式对话框。新的NuGet包管理器可以异步加载包的信息,同时做其他工作。这是异步编程内置到Visual Studio 2015中带来的重要变化之一。
很多.NET Framework的API都提供了同步版本和异步版本。因为同步版本的API用起来更为简单,所以常常在不适合使用时也用了同步版本的API。在新的Windows运行库(WinRT)中,如果一个API调用时间超过40ms,就只能使用其异步版本。自从C# 5开始,异步编程和同步编程一样简单,所以用异步API应该不会有任何的障碍。
async和await关键字只是编译器功能。编译器会用Task类创建代码。如果不使用这两个关键字,也可以用C# 4.0和Task类的方法来实现同样的功能,只是没有那么方便。
所有示例Foundations的代码都使用了如下依赖项和名称空间: 依赖项: NETStandard.Library 名称空间:
using System; using System.Threading; using System.Threading.Tasks; using static System.Console;下面从同步方法Greeting开始,该方法等待一段时间后,返回一个字符串:
static string Greeting(string name) { Task.Delay(3000).Wait(); return $"Hello, {name}"; }定义方法GreetingAsync,可以使方法异步化。基于任务的异步模式指定,在异步方法名后加上Async后缀,并返回一个任务。异步方法GreetingAsync和同步方法Greeting具有相同的输入参数,但是它返回的是Task 。Task 定义了一个返回字符串的任务。一个比较简单的做法是用Task.Run方法返回一个任务。泛型版本的Task.Run ()创建一个返回字符串的任务:
static Task<string> GreetingAsync(string name) { return Task.Run<string>(() => { return Greeting(name); }); }可以使用await关键字来调用返回任务的异步方法GreetingAsync。使用await关键字需要有用async修饰符声明的方法。在GreetingAsync方法完成前,该方法内的其他代码不会继续执行。但是,启动CallerWithAsync方法的线程可以被重用。该线程没有阻塞:
private async static void CallerWithAsync() { string result = await GreetingAsync("Stephanie"); WriteLine(result); }如果异步方法的结果不传递给变量,也可以直接在参数中使用await关键字。在这里,GreetingAsync方法返回的结果将像前面的代码片段一样等待,但是这一次的结果会直接传给WriteLine方法:
private async static void CallerWithAsync2() { WriteLine(await GreetingAsync("Stephanie")); }async修饰符只能用于返回.NET类型的Task或viod的方法,以及Windows运行库的IAsyncOperation。它不能用于程序的入口点,即Main方法不能使用async修饰符。await只能用于返回Task的方法。
GreetingAsync方法返回一个Task 对象。该Task 对象包含任务创建的信息,并保存到任务完成。Task类的ContinueWith方法定义了任务完成后就调用的代码。指派给ContinueWith方法的委托接收将已完成的任务作为参数传入,使用Result属性可以访问任务返回的结果:
private static void CallerWithContinuationTask() { Task<string> t1 = GreetingAsync("Stephanie"); t1.ContinueWith(t => { string result = t.Result; WriteLine(result); }); }编译器把await关键字后的所有代码放进ContinueWith方法的代码块中来转换await关键字。
如果验证方法中使用的线程,会发现CallerWithAsync方法和CallerWithContinuationTask方法,在方法的不同生命阶段使用了不同的线程。一个线程用于调用GreetingAsync方法,另外一个线程执行await关键字后面的代码,或者继续执行ContinueWith方法内的代码块。
使用一个控制台应用程序,通常不会有什么问题。但是,必须保证在所有应该完成的后台任务完成之前,至少有一个前台线程仍然在运行。示例应用程序调用Console.ReadLine来保证主线程一直在运行,直到按下返回键。
为了执行某些动作,有些应用程序会绑定到指定的线程上(例如,在WPF应用程序中,只有UI线程才能访问UI元素),这将会是一个问题。
如果使用async和await关键字,当await完成之后,不需要进行任何特别处理,就能访问UI线程。默认情况下,生成的代码就会把线程转换到拥有同步上下文的线程中。WPF应用程序设置了DispatcherSynchronizationContext属性,Windows Forms应用程序设置了WindowsFormsSynchronization-Context属性。如果调用异步方法的线程分配给了同步上下文,await完成之后将继续执行。默认情况下,使用了同步上下文。如果不使用相同的同步上下文,则必须调用Task方法ConfigureAwait (continueOnCapturedContext:false)。例如,一个WPF应用程序,其await后面的代码没有用到任何的UI元素。在这种情况下,避免切换到同步上下文会执行得更快。
在一个异步方法里,可以调用一个或多个异步方法。如何编写代码,取决于一个异步方法的结果是否依赖于另一个异步方法。
使用await关键字可以调用每个异步方法。在有些情况下,如果一个异步方法依赖另一个异步方法的结果,await关键字就非常有用。在这里,GreetingAsync异步方法的第二次调用完全独立于其第一次调用的结果。这样,如果每个异步方法都不使用await,那么整个MultipleAsyncMethods异步方法将更快地返回结果,如下所示:
private async static void MultipleAsyncMethods() { string s1 = await GreetingAsync("Stephanie"); string s2 = await GreetingAsync("Matthias"); WriteLine("Finished both methods.\nResult 1: {s1}\n Result 2: {s2}"); }如果异步方法不依赖于其他异步方法,则每个异步方法都不使用await,而是把每个异步方法的返回结果赋值给Task变量,就会运行得更快。GreetingAsync方法返回Task 。这些方法现在可以并行运行了。组合器可以帮助实现这一点。一个组合器可以接受多个同一类型的参数,并返回同一类型的值。多个同一类型的参数被组合成一个参数来传递。Task组合器接受多个Task对象作为参数,并返回一个Task。 示例代码调用Task.WhenAll组合器方法,它可以等待,直到两个任务都完成。
private async static void MultipleAsyncMethodsWithCombinators1() { Task<string> t1 = GreetingAsync("Stephanie"); Task<string> t2 = GreetingAsync("Matthias"); await Task.WhenAll(t1, t2); WriteLine("Finished both methods.\n " + $"Result 1: {t1.Result}\n Result 2: {t2.Result}"); }Task类定义了WhenAll和WhenAny组合器。从WhenAll方法返回的Task,是在所有传入方法的任务都完成了才会返回Task。从WhenAny方法返回的Task,是在其中一个传入方法的任务完成了就会返回Task。
Task类型的WhenAll方法定义了几个重载版本。如果所有的任务返回相同的类型,那么该类型的数组可用于await返回的结果。GreetingAsync方法返回一个Task ,等待返回的结果是一个字符串(string)形式。因此,Task.WhenAll可用于返回一个字符串数组:
private async static void MultipleAsyncMethodsWithCombinators2() { Task<string> t1 = GreetingAsync("Stephanie"); Task<string> t2 = GreetingAsync("Matthias"); string[] result = await Task.WhenAll(t1, t2); WriteLine("Finished both methods.\n " + $"Result 1: {result[0]}\n Result 2: {result[1]}"); }并非.NET Framework的所有类都引入了新的异步方法。在使用框架中的不同类时会发现,还有许多类只提供了BeginXXX方法和EndXXX方法的异步模式,没有提供基于任务的异步模式。但是,可以把异步模式转换为基于任务的异步模式。 首先,从前面定义的同步方法Greeting中,借助于委托,创建一个异步方法。Greeting方法接收一个字符串作为参数,并返回一个字符串。因此,Func<string, string>委托的变量可用于引用Greeting方法。按照异步模式,BeginGreeting方法接收一个string参数、一个AsyncCallback参数和一个object参数,返回IAsyncResult。EndGreeting方法返回来自Greeting方法的结果——一个字符串——并接收一个IAsyncResult参数。这样,同步方法Greeting就通过一个委托变成异步方法。
private Func<string, string> greetingInvoker = Greeting; private IAsyncResult BeginGreeting(string name, AsyncCallback callback, object state) { return greetingInvoker.BeginInvoke(name, callback, state); } private string EndGreeting(IAsyncResult ar) { return greetingInvoker.EndInvoke(ar); }现在,BeginGreeting方法和EndGreeting方法都是可用的,它们都应转换为使用async和await关键字来获取结果。TaskFactory类定义了FromAsync方法,它可以把使用异步模式的方法转换为基于任务的异步模式的方法(TAP)。
示例代码中,Task类型的第一个泛型参数Task 定义了调用方法的返回值类型。FromAsync方法的泛型参数定义了方法的输入类型。这样,输入类型又是字符串类型。FromAsync方法的前两个参数是委托类型,传入BeginGreeting和EndGreeting方法的地址。紧跟这两个参数后面的是输入参数和对象状态参数。因对象状态没有用到,所以给它分配null值。因为FromAsync方法返回Task类型,即示例代码中的Task ,可以使用await,如下所示:
private static async void ConvertingAsyncPattern() { string s = await Task<string>.Factory.FromAsync<string>(BeginGreeting, EndGreeting, "Angela", null); WriteLine(s); }在一些情况下,后台任务可能运行很长时间,取消任务就非常有用了。对于取消任务,.NET提供了一种标准的机制。这种机制可用于基于任务的异步模式。
取消框架基于协助行为,不是强制性的。一个运行时间很长的任务需要检查自己是否被取消,在这种情况下,它的工作就是清理所有已打开的资源,并结束相关工作。
取消基于CancellationTokenSource类,该类可用于发送取消请求。请求发送给引用CancellationToken类的任务,其中CancellationToken类与CancellationTokenSource类相关联。
首先,使用MainWindow类的私有字段成员定义一个CancellationTokenSource类型的变量cts。该成员用于取消任务,并将令牌传递给应取消的方法:
public partial class MainWindow : Window { private SearchInfo _searchInfo = new SearchInfo(); private object _lockList = new object(); private CancellationTokenSource _cts; //. . .新添加一个按钮,用于取消正在运行的任务,添加事件处理程序OnCancel方法。在这个方法中,变量cts用Cancel方法取消任务:
private void OnCancel(object sender, RoutedEventArgs e) { _cts? .Cancel(); }CancellationTokenSource类还支持在指定时间后才取消任务。CancelAfter方法传入一个时间值,单位是毫秒,在该时间过后,就取消任务。
现在,将CancellationToken传入异步方法。框架中的某些异步方法提供可以传入CancellationToken的重载版本,来支持取消任务。例如HttpClient类的GetAsync方法。除了URI字符串,重载的GetAsync方法还接受CancellationToken参数。可以使用Token属性检索CancellationTokenSource类的令牌。
GetAsync方法的实现会定期检查是否应取消操作。如果取消,就清理资源,之后抛出OperationCanceledException异常。如下面的代码片段所示,catch处理程序捕获到了该异常:
private async void OnTaskBasedAsyncPattern(object sender, RoutedEventArgs e) { _cts = new CancellationTokenSource(); try { foreach (var req in GetSearchRequests()) { var clientHandler = new HttpClientHandler { Credentials = req.Credentials; }; var client = new HttpClient(clientHandler); var response = await client.GetAsync(req.Url, _cts.Token); string resp = await response.Content.ReadAsStringAsync(); //. . . } } catch (OperationCanceledException ex) { MessageBox.Show(ex.Message); } }如何取消自定义任务?Task类的Run方法提供了重载版本,它也传递CancellationToken参数。但是,对于自定义任务,需要检查是否请求了取消操作。下例中,这是在foreach循环中实现的,可以使用IsCancellationRequsted属性检查令牌。在抛出异常之前,如果需要做一些清理工作,最好验证一下是否请求取消操作。如果不需要做清理工作,检查之后,会立即用ThrowIfCancellationRequested方法触发异常:
await Task.Run(() => { var images = req.Parse(resp); foreach (var image in images) { _cts.Token.ThrowIfCancellationRequested(); _searchInfo.List.Add(image); } }, _cts.Token);现在,用户可以取消运行时间长的任务了。