异步编程

通过 Await 暂停和播放

Mads Torgersen

下载代码示例

即将推出的 Visual Basic 和 C# 版本中的异步方法是在异步编程中摆脱回调的不错方式。 在本文中,我将详细介绍新的 await 关键字所实际执行的操作,并首先从基本概念入手,然后逐步深入到本质内容。

顺序组合

Visual Basic 和 C# 是命令式编程语言,并深得用户好评! 具体说来,它们在允许用户通过逐个执行的离散步骤序列 表达编程逻辑方面表现突出。 大多数语句级语言构造为控制结构,允许用户使用各种方法来指定给定代码体离散步骤的执行顺序:

  • 条件语句(如 if 和 switch)允许您根据当前的状态选择不同的后续操作。
  • 循环语句(如 for、foreach 和 while)允许您多次重复执行某组步骤。
  • continue、throw 和 goto 等语句允许您以非本地方式将控制权转交给程序的其他部分。

使用控制结构构建您自己的逻辑将形成顺序组合,这是命令式编程的重要组成部分。 这也是为何有如此多的控制结构可供选择的原因:您希望顺序组合非常方便而且结构合理。

连续执行

在大多数命令式语言(包括最新版本的 Visual Basic 和 C#)中,方法(或者函数、过程或我们选择的这些函数、过程的调用方式)的执行是连续的。 我的意思是说,一旦控制线程 开始执行给定方法,它就会一直这样做,直到该方法停止执行。 是的,有时线程会执行由您的代码体调用的方法中的语句,但这只是该方法执行过程中的一部分。 该线程绝不会切换到您的方法未要求其执行的任何操作。

这种连续性有时会造成问题。 有时,方法可能无法取得任何进展 – 它所能做的,就是等待某个操作发生:下载、文件访问、在另一个线程上发生的计算、到达某个时间点。 在这些情况下,线程被完全占用,但不会执行任何操作。 此情况有一个常用术语,我们称之为该线程被阻塞;导致阻塞的方法称为引发阻塞 的方法。

下面是一个可引发严重阻塞的方法的示例:

static byte[] TryFetch(string url)
{
  var client = new WebClient();
  try
  {
    return client.DownloadData(url);
  }
  catch (WebException) { }
  return null;
}

在调用 client.DownloadData 的大部分时间内,执行此方法的线程将保持静止,不执行任何实际的工作,而只是等待。

在线程非常宝贵的时候(通常都很宝贵),这可能会很糟糕。 在典型的中间层上,轮流处理每个请求需要与后端或其他服务进行通信。 如果每个请求由其自己的线程处理,并且这些线程在等待中间结果的过程中大多被阻塞,则中间层上出现的大量线程可能很快会变成一个性能瓶颈。

最宝贵的线程可能要属 UI 线程:只有一个这样的线程。 实际上,所有 UI 框架都是单线程框架,它们要求所有 UI 相关操作(事件、更新、用户的 UI 操作逻辑)都在同一个专用线程上发生。 如果这些活动中的其中某个活动(例如,选择从 URL 进行下载的事件处理程序)开始等待,则整个 UI 将趋于停滞,因为其线程虽然看起来很忙,但实际上却是“无所事事”。

此时,我们需要通过某种方式让多个顺序进行的活动共享线程。 为此,我们需要它们偶尔“稍作休息”。也就是说,在其执行过程中留出一些时间,以便其他活动在同一线程上进行操作。 换言之,它们有时需要断断续续。 如果这些顺序活动能够在不执行任何操作的时候“稍作休息”,那就再好不过了。 解决方案:异步编程!

异步编程

当前,由于方法始终是连续的,因此,您必须将间断的活动(如下载之前和下载之后)划分到多个方法中。 要在方法的执行过程中挤出时间,您必须将方法分割成连续的片段。 API 可提供运行时间较长的方法的异步(非阻塞)版本来启动操作(例如,启动下载),并在操作完成时存储用于执行的传入回调,然后立即返回到调用方,因此是一种很有用的方式。 但为了便于调用方提供回调,需要将“后续”活动分解到一个单独的方法中。

以下代码说明了之前的 TryFetch 方法是如何执行上述操作的:

static void TryFetchAsync(string url, Action<byte[], Exception> callback)
{
  var client = new WebClient();
  client.DownloadDataCompleted += (_, args) =>
  {
    if (args.Error == null) callback(args.Result, null);
    else if (args.Error is WebException) callback(null, null);
    else callback(null, args.Error);
  };
  client.DownloadDataAsync(new Uri(url));
}

从中您了解到一些传递回调的不同方法:DownloadDataAsync 方法要求事件处理程序已注册到 DownloadDataCompleted 事件,这也是传递该方法“后续”部分所使用的方法。 TryFetchAsync 本身还需要处理其调用方的回调。 您可以采用更加简单的方法,即直接将回调视作参数,而无需自己设置整个事件。 好在我们可以将 lambda 表达式用于该事件处理程序,以便该处理程序能够直接捕获并使用“回调”参数;如果您尝试使用已命名的方法,则必须考虑以某种方式将回调分派给事件处理程序。 请暂停片刻,考虑如何在不使用 lambdas 的情况下编写这段代码。

然而,我们需要关注的重点是控制流的变化情况。 您不必使用这种语言的控制结构来表示控制流,相反,您可以模拟它们:

  • return 语句通过调用回调来模拟。
  • 异常的隐式传播通过调用回调来模拟。
  • 异常处理使用类型检查来模拟。

当然,这是一个非常简单的示例。 随着所需的控制结构变得更加复杂,模拟该结构也会愈加复杂。

总体而言,我们实现了非连续,因而能够使线程在“等待”下载时执行其他操作。 然而,我们也失去了使用控制结构来表示控制流的便利。 我们放弃了作为结构化命令式语言的传统。

异步方法

以这种方式看待问题时,您就会明白下一版本的 Visual Basic 和 C# 如何通过异步方法为我们提供帮助:您可以通过它们来表示非连续顺序代码

 使用新语法的 TryFetch 的异步版本如下所示:

static async Task<byte[]> TryFetchAsync(string url)
{
  var client = new WebClient();
  try
  {
    return await client.DownloadDataTaskAsync(url);
  }
  catch (WebException) { }
  return null;
}

您可以使用异步方法在执行代码的过程中“稍作休息”:您不仅可以使用您最喜爱的控制结构来表示顺序组合,而且可以使用 await 表示式在执行过程中挤出时间,以便线程能够在这段时间内执行其他操作。

一种不错的思考方式,是想像异步方法也具有“暂停”和“播放”按钮。 当正在执行的线程到达 await 表达式时,将触发“暂停”按钮,于是方法暂停执行。 当处于等待状态的任务完成时,将触发“播放”按钮,于是方法恢复执行。

重新编写编译器

如果某种复杂的事物看似简单,这通常意味着其本质发生了有趣的转变,异步方法就属于这种情况。 这种简化为您提供了很好的抽象,使得编写和读取异步代码变得更加方便。 您不需要了解其本质发生了什么转变。 但如果您确实了解这种转变,这将肯定有助于您成为更优秀的异步程序员,并且能够更充分地利用该特性。 而且,如果您正在阅读本文,很可能您只是出于好奇。 所以我们来进行进一步的研究:异步方法以及其中的 await 表达式到底有什么作用?

当 Visual Basic 或 C# 编译器遇到异步方法时,它会在编译过程中将其完全打乱:基础运行时并不直接支持异步方法的非连续性,必须由编译器对其进行模拟。 因此,您不必自己分解异步方法,编译器会为您完成这个任务。 但是,编译器的分解方式与您可能采用的手动分解方式截然不同。

编译器会将您的异步方法转换成状态机。 该状态机会追踪您在执行中的位置以及您的本地状态。 这种状态可能为正在运行已暂停。 如果异步方法正在运行,则表示它可能到达 await 表达式,这会触发“暂停”按钮并暂停执行。 如果异步方法已暂停,某个操作可能会触发“播放”按钮,然后就会返回运行状态。

await 表达式负责进行设置,使得处于等待状态的任务在完成时按下“播放”按钮。 但是,在了解这一切之前,我们首先来分析一下状态机本身,看看“暂停”和“播放”按钮到底是什么。

任务生成器

异步方法会生成 Task。 更具体地说,异步方法会返回类型 Task 或来自 System.Threading.Tasks 的 Task<T> 的一个实例,并且该实例会自动生成。 用户代码不必(并且无法)提供该实例。 (这是一个小谎言:异步方法能够返回 void,但我们暂时忽略这一点。)

从编译器的角度看,生成 Task 很简单。 它依赖于 System.Runtime.CompilerServices 中由框架提供的任务生成器的概念(因为通常情况下,它并不是让人类来直接理解的)。 例如,有一种如下所示的类型:

public class AsyncTaskMethodBuilder<TResult>
{
  public Task<TResult> Task { get; }
  public void SetResult(TResult result);
  public void SetException(Exception exception);
}

生成器允许编译器获得一个 Task,然后完成该 Task 并生成结果或异常。 图 1 是用于 TryFetchAsync 的此机制的草图。

图 1 生成 Task

static Task<byte[]> TryFetchAsync(string url)
{
  var __builder = new AsyncTaskMethodBuilder<byte[]>();
  ...
Action __moveNext = delegate
  {
    try
    {
      ...
return;
      ...
__builder.SetResult(…);
      ...
}
    catch (Exception exception)
    {
      __builder.SetException(exception);
    }
  };
  __moveNext();
  return __builder.Task;
}

请注意以下要点:

  • 首先创建生成器。
  • 然后创建 __moveNext 委托。 此委托为“播放”按钮。 我们称其为恢复委托,其中包含:
    • 异步方法的原始代码(虽然我们暂时省略这些代码)。
    • return 语句,表示按下“暂停”按钮。
    • 完成生成器并返回成功结果的调用,与原始代码中的 return 语句对应。
    • 完成生成器并返回任何转义的异常的封装 try/catch。
  • 现在按下“播放”按钮;调用恢复委托。 该委托继续运行,直到按下“暂停”按钮。
  • 然后该 Task 返回给调用方。

Task 生成器是一种仅用于编译器的特殊帮助程序。 但是,它们的行为与您直接使用任务并行库 (TPL) 的 TaskCompletionSource 类型时的情况并无太大差异。

到现在为止,我已创建一个要返回的 Task 和一个供用户在恢复执行时调用的“播放”按钮(恢复委托)。 我还需要了解如何恢复执行,以及 await 表达式如何进行设置以恢复执行。 但在综合这些内容之前,我们首先来了解如何使用任务。

可等待类型和等待程序

如上所述,Task 可能处于等待状态。 但是,Visual Basic 和 C# 均“乐于”等待其他操作,只要这些操作是可等待 操作;也就是说,只要它们具有某种状态,能够根据其编译 await 表达式。 要成为可等待操作,必须具有 GetAwaiter 方法,该方法又会返回一个等待程序。 例如,Task<TResult> 具有返回以下类型的 GetAwaiter 方法:

public struct TaskAwaiter<TResult>
{
  public bool IsCompleted { get; }
  public void OnCompleted(Action continuation);
  public TResult GetResult();
}

编译器可以通过等待程序的成员检查可等待操作是否已完成,将一个回调注册到该操作(如果尚未注册),并在注册后获得结果(或异常)。

现在我们可以开始了解,要暂停和恢复可等待操作,await 表达式应做些什么。 例如,我们的 TryFetchAsync 示例中的 await 将与以下代码类似:

 

__awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
  if (!__awaiter1.IsCompleted) {
    ...
// Prepare for resumption at Resume1
    __awaiter1.OnCompleted(__moveNext);
    return; // Hit the "pause" button
  }
Resume1:
  ...
__awaiter1.GetResult()) ...

