C# 编译器借助 .NET 框架基类库实现了异步功能。运行时本身无需任何更改即可支持异步。这意味着 await 是被转换成了某种使用早期版本的 C# 我们自己就可以编写的东西。我们可以使用 .NET Reflector 这样的反编译器来查看生成的代码。
除了单纯的感兴趣之外,理解这些生成的代码还有助于调试、性能分析以及对异步代码进行其他诊断。
存根方法(Stub Method)
异步方法被替换为存根方法。调用异步方法时,首先运行的是存根方法。让我们以这个简单的异步方法为例:
cs
public async Task<int> AlexsMethod()
{
int foo = 3;
await Task.Delay(500);
return foo;
}
编译器生成的存根方法如下所示:
cs
public Task<int> AlexsMethod()
{
<AlexsMethod>d__0 stateMachine = new <AlexsMethod>d__0();
stateMachine.<>4__this = this;
stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<<AlexsMethod>d__0>(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
为了使其更容易理解,我对变量名称进行了一些修改。
正如我们在第四章 "Async、方法签名和接口"中看到的,async关键字对于该方法如何被外部使用没有影响。这一点很明显------你可以看到存根方法的签名始终与原始异步方法相同,只是没有了 async 关键字。
你会注意到,存根方法中并没有我写的代码(方法AlexMethod的具体实现)。存根方法的大部分工作是初始化了一个名为 <AlexsMethod>d_0 的结构体的变量。该结构体是一个状态机,所有繁重的工作都在这里完成。存根方法调用 Start 方法,然后返回一个 Task。为了理解里面发生了什么,我们需要查看状态机结构体本身的实现细节。
状态机结构体
编译器会生成一个结构体来充当状态机,并包含我原始方法的所有代码。这样做是为了创建一个能够表示方法状态的对象,以便在执行到 await 时存储该状态。请记住,当我们到达 await 时,方法中所有关于当前位置的信息都会被记录下来,以便在方法恢复时使用。
在方法暂停时(译者注:到达 await 时),虽然编译器可以遍历并存储方法中的每个局部变量,但这会生成大量代码。更好的做法是将方法的所有局部变量更改为某个类型的成员变量,这样我们只需存储该类型的实例,它的所有局部变量也会自动保留,这正是这个结构体出现的原因。
小贴士:
出于性能考虑,状态机被定义为一个结构体而不是一个类。这意味着当异步方法以同步的方式完成时,它不需要被分配到堆上。不幸的是,作为一个结构体,它使我们更难进行推理。
状态机是一个包含了异步方法的类型的内部结构体。所以很容易确定它是从哪个方法生成的,但这样做的主要目的是为了能够访问该类型的私有成员。
让我们看一下根据示例生成的状态机结构体 <AlexsMethod>d_0。现在我们来看看它的成员变量:
cs
public int <>1__state;
public int <foo>5__1;
public AlexsClass <>4__this;
public AsyncTaskMethodBuilder<int> <>t__builder;
private object <>t__stack;
private TaskAwaiter <>u__$awaiter2;
小贴士:
所有变量名中都有尖括号。这仅仅是为了标记它们是编译器生成的。当编译器生成的代码必须与用户代码共存时,用尖括号区分很重要,因为在有效的 C# 中,变量不能使用尖括号。但在这里,这并不是必要的。
首先,状态变量 <>1__state 用于存储我们到达的 await 编号。在到达任何 await 语句之前,它的值为 -1。原始方法中的每个 await 语句都有编号,当方法暂停时,要恢复的 await 语句编号会被赋给这个状态变量。(译者:这个名字起的不好,应该叫 __indexOfAwaitToResume 才好)
接下来是 <foo>5__1,它存储了原始变量 foo 的值。我们很快就会看到,所有对 foo 的访问都会被替换为对该成员变量的访问。
然后是 <>4__this。它仅出现在非静态异步方法的状态机中,并包含异步方法所属的对象。在某种程度上,可以将其视为方法中的另一个局部变量,当您访问同一对象的其他成员时,它恰好会被隐式使用。异步转换之后,它需要被显式存储和使用,因为我的代码已从其它的原始对象移动到了状态机结构。
AsyncTaskMethodBuilder 是一个辅助类型,它包含了所有这种状态机共享的逻辑。它负责创建 Task 并由存根方法返回。实际上,它的作用与 TaskCompletionSource 非常相似,因为它会创建一个虚拟 Task (puppet Task),稍后Task才会完成。与 TaskCompletionSource 的区别在于,它针对异步方法进行了优化,并使用了一些技巧,例如使用结构体而不是类来提升性能。
小贴士:
返回 void 的异步方法使用 AsyncVoidMethodBuilder 作为其辅助类型,而返回 Task<T> 的异步方法使用泛型版本的 AsyncTaskMethodBuilder<T>。
栈变量 <>t__stack 用于作为较大表达式一部分的 await。.NET 中间语言 (IL) 是一种基于栈的语言,因此复杂的表达式由一些小指令构成,这些小指令会操作一堆数值。当 await 位于这种复杂表达式的中间时,栈里面当前的值将存入这个stack变量中,如果存在多个值,则将其放入 Tuple 中。
最后,TaskAwaiter 变量被用来临时存储对象,该对象帮助 await 关键字注册任务完成时的通知。
MoveNext 方法
状态机有一个名为 MoveNext 的方法,所有的原始代码最终都会执行到该方法中。MoveNext 在方法首次运行时以及从 await 恢复时都会被调用。即使对于最简单的异步方法,它的实现看起来也极其复杂,因此我将尝试分步骤解释这一转换过程。我会跳过一些不太相关的细节,因此从描述上会有很多地方不完全准确。
小贴士:
该方法最初之所以被称为 MoveNext,是因为它与早期 C# 版本中的迭代器块生成的 MoveNext 方法类似。这些方法使用 yield return 关键字在单个方法中实现 IEnumerable。那里使用的状态机系统与异步状态机类似,但更简单一些。
你的代码
第一步是将你的代码复制到 MoveNext 方法中。请记住,对任何变量的访问都需要指向状态机里的新成员变量。在 await 所在的位置,我先留出一个空白,稍后再进行填充。
cs
<foo>5__1 = 3;
Task t = Task.Delay(500);
Logic to await t goes here
return <foo>5__1;
将"返回"转换为"完成"
原始代码中的每个 return 语句都需要进行转换,转换后的代码要能够完成存根方法返回的 Task。实际上,MoveNext 返回 void,所以语句 return foo; 甚至是无效的。
cs
<>t_builder.SetResult(<foo>5__1);
return;
当然,在 Task 完成后,我们使用了 return; 退出 MoveNext 方法。
跳转到方法中的正确位置
因为 MoveNext 会在每次 await 后恢复执行时被调用,也会在方法首次启动时被调用,所以我们需要首先跳转到方法中的正确位置才能开始。这使用了类似于 switch 语句生成的 IL 代码达成,就像我们在切换状态一样。
cs
switch (<>1__state)
{
case -1: // Right at the start of the method
<foo>5__1 = 3;
Task t = Task.Delay(500);
Logic to await t goes here
case 0: // There's only one await, so it is number 0
<>t__builder.SetResult(<foo>5__1);
return;
}
--- <未完待续> ---