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。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);

现在,用户可以取消运行时间长的任务了。

posted @ 2020-05-12 07:14  多弗朗强哥  阅读(305)  评论(1编辑  收藏  举报