同样,请注意以下情况:

  • 为 DownloadDataTaskAsync 返回的任务获得一个等待程序。
  • 如果该等待程序未完成,则将“播放”按钮(恢复委托)作为回调传递给该等待程序。
  • 等待程序恢复执行(位于 Resume1)时,获取结果并将其用在后续代码中。

很明显,可等待操作通常为 Task 或 Task<T>。 确实,这些类型(已存在于 Microsoft .NET Framework 4 中)已为这一目的进行了大量优化。 但是,我们还是有充分的理由使用其他可等待类型:

  • 桥接其他技术:例如,F# 具有类型 Async<T>,该类型大致对应于 Func<Task<T>>。 能够等待直接源自 Visual Basic 和 C# 的 Async<T> 有助于桥接以这两种语言编写的异步代码。 同样,F# 还能够将桥接功能用于其他目的 – 直接在异步 F# 代码中使用 Task。
  • 实现特殊的语义:TPL 本身增加了几个这样的简单示例。 例如,静态 Task.Yield 实用程序方法返回一个将声称(通过 IsCompleted)未完成的可等待操作,但会立即调度传递给它的 OnCompleted 方法的回调,就好像该操作实际已完成一样。 这允许您强制进行调度并绕过编译器为跳过它而进行的优化(如果结果已可用)。 您可以通过这种方式在“实时”代码中挤出时间,同时提高并未处于空闲状态的代码的响应性。 Task 本身无法代表已完成但声称未完成的操作,因此,将使用某种特殊的可等待类型。

