MoveNext
所以,入口点方法已调用,初始化了状态机结构体,调用了 Start
,并触发了 MoveNext
。什么是 MoveNext
?它是包含开发人员方法中所有原始逻辑的方法,但有了大量更改。让我们首先看一下该方法的基本结构。这是编译器为我们的方法发出的反编译版本,但已删除了生成的 try
块内部的所有内容:.
private void MoveNext()
{
try
{
... // all of the code from the CopyStreamToStreamAsync method body, but not exactly as it was written
}
catch (Exception exception)
{
<>1__state = -2;
<buffer>5__2 = null;
<>t__builder.SetException(exception);
return;
}
<>1__state = -2;
<buffer>5__2 = null;
<>t__builder.SetResult();
}
除了 MoveNext
执行的其他工作之外,它还有责任在所有工作完成时完成异步 Task
方法返回的任务。如果 try
块的主体抛出一个未处理的异常,那么该任务将被设置为故障并带有该异常。如果异步方法成功到达其结尾(相当于同步方法返回),则将成功完成返回的任务。在这两种情况下,它都设置状态机的状态以指示完成。(我有时听到开发人员理论上认为,在第一个 await
之前和之后抛出的异常存在差异...基于上述内容,应该清楚这并不是真的。任何在异步方法中未经处理的异常,无论它在方法中的哪个位置以及方法是否已经 yield
,都会最终进入上面的 catch
块,然后将捕获的异常存储到从异步方法返回的 Task
中。)
还要注意,这个完成是通过 builder 进行的,使用了它的 SetException
和 SetResult
方法,这些方法是编译器期望的 builder 模式的一部分。如果异步方法先前已经暂停,则 builder 已经必须在处理该暂停时生成一个 Task
(我们很快就会看到如何以及何处进行此操作),在这种情况下,调用 SetException
/SetResult
将完成该任务。但是,如果异步方法之前尚未暂停,则我们尚未创建任何任务或返回任何内容给调用者,因此 builder 在如何生成该任务方面具有更大的灵活性。如果您还记得入口点方法中的最后一件事,那就是将任务返回给调用者,这是通过返回访问 builder 的 Task 属性的结果来实现的(我知道有太多叫做“Task”的东西):
public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
...
return stateMachine.<>t__builder.Task;
}
如果该方法曾经暂停过,builder 将知道这一点,并返回已经创建的任务。如果该方法从未暂停过,且 builder 还没有任务,则它可以在此处生成一个已完成的任务。在这种情况下,对于成功完成的任务,它可以使用 Task.CompletedTask
而不是分配一个新的任务,避免任何分配。对于泛型 Task<TResult>
,builder 可以使用 Task.FromResult<TResult>(TResult result)
。
builder 还可以根据它正在创建的对象进行任何适当的转换。例如,Task
实际上有三种可能的最终状态:成功、失败和取消。AsyncTaskMethodBuilder
的 SetException
方法特别处理 OperationCanceledException
,如果提供的异常是 OperationCanceledException
或其派生类,则将任务转换为 TaskStatus.Canceled
最终状态;否则,任务以 TaskStatus.Faulted
结束。这种区别通常在消费代码中不明显;因为无论异常是否标记为 Canceled
或 Faulted
,都会将异常存储到 Task
中,等待该 Task
的代码 await
无法观察状态之间的差异(原始异常将在任一情况下传播)……它只影响直接与 Task
交互的代码,例如通过 ContinueWith
进行交互,其中的重载允许仅针对完成状态的子集调用延续。
现在我们了解了生命周期方面,下面是 MoveNext
中 try
块中填写的所有内容:
private void MoveNext()
{
try
{
int num = <>1__state;
TaskAwaiter<int> awaiter;
if (num != 0)
{
if (num != 1)
{
<buffer>5__2 = new byte[4096];
goto IL_008b;
}
awaiter = <>u__2;
<>u__2 = default(TaskAwaiter<int>);
num = (<>1__state = -1);
goto IL_00f0;
}
TaskAwaiter awaiter2 = <>u__1;
<>u__1 = default(TaskAwaiter);
num = (<>1__state = -1);
IL_0084:
awaiter2.GetResult();
IL_008b:
awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (<>1__state = 1);
<>u__2 = awaiter;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
IL_00f0:
int result;
if ((result = awaiter.GetResult()) != 0)
{
awaiter2 = destination.WriteAsync(<buffer>5__2, 0, result).GetAwaiter();
if (!awaiter2.IsCompleted)
{
num = (<>1__state = 0);
<>u__1 = awaiter2;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
return;
}
goto IL_0084;
}
}
catch (Exception exception)
{
<>1__state = -2;
<buffer>5__2 = null;
<>t__builder.SetException(exception);
return;
}
<>1__state = -2;
<buffer>5__2 = null;
<>t__builder.SetResult();
}
这种复杂性可能会让人有点熟悉。还记得我们手动实现的基于 APM 的 BeginCopyStreamToStream
有多么复杂吗?这不是很复杂,但编译器正在为我们做这项工作,将方法重写为一种传递延续的形式,并确保所有必要的状态都为这些延续所保留。即使如此,我们仍然可以看得清楚并跟随。记住,在入口点中状态被初始化为 -1。然后,我们进入 MoveNext
,发现该状态(现在存储在 num 局部变量中)既不是 0 也不是 1,因此执行创建临时缓冲区的代码,然后分支到标签 IL_008b
,在那里调用 stream.ReadAsync
。请注意,在这一点上,我们仍然从 MoveNext
调用和 Start
调用同步运行,并从入口点同步运行,这意味着开发人员的代码调用了 CopyStreamToStreamAsync
,它仍在同步执行,尚未返回一个表示该方法最终完成的 Task
。这可能即将改变……
我们调用 Stream.ReadAsync
并从中得到一个 Task<int>
。读取可能已同步完成,也可能已异步完成但非常快,因此现在已经完成,或者可能尚未完成。无论如何,我们都有一个 Task<int>
,代表它最终的完成状态,编译器生成的代码会检查该 Task<int>
,以确定如何继续进行:如果该 Task<int>
已经完成(无论是同步完成还是在我们检查之前完成),则该方法的代码可以继续同步运行...没有必要花费不必要的开销来排队一个工作项来处理方法剩余的执行,而我们可以在此时此地继续运行。但是为了处理 Task<int>
尚未完成的情况,编译器需要发出连接 continuation 到 Task
的代码。因此,它需要发出询问 Task
“你完成了吗?”的代码。它是否直接与 Task
交谈来询问呢?
如果在 C# 中唯一可以 await
的是 System.Threading.Tasks.Task
,那将是很受限制的。同样地,如果 C# 编译器必须了解每种可能的可等待类型,也是很受限制的。相反,在这种情况下,C# 像通常一样采用 API 模式。代码可以 await
任何公开适当模式(就像可以 foreach
任何提供适当“可枚举”模式的东西一样)。例如,我们可以增强之前编写的 MyTask
类来实现 awaiter
模式:
class MyTask
{
...
public MyTaskAwaiter GetAwaiter() => new MyTaskAwaiter { _task = this };
public struct MyTaskAwaiter : ICriticalNotifyCompletion
{
internal MyTask _task;
public bool IsCompleted => _task._completed;
public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
public void UnsafeOnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
public void GetResult() => _task.Wait();
}
}
如果一个类型公开了 GetAwaiter()
方法,那么它就可以被 await
调用,而 Task
就是这样做的。该方法需要返回一个结构,该结构还公开了几个成员,包括 IsCompleted
属性,用于在调用 IsCompleted
时检查操作是否已经完成。您可以看到它正在发生:在 IL_008b
中,从 ReadAsync
返回的 Task
上调用了 GetAwaiter()
,然后在该结构 awaiter
实例上访问了 IsCompleted
。如果 IsCompleted
返回 true
,则我们将最终落入到 IL_00f0
,该代码调用 awaiter
的另一个成员:GetResult()
。如果操作失败,GetResult()
负责抛出异常以将其传播到异步方法中的 await
之外;否则,GetResult() 负责返回操作的结果(如果有的话)。在这里的 ReadAsync
操作中,如果结果为 0,则跳出读取/写入循环,转到方法的结尾处,调用 SetResult
,完成任务。
然而,让我们暂停一下,真正有趣的部分是如果 IsCompleted
检查实际返回 false
时会发生什么。如果它返回 true
,我们将继续处理循环,类似于 APM 模式中 CompletedSynchronously
返回 true
并且 Begin
方法的调用者(而不是回调)负责继续执行。但是如果 IsCompleted
返回 false
,我们需要挂起异步方法的执行,直到 await
操作完成。这意味着从 MoveNext
中返回,并且由于这是 Start
的一部分,而我们仍在入口点方法中,因此这意味着将 Task
返回给调用方。但在任何这些操作发生之前,我们需要将 continuation 勾连到正在等待的 Task
上(注意,为避免像 APM 情况中那样栈过深,如果异步操作在 IsCompleted
返回 false
之后但在我们连接 continuation 之前完成,则 continuation 仍然需要从调用线程异步调用,因此它将被排队)。由于我们可以 await
任何内容,因此不能直接与 Task
实例交谈;相反,我们需要通过某种基于模式的方法来执行此操作。
这是否意味着在 awaiter 上有一个方法将会连接后续呢?这是有道理的;毕竟,Task
本身支持 continuations,具有 ContinueWith
等方法......返回自 GetAwaiter
的 TaskAwaiter
应该公开让我们设置后续的方法,对吗?事实上确实如此。awaiter 模式要求 awaiter 实现 INotifyCompletion
接口,其中包含单个方法 void OnCompleted(Action continuation)
。一个 awaiter 也可以选择性地实现 ICriticalNotifyCompletion
接口,它继承了 INotifyCompletion
并添加了一个 void UnsafeOnCompleted(Action continuation)
方法。根据我们之前对 ExecutionContext
的讨论,您可以猜到这两种方法之间的区别是什么:两者都可以连接后续任务,但 OnCompleted
应该流传 ExecutionContext
,而 UnsafeOnCompleted
则不必。这里需要两种不同的方法,即INotifyCompletion.OnCompleted
和 ICriticalNotifyCompletion.UnsafeOnCompleted
,这在很大程度上是历史遗留问题,与代码访问安全(Code Access Security
)或 CAS 有关。在 .NET Core 中,CAS 已经不存在,并且在 .NET Framework 中默认关闭,只有当您选择重新启用遗留部分信任功能时才会生效。使用部分信任时,CAS信息作为ExecutionContext
的一部分流动,因此不流动它是“不安全”的,这就是为什么不流动 ExecutionContext
的方法以“Unsafe”为前缀的原因。这些方法也被标记为[SecurityCritical]
,部分受信任的代码无法调用[SecurityCritical]
方法。因此,创建了两个 OnCompleted
的变体,编译器优先使用 UnsafeOnCompleted
(如果提供),但始终提供 OnCompleted
变体,以防止 awaiter 需要支持部分信任。从异步方法的角度来看,建造者始终在 await 的地方流传 ExecutionContext
,因此一个 awaiter 也这样做是不必要的和重复的工作。
因此,awaiter 确实公开了一个方法来连接 continuation。编译器可以直接使用它,但是有一个非常关键的问题:什么应该是 continuation?更重要的是,它应该与哪个对象相关联?请记住,状态机结构体在堆栈上,而我们当前正在运行的 MoveNext
调用是该实例上的方法调用。我们需要保留状态机,以便在恢复时具有所有正确的状态,这意味着状态机不能仅继续存在于堆栈上;它需要被复制到堆上的某个位置,因为堆栈最终将用于由此线程执行的其他后续不相关工作。然后,continuation 需要在堆上的该状态机副本上调用 MoveNext
方法。
此外,ExecutionContext
也与此相关。状态机需要确保在暂停点捕获并在恢复点应用任何存储在ExecutionContext
中的环境数据,这意味着 continuation 还需要包括该 ExecutionContext
。因此,仅创建指向状态机的 MoveNext
的委托是不够的。这也是不必要的开销。如果我们在挂起时创建指向状态机的 MoveNext
的委托,每次这样做时,我们都会对状态机结构体进行装箱(即使它已经作为某个其他对象的一部分在堆上),并分配一个额外的委托(委托的 this
对象引用将指向新装箱的结构体副本)。因此,我们需要进行复杂的操作,确保我们仅在方法第一次挂起执行时将 struct
从堆栈提升到堆上,但所有其他时间都使用相同的堆目标作为 MoveNext
的目标,并在此过程中确保我们已捕获正确的上下文,在恢复时确保我们使用已捕获的上下文来调用操作。
这比我们希望编译器生成的逻辑要多得多……我们希望它封装在一个帮助类中,有几个原因。首先,这是要生成到每个用户程序集中的大量复杂代码。其次,我们希望允许定制该逻辑作为实现生成器模式的一部分(稍后当谈到池化时,我们将看到示例)。第三,我们希望能够发展和改进该逻辑,并使现有先前编译的二进制文件变得更好。这并不是一个假设性的情况;这种支持的库代码在 .NET Core 2.1中得到了完全改进,因此该操作比 .NET Framework 上的效率要高得多。我们将首先探索在 .NET Framework上它是如何工作的,然后再看看现在在 .NET Core 中会发生什么。
当需要挂起时,您可以在C# 编译器生成的代码中看到以下情况:
if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false
{
<>1__state = 1;
<>u__2 = awaiter;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
我们正在将状态ID存储到 state
字段中,该状态ID指示方法恢复时应跳转的位置。然后,我们将 awaiter 本身持久化到一个字段中,以便在恢复后可以用于调用 GetResult
。然后,在退出 MoveNext
调用之前,我们要做的最后一件事情就是调用 <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this)
,请求生成器将continuation连接到此状态机的awaiter上。(请注意,它调用生成器的 AwaitUnsafeOnCompleted
而不是生成器的 AwaitOnCompleted
,因为awaiter实现了 ICriticalNotifyCompletion
;状态机处理流传 ExecutionContext
,因此我们不需要要求 awaiter
也执行此操作......如前所述,这样做只会是冗余和不必要的开销。)
那个 AwaitUnsafeOnCompleted
方法的实现过于复杂,无法在此进行复制,因此我将总结一下它在 .NET Framework 上的功能:
-
使用
ExecutionContext.Capture()
来捕获当前上下文 -
然后它会分配一个
MoveNextRunner
对象,用于包装捕获的上下文以及已装箱化的状态机(如果这是方法第一次挂起,我们还没有状态机,因此只需使用null
作为占位符) -
然后它创建一个指向该
MoveNextRunner
上的Run
方法的Action
委托;这是它能够获取一个委托的方法,该委托可以在捕获的ExecutionContext
上下文中调用状态机的MoveNext
-
如果这是方法第一次挂起,我们还没有已装箱的状态机,所以它在此时会将其装箱,并通过将该实例存储到一个类型为
IAsyncStateMachine
接口的局部变量中,在堆上创建了一份副本。然后,该盒子被存储到分配的MoveNextRunner
中 -
现在是一个有些费解的步骤。如果你回头看一下状态机结构体的定义,它包含了 builder,即
public AsyncTaskMethodBuilder <>t__builder;
,而如果你查看 builder 的定义,它包含了internal IAsyncStateMachine m_stateMachine;
。builder 需要引用已装箱化的状态机,以便在随后的挂起中可以看到已经装箱化了状态机,而不需要再次进行装箱。但我们刚刚已经把状态机装箱了,而该状态机包含一个 builder,其m_stateMachine
字段为null
。我们需要改变那个装箱状态机的 builder 的m_stateMachine
,使其指向其父级盒子。为了实现这一点,编译器生成的状态机结构体实现了一个void SetStateMachine(IAsyncStateMachine stateMachine);
方法,而该状态机结构体包含该接口方法的实现:private void SetStateMachine(IAsyncStateMachine stateMachine) => <>t__builder.SetStateMachine(stateMachine);
因此,builder 装箱状态机,然后将该装箱后的对象传递给要装箱的
SetStateMachine
方法,该方法调用 builder 的SetStateMachine
方法,并将该对象存储到字段中。哇。 -
最后,我们有一个代表继续执行的
Action
,并将其传递给等待对象的UnsafeOnCompleted
方法。在TaskAwaiter
的情况下,任务将该Action
存储到任务的继续列表中,以便当任务完成时,它会调用该Action
,通过MoveNextRunner.Run
回调,通过ExecutionContext.Run
回调,并最终调用状态机的MoveNext
方法以重新进入状态机并从挂起的位置继续运行。
这就是在 .NET Framework 上发生的情况,你可以通过分析器观察这个过程,例如运行一个分配分析器来查看每个 await 分配了什么。让我们看一下这个简单的程序,我只是为了突出涉及的分配成本而编写的:
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var al = new AsyncLocal<int>() { Value = 42 };
for (int i = 0; i < 1000; i++)
{
await SomeMethodAsync();
}
}
static async Task SomeMethodAsync()
{
for (int i = 0; i < 1000; i++)
{
await Task.Yield();
}
}
}
这个程序创建了一个 AsyncLocal<int>
,通过所有后续的异步操作传递值 42。然后它调用 SomeMethodAsync
1000 次,每个方法都会挂起/恢复 1000 次。在 Visual Studio 中,我使用 .NET 对象分配追踪器分析器运行此程序,得到以下结果:

