作者:Mads Torgersen | 2011 年 10 月
即将推出的 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 可以通过提供异步(非阻塞)版本的长期运行方法来启动操作(例如,开始下载),存储传入的回调以在完成时执行,然后立即返回给调用者。但是为了让调用者提供回调,“after”活动需要被分解成一个单独的方法。
以下是上述 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 事件,因此这就是您传递方法“after”部分的方式。TryFetchAsync 本身也需要处理其调用者的回调。您不必自己设置整个事件业务,而是使用更简单的方法,将回调作为参数。我们可以为事件处理程序使用 lambda 表达式,这样它就可以直接捕获和使用“回调”参数,这是一件好事;如果您尝试使用命名方法,则必须想办法将回调委托传递给事件处理程序。稍等片刻,想一想如果没有 lambda,您将如何编写此代码。
但这里要注意的主要事情是控制流改变了多少。不是使用语言的控制结构来表达流程,而是模拟它们:
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# 编译器获得异步方法时,它会在编译期间对其进行相当多的处理:底层运行时不直接支持该方法的不连续性,必须由编译器模拟。因此,您不必将方法分解成位,编译器会为您完成。但是,它与您手动执行此操作的方式完全不同。
编译器将您的异步方法成的statemachine。状态机会跟踪您在执行中的位置以及您的本地状态。它可以运行或暂停。当它运行时,它可能会到达等待,它会点击“暂停”按钮并暂停执行。当它暂停时,某些东西可能会点击“播放”按钮以使其恢复运行。
await 表达式负责进行设置,以便在等待的任务完成时按下“播放”按钮。然而,在我们开始之前,让我们看看状态机本身,以及那些暂停和播放按钮到底是什么。
任务构建器
异步方法产生任务。更具体地说,异步方法从 System.Threading.Tasks 返回 Task 或 Task<T> 类型之一的实例,并且该实例是自动生成的。它不必(也不能)由用户代码提供。(这是一个小谎言:异步方法可以返回 void,但我们暂时忽略它。)
从编译器的角度来看,生成任务是容易的部分。它依赖于框架提供的任务构建器概念,在 System.Runtime.CompilerServices 中找到(因为它通常不是供人类直接使用的)。例如,有一个这样的类型:
public class AsyncTaskMethodBuilder<TResult>{ public Task<TResult> Task { get; } public void SetResult(TResult result); public void SetException(Exception exception); }
构建器让编译器获得一个任务,然后让它以结果或异常完成任务。图 1是 TryFetchAsync 的这种机制的草图。
图 1 构建任务
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 语句,表示按下“暂停”按钮。
以成功的结果完成构建器的调用,对应于原始代码的返回语句。
一个包装的 try/catch,它完成了带有任何转义异常的构建器。
现在按下“播放”按钮;调用恢复委托。它会一直运行,直到点击“暂停”按钮。
任务返回给调用者。
任务构建器是特殊的帮助器类型,仅用于编译器使用。但是,它们的行为与直接使用任务并行库 (TPL) 的 TaskCompletionSource 类型时发生的情况没有太大不同。
到目前为止,我已经创建了一个要返回的任务和一个“播放”按钮——恢复委托——供某人在恢复执行时调用。我仍然需要看看如何恢复执行以及 await 表达式如何设置以执行此操作。不过,在我把它们放在一起之前,让我们先看看任务是如何消耗的。
等待者和等待者
如您所见,可以等待任务。但是,Visual Basic 和 C# 也非常乐意等待其他东西,只要它们是可等待的;也就是说,只要它们具有可以编译 await 表达式的特定形状。为了可以等待,某些东西必须有一个 GetAwaiter 方法,该方法又返回一个awaiter。例如, Task<TResult> 有一个 GetAwaiter 方法返回此类型:
public struct TaskAwaiter<TResult>{ public bool IsCompleted { get; } public void OnCompleted(Action continuation); public TResult GetResult(); }
awaiter 上的成员让编译器检查 awaitable 是否已经完成,如果尚未完成,则注册一个回调,并在完成时获取结果(或异常)。
我们现在可以开始看看 await 应该做什么来暂停和恢复 awaitable。例如,我们 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 返回的任务获取等待程序。
如果 awaiter 未完成,则“播放”按钮(恢复委托)将作为回调传递给 awaiter。
当 awaiter 恢复执行时(在 Resume1 处),将获得结果并在其后的代码中使用。
显然,常见的情况是可等待对象是 Task 或 Task<T>。实际上,Microsoft .NET Framework 4 中已经存在的那些类型已经针对此角色进行了敏锐的优化。但是,也有充分的理由允许其他可等待类型:
与其他技术的桥接:例如,F# 有一个 Async<T> 类型,大致对应于 Func<Task<T>>。能够直接从 Visual Basic 和 C# 等待 Async<T> 有助于在用两种语言编写的异步代码之间架起桥梁。F# 类似地将桥接功能暴露为另一种方式——直接在异步 F# 代码中使用任务。
实现特殊语义:TPL 本身正在添加一些简单的示例。例如,静态 Task.Yield 实用程序方法返回一个可等待对象,它会声称(通过 IsCompleted)未完成,但会立即安排传递给其 OnCompleted 方法的回调,就好像它实际上已完成一样。这使您可以强制调度并绕过编译器的优化,如果结果已经可用则跳过它。这可用于在“实时”代码中戳洞,并提高非闲置代码的响应能力。任务本身不能代表已完成但声称不是的事物,因此为此使用了特殊的可等待类型。
在我进一步研究 Task 的 awaitable 实现之前,让我们先看看编译器对异步方法的重写,并充实跟踪方法执行状态的簿记。
状态机
为了将它们拼接在一起,我需要围绕任务的生产和消费建立一个状态机。本质上,原始方法中的所有用户逻辑都放入了恢复委托中,但是本地人的声明被取消了,以便它们可以在多次调用中幸存下来。此外,还引入了一个状态变量来跟踪事情的进展情况,并且恢复委托中的用户逻辑被包裹在一个查看状态并跳转到相应标签的大开关中。因此,无论何时调用恢复,它都会立即跳回到上次停止的位置。图 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; }
满口的!我敢肯定您会问自己,为什么这段代码比前面显示的手动“异步”版本要冗长得多。有几个很好的理由,包括效率(在一般情况下分配较少)和通用性(它适用于用户定义的可等待对象,而不仅仅是任务)。但是,主要原因是:毕竟您不必将用户逻辑拉开;您只需通过一些跳跃和返回等来增强它。
虽然这个例子太简单而无法真正证明它的合理性,但将方法的逻辑重写为语义等效的一组离散方法,用于等待之间的每个连续逻辑位是非常棘手的业务。等待嵌套的控制结构越多,情况就越糟。当不只是包含 continue 和 break 语句的循环,还有 try-finally 块甚至 goto 语句围绕等待时,如果确实可能的话,产生高保真重写是极其困难的。
与其尝试这样做,似乎一个巧妙的技巧是将用户的原始代码与另一层控制结构叠加起来,根据情况的需要将您空运进(带条件跳转)和空运(带返回)。播放和暂停。在 Microsoft,我们一直在系统地测试异步方法与同步方法的等效性,我们已经确认这是一种非常可靠的方法。没有比首先保留描述这些语义的代码更好的方法来将同步语义保留到异步领域中。
精美印刷品
我提供的描述稍微理想化了——正如您可能已经怀疑的那样,重写还有一些技巧。以下是编译器必须处理的一些其他问题:
Goto 语句图 2中的重写实际上并未编译,因为 goto 语句(至少在 C# 中)无法跳转到嵌套结构中的标签。这本身没有问题,因为编译器生成的是中间语言 (IL),而不是源代码,并且不受嵌套的困扰。但即使是 IL 也不允许跳到 try 块的中间,就像我的示例中所做的那样。相反,真正发生的情况是您跳转到 try 块的开头,正常输入,然后切换并再次跳转。
finally块当由于等待而从恢复委托中返回时,您不希望 finally 体被执行。当执行来自用户代码的原始返回语句时,应保存它们。你可以通过生成一个布尔标志来控制是否应该执行 finally 体,并增加它们以检查它。
求值顺序await 表达式不一定是方法或运算符的第一个参数;它可以发生在中间。为了保持求值顺序,所有前面的参数必须在 await 之前求值,并且在 await 之后存储它们并再次检索它们的行为令人惊讶地涉及。
除此之外,还有一些您无法绕过的限制。例如,在 catch 或 finally 块中不允许等待,因为我们不知道在等待之后重新建立正确的异常上下文的好方法。
任务等待者
编译器生成的代码用于实现 await 表达式的 awaiter 在如何调度恢复委托方面具有相当大的自由度,即异步方法的其余部分。但是,在您需要实现自己的等待程序之前,场景必须非常先进。任务本身在如何调度方面具有很大的灵活性,因为它们尊重本身可插入的调度上下文的概念。
如果我们从一开始就为它设计,调度上下文可能看起来会更好一些。事实上,它是一些现有概念的混合体,我们决定不通过尝试在顶部引入一个统一的概念来进一步搞砸。让我们在概念层面看一下这个想法,然后我将深入了解实现。
为等待的任务调度异步回调的基本原理是,您希望继续执行“您之前所在的位置”,以获得“where”的某个值。这就是我称之为调度上下文的“位置”。调度上下文是一个线程仿射概念;每个线程都有(最多)一个。当你在一个线程上运行时,你可以要求它运行的调度上下文,当你有一个调度上下文时,你可以安排在其中运行的东西。
所以这就是异步方法在等待任务时应该做的事情:
暂停时:询问正在运行的线程的调度上下文。
恢复时:将恢复委托安排回该调度上下文。
为什么这很重要?考虑 UI 线程。它有自己的调度上下文,它通过消息队列将新工作发送回 UI 线程来调度新工作。这意味着如果您在 UI 线程上运行并等待一个任务,当任务的结果准备好时,异步方法的其余部分将在 UI 线程上运行。因此,所有只能在 UI 线程上执行的操作(操作 UI)在 await 之后仍然可以执行;您不会在代码中间遇到奇怪的“线程跳跃”。
其他调度上下文是多线程的;具体来说,标准线程池由单个调度上下文表示。当新工作被安排给它时,它可能会在池的任何线程上进行。因此,开始在线程池上运行的异步方法将继续这样做,尽管它可能会在不同的特定线程之间“跳跃”。
实际上,没有与调度上下文相对应的单一概念。粗略地说,线程的 SynchronizationContext 充当其调度上下文。因此,如果一个线程具有其中之一(可以由用户实现的现有概念),它将被使用。如果没有,则使用线程的 TaskScheduler(TPL 引入的类似概念)。如果它也没有其中之一,则使用默认的 TaskScheduler;那个调度恢复到标准线程池。
当然,所有这些调度业务都有性能成本。通常,在用户场景中,它是微不足道的并且非常值得:将您的 UI 代码切成可管理的实际实时工作的位,并在等待结果可用时通过消息泵泵入,这通常正是医生所要求的。
但有时——尤其是在库代码中——事情可能会变得过于细粒度。考虑:
async Task<int> GetAreaAsync() { return await GetXAsync() * await GetYAsync(); }
这将调度回调度上下文两次——在每次等待之后——只是为了在“正确的”线程上执行乘法。但谁在乎你乘的是什么线程?这可能很浪费(如果经常使用),并且有一些技巧可以避免它:您基本上可以将等待的任务包装在一个非任务等待中,它知道如何关闭调度返回行为,并在任何线程完成时运行恢复任务,避免上下文切换和调度延迟:
async Task<int> GetAreaAsync() { return await GetXAsync().ConfigureAwait(continueOnCapturedContext: false) * await GetYAsync().ConfigureAwait(continueOnCapturedContext: false); }
可以肯定的是,不太漂亮,但在库代码中使用的一个巧妙技巧最终成为调度的瓶颈。
前进和异步化
现在您应该对异步方法的基础有了一个有效的理解。可能最有用的要点是:
编译器通过实际保留控制结构来保留控制结构的含义。
异步方法不会调度新线程——它们允许您在现有线程上进行多路复用。
当任务被等待时,他们会让你回到“你所在的地方”,以合理定义这意味着什么。
如果你和我一样,你已经在阅读这篇文章和输入一些代码之间交替进行了。您在同一个线程上复用了多个控制流——阅读和编码:你。这正是异步方法让您做的事情。
Mads Torgersen 是 Microsoft C# 和 Visual Basic 语言团队的首席项目经理。
感谢以下技术专家对本文的审阅: Stephen Toub