在深入分析 Task 的可等待实现之前,我们先了解编译器是如何重新编写异步方法的,并对用于追踪方法执行状态的记录方式进行完善。

状态机

为整合所有要素,我需要围绕任务的生成和使用构建一个状态机。 基本上,原始方法中的所有用户逻辑都将置入恢复委托中,但留出了局部变量声明,以便将局部变量用在多次调用中。 此外,状态机还引入了用于跟踪操作执行情况的状态变量,恢复委托中的用户逻辑则封装在一个用于监视状态并跳转到相应标签的“大开关”中。 因此,任何时候调用恢复委托,它都将跳转到上次离开的位置。 图 2 综合所有要素。

图 2 创建状态机

static Task<byte[]> TryFetchAsync(string url)
{
  var __builder = new AsyncTaskMethodBuilder<byte[]>();
  int __state = 0;
  Action __moveNext = null;
  TaskAwaiter<byte[]> __awaiter1;
 
  WebClient client = null;
 
  __moveNext = delegate
  {
    try
    {
      if (__state == 1) goto Resume1;
      client = new WebClient();
      try
      {
        __awaiter1 = client.DownloadDataTaskAsync(url).GetAwaiter();
        if (!__awaiter1.IsCompleted) {
          __state = 1;
          __awaiter1.OnCompleted(__moveNext);
          return;
        }
        Resume1:
        __builder.SetResult(__awaiter1.GetResult());
      }
      catch (WebException) { }
      __builder.SetResult(null);
    }
    catch (Exception exception)
    {
      __builder.SetException(exception);
    }
  };
 
  __moveNext();
  return __builder.Task;
}