这是...非常多的分配!让我们逐个检查它们,以了解它们来自何处。
-
ExecutionContext
, 这里有一百多万个这样的实例被分配了。为什么?因为在 .NET Framework 中,ExecutionContext
是一个可变的数据结构。由于我们希望流过异步操作分叉时存在的数据,并且不希望它看到在该分叉之后进行的更改,我们需要复制ExecutionContext
。每个分叉操作都需要这样的复制,所以在调用SomeMethodAsync
1000 次,每个方法都会挂起/恢复 1000 次的情况下,就有一百万个ExecutionContext
实例。痛苦。 -
Action
, 同样地,每当我们等待某些尚未完成的东西(在我们的一百万个await Task.Yield()
中就是这种情况),我们就需要分配一个新的Action
委托,以便将其传递给该等待对象的UnsafeOnCompleted
方法。 -
MoveNextRunner
, 同样的情况;由于在先前步骤的概述中,每次挂起时我们都要分配一个新的MoveNextRunner
来存储Action
和ExecutionContext
,并使用后者执行前者,因此就有一百万个这样的实例。 -
LogicalCallContext
, 又有一百万个。这些是 .NET Framework 上AsyncLocal<T>
的实现细节;AsyncLocal<T>
将其数据存储到ExecutionContext
的 “LogicalCallContext
” 中,这是指随ExecutionContext
流动的通用状态的高级方式。因此,如果我们制作了一百万份ExecutionContext
的副本,那么也要制作一百万份LogicalCallContext
的副本。 -
QueueUserWorkItemCallback
, 每个Task.Yield()
都将一个工作项排队到线程池中,导致分配一百万个工作项对象,用于表示这一百万个操作。 -
Task<VoidResult>
, 这里有一千个实例,所以至少我们不再属于“百万”俱乐部。每个异步 Task 调用,在异步完成时需要分配一个新的 Task 实例来表示该调用的最终完成。 -
<SomeMethodAsync>d__1
, 这是编译器生成的状态机结构体的装箱。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 中,身份验证会作为ExecutionContext
的一部分流动;在 .NET Core 中,身份验证通过使用一个valueChangedHandler
的AsyncLocal<SafeAccessTokenHandle>
来进行流动,并对当前线程进行适当的更改。 -
QueueSegment
/IThreadPoolWorkItem[]
. 在 .NET Core 中,ThreadPool
的全局队列现在是用ConcurrentQueue<T>
实现的,并且ConcurrentQueue<T>
已被重写为非固定大小的循环段链表。一旦段的大小足够大,以至于该段永远不会满,因为稳态出队能够跟上稳态入队,就不需要分配额外的段,而是无限地使用相同的足够大的段。
那么其他的分配,比如 Action
、MoveNextRunner
和 <SomeMethodAsync>d__1
呢?理解如何消除其余的分配需要深入了解它在 .NET Core 中的工作原理。
让我们回到我们讨论挂起时发生了什么的那个部分:
if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false
{
<>1__state = 1;
<>u__2 = awaiter;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
在这里生成的代码与所针对的平台无关,因此无论是 .NET Framework 还是 .NET Core,该挂起的生成 IL 都是相同的。但是,AwaitUnsafeOnCompleted
方法的实现会发生变化,在 .NET Core 上会有很大不同:
-
事情开始时是相同的:该方法调用
ExecutionContext.Capture()
方法来获取当前的执行上下文。 -
然后,它们与 .NET Framework 不一样。在 .NET Core 中,生成器只有一个字段:
public struct AsyncTaskMethodBuilder { private Task<VoidTaskResult>? m_task; ... }
在捕获了
ExecutionContext
之后,它会检查该m_task
字段是否包含AsyncStateMachineBox
的实例, 其中TStateMachine
是编译器生成的状态机结构体的类型。这个AsyncStateMachineBox<TStateMachine>
类型就是“魔法”。它的定义如下:private class AsyncStateMachineBox<TStateMachine> : Task<TResult>, IAsyncStateMachineBox where TStateMachine : IAsyncStateMachine { private Action? _moveNextAction; public TStateMachine? StateMachine; public ExecutionContext? Context; ... }
这个类型不再有独立的
Task
,它本身就是任务(请注意其基础类型)。这个结构体不再被装箱,而只是作为一个强类型字段存在于此任务上。不再需要一个独立的MoveNextRunner
来存储Action
和ExecutionContext
,它们只是这个类型的字段。由于这个实例会存储到生成器的m_task
字段中,我们可以直接访问它,并且不需要在每次挂起时重新分配东西。如果ExecutionContext
改变了,我们可以用新的上下文覆盖该字段,而不需要分配其他任何东西;我们仍然可以通过任何Action
指向正确的位置。因此,在捕获了ExecutionContext
之后,如果我们已经拥有这个AsyncStateMachineBox<TStateMachine>
的实例,那么这不是该方法第一次挂起,我们可以将新捕获的ExecutionContext
存储到其中。如果我们还没有AsyncStateMachineBox<TStateMachine>
的实例,那么我们需要对它进行分配:var box = new AsyncStateMachineBox<TStateMachine>(); taskField = box; // important: this must be done before storing stateMachine into box.StateMachine! box.StateMachine = stateMachine; box.Context = currentContext;
请注意源代码中标注为“important”的那一行。这取代了 .NET Framework 中的复杂
SetStateMachine
操作,因此在 .NET Core 中根本不使用SetStateMachine
。你在那里看到的taskField
是一个对AsyncTaskMethodBuilder
的m_task
字段的引用。我们分配AsyncStateMachineBox<TStateMachine>
,然后通过taskField
将该对象存储到生成器的m_task
中(这是在堆栈上的状态机结构体中的生成器),然后将该基于堆的AsyncStateMachineBox<TStateMachine>
中的状态机结构体(现在已经包含对该 box 的引用)复制到其中,以使AsyncStateMachineBox<TStateMachine>
适当地并递归地引用自身。仍然很费脑子,但要高效得多。 -
然后,我们可以获取一个用于调用其
MoveNext
方法的方法的Action
,该方法将在调用状态机的MoveNext
之前进行适当的ExecutionContext
恢复。并且该Action
可以缓存在_moveNextAction
字段中,以便任何后续使用都可以重用相同的Action
。然后,将该Action
传递给等待者的UnsafeOnCompleted
,以连接延续。
这个解释说明了为什么大多数其他分配都消失了:<SomeMethodAsync>d__1
不会被装箱,而是作为任务本身的一个字段存在;而 MoveNextRunner
不再需要,因为它只存储了 Action
和 ExecutionContext
。但是,根据这个解释,我们应该仍然看到 1000 个 Action
分配,即每次方法调用都有一个 Action
,但我们没有看到。为什么?那些 QueueUserWorkItemCallback
对象呢……我们仍然作为 Task.Yield()
的一部分排队,所以他们为什么没有出现?
正如我所指出的,将实现细节推迟到核心库中的好处之一是它可以随着时间的推移不断发展实现。我们已经看到了从 .NET Framework 到 .NET Core 的演变方式。它还进一步演变了从 .NET Core 开始进行的初始重写,使用了更多受益于内部访问关键组件的优化。特别是,异步基础结构知道核心类型(如 Task
和 TaskAwaiter
)。由于它知道这些类型并具有内部访问权限,因此它不必遵守公开定义的规则。C# 语言遵循的等待程序模式要求 awaiter 具有 AwaitOnCompleted
或 AwaitUnsafeOnCompleted
方法,两者都将延续作为 Action
进行处理,这意味着基础结构需要能够创建一个 Action
来表示延续,以便与基础结构不知道的任意等待程序一起工作。但是,如果基础结构遇到它已知的等待程序,则不需要采用相同的代码路径。因此,对于 System.Private.CoreLib
中定义的所有基本等待程序,基础结构都有一个更简单的路径可以遵循,根本不需要 Action
。这些等待程序都知道 IAsyncStateMachineBox
,并且能够将对象本身作为延续处理。例如,Task.Yield
返回的 YieldAwaitable
能够直接将 IAsyncStateMachineBox
本身作为工作项排队到线程池中,而在 await
Task 时使用的 TaskAwaiter
能够直接将 IAsyncStateMachineBox
本身存储到 Task
的延续列表中。不需要 Action
,也不需要 QueueUserWorkItemCallback
。
因此,在常见情况下,异步方法仅等待 System.Private.CoreLib
中的内容(Task
、Task<TResult>
、ValueTask
、ValueTask<TResult>
、YieldAwaitable
和这些的 ConfigureAwait
变体),最坏情况是与整个异步方法生命周期相关联的开销只有一个分配:如果方法挂起,它会分配这个单一的 Task
派生类型,该类型存储所有其他所需的状态;如果该方法不挂起,则不会产生任何额外的分配。
如果需要的话,我们也可以摆脱最后一个分配,至少在摊销方式下可以这样做。如已经展示的那样,与 Task
关联的有一个默认生成器(AsyncTaskMethodBuilder
),与 Task<TResult>
和 ValueTask
以及 ValueTask<TResult>
同样存在默认生成器(分别是 AsyncTaskMethodBuilder<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
:
static async ValueTask SomeMethodAsync()
{
for (int i = 0; i < 1000; i++)
{
await Task.Yield();
}
}
这将导致这个生成的入口方法:
[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
private static ValueTask SomeMethodAsync()
{
<SomeMethodAsync>d__1 stateMachine = default;
stateMachine.<>t__builder = AsyncValueTaskMethodBuilder.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
现在我们添加 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
到 SomeMethodAsync
方法:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
static async ValueTask SomeMethodAsync()
{
for (int i = 0; i < 1000; i++)
{
await Task.Yield();
}
}
编译器会输出这个:
AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
private static ValueTask SomeMethodAsync()
{
<SomeMethodAsync>d__1 stateMachine = default;
stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
实际上生成整个实现的 C# 代码,包括整个状态机(未显示),几乎是相同的;唯一的区别是创建和存储的生成器类型,因此在我们之前看到引用生成器的所有位置都使用了生成器类型。如果查看 PoolingAsyncValueTaskMethodBuilder
的代码,你会发现它的结构几乎与 AsyncTaskMethodBuilder
相同,包括使用某些完全相同的共享例程来执行特定 awaiter 类型的特殊处理。关键区别是,在方法首次挂起时,它不是执行 new AsyncStateMachineBox<TStateMachine>()
操作,而是执行 StateMachineBox<TStateMachine>.RentFromCache()
操作。当 async 方法(SomeMethodAsync
) 完成并等待返回的 ValueTask 完成时,已租用的 Box 将被返回到缓存中。这意味着(摊销)零分配:

这个缓存本身有点有趣。对象池可能是一个好主意,也可能是一个坏主意。创建对象的成本越高,将它们加入到对象池中的价值就越大;因此,例如,池化非常大的数组比池化非常小的数组更有价值,因为更大的数组不仅需要更多的 CPU 周期和内存访问来清零,而且对垃圾收集器产生更多的压力以更频繁地进行回收。然而,对于非常小的对象,将它们加入到对象池中可能会产生负面影响。池只是内存分配器,GC 也是内存分配器,因此当你使用池时,你是在交换与另一个分配器相关的成本,GC 对处理大量微小、短暂对象非常有效。如果在对象的构造函数中执行大量工作,则避免这些工作可能会使分配器本身的成本变得微不足道,从而使池具有价值。但是,如果在对象的构造函数中几乎没有或根本没有工作,并将其放入池中,则赌注就是你的分配器(池)比 GC 更有效地处理所采用的访问模式,这通常是个不好的赌注。还有其他成本,有些情况下,你可能在实际上与 GC 的启发式算法抗衡;例如,GC 是根据从较高代(例如 gen2)对象到较低代(例如 gen0)对象的引用相对稀少这个前提进行优化的,但是池化对象会使这些前提不再成立。
现在,由异步方法创建的对象并不是 微小 的,并且它们可能位于超级热的路径上,因此池化可能是合理的。但为了使其尽可能有价值,我们还希望尽可能避免开销。因此,该池非常简单,选择使租用和返回变得非常快速,几乎不会争用,即使这意味着它可能会分配更多的内容,如果采用更积极的缓存策略,就可以减少分配量。对于每个状态机类型,池的实现中最多租用一个状态机 Box,并且每个核心最多租用一个状态机 Box;这使得它能够在最小的开销和最小的争用下租用和返回对象(没有其他线程可以同时访问线程特定缓存,而且很少有其他线程同时访问核心特定缓存)。虽然这看起来可能是一个相对较小的池,但鉴于该池只负责存储当前未被使用的对象,它也非常有效地显著减少了稳定状态的分配量;你可以在任何时候有数百万个异步方法处于活动状态,即使该池每个线程和每个核心最多只能存储一个对象,它仍然可以避免删除大量的对象,因为它只需要将一个对象存储足够长的时间以便将其从一个操作传输到另一个操作,而不是在该操作使用期间。
SynchronizationContext 和 ConfigureAwait
我们之前在 EAP 模式的上下文中讨论了 SynchronizationContext
,并提到它将再次出现。SynchronizationContext
使得调用可重用的帮助程序并在任何时候自动安排回来到合适的调用环境变得可能。因此,可以自然地期望这个机制 “只需正常工作” 与 async
/await
配合使用,并且确实如此。回到我们之前的按钮单击处理程序:
ThreadPool.QueueUserWorkItem(_ =>
{
string message = ComputeMessage();
button1.BeginInvoke(() =>
{
button1.Text = message;
});
});
使用 async
/await
,我们希望能够将其改写如下:
button1.Text = await Task.Run(() => ComputeMessage());
对 ComputeMessage
方法的调用被转移到线程池中,在方法完成后,执行流程会回到与按钮关联的 UI 线程,并在该线程上设置其 Text 属性。
与 SynchronizationContext
的集成由 awaiter 实现(状态机生成的代码对 SynchronizationContext
一无所知)处理,因为 awaiter 负责在表示的异步操作完成时实际调用或排队提供的继续操作。虽然自定义 awaiter 没有必要关注 SynchronizationContext.Current
,但 Task
、Task<TResult>
、ValueTask
和 ValueTask<TResult>
的 awaiter 都会遵循该上下文。这意味着,默认情况下,当你等待 Task
、Task<TResult>
、ValueTask
、ValueTask<TResult>
或甚至 Task.Yield()
调用的结果时,awaiter 默认将查找当前的 SynchronizationContext
,如果成功获取到了非默认的 SynchronizationContext
,它最终会将继续操作排队到那个上下文中。
如果我们查看涉及 TaskAwaiter
的代码,可以看到这一点。以下是来自 Corelib 的相关代码片段
internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
{
if (continueOnCapturedContext)
{
SynchronizationContext? syncCtx = SynchronizationContext.Current;
if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext))
{
var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false);
if (!AddTaskContinuation(tc, addBeforeOthers: false))
{
tc.Run(this, canInlineContinuationTask: false);
}
return;
}
else
{
TaskScheduler? scheduler = TaskScheduler.InternalCurrent;
if (scheduler != null && scheduler != TaskScheduler.Default)
{
var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false);
if (!AddTaskContinuation(tc, addBeforeOthers: false))
{
tc.Run(this, canInlineContinuationTask: false);
}
return;
}
}
}
...
}
这是一种确定要存储到 Task
作为继续的对象的方法的一部分。它被传递了 stateMachineBox
,正如之前所提到的那样,可以直接将其存储到 Task
的继续列表中。然而,这种特殊的逻辑可能会包装 IAsyncStateMachineBox
以整合调度程序(如果存在)。它检查当前是否存在非默认的 SynchronizationContext
,如果存在,则创建一个 SynchronizationContextAwaitTaskContinuation
作为实际存储为 continuation 的对象;该对象反过来又包装了原始值和已捕获的 SynchronizationContext
,并知道如何在排队到后者的工作项中调用前者的 MoveNext
。这就是你能够在 UI 应用程序的某个事件处理程序中使用 await
并使得 await
完成后的代码继续在正确的线程上运行的方式。这里需要注意的下一个有趣点是,它不仅关注 SynchronizationContext
:如果无法找到要使用的自定义 SynchronizationContext
,它还会查看 Task
使用的 TaskScheduler
类型是否具有需要考虑的自定义类型。与 SynchronizationContext
一样,如果存在非默认类型,则使用原始 box 包装它,并将其用作 continuation 对象的 TaskSchedulerAwaitTaskContinuation
。
但是,可能最有趣的事情是注意方法体的第一行:if (continueOnCapturedContext)
。只有在 continueOnCapturedContext
为 true
时我们才会检查 SynchronizationContext/TaskScheduler
;如果它为 false
,则实现的行为就像两者都为默认值并忽略它们。你可能已经猜到了是什么将 continueOnCapturedContext
设置为 false:使用非常流行的 ConfigureAwait(false)
。
我在 ConfigureAwait FAQ 中详细讨论了 ConfigureAwait
,因此我鼓励您阅读以获取更多信息。可以简单地说,作为 await
的一部分,ConfigureAwait(false)
唯一的作用就是将其参数 Boolean
作为该 continueOnCapturedContext
值传递给此函数(以及其他类似的函数),以便跳过对 SynchronizationContext/TaskScheduler
的检查,并表现得好像它们都不存在。对于 Task
,这使得 Task
可以在任何地方调用其 continuation,而不是被强制排队执行到某个特定计划程序上。
我之前提到了 SynchronizationContext
的另一个方面,并说我们稍后会再次看到它:OperationStarted/OperationCompleted
。现在是时候了。它们作为每个人都爱恨交加的特性的一部分而出现:async void
。除去 ConfigureAwait
,可以说 async void
是添加到 async/await
中最具争议性的特性之一。它添加的唯一原因是事件处理程序。在 UI 应用程序中,你希望能够编写以下代码:
button1.Click += async (sender, eventArgs) =>
{
button1.Text = await Task.Run(() => ComputeMessage());
};
但是,如果所有的 async
方法都必须像 Task
一样具有返回类型,那么你就无法写出这样的代码。Click
事件具有签名 public event EventHandler? Click;
,其中 EventHandler
被定义为 public delegate void EventHandler(object? sender, EventArgs e);
,因此要提供一个与该签名匹配的方法,该方法需要返回 void
。
async void
被认为是不好的,有各种各样的原因,很多文章建议尽可能避免使用它,而且也有 分析器 用于标记它们的使用。最大的问题之一是委托推断。考虑下面这个程序:
using System.Diagnostics;
Time(async () =>
{
Console.WriteLine("Enter");
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Exit");
});
static void Time(Action action)
{
Console.WriteLine("Timing...");
Stopwatch sw = Stopwatch.StartNew();
action();
Console.WriteLine($"...done timing: {sw.Elapsed}");
}
人们可能会期望这个程序输出至少 10 秒的耗费时间,但如果你运行它,你会发现输出如下:
Timing...
Enter
...done timing: 00:00:00.0037550
嗯?当然,基于我们在本文中讨论的一切,应该能够理解问题所在。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
相关联的 builder 是 AsyncVoidMethodBuilder
。还记得在异步方法的入口点中编译器生成的代码如何调用 builder 的静态 Create
方法以获取适当的 builder 实例吗?AsyncVoidMethodBuilder
利用这一点来创建 hook 和调用 OperationStarted
:
public static AsyncVoidMethodBuilder Create()
{
SynchronizationContext? sc = SynchronizationContext.Current;
sc?.OperationStarted();
return new AsyncVoidMethodBuilder() { _synchronizationContext = sc };
}
同样地,当使用 SetResult
或 SetException
标记 builder 为完成时,它调用相应的 OperationCompleted
方法。这就是像 xunit 这样的单元测试框架如何能够拥有 async void
测试方法并仍在并发测试执行中实现最大程度的并发性,例如在 xunit 的 AsyncTestSyncContext 中。
有了这些知识,我们现在可以重写我们的定时示例:
using System.Diagnostics;
Time(async () =>
{
Console.WriteLine("Enter");
await Task.Delay(TimeSpan.FromSeconds(10));
Console.WriteLine("Exit");
});
static void Time(Action action)
{
var oldCtx = SynchronizationContext.Current;
try
{
var newCtx = new CountdownContext();
SynchronizationContext.SetSynchronizationContext(newCtx);
Console.WriteLine("Timing...");
Stopwatch sw = Stopwatch.StartNew();
action();
newCtx.SignalAndWait();
Console.WriteLine($"...done timing: {sw.Elapsed}");
}
finally
{
SynchronizationContext.SetSynchronizationContext(oldCtx);
}
}
sealed class CountdownContext : SynchronizationContext
{
private readonly ManualResetEventSlim _mres = new ManualResetEventSlim(false);
private int _remaining = 1;
public override void OperationStarted() => Interlocked.Increment(ref _remaining);
public override void OperationCompleted()
{
if (Interlocked.Decrement(ref _remaining) == 0)
{
_mres.Set();
}
}
public void SignalAndWait()
{
OperationCompleted();
_mres.Wait();
}
}
在这里,我创建了一个跟踪待处理操作计数并支持阻塞等待它们全部完成的 SynchronizationContext
。当我运行它时,得到以下输出:
Timing...
Enter
Exit
...done timing: 00:00:10.0149074
大功告成!
State Machine Fields
到目前为止,我们已经看到了生成的入口点方法以及 MoveNext
实现中的所有内容。我们也瞥见了一些在状态机上定义的字段。让我们更仔细地看看这些字段。
对于之前展示的 CopyStreamToStream
方法:
public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
var buffer = new byte[0x1000];
int numRead;
while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
{
await destination.WriteAsync(buffer, 0, numRead);
}
}
这是我们最终得到的字段:
private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public Stream source;
public Stream destination;
private byte[] <buffer>5__2;
private TaskAwaiter <>u__1;
private TaskAwaiter<int> <>u__2;
...
}
它们各自是什么?
-
<>1__state
, “状态机”中的“状态”。它定义了状态机的当前状态,最重要的是下一次调用MoveNext
时应该执行什么。如果状态为 -2,则操作已经完成。如果状态为 -1,则我们要么将第一次调用MoveNext
,要么MoveNext
代码正在某个线程上运行。如果你在调试async
方法的处理过程,并且看到状态为 -1,那么就意味着某个线程正在实际执行方法中包含的代码。如果状态为 0 或更大,则该方法被暂停,并且状态的值告诉你它暂停在哪个await
上。虽然这不是一个硬性规则(某些代码模式可能会混淆编号),但通常分配的状态与源代码自上而下排序的await
的从 0 开始的编号相对应。因此,例如,如果async
方法的主体完全是:await A(); await B(); await C(); await D();
如果你发现状态值为 2,那几乎可以确定异步方法当前已暂停并且等待从
C()
返回的任务完成。 -
<>t__builder
. 这是状态机的建造者/构建者(builder),例如对于Task
是AsyncTaskMethodBuilder
,对于ValueTask<TResult>
是AsyncValueTaskMethodBuilder<TResult>
,对于async void
方法是AsyncVoidMethodBuilder
,或者无论是在异步返回类型上通过[AsyncMethodBuilder(...)]
声明的构建器,还是在异步方法本身上通过这样的属性进行重写。如前所述,builder 负责异步方法的生命周期,包括创建返回任务,最终完成该任务,并作为媒介进行暂停,异步方法中的代码会要求 builder 暂停,直到特定的 awaiter 完成。 -
source
/destination
. 这些是方法参数。你可以看出来,因为它们没有被名称混淆;编译器已经准确地按照指定的参数名称命名它们。如前所述,所有在方法体中使用的参数都需要存储到状态机中,以便MoveNext
方法可以访问它们。请注意,我说了“被使用的”。如果编译器发现一个参数在异步方法的主体中未使用,它可以优化掉存储字段的需求。例如,给定以下方法:public async Task M(int someArgument) { await Task.Yield(); }
编译器会将这些字段发送到状态机上:
private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; private YieldAwaitable.YieldAwaiter <>u__1; ... }
请注意,没有名为
someArgument
的东西。但是,如果我们更改异步方法实际上以任何方式使用参数:public async Task M(int someArgument) { Console.WriteLine(someArgument); await Task.Yield(); }
它就会出现了:
private 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
,它没有相应的字段在状态机中。为什么?因为它不是必需的。该局部变量作为ReadAsync
调用的结果进行设置,然后作为输入用于WriteAsync
调用。在这两个调用之间没有任何await
,也没有跨越numRead
需要存储值的await
。正如在同步方法中 JIT 编译器可以选择将这样的值完全存储在寄存器中,并且实际上从未把它溢出到堆栈中一样,C# 编译器可以避免将此局部变量提升为字段,因为它不需要在任何await
之间保留其值。通常,C# 编译器可以省略提升本地变量,如果它能证明它们的值不需要在await
之间保留。 -
<>u__1
and<>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,但那会导致额外的分配成本)。编译器将尽可能地重用字段。例如:public 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 字段:private struct <M>d__0 : IAsyncStateMachine { public int <>1__state; public AsyncTaskMethodBuilder <>t__builder; private TaskAwaiter<int> <>u__1; private TaskAwaiter<bool> <>u__2; ... }
那么如果我将我的示例改为:
public 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 与从Task.GetAwaiter()
返回的 awaiter 不同...这再次可以从编译器创建的 awaiter 字段中明显地看出:private 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; ... }
如果你想要优化与异步状态机相关联的大小,你可以考虑是否可以合并正在等待的内容的类型,从而合并这些 awaiter 字段。
你可能会看到在状态机上定义的其他类型的字段。值得注意的是,你可能会看到一些包含单词 “wrap” 的字段。考虑下面这个愚蠢的例子:
public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;
这会生成一个具有以下字段的状态机:
private struct <M>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<int> <>t__builder;
private TaskAwaiter<int> <>u__1;
...
}
到目前为止都没有什么特别的。现在颠倒添加的表达式的顺序:
public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);
现在你会得到以下字段:
private 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
字段来实现这一点。如果你发现自己过度优化异步方法实现以减少分配的内存量,可以查找这样的字段,看看是否可以通过对源代码进行小调整来避免溢出,从而避免需要这样的临时变量。
总结
我希望这篇文章可以帮助你深入了解在使用异步/等待时发生了什么,但幸运的是,通常你不需要知道或关心。许多不断发展的这些组件都汇聚在一起,从而创建出了一种高效的解决方案,用于编写可扩展的异步代码,而无需处理回调嵌套。然而,归根结底,这些组件实际上相对简单:一种通用的表示任何异步操作的对象、一种语言和编译器,能够将普通控制流重写为协程的状态机实现,以及将它们全部绑定在一起的模式。其他一切都是优化的加分项。