本文翻译自 How Async/Await Really Works in C#
声明
- 翻译会在易读易懂的基础上略微修改原文的分段和用词
- 出现专用名词或是难以翻译的地方会用括号标注原词
前言
几周前,.NET 博客发布了一篇名为 “什么是 .NET,为什么要选择它?” 的文章。这篇对 .NET 平台的概述涵盖了各种组件和设计决策,并承诺会有后续文章深入讲解各个方面。这篇文章就是该系列后续的第一篇,深入剖析了 async
/await
在 C# 和 .NET 中的发展历史、设计决策以及实现细节。
async
/await
的引入已有十多年,它彻底革新了 .NET 中编写可扩展代码的方式。即便许多人并不完全了解其底层原理,也能轻松上手使用它。我们从一个同步方法开始说起(所谓“同步”,是指调用方在操作完成并返回控制权之前,无法执行其他任务):
1 | // 同步地复制所有数据 |
只需添加一些关键字并稍作调整方法名,就可以将其转换为如下的异步方法(所谓“异步”,是指它在调用后会迅速将控制权返回给调用方,而整个操作仍在后台继续执行):
1 | // 异步地复制所有数据 |
这些代码在语法上几乎完全相似:它们保留了相同的控制流,却具备了非阻塞的特性。然而,其背后的执行模型却发生了巨大的变化——这些繁琐的工作已由 C# 编译器和核心库为你处理。
虽然在日常使用 async
/await
时无需深刻理解其底层运行机制,但了解其原理会让你更高效地使用这些特性,尤其是在需要深入分析问题时(例如调试复杂异常或优化性能)。因此,本文将深入探讨 await
在语言、编译器和库层面的具体实现细节,帮助你更好地掌握和应用这些强大的工具。
在此之前,我们需要先回顾 async
/await
出现之前的异步代码形式,看看在没有这些特性时,异步编程是如何实现的——不得不说,那并不优雅。
起初…
在 .NET Framework 1.0 中,异步编程采用了一种称为异步编程模型(Asynchronous Programming Model, APM)的模式,也被称为 Begin/End 模式 或 IAsyncResult 模式。这种模式相对抽象和简单,专注于为每个同步方法提供对应的异步方法。
假设我们有一个同步方法 DoStuff
,它的定义如下:
1 | class Handler |
在 APM 模式下,DoStuff
方法会有两个额外的异步版本:一个用于启动异步操作(BeginDoStuff
),另一个用于获取操作结果(EndDoStuff
)。它们的定义如下:
1 | class Handler |
BeginDoStuff
方法接受与 DoStuff
相同的参数,同时还额外接受一个 AsyncCallback
委托和一个可选的状态对象(可以味null
的state
)。Begin
方法主要是为了启动异步操作。如果提供了回调(通常称为“后续操作”),它会在异步操作完成时触发该回调。此外,Begin
方法会返回一个实现 IAsyncResult
接口的对象,并通过 state
参数设置其 AsyncState
属性。
IAsyncResult
和 AsyncCallback
是 APM 模式的核心,定义如下:
1 | namespace System |
BeginDoStuff
返回的 IAsyncResult
不仅作为调用结果,还会传递给回调函数 AsyncCallback
。在操作完成后,调用方需要将该 IAsyncResult
实例传递给 EndDoStuff
,它负责确保操作完成(如果未完成则会阻塞等待)并返回结果或抛出操作中发生的异常。因此,和如下同步代码相比:
1 | try |
Begin/End 方法可以通过如下方式来实现相同的异步操作:
1 | try |
对于熟悉基于回调 API 的开发者来说,无论使用哪种语言,这种模式都应该比较直观。
然而,复杂性从这里开始显现。例如,一个常见的问题被称为“栈溢出(stack dives)”。栈溢出是指代码在递归调用或深度嵌套时导致调用栈不断加深,最终耗尽栈空间,引发栈溢出异常。特别是在操作同步完成时,Begin 方法可能会直接同步触发回调,而这种行为是 APM 模式中的常见现象。
值得注意的是,“异步”操作并不意味着它们一定会异步完成。之所以称为“异步”,是因为它们有能力以异步方式完成,但实际执行过程中,它们可能同步完成。
举个例子,假设我们正在从网络流中异步读取数据,比如通过 socket 接收数据。如果每次操作只需要少量数据(例如读取响应头),通常我们会使用一个缓冲区来减少频繁的系统调用开销。与每次读取少量数据相比,一次性读取较多数据放入缓冲区,然后按需从缓冲区中提取数据,这种方式效率更高,减少了与 socket 的交互次数。
缓冲区的存在可能被隐藏在异步操作的抽象背后。第一次填充缓冲区的操作可能确实是异步完成的,但后续操作如果只需要从缓冲区中提取数据,就不再需要真正的 I/O 操作,因此可以同步完成。当 Begin 方法处理这种同步完成的操作时,它可能直接调用回调。如果回调在 Begin 方法中同步调用,调用栈将会包含以下层级:调用 Begin 方法的栈帧;Begin 方法本身的栈帧和回调函数的栈帧。
如果回调在执行过程中再次调用了 Begin 方法,并且新操作同样同步完成且立即调用回调,这会进一步加深栈深度。如此循环反复,栈空间最终会被耗尽,导致栈溢出异常。
这种情况不仅可能发生,而且相对容易复现。例如,以下代码可以在 .NET Core 上验证该问题的表现:
1 | using System.Net; |
在这段代码中,我搭建了一个简单的 socket 客户端和服务端,它们相互通信。服务器向客户端发送了 100000 字节的数据,客户端通过 BeginRead/EndRead
以“异步”的方式逐字节处理这些数据(虽然效率低下,但此处仅为教学演示)。BeginRead
的回调中调用 EndRead
完成数据读取后,如果成功读取了字节(即未到达数据流末尾),它会通过本地函数 ReadAgain
的递归调用,发起下一次 BeginRead
。
然而,在 .NET Core 中,socket 操作比 .NET Framework 快得多。当操作系统能够立即满足请求时,这些操作会同步完成(操作系统内核有一个 socket 缓冲区用于满足接收请求)。因此,这段代码未正确处理同步完成的情况,会导致栈溢出。
为了解决这个问题,APM 模式设计了两种补救机制:
不允许同步调用回调 (
AsyncCallback
)。在操作同步完成时确保回调以异步方式调用,这样可以避免栈深度不断增加。然而,这种方式会对性能造成影响。同步完成(即便快到看不出来)的操作非常常见,如果强制每次同步操作都排队等待回调执行,会显著增加延迟和开销。当操作同步完成时,由调用 Begin 方法的调用者而非回调函数执行“继续操作”。这种高效的方式避免了栈深度的增加。
APM 最终选择了这种机制。为实现上述方案,IAsyncResult
接口引入了两个关键属性:IsCompleted
和 CompletedSynchronously
。IsCompleted
表示操作是否完成,你可以多次检查其值,最终它会从 false
变为 true
(然后保持不变)。而 CompletedSynchronously
表示操作是否以同步方式完成。其值在操作生命周期内不会改变(如果改变,说明程序有严重问题);它的值由 Begin 方法的调用者和 AsyncCallback
回调共享,用于决定谁负责执行“继续操作”。
如果 CompletedSynchronously
为 false
,则表示操作以异步方式完成,此时“继续操作”交由回调处理,因为调用者无法立即完成后续操作(调用者尝试调用 End 时会阻塞等待)。但是,如果 CompletedSynchronously
为 true
,则操作已同步完成,此时由调用者处理“继续操作”,避免回调函数造成栈深度增加。因此,任何关心栈溢出的实现都需要检查 CompletedSynchronously
,并在为 true
时让 Begin 的调用者来完成“继续操作”,而不应该让回调进行。这也是为什么 CompletedSynchronously
的值必须保持不变,以确保调用者和回调看到一致的状态,防止“继续操作”被重复执行。
对于之前的 DoStuff
例子,改良后代码如下:
1 | try |
这确实相当复杂!而且到目前为止,我们仅仅讨论了如何使用这种模式,还没有深入探讨它的实现细节。虽然大多数开发者无需关注这些底层实现(例如,与操作系统交互的 Socket.BeginReceive/EndReceive
方法),但有时开发者需要将多个异步操作组合在一起以构建更复杂的功能。这不仅需要调用其他的 Begin/End 方法,还可能需要自行实现这些方法,以便将组合后的操作暴露给其他代码使用。
更麻烦的是,前面 DoStuff
的例子中甚至没有包含任何控制流。如果需要处理多个操作,即使是简单的控制流(比如循环)也会迅速变得复杂,这种工作只有那些有受虐倾向的专家或者博客作者才愿意深入探讨。
为了更好地理解这一点,让我们实现一个完整的例子。在文章开头,我展示了一个 CopyStreamToStream
方法,它将所有的数据从一个流复制到另一个流(类似于 Stream.CopyTo
,但为了讲解的目的,假设这个方法并不存在):
1 | public void CopyStreamToStream(Stream source, Stream destination) |
很简单:我们不断从一个流读取数据,然后将结果写入另一个流,反复进行直到没有剩下的数据。那么,如何使用 APM 模式实现这个操作的异步版本呢?可以像这样:
1 | public IAsyncResult BeginCopyStreamToStream( |
即使有了这一大堆乱七八糟的代码,它也不是一个非常好的实现。例如,IAsyncResult
的实现对于每个操作都加了同步锁,而没能采用无锁的方式;异常被原始地存储,而没使用 ExceptionDispatchInfo
,这会使得在传递异常时过多地调用堆栈。此外,每个单独的操作都会涉及大量的内存分配(例如,每次调用 BeginWrite
时都会分配一个新的委托),如此等等。
想象一下,每次你想编写一个可重用的方法来处理另一个异步操作时,你都必须亲自做这些事。而如果你想编写可重用的、组合的、可以高效地操作多个 IAsyncResult
(类似于 Task.WhenAll
的功能),那更是错综复杂;每个操作都实现并暴露其特定的 API,这意味着没有一种通用操作来处理它们(尽管一些开发者写了库来尝试减轻这个负担:通常是通过添加另一层回调来使 API 可以为 Begin
方法提供一个合适的 AsyncCallback
)。
这些复杂性意味着很少有人尝试这么做,而那些尝试的人通常也会遇到很多 bug。客观地说,这其实并不是在批判 APM 这个模式,而是在批判所有基于回调的异步编程方式。我们都已经习惯了现代语言中提供的强大而简洁的控制流结构,但一旦稍稍复杂,这种基于回调的异步方式通常就和这些控制流相冲突。而且,其他主流语言也没有提供更好的替代方案。
因此,我们需要一种能够扬 APM 之长,避 APM 之短的更好方法。值得注意的是,APM 模式本质上只是一个模式;运行时、核心库和编译器并没有在处理或实现这个模式时提供任何帮助。
事件驱动异步模式
.NET Framework 2.0 引入了一些新的 API,用来实现另一种异步操作模式,这个模式主要是为客户端应用程序的场景设计的。这种模式被称为 事件驱动异步模式(Event-based Asynchronous Pattern,简称 EAP),它同样有两个(或多个)成员,一个用于启动异步操作的方法,另一个则是用于监听操作完成的事件。比如我们之前提到的 DoStuff
方法可以被改写如下:
1 | class Handler |
你可以通过注册 DoStuffCompleted
事件来完成“继续操作”,然后调用 DoStuffAsync
方法来启动异步操作。当这个操作完成时,DoStuffCompleted
事件会被异步地触发,这时你的处理函数就可以执行“继续操作”,通常还会检查提供的 userToken
是否与预期相符,这样可以支持多个处理函数同时 hook 到事件。
这种模式在某些使用场景下确实更方便,但在另外一些场景下却变得更加复杂(之前在 APM 模式下实现 CopyStreamToStream
的例子就显得很有说服力了)。EAP 没有被大规模推广,基本上只出现在 .NET Framework 的单个版本中,即使它留下了一些在此期间添加的 API,例如 Ping.SendAsync
和 Ping.PingCompleted
:
1 | public class Ping : Component |
不过,EAP 确实引入了一项值得注意的改进,它没有在 APM 中考虑到,但却一直延续到今天的各种异步编程模型中:SynchronizationContext(同步上下文)。
SynchronizationContext
作为一种通用调度器的抽象层也是在 .NET Framework 2.0 中引入的。它最常用的方法是 Post
,用于将工作项排入该上下文表示的调度器。例如,SynchronizationContext.Post
的实现只是代表了 ThreadPool
,因此这个方法只是简单地调用了 ThreadPool.QueueUserWorkItem
,给线程池中的线程执行提供回调。然而,SynchronizationContext
的真正价值不在于支持任意调度器,而在于根据不同应用程序模型的需求来调度工作。
举个例子,像 Windows Forms 这样的 UI 框架。和大多数 Windows UI 框架一样,控件都和特定的线程关联,这个线程运行消息循环以处理与控件交互的任务:只有这个线程才能操作这些控件,其他任何线程想要与这些控件交互,都应该通过向 UI 线程的消息循环发送消息来完成。Windows Forms 提供了 Control.BeginInvoke
这样的方法,让你很容易将要执行的委托和参数排到控件所属的线程上去执行。因此你可以这样写:
1 | private void button1_Click(object sender, EventArgs e) |
这段代码会把 ComputeMessage()
的工作交给线程池中的某个线程来处理(可以保持 UI 的响应),当处理完成时,再将更新 button1
标签的操作排到与 button1
关联的线程去执行。同样,在 WPF 中,你可以用类似的 Dispatcher
类型来实现:
1 | private void button1_Click(object sender, RoutedEventArgs e) |
.NET MAUI 也有类似的机制。那么,如果你想把这段代码封装到一个辅助方法中呢?比如这样:
1 | // 调用 ComputeMessage 并随后调用 update 操作来更新控件。 |
然后在按钮点击时这样使用:
1 | private void button1_Click(object sender, EventArgs e) |
如何实现 ComputeMessageAndInvokeUpdate
使其能够在任何应用程序中都能正常工作呢?难道需要硬编码去识别每个可能的 UI 框架吗?这就是 SynchronizationContext
发挥作用的地方。我们可以这样实现这个方法:
1 | internal static void ComputeMessageAndInvokeUpdate(Action<string> update) |
这里使用 SynchronizationContext
作为抽象层,决定应该使用哪个“调度器”来回到正确的环境中进行 UI 操作。每种应用程序模型都确保其当前线程的 SynchronizationContext
是一个派生自 SynchronizationContext
的类型,并且能够正确调度。例如,Windows Forms 有这样的实现:
1 | public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable |
WPF 则是这样的:
1 | public sealed class DispatcherSynchronizationContext : SynchronizationContext |
ASP.NET 以前也有自己的同步上下文,它并不关心具体的线程,而是确保与某个请求相关的所有工作能按顺序执行,不会有多个线程并发访问相同的 HttpContext
:
1 | internal sealed class AspNetSynchronizationContext : AspNetSynchronizationContextBase |
SynchronizationContext
并不限定于这些主流的应用程序模型。例如,xunit 是一个流行的单元测试框架,.NET 的核心库也是用它进行单元测试的,它也用到了多个自定义的 SynchronizationContext
。例如,你可以允许多个测试并行运行,但限制同时运行的测试数量,这个机制也是通过 SynchronizationContext
实现的:
1 | public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable |
MaxConcurrencySyncContext
的 Post
方法只是把操作排入到它自己的内部队列,然后用它自己的工作线程处理这些任务,控制线程的数量以满足最大并发的要求。相信你已经理解了其用法。
那么这与事件驱动异步模式(EAP)有什么关系呢?EAP 和 SynchronizationContext
是在同一时间被引入的,EAP 规定,异步操作完成的事件应该被排入异步操作启动时的 SynchronizationContext
中。为了稍微简化这种处理(虽然不一定值得),System.ComponentModel
中还引入了一些辅助类型,特别是 AsyncOperation
和 AsyncOperationManager
。前者只是一个元组,包装了用户提供的状态对象和捕获的 SynchronizationContext
,后者只是一个简单的工厂,用来捕获当前的上下文并创建 AsyncOperation
实例。然后 EAP 的实现会使用它们,比如 Ping.SendAsync
会调用 AsyncOperationManager.CreateOperation
来捕获 SynchronizationContext
,然后当操作完成时,会调用 AsyncOperation
的 PostOperationCompleted
方法来调用存储的 SynchronizationContext
的 Post
方法。
SynchronizationContext
还提供了一些值得一提的功能,稍后我们会提到它们。特别是它暴露了 OperationStarted
和 OperationCompleted
方法。这些虚方法不做任何操作,但派生类可以重写它们来追踪正在进行的操作。这意味着 EAP 实现会在每个操作的开始和结束时调用这些方法,以通知 SynchronizationContext
并允许它追踪状态。这对于 EAP 模式尤为重要,因为启动异步操作的方法返回值是 void
,你得不到任何返回值来追踪操作状态。
因此,我们需要一种比 APM 模式更好的方法,而紧随其后的 EAP 虽然引入了一些新概念,但并没有真正解决我们面临的核心问题。我们依然需要更好的解决方案。
Task 介绍
.NET Framework 4.0 新增了 System.Threading.Tasks.Task
类型。它代表某个异步操作的完成(在其他框架中,类似地称为“promise”或“future”)。Task
用来代表某个操作,当这个操作完成时,其结果会被存储在这个 Task
中。这听起来很简单。但 Task
关键在于它蕴含了“继续操作”(continuation)这个概念,使得它比 IAsyncResult
更有用。
借助 Task
,您可以在任何时候查看某个任务的状态,并要求在任务完成时异步通知。任务本身还会处理同步工作,以确保不论任务是已经完成、尚未完成,还是在通知请求时恰好完成,其“继续操作”都能够被正确地调用。这使得 Task
比以往的异步模式先进了很多。想想以前使用的异步编程模型(APM)有两个主要问题:
- 每个操作都需要一个自定义的
IAsyncResult
实现,无法通用。 - 必须在调用
Begin
方法之前就决定操作完成后需要做什么,这使得实现组合的异步操作和其他通用代码来处理任意的异步操作变得非常困难。
与之相比,Task
作为一种统一的抽象,允许您在操作启动后动态添加后续逻辑,无需在操作开始时预先绑定回调。任何执行异步操作的代码都可以生成一个 Task
,而任何需要处理异步结果的代码都能直接使用这个 Task
,无需额外的适配。Task
作为异步操作的“通用语言”,重塑了 .NET 的异步编程模式。
接下来,我们通过一个简单的实现来更好地理解这一点。为了教学目的,我们会实现一个简单的版本,仅仅为了让大家理解 Task 的核心是什么。基本上,Task 就是一个用来协调完成信号的设置和接收的数据结构。我们先从几个字段开始:
1 | class MyTask |
我们需要一个字段来跟踪任务是否完成(_completed
),还需要一个字段来存储导致任务失败的错误(_error
);如果我们实现一个泛型版本 MyTask<TResult>
,还会有一个 _result
字段来存储成功的结果。到目前为止,这看起来很像我们之前的 IAsyncResult
实现(这当然不是巧合)。现在,我们增加一个关键部分:_continuation
字段。在这个简单的实现中,我们只支持一个延续,不过这足以说明问题(实际的 Task
使用一个对象字段,可以是一个单独的延续对象,也可以是 List<>
延续对象的集合)。这是一个在任务完成时调用的委托。
接下来我们需要增加一些公开的方法。前面提到,Task 相对于之前的模型的一个重要进步是可以在操作启动之后再提供回调。为此,我们需要添加一个 ContinueWith
方法:
1 | public void ContinueWith(Action<MyTask> action) |
如果在调用 ContinueWith
时任务已经完成,那么 ContinueWith
直接将这个委托排入队列。否则,方法会存储这个委托,以便在任务完成时触发它(这里还捕获了 ExecutionContext
,稍后会用到,但现在可以先不用担心这个细节)。逻辑非常简单。
然后,我们需要标记任务已经完成,这意味着它所代表的异步操作已经结束。为此,我们公开了两个方法,一个标记任务成功完成(“SetResult
”),一个标记任务以错误结束(“SetException
”):
1 | public void SetResult() => Complete(null); |
我们会存储任何错误,并标记任务已完成,然后如果之前有注册延续,则将其排入队列等待调用。
最后,我们需要一种方式来传播可能在任务中发生的异常(如果是 MyTask<T>
版本,还要返回结果 _result
);为了支持某些场景,我们还允许此方法阻塞等待任务完成,这可以通过 ContinueWith
来实现(延续通过信号量 ManualResetEventSlim
通知调用者任务已经完成)。
1 | public void Wait() |
这就是基本实现。当然,真正的 Task
要复杂得多,拥有更高效的实现,支持多个延续,还有各种选项来配置行为(例如延续是排队等待执行还是同步调用等),并且能够存储多个异常而不仅仅是一个,还包括取消、许多常用操作的帮助方法(如 Task.Run
),等等。但这一切的核心思想,和这里演示的其实差不多。
你可能还注意到,我这个 MyTask
的 SetResult
和 SetException
是公开的,而 Task
并没有。实际上,Task
确实有这些方法,只不过是内部的,任务完成通常由 System.Threading.Tasks.TaskCompletionSource
来处理,这样做的目的是将完成的控制权与消费的部分分离开来。这样你可以把一个 Task
交给别人,而不用担心它被其他人提前完成。类似的设计也出现在 CancellationToken
和 CancellationTokenSource
中,CancellationToken
只是 CancellationTokenSource
的一个结构包装器,提供给用户的是消费取消信号的部分,而产生取消信号的能力被限制给了拥有 CancellationTokenSource
的人。
当然,我们也可以为这个 MyTask
实现类似于 Task
的组合器和帮助器。想要一个简单的 MyTask.WhenAll
吗?如下:
1 | public static MyTask WhenAll(MyTask t1, MyTask t2) |
想要一个 MyTask.Run
?如下:
1 | public static MyTask Run(Action action) |
再来一个 MyTask.Delay
?如下:
1 | public static MyTask Delay(TimeSpan delay) |
你应该明白大概的意思了。
有了 Task
,之前所有的 .NET 异步模式都成了历史。任何用 APM 模式或 EAP 模式实现的异步功能,现在都提供了返回 Task
的新方法。
引入 ValueTask
Task 依然是 .NET 异步编程的主力军,每个新版本都会引入新的方法,整个生态系统中也在不断推出返回 Task
和 Task<TResult>
的方法。然而,Task
是一个类,这意味着创建它会涉及内存分配。对于大多数长时间运行的异步操作来说,这点额外的分配开销微不足道,除非是对性能极其敏感的操作才会有所影响。然而,正如之前提到的,异步操作同步完成的情况相当普遍。例如 Stream.ReadAsync
返回一个 Task<int>
,但如果你正在读取一个 BufferedStream
,由于它只需要从内存缓冲区中读取数据,而不涉及系统调用和真正的 I/O,许多读取操作很可能是同步完成的。为这样的同步读取创建一个额外对象的分配就显得很不划算(这种情况也出现在 APM 模式中)。对于那些不带泛型的 Task
方法,方法可以直接返回一个已完成的 Task
实例,而事实上,Task.CompletedTask
正是用于这种情况的单例。然而对于 Task<TResult>
来说,要缓存每个可能的 TResult
值对应的 Task
是不现实的。那么我们如何让这种同步完成变得更快呢?
其实,某些 Task<TResult>
是可以缓存的。例如,Task<bool>
非常常见,而对于布尔值来说,只有两个有意义的缓存对象:一个 Task<bool>
的 Result
为 true
,另一个为 false
。或者,虽然我们不想缓存数十亿个 Task<int>
来满足所有可能的 Int32
值,但一些常用的小整数值,例如 -1 到 8,是可以缓存的。对于任意类型来说,default
值也相当常见,因此我们可以缓存一个 Result
为 default(TResult)
的 Task<TResult>
实例。事实上,Task.FromResult
在最近的 .NET 版本中就采用了这样的小型缓存,用于这些可复用的 Task<TResult>
实例,如果有合适的结果就直接返回它们,否则就为精确的结果值分配一个新的 Task<TResult>
。其他方案也可以用来处理一些常见的情况。例如,在使用 Stream.ReadAsync
时,同一个流上多次调用读取相同数量的字节是很常见的,而且实现中完全满足读取字节数请求的情况也很常见。这意味着 Stream.ReadAsync
很可能多次返回相同的整数结果。为了避免多次分配,某些流类型(如 MemoryStream
)会缓存它们最后一次成功返回的 Task<int>
,如果下一次读取也是同步成功完成且结果相同,它可以直接返回相同的 Task<int>
,而不是创建一个新的。那么,对于其他情况呢?如果我们真的非常关心性能,并希望避免同步完成的分配,有什么更普遍的方法呢?
这就引出了 ValueTask<TResult>
(可以详细看看 ValueTask<TResult>
的深入分析)。ValueTask<TResult>
最初是 TResult
和 Task<TResult>
之间的“可判别联合”。简单来说,忽略细节和附加功能,它要么是一个立即可得的结果,要么是一个将来某个时刻会有结果的“承诺”:
1 | public readonly struct ValueTask<TResult> |
这样一个方法就可以返回 ValueTask<TResult>
而不是 Task<TResult>
,以较大的返回类型和一些间接操作为代价,避免在已知 TResult
的情况下再去分配 Task<TResult>
。
但是,在某些极端高性能的场景中,甚至在异步完成的情况下,我们也希望能够避免 Task<TResult>
的分配。举例来说,Socket
位于网络堆栈的底层,其上的 SendAsync
和 ReceiveAsync
方法属于许多服务的热点路径,其中同步和异步完成都是很常见的(大多数发送操作是同步完成的,许多接收操作由于数据已被缓存在内核中也会同步完成)。如果在给定的 Socket
上,无论操作是同步还是异步完成,我们都能实现发送和接收的零分配,那岂不是很好?
这时候 System.Threading.Tasks.Sources.IValueTaskSource<TResult>
出现了:
1 | public interface IValueTaskSource<out TResult> |
IValueTaskSource<TResult>
接口允许一个实现提供自己的对象来支持 ValueTask<TResult>
,通过 GetResult
方法来获取操作结果,通过 OnCompleted
方法连接延续操作。通过这样的方式,ValueTask<TResult>
的定义发生了小改动,它原来的 Task<TResult>? _task
字段被 object? _obj
字段取代了:
1 | public readonly struct ValueTask<TResult> |
原先 _task
字段要么是 Task<TResult>
,要么为 null;现在 _obj
字段可以是一个 IValueTaskSource<TResult>
。Task<TResult>
一旦标记为已完成,它就会保持完成状态,不会再转回为未完成状态。而一个实现了 IValueTaskSource<TResult>
的对象则完全控制其实现,可以在完成和未完成状态之间双向转换,因为 ValueTask<TResult>
的契约是一个给定实例只能被消费一次,因此通过设计,它不应在消费之后观察到底层实例的状态变化(这也是诸如分析规则 CA2012 存在的原因)。这使得像 Socket
这样的类型可以池化 IValueTaskSource<TResult>
实例用于重复调用。Socket
缓存了最多两个这样的实例,一个用于读取,一个用于写入,因为 99.999% 的情况是最多有一个接收和一个发送处于飞行中状态。
我提到了 ValueTask<TResult>
,但还没说 ValueTask
。当仅仅是为了避免同步完成的分配时,使用非泛型 ValueTask
(代表无结果的空操作)并没有什么性能优势,因为相同的情况可以用 Task.CompletedTask
表示。但一旦我们在异步完成的情况下也希望使用可池化的底层对象来避免分配,那么这对非泛型版本也同样重要。因此,在引入 IValueTaskSource<TResult>
时,也引入了 IValueTaskSource
和 ValueTask
。
所以,我们现在有 Task
、Task<TResult>
、ValueTask
和 ValueTask<TResult>
。我们可以用各种方式与它们交互,代表任意异步操作,并连接延续处理这些异步操作的完成。而且,没错,我们可以在操作完成之前或之后进行这些操作。
但是……那些延续(continuations)依然是回调!
我们仍然不得不使用传递延续的方式来编码我们的异步控制流!!
这依然非常难以正确实现!!!
那么,我们该如何解决这个问题呢?
C# 迭代器来救场
其实,早在 Task 出现的几年前,解决方案的曙光就已经伴随着 C# 2.0 的迭代器支持出现了。
“迭代器?”你可能会问,“是指 IEnumerable
1 | public static IEnumerable<int> Fib() |
之后就可以用 foreach
来遍历这个方法:
1 | foreach (int i in Fib()) |
也可以通过 System.Linq.Enumerable 中的组合器将它和其他 IEnumerable
1 | foreach (int i in Fib().Take(12)) |
或者直接通过 IEnumerator
1 | using IEnumerator<int> e = Fib().GetEnumerator(); |
上面的代码都会输出:
1 | 0 1 1 2 3 5 8 13 21 34 55 89 |
最有趣的地方在于,为了实现上述功能,我们需要多次进入和退出 Fib
方法。我们调用 MoveNext
,进入方法,方法执行到 yield return
语句时,MoveNext
调用返回 true
,而之后访问 Current
时就会返回产出的值。再次调用 MoveNext
,则需要在上一次离开的地方继续执行,并且保持之前调用的状态不变。本质上,迭代器就是由 C# 语言/编译器提供的协程,编译器把我的 Fib
迭代器扩展为一个完整的状态机:
1 | public static IEnumerable<int> Fib() => new <Fib>d__0(-2); |
现在,所有 Fib
方法的逻辑都被移到了 MoveNext
方法里,通过一个跳转表来记录上一次离开的地方,状态会保存在生成的枚举器类型的字段中。而原本定义为局部变量的 prev
、next
和 sum
也被“提升”为枚举器的字段,这样它们就能在每次 MoveNext
调用之间保留状态。
(注意,前面的代码示例并不能直接编译。因为 C# 编译器会生成一些“不可见”的名字,这些名字对 IL(中间语言)来说是有效的,但对 C# 来说却是无效的,以避免与用户定义的类型和成员发生冲突。你可以把这些名字改为合法的 C# 名字以便自己尝试。)
在前面的示例中,我展示的最后一种枚举形式涉及手动使用 IEnumerator<T>
。在这种层次上,我们是手动调用 MoveNext()
,决定什么时候合适再次进入这个协程。但是……如果我们可以在异步操作完成时,把下一次调用 MoveNext
的任务交给它继续处理,这样会怎么样?如果我可以 yield return
一个表示异步操作的对象,而让消费代码为这个产出的对象挂接一个延续(continuation),由这个延续来调用 MoveNext
?通过这种方式,我可以写出一个像这样的辅助方法:
1 | static Task IterateAsync(IEnumerable<Task> tasks) |
现在有趣了。我们得到了一个可以枚举的任务集合,然后通过 MoveNext
移动到下一个任务,把它取出来,再挂接一个延续到这个任务上,当任务完成时,延续就会调用回同一个逻辑,继续调用 MoveNext
,获取下一个任务,如此往复。这构建了 Task
作为所有异步操作的统一表示的理念,因此传入的可枚举对象可以是任何异步操作的序列。那么这样的序列从哪里来呢?当然可以从迭代器产生。还记得之前那个使用 APM 实现的惨不忍睹的 CopyStreamToStream
示例吗?来看这个改进版:
1 | static Task CopyStreamToStreamAsync(Stream source, Stream destination) |
哇,这段代码几乎是可以直接读懂的。我们调用了 IterateAsync
帮助方法,传入的是由一个迭代器生成的可枚举对象,这个迭代器处理了所有的控制流逻辑。它调用 Stream.ReadAsync
,然后 yield return
该任务;这个产出的任务会在 MoveNext
被调用后传给 IterateAsync
,而 IterateAsync
会为该任务挂接一个延续,任务完成后就会再次调用 MoveNext
,并返回到迭代器中紧接着 yield
的位置。然后迭代器获取读取结果,调用 WriteAsync
,再 yield
它产生的任务,以此类推。
这就是 C# 和 .NET 中 async/await
的起源。C# 编译器中对迭代器和 async/await
的支持,有 95% 的逻辑是共通的。只是语法不同,涉及的类型不同,但本质上是相同的转换。稍微眯起眼看看这些 yield return
,几乎就能看到它们被替换成了 await
。
实际上,在 async/await
出现之前,一些有想法的开发者就已经用这种方式将迭代器用于异步编程。而这种类似的转换曾经在实验性的 Axum 编程语言中得到验证,并成为 C# async
支持的重要灵感来源。Axum 提供了 async
关键字,可以像 C# 中一样放在方法上。当时 Task
还没有那么普及,所以在 async
方法中,Axum 编译器会启发式地将同步方法调用映射到它们的 APM 对应版本上,比如看到调用 stream.Read
,它会查找相应的 stream.BeginRead
和 stream.EndRead
方法,并生成合适的委托传给 Begin
方法,同时生成一个完整的 APM 实现以便这个异步方法是可组合的。它甚至还与 SynchronizationContext
集成!虽然 Axum 最终被搁置,但它为后来 C# 的 async/await
提供了一个非常棒且激励人心的原型。
async
/await
的面纱
既然我们已经了解了如何走到这一步,现在来深入看看它是如何实际工作的。作为参考,先来看一下之前那个同步方法的例子:
1 | public void CopyStreamToStream(Stream source, Stream destination) |
然后这是使用 async/await 重写后的对应方法:
1 | public async Task CopyStreamToStreamAsync(Stream source, Stream destination) |
相比之前见过的复杂实现,这样的代码简直就是一股清新的风。方法签名从 void
改成了 async Task
,然后将 Read
和 Write
分别替换为 ReadAsync
和 WriteAsync
,并在它们前面加上 await
。仅此而已,剩下的工作就交给编译器和核心库,它们从根本上改变了代码的执行方式。让我们深入了解一下它背后的原理吧。
编译器转换
如同我们之前看到的那样,就像迭代器一样,编译器将 async
方法改写为基于状态机的形式。开发者写的方法签名看起来和原来一样(例如 public Task CopyStreamToStreamAsync(Stream source, Stream destination)
),但实际上它的方法体完全被改变了:
1 | [ ] |
你会注意到,唯一的区别就是开发者写的签名里少了 async
关键字。async
实际上并不属于方法的签名部分;类似于 unsafe
,当你在方法签名里添加 async
时,它只是方法实现的一个细节,而不是对外暴露的契约的一部分。使用 async/await
来实现一个返回 Task
的方法,只是实现细节而已。
编译器生成了一个名为 <CopyStreamToStreamAsync>d__0
的结构体,并在栈上零初始化了这个结构体的一个实例。关键是,如果异步方法同步完成,这个状态机就始终不会离开栈,也就是说,只有在方法需要异步完成(例如等待某些尚未完成的操作)时,才会有与状态机相关的分配。
这个结构体就是该方法的状态机,里面不仅包含了开发者编写的代码的变换逻辑,还包含了用于跟踪当前执行位置的字段,以及那些在 MoveNext
调用之间需要保留的“局部”状态。它与我们在迭代器中看到的 IEnumerable<T>
/IEnumerator<T>
的实现逻辑上是类似的。(注意,我展示的代码是来自发布版的;在调试版中,C# 编译器会将这些状态机类型生成为类,这样可以帮助进行某些调试工作。)
初始化状态机后,接下来我们看到了一次 AsyncTaskMethodBuilder.Create()
的调用。虽然我们现在关注的是 Task
,但 C# 语言和编译器实际上允许异步方法返回任意“类似任务”的类型,例如我可以写一个方法 public async MyTask CopyStreamToStreamAsync
,只要我们以适当的方式增强之前定义的 MyTask
,它也能正常编译。增强的方式包括为该类型声明一个“builder”类型,并通过 AsyncMethodBuilder
属性将其关联:
1 | [ ] |
在这种上下文中,“builder” 是一个知道如何创建该类型实例的东西(即 Task
属性),它可以成功完成任务并在适当时带有结果(SetResult
),或用异常完成任务(SetException
),并且负责处理挂起任务未完成时的继续操作(AwaitOnCompleted
/ AwaitUnsafeOnCompleted
)。对于 System.Threading.Tasks.Task
,它默认与 AsyncTaskMethodBuilder
相关联。通常,这种关联是通过在类型上应用 [AsyncMethodBuilder(...)]
属性来实现的,但 Task
在 C# 中是特别处理的,因此没有显式标注该属性。因此,编译器为这个异步方法使用了相应的 builder,并通过 Create
方法创建了它的一个实例。注意,和状态机一样,AsyncTaskMethodBuilder
也是一个结构体,因此这里也没有对象分配。
然后状态机会被初始化,将入口方法的参数存储起来。这些参数需要在方法体的 MoveNext
中使用,因此需要保存在状态机中,以便在后续的 MoveNext
调用中引用。如果状态是 -1
,那么调用 MoveNext
时就会从方法的逻辑开头开始执行。
接下来最不起眼但最重要的一行:调用了 builder 的 Start
方法。这也是一个异步方法返回类型必须暴露的部分,负责初始调用状态机的 MoveNext
。builder 的 Start
方法其实就是这样的:
1 | public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine |
所以调用 stateMachine.<>t__builder.Start(ref stateMachine)
实际上就是调用 stateMachine.MoveNext()
。那么,为什么编译器不直接发出这行代码呢?为什么还要引入 Start
方法?答案是,Start
里其实还有一些额外的处理,不过要了解这个问题,我们需要稍微了解一下 ExecutionContext
。
ExecutionContext
我们都熟悉在方法之间传递状态的方式。你调用一个方法,如果该方法需要参数,你就通过传递实参将数据传递给被调用者,这种方式称为显式地传递数据。不过还有一些更隐含的方法。例如,一个方法可以没有参数,但要求在调用前某些特定的静态字段已经被填充好,这样方法就可以直接从这些静态字段中获取状态。在这种情况下,方法的签名并没有表明它接收参数,因为它确实没有:只是存在一种隐含的契约,调用者可能会事先填充某些内存位置,然后被调用者会从那些内存位置读取数据。即使是中间的调用者或被调用者也可能没有意识到这些操作正在发生,比如:方法 A 填充了静态变量,然后调用 B,B 再调用 C,C 调用 D,最后 D 调用了 E,而 E 则从这些静态字段中读取数据。这种“悬挂”在那里的数据通常被称为“环境数据”(ambient data),它不是通过参数传递给你的,而是“在那里”,你想用就用。
我们可以进一步深化这个概念,利用线程本地状态(thread-local state)。线程本地状态(在 .NET 中可以通过标记为 [ThreadStatic] 的静态字段或者使用 ThreadLocal
但如果涉及异步操作又如何呢?如果我们调用一个异步方法,并且该方法内部的逻辑想要访问这些环境数据,它该如何做到呢?如果数据存储在普通的静态字段中,异步方法是可以访问的,但这样只能确保一次只能有一个异步方法在运行,否则多个调用者可能会互相覆盖彼此的状态,因为他们都在写入共享的静态字段。如果数据存储在线程本地状态中,异步方法也可以访问,但只限于它在调用线程上同步运行的部分;一旦它挂起并连接到另一个操作,并且这个操作的后续部分在其他线程上执行,那么它将无法再访问到线程本地的数据。即使巧合或调度器强制让它仍在同一个线程上执行,在它运行时很可能数据已经被其他操作移除或覆盖掉了。对于异步操作,我们需要一种机制,允许在这些异步点之间传递任意的环境数据,这样无论逻辑在何时何地执行,它都能访问到相同的数据。
这时候,ExecutionContext(执行上下文)就派上用场了。ExecutionContext 类型是异步操作之间传递环境数据的工具。它存在于一个 [ThreadStatic] 字段中,当异步操作开始时,它会被“捕获”(简单来说就是“从那个线程本地字段中复制一份”),然后在异步操作的后续步骤执行时,这个 ExecutionContext 会被恢复到执行该操作的线程的 [ThreadStatic] 字段中。因此,ExecutionContext 是 AsyncLocal
1 | var number = new AsyncLocal<int>(); |
每次运行时,这段代码都会打印 42。这是因为当我们把委托排队到线程池时,ExecutionContext 被捕获了,这个捕获过程记录了当时 AsyncLocal
1 | using System.Collections.Concurrent; |
在这里,MyThreadPool 使用了一个 BlockingCollection<(Action, ExecutionContext?)>
来表示它的工作项队列,每个工作项由要执行的委托和与该工作关联的 ExecutionContext 组成。线程池的静态构造函数会启动多个线程,每个线程在无限循环中不断地取出下一个工作项并运行它。如果某个工作项没有捕获到 ExecutionContext,那就直接调用这个委托。但如果捕获到了 ExecutionContext,就使用 ExecutionContext.Run
方法来恢复并设置当前上下文,然后再执行委托,最后再将上下文还原。这段代码同样使用之前的 AsyncLocal
顺便提一句,你可能注意到了我在 MyThreadPool 的静态构造函数中调用了 UnsafeStart
方法。启动新线程是会触发 ExecutionContext 传递的异步点之一。实际上,Thread
的 Start
方法会使用 ExecutionContext.Capture
来捕获当前的上下文,存储在 Thread 中,然后在最终调用该线程的 ThreadStart 委托时使用捕获的上下文。但在这个例子中,我不希望在线程池初始化时捕获任何现有的 ExecutionContext(那样会使展示 ExecutionContext 的例子更复杂),所以我用了 UnsafeStart
。凡是以 Unsafe
开头的线程相关方法都与它们没有 Unsafe
前缀的对应方法行为一致,唯一不同的是它们不会捕获 ExecutionContext。例如,Thread.Start
和 Thread.UnsafeStart
做的事情完全相同,但前者会捕获 ExecutionContext,而后者则不会。
回到开始
我们在讨论 AsyncTaskMethodBuilder.Start
方法的实现时,走了一段关于 ExecutionContext
的“弯路”。当时我说 Start
方法大致是这样的:
1 | public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine |
并提到我其实做了些简化。而这种简化忽略了方法实际上需要考虑 ExecutionContext
,因此更接近如下代码:
1 | public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine |
与之前我建议的直接调用 stateMachine.MoveNext()
不同,这里需要先获取当前的 ExecutionContext
,然后调用 MoveNext
,在它完成之后再将上下文重置为调用前的状态。
这样做的原因是为了防止异步方法中的环境数据泄漏到它的调用者中去。一个例子可以解释为什么这很重要:
1 | async Task ElevateAsAdminAndRunAsync() |
“模拟”(Impersonation)是一种将当前用户的信息改变为另一个用户的行为,这样可以让代码以其他人的身份执行,使用他们的权限和访问权限。在 .NET 中,这种用户模拟在异步操作之间是会传递的,这意味着它是 ExecutionContext
的一部分。现在想象一下,如果 Start
没有恢复之前的上下文,再看看这段代码:
1 | Task t = ElevateAsAdminAndRunAsync(); |
在这种情况下,可能会出现的情况是,ElevateAsAdminAndRunAsync
方法内修改的 ExecutionContext
在它返回给同步调用者后仍然存在(这种情况发生在方法第一次遇到尚未完成的 await 时)。这是因为在调用 Impersonate
后,我们又调用了 DoSensitiveWorkAsync
并等待它返回的任务。如果那个任务没有立即完成,ElevateAsAdminAndRunAsync
就会暂停并返回给调用者,而模拟仍然在当前线程上生效。这不是我们想要的结果。
因此,Start
方法加入了这种保护措施,确保对 ExecutionContext
的任何修改都不会流出同步方法的调用范围,只会在方法执行的后续工作中继续传播。
MoveNext
我们在讨论到 AsyncTaskMethodBuilder.Start
方法的实现时,提到入口点的方法被调用了,状态机结构体被初始化,接着调用了 Start
方法,最终调用了 MoveNext
。那么,MoveNext
是什么呢?它是一个包含了开发者原始方法所有逻辑的函数,不过有很多修改。让我们先从方法的框架部分入手。这是编译器为我们的方法生成的代码的反编译版本,但我移除了生成的 try
块中的具体逻辑:
1 | private void MoveNext() |
无论 MoveNext
里进行什么其他工作,它都负有在所有工作完成后将 async Task
方法返回的 Task
设为完成的责任。如果 try
块中的代码抛出了未处理的异常,那么任务会因该异常而进入失败状态。而如果异步方法成功执行到末尾(类似于同步方法返回),它会标记返回的任务为成功完成。在这两种情况下,它都会通过设置状态机的状态来表示完成状态。(很多开发者会猜测异常在第一个 await
之前和之后会有不同的处理方式,但从上面可以看到事实并非如此。异步方法中任何未处理的异常,无论在哪个位置,无论方法是否已经挂起,都会进入上面的 catch
块,并且捕获的异常会存储到异步方法返回的 Task
中。)
还要注意,这个“完成”的处理是通过构建器的 SetException
和 SetResult
方法来实现的,这是编译器期望的构建器模式的一部分。如果异步方法之前已经挂起,那么构建器早已在挂起处理时生成了一个 Task
(我们很快会看到是如何做到的),那么调用 SetException
或 SetResult
会完成这个 Task
。但如果异步方法之前从未挂起,那么我们还没有创建任何 Task
或返回任何东西给调用者,这时构建器有更多灵活性来生成这个 Task
。还记得之前入口点的方法中,最后一步是返回 Task
给调用者吗?这一步通过访问构建器的 Task
属性实现(是的,很多东西都叫 “Task”,我知道有点绕):
1 | public Task CopyStreamToStreamAsync(Stream source, Stream destination) |
构建器知道方法是否挂起过,如果挂起过,它就返回一个已经创建的 Task
。如果方法没有挂起过,并且构建器还没有生成 Task
,它就可以在这里创建一个完成状态的任务。在这种情况下,如果成功完成,构建器可以直接使用 Task.CompletedTask
,而不是分配新的任务,这样可以避免内存分配。而对于 Task<TResult>
的泛型版本,构建器可以使用 Task.FromResult<TResult>(TResult result)
。
构建器也可以对它正在创建的对象进行适当的转换。例如,Task
实际上有三种可能的最终状态:成功、失败和取消。而 AsyncTaskMethodBuilder
的 SetException
方法会对 OperationCanceledException
进行特殊处理,如果提供的异常是或派生自 OperationCanceledException
,那么任务的最终状态就会是 TaskStatus.Canceled
;否则,任务的状态会是 TaskStatus.Faulted
。这种区别在消费代码中往往并不明显,因为无论标记为 Canceled
还是 Faulted
,异常都会存储在 Task
中,因此在代码中等待这个任务时,这两种状态没有明显的差别(异常会被传递出来)。但它会影响直接与 Task
交互的代码,例如通过 ContinueWith
,后者有一些重载可以让续续操作仅针对部分完成状态执行。
现在我们了解了生命周期的各个方面,来看一下 MoveNext
方法中的 try
块中填充的完整代码:
1 | private void MoveNext() |
这种复杂的代码也许让人感到有些眼熟。还记得我们手动实现基于 APM 的 BeginCopyStreamToStream
时有多么繁琐吗?虽然这段代码没有那么复杂,但依然相当不错,因为编译器帮我们完成了这些工作,把方法改写为一种续续传递的形式,同时确保了所有必需的状态在这些续续执行时得到正确保存。即使如此,我们仍然可以跟着代码看个大概。记得状态在入口点初始化为 -1,然后我们进入 MoveNext
,发现当前状态既不是 0 也不是 1,因此我们执行了创建临时缓冲区的代码,接着跳转到标签 IL_008b
,在这里调用了 stream.ReadAsync
。注意,此时我们还在同步地从 MoveNext
运行,也就是说还在同步地从 Start
运行,也就是说还在同步地从入口点运行,这意味着开发者的代码调用了 CopyStreamToStreamAsync
,它仍然在同步地执行,还没有返回表示最终完成的任务。这种情况可能马上就要改变了……
我们调用 Stream.ReadAsync
,并得到了一个 Task<int>
。读取操作可能同步完成,也可能异步完成得很快,以至于在我们检查的时候它已经完成了,或者它可能还没有完成。不管哪种情况,我们手头有一个代表最终完成的 Task<int>
,编译器会生成代码来检查这个 Task<int>
的状态,以决定如何继续:如果 Task<int>
已经完成(不管它是同步完成还是在我们检查时完成),那么这个方法可以继续同步执行——既然我们可以继续在这里直接执行,就没有必要去花额外的开销将方法的剩余部分排入任务队列。但如果 Task<int>
尚未完成,编译器就需要生成代码为这个 Task
设置一个延续(continuation)。因此,它需要生成代码询问这个任务“你完成了吗?”。那么它是直接与 Task
对话吗?
如果在 C# 中只能等待 System.Threading.Tasks.Task
类型,那将非常局限。类似地,如果 C# 编译器必须知道每种可能被 await
的类型,那也会很受限。因此,C# 采用了它通常在这种情况下的做法:使用 API 的模式。任何实现了适当模式(“awaiter”模式)的对象都可以被 await
(就像你可以对任何提供了合适“可枚举”模式的对象使用 foreach
一样)。例如,我们可以增强之前写的 MyTask
类型,使其实现 awaiter
模式:
1 | class MyTask |
一个类型如果可以被 await
,就需要有一个 GetAwaiter()
方法,而 Task
就有这个方法。这个方法返回的对象需要暴露几个成员,包括一个 IsCompleted
属性,用于检查操作是否已经完成。你可以看到这一过程:在标签 IL_008b
处,调用 ReadAsync
返回的 Task
上的 GetAwaiter
,然后在那个 awaiter
实例上访问 IsCompleted
。如果 IsCompleted
返回 true
,代码就会进入 IL_00f0
,在那里调用 GetResult()
方法。如果操作失败了,GetResult()
负责抛出异常,将异常传播出异步方法的 await
;否则,GetResult()
则负责返回操作的结果。如果这里的 ReadAsync
结果是 0,那我们就会跳出读写循环,到方法的末尾调用 SetResult
,然后结束。
不过,最有趣的是 IsCompleted
返回 false
时会发生什么。如果 IsCompleted
返回 true
,我们就继续处理循环,这类似于 APM 模式中 CompletedSynchronously
返回 true
时,由调用 Begin
方法的一方(而不是回调)负责继续执行。但如果 IsCompleted
返回 false
,我们就需要暂停异步方法的执行,直到 await
的操作完成。这意味着需要从 MoveNext
中返回,因为这是 Start
的一部分,我们还在入口点方法中,所以也意味着需要返回一个 Task
给调用者。但在这一切发生之前,我们需要为正在等待的 Task
设置一个延续操作(需要注意的是,为了避免像 APM 中的栈“溢出”问题,如果异步操作在 IsCompleted
返回 false
之后但在设置延续之前完成,这个延续依然需要在调用线程上以异步方式执行,因此它会被排入任务队列)。由于我们可以 await
任意类型,我们不能直接与 Task
实例交互;而是需要通过一些基于模式的方法来执行这个过程。
那么这是否意味着 awaiter
上有一个方法可以为延续设置处理?确实如此;实际上,这正是 awaiter
模式的要求:awaiter
需要实现 INotifyCompletion
接口,该接口包含一个方法 void OnCompleted(Action continuation)
。awaiter
还可以选择实现 ICriticalNotifyCompletion
接口,后者继承自 INotifyCompletion
并增加了一个 void UnsafeOnCompleted(Action continuation)
方法。基于我们之前对 ExecutionContext
的讨论,你大概可以猜到这两个方法的区别:两者都用于设置延续,但 OnCompleted
应该传递 ExecutionContext
,而 UnsafeOnCompleted
则不需要。这里之所以需要两个不同的方法,INotifyCompletion.OnCompleted
和 ICriticalNotifyCompletion.UnsafeOnCompleted
,主要是历史原因,与代码访问安全(CAS)有关。CAS 在 .NET Core 中已经不存在,并且在 .NET Framework 中默认也是关闭的,只有当你选择使用传统的部分信任功能时才会生效。当使用部分信任时,CAS 信息会作为 ExecutionContext
的一部分传递,因此不传递它是不“安全”的,这就是为什么那些不传递 ExecutionContext
的方法被加上了“Unsafe”前缀。这些方法也被标记为 [SecurityCritical]
,部分信任的代码不能调用 [SecurityCritical]
方法。结果就产生了两个版本的 OnCompleted
,编译器更倾向于使用 UnsafeOnCompleted
,如果有提供的话,但 OnCompleted
版本是必须的,以便在 awaiter
需要支持部分信任时使用。不过从异步方法的角度来看,构建器总是在 await
点之间传递 ExecutionContext
,因此 awaiter
也这么做就显得不必要而且重复了。
好了,awaiter
确实暴露了一个方法来设置延续操作。编译器本可以直接使用它,除了一件关键的事情:延续操作到底应该是什么?更具体地说,它应该和哪个对象关联?记住,状态机结构体是在栈上的,而我们目前执行的 MoveNext
调用就是这个实例的方法。我们需要保留状态机,以便在恢复时拥有正确的状态,这意味着状态机不能一直留在栈上;它需要被复制到堆上,因为栈将被用于该线程执行的其他后续无关工作。然后,延续操作需要调用堆上的状态机的 MoveNext
方法。
此外,ExecutionContext
在这里也非常重要。状态机需要确保在挂起时捕获的任何环境数据在恢复时被应用,这意味着延续操作还需要包含这个 ExecutionContext
。所以,仅仅创建一个指向状态机上 MoveNext
的委托是不够的。这也会带来不必要的开销。如果在挂起时我们创建一个指向状态机上 MoveNext
的委托,每次这样做时都会对状态机结构体进行装箱(即使它已经作为其他对象的一部分在堆上),并分配一个额外的委托(该委托的 this
对象引用会指向一个新装箱的结构体副本)。因此,我们需要进行一种复杂的操作,确保第一次方法挂起时才将结构体从栈中提升到堆上,而后续所有情况都使用堆上的同一个对象作为 MoveNext
的目标,同时确保捕获了正确的上下文,并在恢复时使用该捕获的上下文来调用操作。
我们希望将这些复杂的逻辑封装到一个辅助类中,而不是让编译器直接生成这些代码,原因有几个。首先,这是一大堆复杂的代码,生成在每个用户的程序集里会显得繁琐。其次,我们希望这种逻辑可以通过构建器模式实现自定义(后面讲到池化时会看到一个例子说明为什么要这样做)。最后,我们希望能够对这种逻辑进行优化和改进,让之前编译过的二进制文件也能从中受益。这并不是假设,实际上在 .NET Core 2.1 中,这部分库代码得到了彻底重构,使得操作比在 .NET Framework 中更高效。我们先来看看在 .NET Framework 中具体是怎么实现的,然后再看 .NET Core 中发生了什么变化。
你可以在 C# 编译器生成的代码中看到挂起时的处理逻辑:
1 | if (!awaiter.IsCompleted) // 当 IsCompleted 为 false 时我们需要挂起 |
这里我们将状态字段设置为一个状态 id,表示方法恢复时应该跳转到的位置。然后,我们把 awaiter
自身保存到一个字段中,以便恢复后可以调用 GetResult
。在 MoveNext
调用返回之前的最后一步,我们调用了 <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this)
,要求构建器为这个状态机中的 awaiter
连接一个延续操作。(注意这里调用的是构建器的 AwaitUnsafeOnCompleted
而不是 AwaitOnCompleted
,因为 awaiter
实现了 ICriticalNotifyCompletion
接口;状态机已经处理了 ExecutionContext
的传递,因此我们不需要 awaiter
也这么做,正如之前提到的,这样做只会造成重复和不必要的开销。)
AwaitUnsafeOnCompleted
方法的实现太复杂,无法全部展示在这里,我将简要总结它在 .NET Framework 中的做法:
它使用
ExecutionContext.Capture()
来捕获当前的上下文。然后分配一个
MoveNextRunner
对象,这个对象用来包装捕获的上下文和状态机的装箱版本(如果这是方法第一次挂起,状态机还没有装箱,那么我们只用null
作为占位符)。接着它创建了一个指向
MoveNextRunner
中Run
方法的Action
委托;这样可以生成一个委托,后续在捕获的ExecutionContext
中调用状态机的MoveNext
方法。如果这是方法第一次挂起,我们还没有装箱的状态机,所以此时它会进行装箱,通过将实例存储到
IAsyncStateMachine
类型的本地变量中,创建堆上的副本。这个副本被存储到之前分配的MoveNextRunner
中。
接下来是一个有些烧脑的步骤。如果你回顾一下状态机结构体的定义,它包含了一个构建器:public AsyncTaskMethodBuilder <>t__builder;
。而如果你查看构建器的定义,它包含一个内部字段 internal IAsyncStateMachine m_stateMachine;
。构建器需要引用装箱的状态机,以便在后续挂起时可以看到状态机已经装箱了,不需要再次装箱。但我们刚刚完成了状态机的装箱,而该状态机包含的构建器的 m_stateMachine
字段还是 null
。我们需要将这个装箱状态机的构建器的 m_stateMachine
字段修改为指向它自己的装箱副本。为了实现这一点,编译器生成的状态机结构体实现了 IAsyncStateMachine
接口,其中包含一个 void SetStateMachine(IAsyncStateMachine stateMachine)
方法,而状态机结构体实现了这个接口方法:
1 | private void SetStateMachine(IAsyncStateMachine stateMachine) => |
因此,构建器装箱了状态机,然后将这个装箱的对象传递给装箱对象的 SetStateMachine
方法,这个方法又调用构建器的 SetStateMachine
方法,从而将这个装箱副本存储到字段中。搞定了,真是一番曲折的过程。
最后,我们有一个表示延续的 Action
,将其传递给 awaiter
的 UnsafeOnCompleted
方法。对于 TaskAwaiter
,任务会将这个 Action
存储到任务的延续列表中,当任务完成时,它会调用这个 Action
,通过 MoveNextRunner.Run
,再通过 ExecutionContext.Run
,最终调用状态机的 MoveNext
方法,重新进入状态机,从上次中断的地方继续执行。
这就是在 .NET Framework 中发生的过程,你可以通过一些分析工具看到这种结果,例如运行内存分配分析器,查看每次 await
时的分配情况。我们来看这个例子,我编写这个程序只是为了突出其间涉及的内存分配成本:
1 | using System.Threading; |
这个程序创建了一个 AsyncLocal<int>
,用来将值 42 传递给所有后续的异步操作。然后它调用 SomeMethodAsync
1000 次,每次都进行 1000 次挂起和恢复。在 Visual Studio 中,我使用 .NET 对象分配跟踪分析器运行这个程序,得到了如下结果:
这……真的很多内存分配啊!让我们来逐个分析这些内存分配,看看它们从哪里来的。
ExecutionContext。这里有超过一百万个
ExecutionContext
被分配了。为什么呢?因为在 .NET Framework 中,ExecutionContext
是一个可变的数据结构。由于我们希望传递异步操作分叉时的环境数据,并且不希望它受之后的变更影响,因此我们需要复制ExecutionContext
。每一个分叉的操作都需要这样一个副本,因此对于调用了 1000 次SomeMethodAsync
,每次都挂起和恢复 1000 次的情况,我们就有了一百万个ExecutionContext
实例。哎呀,这代价太大了。Action。同样地,每次等待尚未完成的操作(正如我们代码中的一百万次
await Task.Yield()
),都会导致一个新的Action
委托被分配,以传递给该awaiter
的UnsafeOnCompleted
方法。MoveNextRunner。这也一样,有一百万个。前面我们提到的步骤中,每次挂起时,我们都会分配一个新的
MoveNextRunner
,用来保存Action
和ExecutionContext
,以便使用后者执行前者。LogicalCallContext。还有一百万个。这是 .NET Framework 中
AsyncLocal<T>
的实现细节。AsyncLocal<T>
会将它的数据存储到ExecutionContext
的“逻辑调用上下文”中,简单来说,这就是随着ExecutionContext
传递的一般状态。因此,如果我们制作了一百万个ExecutionContext
的副本,我们也会制作一百万个LogicalCallContext
的副本。QueueUserWorkItemCallback。每次
Task.Yield()
都会将一个工作项排入线程池,这导致了一百万个工作项对象被分配,用来表示这些操作。Task
。这里有一千个,至少不再是百万级别了。每一个异步 Task
调用,如果异步完成,都需要分配一个新的Task
实例来表示这个调用的最终完成。<SomeMethodAsync>d__1
。这是编译器生成的状态机结构体的装箱对象。1000 个方法挂起,发生了 1000 次装箱。QueueSegment/IThreadPoolWorkItem[]。这里有几千个,这些并不直接与异步方法相关,而是与线程池排队的工作相关。在 .NET Framework 中,线程池的队列是一个由非循环段组成的链表。这些段不会重复使用;对于长度为 N 的段,当其中的 N 个工作项都已经入队和出队后,该段就会被丢弃并交由垃圾回收。
这些是 .NET Framework 中的情况。接下来我们来看 .NET Core 中的情况:
这样看起来就好多了!在 .NET Framework 中运行的示例程序有超过 500 万次的内存分配,总共分配了约 145MB 的内存。而在 .NET Core 中运行的相同示例,只有大约 1000 次内存分配,总共只分配了约 109KB。为什么差距这么大呢?
ExecutionContext
在 .NET Core 中,ExecutionContext
现在是不可变的。这样做的缺点是,每次更改上下文(比如设置 AsyncLocal<T>
的值)都需要分配一个新的 ExecutionContext
。但好处是,传递上下文要比更改上下文常见得多得多。而由于 ExecutionContext
现在是不可变的,我们不再需要在传递时进行克隆。“捕获”上下文现在只需直接从字段中读取,而不再需要读取并克隆其内容。因此,传递上下文不仅远比修改它更为常见,而且成本也更低。
LogicalCallContext
在 .NET Core 中,这个概念已经不存在了。在 .NET Core 中,ExecutionContext
唯一存在的作用是用于存储 AsyncLocal<T>
。以前那些在 ExecutionContext
中有特殊位置的数据,现在都通过 AsyncLocal<T>
来建模。例如,在 .NET Framework 中,模拟操作(Impersonation)会作为 ExecutionContext
中 SecurityContext
的一部分传递;而在 .NET Core 中,模拟操作则通过 AsyncLocal<SafeAccessTokenHandle>
来传递,并使用 valueChangedHandler
来对当前线程进行适当的修改。
QueueSegment/IThreadPoolWorkItem[]
在 .NET Core 中,线程池的全局队列现在实现为 ConcurrentQueue<T>
,而 ConcurrentQueue<T>
被重写为一个非固定大小的循环段链表。一旦段的大小足够大,以至于由于稳态出队速度能够跟上稳态入队速度而导致该段不会被填满,那么就不需要分配额外的段,只需不断地重复使用这个足够大的段即可。
其他分配(如 Action、MoveNextRunner 和 d__1)
要了解如何去除剩余的分配,我们需要深入研究这些操作在 .NET Core 中的实现方式。
让我们回顾一下挂起时发生的事情:
1 | if (!awaiter.IsCompleted) // 当 IsCompleted 为 false 时,我们需要挂起 |
这里生成的代码在目标平台(无论是 .NET Framework 还是 .NET Core)中是相同的,因此挂起的 IL 代码在这两种平台中都是一样的。然而不同的是 AwaitUnsafeOnCompleted
方法的实现,在 .NET Core 中它的实现与 .NET Framework 有很大不同。
首先,事情的开始是一样的:该方法调用 ExecutionContext.Capture()
来获取当前的执行上下文。
但之后的步骤和 .NET Framework 开始分道扬镳了。在 .NET Core 中,构建器只有一个字段:
1 | public struct AsyncTaskMethodBuilder |
在捕获了 ExecutionContext
之后,它会检查 m_task
字段是否包含一个 AsyncStateMachineBox<TStateMachine>
实例,其中 TStateMachine
是编译器生成的状态机结构体的类型。AsyncStateMachineBox<TStateMachine>
类型就是这里的“魔法”:
1 | private class AsyncStateMachineBox<TStateMachine> : |
这里没有分配单独的 Task
,而是直接继承自 Task
(注意它的基类)。状态机也不需要装箱,它作为这个任务对象上的强类型字段存在。同样,也不再有单独的 MoveNextRunner
来存储 Action
和 ExecutionContext
,它们都变成了这个类型上的字段。而因为这个实例被存储到构建器的 m_task
字段中,我们可以直接访问它,不需要在每次挂起时重新分配。如果 ExecutionContext
发生了变化,我们只需将新上下文覆盖到字段中,而不需要再分配其他东西;任何指向 Action
的引用仍然指向正确的位置。因此,在捕获了 ExecutionContext
之后,如果我们已经有一个 AsyncStateMachineBox<TStateMachine>
实例,这就不是方法第一次挂起,我们可以直接将新捕获的 ExecutionContext
存储到其中。如果还没有 AsyncStateMachineBox<TStateMachine>
实例,那么我们需要分配它:
1 | var box = new AsyncStateMachineBox<TStateMachine>(); |
注意那行注释为“重要”的代码。这样做取代了 .NET Framework 中复杂的 SetStateMachine
操作,因此在 .NET Core 中实际上根本不需要使用 SetStateMachine
。这里的 taskField
是对 AsyncTaskMethodBuilder
的 m_task
字段的引用。我们分配了 AsyncStateMachineBox<TStateMachine>
,然后通过 taskField
将对象存储到构建器的 m_task
中(这个构建器位于栈上的状态机结构体中),然后将栈上的状态机(现在已经包含对这个 box 的引用)复制到堆上的 AsyncStateMachineBox<TStateMachine>
,使得 AsyncStateMachineBox<TStateMachine>
适当地递归引用自身。虽然还是有些复杂,但相比之下效率高得多。
接下来,我们可以获得一个指向这个实例上某个方法的 Action
,它会调用该实例的 MoveNext
方法,并在调用状态机的 MoveNext
之前进行适当的 ExecutionContext
恢复。这个 Action
会被缓存到 _moveNextAction
字段中,以便后续的使用可以重用同一个 Action
。然后将这个 Action
传递给 awaiter
的 UnsafeOnCompleted
,以设置延续操作。
这个解释说明了为什么其余大部分的分配被去掉了:<SomeMethodAsync>d__1
没有被装箱,而是直接作为任务对象上的字段存在,而 MoveNextRunner
也不再需要,因为它的唯一作用就是存储 Action
和 ExecutionContext
。但根据这个解释,我们应该还能看到 1000 个 Action
分配,每个方法调用对应一个,但实际上并没有。为什么?还有那些 QueueUserWorkItemCallback
对象呢……我们仍然在 Task.Yield()
的过程中进行排队,为什么它们没有显示出来?
正如我提到的,将实现细节推到核心库中的一个好处是它可以随着时间的推移进行改进,我们已经看到了它从 .NET Framework 演进到 .NET Core 的变化。从最初对 .NET Core 的重写以来,它又进一步演变,进行了额外的优化,这些优化利用了对系统中关键组件的内部访问能力。尤其是,异步基础设施了解 Task
和 TaskAwaiter
这样的核心类型。由于它们对这些类型了解并且具有内部访问权限,它们不需要遵循公开定义的规则。C# 语言所遵循的 awaiter
模式要求 awaiter
拥有一个 AwaitOnCompleted
或 AwaitUnsafeOnCompleted
方法,这两个方法都需要接受 Action
作为参数,这意味着基础设施需要能够创建一个 Action
来表示延续,以便与基础设施不熟悉的任意 awaiter
进行交互。但是,如果基础设施遇到了它熟悉的 awaiter
,它就没有义务走相同的代码路径。对于在 System.Private.CoreLib
中定义的所有核心 awaiter
,基础设施有一条更精简的路径可以遵循,这条路径根本不需要 Action
。这些 awaiter
都了解 IAsyncStateMachineBoxes
,并且能够将盒子对象本身作为延续操作处理。所以,例如,Task.Yield
返回的 YieldAwaitable
可以直接将 IAsyncStateMachineBox
排入线程池作为工作项,而在等待一个 Task
时使用的 TaskAwaiter
可以直接将 IAsyncStateMachineBox
存入任务的延续列表中。不需要 Action
,也不需要 QueueUserWorkItemCallback
。
因此,在非常常见的情况下,如果一个异步方法只等待来自 System.Private.CoreLib
的内容(如 Task
、Task<TResult>
、ValueTask
、ValueTask<TResult>
、YieldAwaitable
,以及它们的 ConfigureAwait
变体),那么最坏情况下整个异步方法生命周期内只会有一次分配:如果方法曾经挂起,它会分配一个派生自 Task
的类型,该类型存储所有其他所需状态;如果方法从未挂起,则不会产生任何额外的分配。
我们其实还可以消除最后的内存分配,至少以摊销的方式来实现它。正如前面展示的,每个 Task
(对应 AsyncTaskMethodBuilder
)都有一个默认的构建器,类似地,Task<TResult>
(对应 AsyncTaskMethodBuilder<TResult>
)、ValueTask
和 ValueTask<TResult>
(分别对应 AsyncValueTaskMethodBuilder
和 AsyncValueTaskMethodBuilder<TResult>
)也有默认的构建器。对于 ValueTask
/ValueTask<TResult>
,它们的构建器实际上非常简单,因为它们只处理同步且成功完成的情况。在这种情况下,异步方法在没有挂起的情况下完成,构建器可以直接返回一个 ValueTask.Completed
或者一个包装了结果值的 ValueTask<TResult>
。对于其他情况,它们只是将操作委托给 AsyncTaskMethodBuilder
/AsyncTaskMethodBuilder<TResult>
,因为 ValueTask
/ValueTask<TResult>
最终返回的内容只需包装一个 Task
,而它们可以共享相同的逻辑。但是,.NET 6 和 C# 10 引入了可以基于每个方法来覆盖构建器的能力,并且为 ValueTask
/ValueTask<TResult>
引入了几个专用构建器,这些构建器能够使用池化的 IValueTaskSource
/IValueTaskSource<TResult>
对象来表示最终的完成状态,而不是使用 Task
。
我们可以在示例中看到这种变化的影响。让我们稍微修改下我们分析的 SomeMethodAsync
方法,让它返回 ValueTask
而不是 Task
:
1 | static async ValueTask SomeMethodAsync() |
这会生成如下的入口代码:
1 | [ ] |
现在,我们给 SomeMethodAsync
增加 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
标记:
1 | [ ] |
编译器生成的代码会变成这样:
1 | [ ] |
生成的 C# 代码(包括整个状态机部分,未显示)几乎是完全一样的,唯一的区别是构建器的类型不同,存储和使用的构建器也就不同。如果你查看 PoolingAsyncValueTaskMethodBuilder
的代码,你会发现它的结构和 AsyncTaskMethodBuilder
非常相似,甚至在处理已知 awaiter
类型时使用了一些完全相同的共享例程。关键的区别在于,在方法第一次挂起时,它不再执行 new AsyncStateMachineBox<TStateMachine>()
,而是执行 StateMachineBox<TStateMachine>.RentFromCache()
,并且当异步方法(如 SomeMethodAsync
)完成时,以及对返回的 ValueTask
的 await
完成时,租借的 box 被返回到缓存中。这意味着摊销下来几乎零分配:
这种缓存本身也很有趣。对象池化有时候是个好主意,有时候则不是。对象越难创建,池化它们的价值就越大。例如,池化非常大的数组比池化非常小的数组更有价值,因为更大的数组不仅需要更多的 CPU 周期和内存访问来清零,还会对垃圾回收器施加更多压力,导致它更频繁地进行回收。但对于非常小的对象,池化它们有时可能是负面的。池化本质上也是一种内存分配方式,和垃圾回收一样,因此当你选择池化时,你其实是在权衡一种分配器的成本与另一种分配器的成本,而垃圾回收器非常擅长处理大量小型、短命的对象。如果在对象的构造函数中做了很多工作,避免这些工作可以超过分配器本身的开销,使得池化有价值。但如果在对象的构造函数中几乎没有做什么工作,而你仍然池化它,那么你就要赌你的分配器(你的池)比垃圾回收器在处理访问模式上更高效,这通常是个不太好的赌注。此外,还有其他开销,在某些情况下你可能会与垃圾回收器的启发式方法相冲突;例如,垃圾回收器的优化假设来自更高代(如 gen2)对象到更低代(如 gen0)对象的引用相对较少,但池化对象可能会破坏这些假设。
异步方法创建的对象并不小,并且它们可能会出现在非常高频的路径中,因此池化在这里是合理的。但为了使它尽可能有价值,我们也希望尽可能减少开销。因此,这个池非常简单,选择使租借和归还非常快,并尽可能减少争用,即使这意味着它可能会比更积极缓存更多对象时多进行分配。对于每个状态机类型,实现会在每个线程和每个核心上各缓存一个状态机 box,这样就可以以最小的开销和最小的争用进行租借和归还(没有其他线程可以同时访问线程专有的缓存,而另一个线程访问核心特定缓存的情况也很少见)。虽然这个池看起来相对较小,但它在显著减少稳态分配方面非常有效,因为池只负责存储当前未使用的对象;即便在任意时刻有一百万个异步方法在执行中,池只存储每个线程和每个核心的一个对象,它仍然可以避免丢弃大量对象,因为它只需要在对象从一个操作转移到另一个操作时存储它,而不是在对象被使用的过程中存储它。
SynchronizationContext 和 ConfigureAwait
我们之前在讨论 EAP 模式时提到了 SynchronizationContext
,并提到它还会再次出现。SynchronizationContext
使得调用可重用的帮助器函数成为可能,并且可以根据调用环境的需要自动安排何时何地继续执行。因此,很自然地我们希望在 async/await
中也能“直接工作”,而它确实可以。回到我们之前的按钮点击处理程序:
1 | ThreadPool.QueueUserWorkItem(_ => |
使用 async/await
我们希望能像下面这样写:
1 | button1.Text = await Task.Run(() => ComputeMessage()); |
这里对 ComputeMessage
的调用被转移到线程池中执行,完成后,执行会返回到与按钮相关联的 UI 线程上,然后在该线程上设置 Text
属性。
与 SynchronizationContext
的集成是由 awaiter
的实现来负责的(生成状态机的代码并不关心 SynchronizationContext
),因为 awaiter
负责在代表的异步操作完成时实际调用或排队延续操作(continuation)。虽然自定义的 awaiter
可以选择不理会 SynchronizationContext.Current
,但 Task
、Task<TResult>
、ValueTask
和 ValueTask<TResult>
的 awaiter
都会尊重它。这意味着默认情况下,当你 await
一个 Task
、Task<TResult>
、ValueTask
、ValueTask<TResult>
甚至是 Task.Yield()
返回的结果时,awaiter
默认会查找当前的 SynchronizationContext
,然后如果找到了一个非默认的上下文,最终会将延续操作排队到该上下文中。
我们可以通过查看 TaskAwaiter
中的相关代码来看到这一点。以下是来自 Corelib 的相关代码片段:
1 | internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext) |
这是一个决定将哪个对象存储到任务中作为延续操作的方法的一部分。它接收 stateMachineBox
,如之前提到的,可以直接存储到任务的延续列表中。然而,这段特殊的逻辑会将 IAsyncStateMachineBox
包装起来,并结合调度器信息进行处理。它首先检查当前是否有一个非默认的 SynchronizationContext
,如果有,它会创建一个 SynchronizationContextAwaitTaskContinuation
,作为实际存储的延续对象;这个对象会包装原始状态机和捕获的 SynchronizationContext
,并知道如何在队列中的工作项中调用状态机的 MoveNext
。这就是为什么你能够在 UI 应用程序中的事件处理程序中使用 await
,并让 await
后的代码继续在正确的线程上执行的原因。这里另一个有趣的地方是,它不只是关注 SynchronizationContext
,如果找不到自定义的 SynchronizationContext
,它还会检查用于任务的 TaskScheduler
类型是否有需要考虑的自定义调度器。和 SynchronizationContext
一样,如果存在一个非默认的调度器,它也会将状态机与这个调度器包装在一起,作为延续对象。
但这里最有趣的部分也许是方法体的第一行:if (continueOnCapturedContext)
。只有当 continueOnCapturedContext
为 true
时,我们才会检查 SynchronizationContext
和 TaskScheduler
,如果它是 false
,则实现会假装它们都是默认的,并忽略它们。那么是什么将 continueOnCapturedContext
设为 false
的呢?你可能已经猜到了:就是常用的 ConfigureAwait(false)
。
我在 ConfigureAwait FAQ
中对 ConfigureAwait
进行了详细的讨论,建议你阅读那篇文章以获取更多信息。简单来说,ConfigureAwait(false)
在 await
中唯一的作用就是将它的布尔参数传递到这个函数(以及类似的函数)作为 continueOnCapturedContext
的值,从而跳过对 SynchronizationContext
和 TaskScheduler
的检查,并假装它们不存在。对于 Task
来说,这样做可以让任务在任何它认为合适的地方执行延续操作,而不是被迫将它们排队到某个特定的调度器上。
我之前还提到了 SynchronizationContext
的另一个方面,我说过它还会再次出现:OperationStarted
/OperationCompleted
。现在就是时候了。这些在大家都讨厌的特性中出现了:async void
。除了 ConfigureAwait
,async void
可以说是 async/await
引入的最具争议的特性之一。它的引入只有一个原因:事件处理程序。在 UI 应用程序中,你希望能够编写类似下面的代码:
1 | button1.Click += async (sender, eventArgs) => |
但如果所有异步方法都必须返回 Task
类型,那么你将无法这样做。Click
事件的签名是 public event EventHandler? Click;
,而 EventHandler
的定义是 public delegate void EventHandler(object? sender, EventArgs e);
,因此要提供一个与其匹配的方法,该方法需要返回 void
。
async void
被认为是有害的原因有很多,许多文章都建议尽可能避免它,并且很多分析器也会标记 async void
的使用。其中一个最大的问题是委托推断。考虑以下程序:
1 | using System.Diagnostics; |
你可能会期望输出至少有 10 秒的耗时,但如果你运行它,你会看到类似这样的输出:
1 | Timing... |
这是怎么回事?当然,基于我们在这篇文章中讨论的所有内容,你应该明白问题所在。async
lambda 实际上是一个 async void
方法。当异步方法遇到第一个挂起点时,它就会返回调用者。如果这是一个 async Task
方法,那么在那时 Task
会被返回。但对于 async void
,什么也不会返回。Time
方法只知道它调用了 action();
,并且委托调用返回了;它完全不知道异步方法实际上还在“运行”,并且稍后会异步完成。
这就是 OperationStarted
/OperationCompleted
的作用所在。这些 async void
方法类似于之前讨论的 EAP 方法:方法启动时返回 void
,因此你需要一些其他机制来追踪所有正在进行的操作。EAP 实现因此在操作启动时调用当前 SynchronizationContext
的 OperationStarted
,操作完成时调用 OperationCompleted
,而 async void
也做同样的事情。与 async void
关联的构建器是 AsyncVoidMethodBuilder
。还记得在异步方法的入口点中,编译器生成的代码调用构建器的静态 Create
方法以获取合适的构建器实例吗?AsyncVoidMethodBuilder
利用这一点来挂钩创建并调用 OperationStarted
:
1 | public static AsyncVoidMethodBuilder Create() |
类似地,当通过 SetResult
或 SetException
标记构建器完成时,它调用相应的 OperationCompleted
方法。这就是为什么像 xunit 这样的单元测试框架能够拥有 async void
测试方法,同时在并发测试执行时最大限度地利用并发度,例如在 xunit 的 AsyncTestSyncContext
中。
有了这些知识,我们可以重新编写我们的计时示例:
1 | using System.Diagnostics; |
在这里,我创建了一个 SynchronizationContext
,它跟踪挂起操作的计数,并支持阻塞等待所有操作完成。当我运行它时,我得到了如下输出:
1 | Timing... |
完美!
状态机里的字段
到目前为止,我们已经看到了生成的入口方法以及 MoveNext
实现中所有内容的工作方式。我们也瞥见了状态机中定义的一些字段。现在让我们仔细看看这些字段。
对于之前展示的 CopyStreamToStreamAsync
方法:
1 | public async Task CopyStreamToStreamAsync(Stream source, Stream destination) |
最终得到的字段如下:
1 | private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine |
这些字段分别是什么?
<>1__state
:这是状态机中的“状态”字段。它定义了状态机当前的状态,最重要的是,当下次调用MoveNext
时应该执行什么。如果状态是 -2,说明操作已经完成。如果状态是 -1,说明我们要么刚开始调用MoveNext
,要么MoveNext
的代码正在某个线程上执行。如果你在调试异步方法的过程中看到状态是 -1,说明有某个线程正在实际执行方法中的代码。如果状态是 0 或更大,说明方法已挂起,这个状态值告诉你它在哪个await
挂起了。虽然这不是绝对的规则(某些代码模式会导致状态编号混乱),但通常状态的编号与await
在源代码中自上而下的顺序是一一对应的。因此,假设一个异步方法的主体是:1
2
3
4await A();
await B();
await C();
await D();如果你发现状态值是 2,几乎可以肯定意味着异步方法当前正在等待
C()
返回的任务完成。<>t__builder
:这是状态机的构建器,例如AsyncTaskMethodBuilder
用于Task
,AsyncValueTaskMethodBuilder<TResult>
用于ValueTask<TResult>
,AsyncVoidMethodBuilder
用于async void
方法,或者是通过[AsyncMethodBuilder(...)]
特性声明的构建器。正如前面讨论的那样,构建器负责异步方法的生命周期,包括创建返回的任务,最终完成该任务,以及作为挂起的中介,异步方法中的代码要求构建器挂起直到特定的awaiter
完成。source
和destination
:这些是方法的参数。你可以看出它们没有被改名(没有被“修饰”),编译器直接使用了方法中定义的参数名称。如前所述,方法体中使用到的所有参数都需要存储到状态机中,以便MoveNext
方法可以访问它们。注意我说的是“使用到的”。如果编译器发现某个参数在异步方法体中未被使用,它可以优化掉对该字段的存储。例如,考虑以下方法:1
2
3
4public async Task M(int someArgument)
{
await Task.Yield();
}编译器会在状态机中生成以下字段:
1
2
3
4
5
6
7private struct <M>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
private YieldAwaitable.YieldAwaiter <>u__1;
...
}注意,这里没有名为
someArgument
的字段。但如果我们修改异步方法以任何方式使用这个参数:1
2
3
4
5public async Task M(int someArgument)
{
Console.WriteLine(someArgument);
await Task.Yield();
}这个参数就会出现:
1
2
3
4
5
6
7
8private struct <M>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public int someArgument;
private YieldAwaitable.YieldAwaiter <>u__1;
...
}<buffer>5__2
:这是本地变量buffer
,它被“提升”为字段以便可以在多个await
之间存活。编译器尽量避免不必要的状态提升。注意源码中还有另一个本地变量numRead
,它没有对应的状态机字段。为什么呢?因为没必要。numRead
的值在调用ReadAsync
后被设置,然后作为输入传递给WriteAsync
。这两个操作之间没有await
,因此没有需要跨await
保存的值。正如在同步方法中,JIT 编译器可以选择将值存储在寄存器中而不实际将其溢出到堆栈一样,C# 编译器也可以避免将本地变量提升为字段,因为它的值不需要跨await
保留。通常,如果 C# 编译器可以证明本地变量的值不需要跨await
保存,它就会避免提升这些本地变量。<>u__1
和<>u__2
:异步方法中有两个await
,一个是ReadAsync
返回的Task<int>
,另一个是WriteAsync
返回的Task
。Task.GetAwaiter()
返回一个TaskAwaiter
,而Task<TResult>.GetAwaiter()
返回一个TaskAwaiter<TResult>
,它们是不同的结构类型。因为编译器需要在await
前获取这些awaiter
(用于调用IsCompleted
、UnsafeOnCompleted
),然后在await
之后再次访问它们(用于调用GetResult
),因此这些awaiter
需要被存储起来。由于它们是不同的结构类型,编译器需要维护两个不同的字段来存储它们(另一种方法是将它们装箱并使用一个object
字段来存储awaiter
,但这样会带来额外的分配开销)。编译器会尽量复用字段,例如:1
2
3
4
5
6
7
8public async Task M()
{
await Task.FromResult(1);
await Task.FromResult(true);
await Task.FromResult(2);
await Task.FromResult(false);
await Task.FromResult(3);
}这里有五个
await
,但涉及到的awaiter
类型只有两种:三个是TaskAwaiter<int>
,两个是TaskAwaiter<bool>
。因此,状态机中只有两个awaiter
字段:1
2
3
4
5
6
7
8private struct <M>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
private TaskAwaiter<int> <>u__1;
private TaskAwaiter<bool> <>u__2;
...
}如果我将示例改为这样:
1
2
3
4
5
6
7
8public async Task M()
{
await Task.FromResult(1);
await Task.FromResult(true);
await Task.FromResult(2).ConfigureAwait(false);
await Task.FromResult(false).ConfigureAwait(false);
await Task.FromResult(3);
}这里仍然只有
Task<int>
和Task<bool>
,但实际上涉及了四种不同的结构awaiter
类型,因为ConfigureAwait
返回的GetAwaiter()
是不同类型的。这可以从编译器生成的awaiter
字段中看出:1
2
3
4
5
6
7
8
9
10private struct <M>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
private TaskAwaiter<int> <>u__1;
private TaskAwaiter<bool> <>u__2;
private ConfiguredTaskAwaitable<int>.ConfiguredTaskAwaiter <>u__3;
private ConfiguredTaskAwaitable<bool>.ConfiguredTaskAwaiter <>u__4;
...
}如果你希望优化异步状态机的大小,可以看看是否能够整合
await
的类型,从而减少这些awaiter
字段的数量。<>7__wrap1
:在状态机中你可能会看到一些包含“wrap”的字段。例如以下简单示例:1
public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;
这会生成包含以下字段的状态机:
1
2
3
4
5
6
7private struct <M>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<int> <>t__builder;
private TaskAwaiter<int> <>u__1;
...
}到目前为止没什么特别的。现在将表达式的顺序对调:
1
public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);
这样就得到这些字段:
1
2
3
4
5
6
7
8private struct <M>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<int> <>t__builder;
private int <>7__wrap1;
private TaskAwaiter<int> <>u__1;
...
}现在多了一个字段:
<>7__wrap1
。为什么呢?因为我们计算了DateTime.Now.Second
的值,然后在计算完之后还需要等待一个异步操作,而第一个表达式的值需要保留下来,以便加到await
的结果上。因此编译器需要确保第一个表达式的临时结果可以用于await
的结果相加,这就意味着它需要将表达式的结果溢出到一个临时变量中,这个临时变量就是<>7__wrap1
字段。如果你需要对异步方法进行超精细的优化以减少内存分配,可以查找这样的字段,看看是否可以通过对源代码进行小的调整来避免溢出,从而避免这些临时变量。
总结
希望这篇文章能够帮你揭开使用 async/await
时背后到底发生了什么,不过幸运的是,一般来说你并不需要知道这些或者去关心它们。在这里有很多环环相扣的部分,共同构建了一种高效的方案,使得我们在编写可扩展的异步代码时不需要面对“回调地狱”的困扰。然而,归根结底,这些部分其实相对简单:一种能代表任意异步操作的通用形式,一种能够将常规控制流重写为协程状态机实现的语言和编译器,以及将它们全部结合起来的模式。其他所有的内容,都是为了优化而锦上添花。
祝编码愉快!