相当长! 可以肯定,您会问自己,为什么这段代码要比之前所示的手动“异步化”版本长很多。 我们有充分的理由这样做,这些理由包括效率(通常情况下分配的数量较小)和通用性(它适用于用户定义的可等待类型,而不仅仅是 Task)。 但是,主要原因在于:您根本不必分解用户逻辑;您只需要通过一些跳转和返回以及诸如此类的操作来扩展用户逻辑。

虽然这个示例非常简单,并不能够提供充足的理由,但是,要重新编写方法的逻辑,为该方法在 await 之间的每个连续逻辑片段提供语义上等价的一组离散方法,是一件非常麻烦的事情。 其中嵌入了 await 的控制结构越多,事情就越麻烦。 如果 await 周围不仅包括使用 continue 和 break 语句的循环,而且包括 try-finally 块甚至是 goto 语句,那么,要在保持高度一致的情况下重新编写方法逻辑,将会非常困难(如果确实可行的话)。

您不必尝试上述方法,但可以采用一个巧妙的技巧,即用另一层控制结构覆盖用户的原始代码,并在情况需要时使用条件跳转和返回。 播放与暂停。 在 Microsoft,我们一直在对异步方法以及与其对应的同步方法之间的等价性进行系统测试,并且我们已证实这是一种非常可靠的测试方式。 要在异步方法中保留同步语义,首先必须保留描述这些语义的代码,除此以外没有更好的办法。

要点

我上面的描述有些理想化;您可能会怀疑,要重新编写代码,是不是需要更多技巧呢? 下面是编译器必须处理的一些其他问题:

