C# 异步编程
● 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
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
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.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
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
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);
现在,用户可以取消运行时间长的任务了。