Goto 语句图 2 中重新编写的代码实际上无法编译,因为 goto 语句无法跳转到嵌套结构中的标签(至少在 C# 中是如此)。 这本身并没有问题,因为编译器生成的是中间语言 (IL),而不是源代码,因此不用担心嵌套。 但即使 IL 也不允许跳转到 try 块的中间,如我的示例所示。 而实际情况则是,您跳转到 try 块的开头,正常进入该块,然后切换并再次跳转。

Finally 块 由于 await 而退出恢复委托时,您并不希望执行 finally 块。 应保留这些块,直到执行用户代码中的原始 return 语句。 您在进行控制时,可通过生成布尔型标记来指示是否应执行 finally 块,然后再通过扩充这些块来检查该标记。

赋值顺序 await 表达式不一定是方法或操作符的第一个参数;它可能处于中间位置。 要保留赋值顺序,必须在遇到 await 之前对前面的所有参数进行赋值,而且出乎意料的是,还需要存储这些参数并在遇到 await 之后再次检索它们。

除此以外,还有一些您无法绕开的限制。 例如,await 不能位于 catch 或 finally 块内,因为我们无法在 await 之后以适当的方式重建合适的异常上下文。

任务等待程序

在如何调度恢复委托(即异步方法的剩余部分)方面,编译器生成的代码在实施 await 表达式时所使用的等待程序拥有相当大的自由。 但是,在您需要实施自己的等待程序之前,相关情形必须非常高级。 在如何进行调度方面,任务本身具有相当大的灵活性,因为它们遵循本身可插入的调度上下文概念。

调度上下文是这样的一种概念:如果我们从一开始就针对它进行设计,那理解起来可能会更容易一些。 实际上,它综合了几个现有的概念,因此我们决定首先尝试引入一个统一的概念,以免造成更大的混乱。 我们首先从概念级别了解这个概念,然后研究如何实现它。

调度处于等待状态的任务的异步回调的基本原理在于:对于某个“位置”,您希望从“您此前所在的位置”继续执行。我将这个“位置”称为调度上下文。 调度上下文是一个与线程关联的概念;每个线程(至少)具有一个调度上下文。 在某个线程上运行时,您可以请求该线程运行时所在的调度上下文;当您获得某个调度上下文时,您可以调度在其中运行的项目。

因此,异步方法在等待任务时应进行如下操作:

  • 在暂停时:请求运行它的线程提供调度上下文。
  • 在恢复时:调度恢复委托返回到该调度上下文。

为什么必须这样做呢? 以 UI 线程为例。 它拥有自己的调度上下文,该上下文在调度新工作时,会通过消息队列将新工作送回到 UI 线程上。 这意味着,如果您正在该 UI 线程上运行并且在等待一个任务,则当这个任务完成后,异步方法的剩余部分将返回到该 UI 线程上运行。 因此,您只能在该 UI 线程上执行的所有操作(操纵 UI),也可以在 await 之后执行;您不会在执行代码的过程中经历怪异的“线程跳跃”。

其他调度上下文属于多线程上下文;确切地说,标准线程池由单独一个调度上下文表示。 如果将新工作调度到这类上下文中,新工作可以在线程池中的任何线程上运行。 因此,在线程池上开始运行的异步方法将继续以这种方式运行,虽然它可能会在不同的特定线程之间“四处跳跃”。

实际上,并没有与调度上下文对应的单一概念。 大体而言,线程的 SynchronizationContext 充当它的调度上下文。 因此,如果线程拥有这其中的一个(可由用户实施的现有概念),将使用该上下文。 如果没有,则使用线程的 TaskScheduler(TPL 引入的一个类似概念)。 如果没有任何一个上下文,则使用默认的 TaskScheduler;即将恢复委托调度到标准线程池中的 TaskScheduler。

当然,所有这些调度操作都要以损失性能为代价。 通常,在用户看来,这种代价可以忽略并且物有所值:将 UI 代码分割成许多易于管理的、具体的实时工作,并在等待的结果可用时通过消息泵将它们泵回到线程上,这正是用户期待的解决方案。

尽管有时候,特别是在库代码中,事情可能会变得非常繁琐。 以下面的代码为例:

async Task<int> GetAreaAsync()
{
  return await GetXAsync() * await GetYAsync();
}

这段代码两次返回到调度上下文(在每个 await 之后),只是为了在“适当的”线程上执行乘法操作。 但谁会在意您在哪个线程上做乘法呢? 这可能会造成浪费(如果经常使用),采用以下技巧可以避免这种情况:基本上,您可以将处于等待状态的 Task 封装在一个知道如何关闭“调度回”行为、并且在任何完成任务的线程上运行恢复委托的非 Task 可等待类型中,从而避免上下文切换和调度延迟,如下所示:

async Task<int> GetAreaAsync()
{
  return await GetXAsync().ConfigureAwait(continueOnCapturedContext: false)
    * await GetYAsync().ConfigureAwait(continueOnCapturedContext: false);
}

确实,这个技巧并不完美,但对于可能最终成为调度瓶颈的库代码,这却是一个绝妙的技巧。

总结及异步化

现在您应该对异步方法有了初步的了解。 最有用的要点可能包括:

  • 编译器通过以实际方式保留控制结构来保留控制结构的语义。
  • 异步方法不调度新线程,它们允许您在现有线程上执行多项任务。
  • 如果任务处于等待状态,它们会让您返回“以前的位置”以获得该位置的合理定义。

如果您像我这样,您可能会不时阅读本文,不时键入一些代码。 您已经在同一个线程上执行了多个控制流,即阅读和编码,而这个线程就是:您自己。 这就是异步方法能够为您做的。

Mads Torgersen 是 Microsoft 的 C# 和 Visual Basic 语言团队的首席项目经理。

*衷心感谢以下技术专家对本文的审阅:*Stephen Toub