async 和 await - 简化内部原理






4.82/5 (32投票s)
async 和 await - 简化 - 内部原理
目录
- 动机
- 必备组件
- 引言
- async 和 await 是如何工作的
- 修改现有方法
- 实例创建
- 实例初始化
- 状态机执行
- 生成的状态机
- 生成状态机的结构
- MoveNext 方法的实现
- 结论
- 延伸阅读
- 参考文献
动机
互联网上有很多关于 async 和 await 内部工作原理的资源。我找到许多很好的网站,它们对 async 和 await 进行了广泛的解释,但我更感兴趣的是了解 Microsoft 究竟是如何实现 async 和 await 的。在阅读了许多文章后,仍然有一些未解答的问题,例如 async 和 await 如何使用 Task。我试图在这篇文章中解答所有这些问题。
必备组件
本文不涵盖 async 和 await 的用法,因为已经有很多很好的网站介绍了这方面内容。为了理解代码片段,需要提前了解 Task、Threading、ThreadPool 以及 C#(:))的知识。
引言
当我们使用 async 和 await 关键字时,编译器会在编译时生成一个称为状态机的结构。为什么生成的结构被称为状态机,而不是策略或代理?这个问题的答案在于状态机的定义。根据互联网上找到的状态机定义:
“一般而言,状态机是任何在给定时间存储某事物状态并能对输入进行操作以改变状态和/或在任何给定变化时引起操作或输出发生的设备。”
生成的结构是否像这样工作?答案是是的。Async/Await 关键字支持挂起和恢复模型,这一切都通过状态机来实现。为了构建状态机,编译器会在编译后的代码中做很多事情。我们将在接下来的部分进行探讨。
async 和 await 是如何工作的
理解 async 和 await 内部机制的最佳方法是通过示例。互联网上有很多关于 async 和 await 用法的示例(我不想浪费您的时间去解释这个 :)),但我将以一个简单的 async 和 await 示例。在下面的示例中,我们在 `asyncDemo` 类中定义了一个 `ReadData` 方法,它接受文件名作为参数,读取文件内容并在控制台显示。
class asyncDemo
{
public async void ReadData(string fileName)
{
UnicodeEncoding uniencoding = new UnicodeEncoding();
using(FileStream fs = new FileStream(fileName,FileMode.Open))
{
byte[] result = new byte[fs.Length];
await fs.ReadAsync(result, 0, (int) fs.Length);
Console.WriteLine(uniencoding.GetString(result));
}
}
}
是不是很简单?? 这是最简单的源代码,但内部发生了什么?将为此代码生成什么样的状态机,以及执行将如何进行?嗯,C# 编译器非常智能,当它看到 `async` 和 `await` 关键字时,会在编译时修改现有的源代码。这将对代码进行彻底的重构,并生成完全不同的 MSIL 代码。正如我一直说的,编译器会生成状态机,是的,C# 编译器会生成一个称为状态机的结构,并修改我们在源代码中定义的 `ReadData` 方法以使用该状态机。我将整篇文章分为两个部分:第一部分介绍 `ReadData` 方法是如何修改以使用状态机的,当然,第二部分是关于状态机本身的。
- 修改现有方法(以上示例中的 ReadData)
- 生成的状态机
修改现有方法
好吧,当我们使用 ILSpy 或 Reflector 对编译后的源代码进行逆向工程时,我们可以看到 C# 编译器将整个程序逻辑移到了状态机中,而 `ReadData` 方法仅用于创建、初始化和启动状态机。首先看看 ILspy 生成的代码。
public void ReadData(string fileName)
{
asyncDemo.<ReadData>d__0 <ReadData>d__;
<ReadData>d__.<>4__this = this;
<ReadData>d__.fileName = fileName;
<ReadData>d__.<>t__builder = AsyncVoidMethodBuilder.Create();
<ReadData>d__.<>1__state = -1;
AsyncVoidMethodBuilder <>t__builder = <ReadData>d__.<>t__builder;
<>t__builder.Start<asyncDemo.<ReadData>d__0>(ref <ReadData>d__);
}
这是 `ReadData` 方法的逆向工程代码。 如果我们泛化上述代码(适用于任何定义了 async 或 await 方法的方法),会有一些必需的字段,而有些是可选的。可选字段可能因输入变量、局部变量以及方法是实例方法还是静态方法而异。 上述代码片段可以分为三个部分:
- 状态机实例的创建(`asyncDemo.<ReadData>d__0`)
- 实例的初始化(`<ReadData>d__`)
- 通过 `Start` 方法开始执行状态机
步骤 1:实例创建
C# 编译器生成的结构(实际上包含了实际的程序逻辑)称为状态机(在我们的例子中是 `asyncDemo.<ReadData>d__0`)。状态机生成了许多字段和方法,我们将在“生成的状态机”部分进行介绍。现在为了理解,我们只需在 `ReadData` 方法中创建状态机结构的实例。
步骤 2:实例初始化
一旦状态机实例被创建,就会进行实例的初始化。在上面提到的代码中,有很多字段被初始化,但状态机结构中比较有趣的字段是 `state` 和 `builder`。状态机结构中的其余字段是可选的,例如,如果**没有输入**变量,上面的代码片段中的 `filename` 字段将不存在;如果 `Read` 方法定义为**静态**方法,则 `this` 变量将不可用。
现在的问题是,状态机中的 `state` 和 `builder` 字段在创建时是否总是具有相同的值?答案是否;`state`在创建时总是用-1初始化,但 `builder` 的值可能因 **ReadData** 方法的返回类型而异。在本例中,ReadData 方法返回 void,因此我们创建的是 `AsyncVoidMethodBuilder.Create()` 的实例。如果 ReadData 方法返回 Task<int> 值怎么办?答案是我们将会创建 `AsyncTaskMethodBuilder<int>.Create()` 的实例。看看修改后的代码:
public async Task<int> ReadData(string fileName)
{
int ts = 0;
UnicodeEncoding uniencoding = new UnicodeEncoding();
using (FileStream fs = new FileStream(fileName, FileMode.Open))
{
byte[] result = new byte[fs.Length];
ts = await fs.ReadAsync(result, 0, (int) fs.Length);
Console.WriteLine(uniencoding.GetString(result));
}
return ts;
}
在上面提到的代码片段中,我们修改了方法以返回 Task<int> 值。生成的编译代码的 `builder` 字段发生了显著变化。我们调用的是 `AsyncTaskMethodBuilder<int>.Create()`,而不是 `AsyncVoidMethodBuilder.Create()`。
public Task<int> ReadData(string fileName)
{
asyncDemo.<ReadData>d__0 <ReadData>d__;
<ReadData>d__.<>4__this = this;
<ReadData>d__.fileName = fileName;
<ReadData>d__.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
<ReadData>d__.<>1__state = -1;
AsyncTaskMethodBuilder<int> <>t__builder = <ReadData>d__.<>t__builder;
<>t__builder.Start<asyncDemo.<ReadData>d__0>(ref <ReadData>d__);
return <ReadData>d__.<>t__builder.Task;
}
AsyncVoidMethodBuilder 类的 Create 方法做了什么?它只是创建 builder 对象实例并返回吗?理论上是这样,但它使用了另一个重定向。为了更好地理解,让我们看看它的类图实现。
我们先来理解 `AsyncVoidMethodBuilder` 类的 `Create` 方法。`Create` 方法返回其自身类(即 `AsyncVoidMethodBuilder`)的实例,在其中实现。为什么我们不能直接创建实例,而不是调用 Create 方法?原因是 `create` 方法允许我们在 `AsyncVoidMethodBuilder` 构造函数中指定同步上下文标志。关于同步上下文已经有很多文章,请参考参考文献部分的文章以更好地理解同步上下文。这是 `AsyncVoidMethodBuilder` 类的 `Create` 方法的生成代码:
public static AsyncVoidMethodBuilder Create()
{
return new AsyncVoidMethodBuilder(SynchronizationContext.CurrentNoFlow);
}
正如我之前提到的,`AsyncVoidMethodBuilder` 使用另一个重定向,该重定向实际上负责启动状态机的执行,即 `AsyncMethodBuilderCore` 结构。`AsyncVoidMethodBuilder` 的构造函数也初始化了 `ASyncMethodBuilderCore` 结构。
步骤 3:状态机执行
一旦状态机的初始化完成,执行就通过 `Start` 方法开始。
<>t__builder.Start<asyncDemo.<ReadData>d__0>(ref <ReadData>d__);
如果我们将上面的代码泛化,它会是这样的:
Builder.<Generated_StateMachine Structure>(stateMachine Instance);
`Start` 方法定义在 `Builder`(`AsyncVoidMethodBuilder`)结构中。`builder`(`AsyncVoidMethodBuilder`)结构中的 `Start` 方法又使用 `AsyncMethodBuilderCore` 结构来启动生成状态机的执行。在调用 `AsyncMethodBuilderCore` 结构 `Start` 方法时,我们会传递状态机实例。利用状态机实例,`AsyncMethodBuilderCore` 结构会调用状态机中定义的 `MoveNext` 方法。因此,状态机的起始点就是 `MoveNext` 方法。此图解释了 `Start` 方法如何启动执行。
生成的状态机
好了,通过调用状态机中定义的 **MoveNext** 方法来调用状态机。让我们先了解一下编译时为状态机生成的代码。我们首先需要了解状态机中定义了哪些方法。
但目前我们知道,状态机的执行起点就是 **MoveNext** 方法。大部分代码、实际程序逻辑以及挂起和恢复代码都包含在状态机的 `MoveNext` 方法中。在我们深入研究状态机执行之前,我们需要了解生成状态机的结构。
生成状态机的结构
这是 C# 编译器生成的初始化状态机的逆向工程代码。
private struct <ReadData>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncVoidMethodBuilder <>t__builder;
public asyncDemo <>4__this;
public string fileName;
public UnicodeEncoding <uniencoding>5__1;
public FileStream <fs>5__2;
public byte[] <result>5__3;
private TaskAwaiter<int> <>u__$awaiter4;
private object <>t__stack;
在状态机结构中,有一些可选字段和一些必填字段。我们已经在“实例初始化”部分讨论了两个必填字段:`state` 和 `builder`。State 局部变量(在状态机结构中)用于保存执行所基于的状态。最初,state 值被分配为-1。其余字段 `this`、`filename`、`Unicode`、`filestream` 和 `byte` 是可选的,这些值根据输入和局部变量而变化。如果没有局部变量和输入变量,这些字段将不存在。最后但同样重要的是,状态机结构中的 `awaiter` 变量用于实现挂起和恢复模型。我们将在接下来的部分讨论 awaiter 变量。
接下来是状态机实现的所有方法?或者它们也会根据某些条件而变化?状态机没有那么大的自由度 :) 它总是实现两个方法:**MoveNext** 和 **SetStateMachine**,而不考虑局部变量或输入变量的数量。这两个方法是 * **IAsyncStateMachine** * 接口强制实现的,该接口由状态机结构实现。
MoveNext 方法的实现
MoveNext 方法是状态机的核心,包含启动异步操作的实际程序逻辑;MoveNext 方法还负责根据状态挂起和恢复状态机。一旦我们查看了 MoveNext 方法的逆向工程代码,就更容易理解 MoveNext 方法是如何工作的。
void IAsyncStateMachine.MoveNext()
{
try
{
bool flag = true;
int num = this.<>1__state;
if (num != -3)
{
if (num != 0)
{
this.<uniencoding>5__1 = new UnicodeEncoding();
this.<fs>5__2 = new FileStream(this.fileName, FileMode.Open);
}
try
{
num = this.<>1__state;
TaskAwaiter<int> taskAwaiter;
if (num != 0)
{
this.<result>5__3 = new byte[this.<fs>5__2.Length];
taskAwaiter = this.<fs>5__2.ReadAsync(this.<result>5__3, 0, (int)this.<fs>5__2.Length).GetAwaiter();
if (!taskAwaiter.IsCompleted)
{
this.<>1__state = 0;
this.<>u__$awaiter4 = taskAwaiter;
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, asyncDemo.<ReadData>d__0>(ref taskAwaiter, ref this);
flag = false;
return;
}
}
else
{
taskAwaiter = this.<>u__$awaiter4;
this.<>u__$awaiter4 = default(TaskAwaiter<int>);
this.<>1__state = -1;
}
taskAwaiter.GetResult();
taskAwaiter = default(TaskAwaiter<int>);
Console.WriteLine(this.<uniencoding>5__1.GetString(this.<result>5__3));
}
finally
{
if (flag && this.<fs>5__2 != null)
{
((IDisposable)this.<fs>5__2).Dispose();
}
}
}
}
catch (Exception exception)
{
this.<>1__state = -2;
this.<>t__builder.SetException(exception);
return;
}
this.<>1__state = -2;
this.<>t__builder.SetResult();
}
如果我们看到上面生成的代码,有两行代码值得理解。这两行代码主要负责启动异步操作以及挂起和恢复状态机。
第一行是:
taskAwaiter = this.<fs>5__2.ReadAsync(this.<result>5__3, 0, (int)this.<fs>5__2.Length).GetAwaiter();
第二行是:
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, asyncDemo.<ReadData>d__0>(ref taskAwaiter, ref this);
如果我用简单的术语重组第一行,整个代码可以重写如下:
taskawaiter = this.filestream.ReadAsync(result,0,Length).GetAwaiter().
好了,在上面一行中,我们调用了两个方法:**ReadAsync**,并在 ReadAsync 方法的返回类型上调用了 **GetAwaiter** 方法。所以,故事的重点是,我们首先需要理解 ReadAsync 方法的返回类型?让我们看看 FileStream 类中 ReadAsync 方法的类图实现:
正如我们在类图中看到的,**ReadAsync** 方法在 Stream 类中定义为**虚拟方法**,并在 FileStream 类中被重载。在 FileStream 类中对 ReadAsync 方法的重载实现中,ReadAsync 方法创建并返回 **FileStreamReadWriteTask** 的实例。最终,FileStreamReadWriteTask 类派生自 Task 类,因此默认继承 Task 类的所有属性。这里需要注意的重要一点是 `ReadAsync` 方法的返回类型,它就是 `FileStreamReadWriteTask` 类的类型(派生自 Task 类)。现在的问题是,FileStreamReadWriteTask 类的实例如何与异步操作关联?答案在于 FileStreamReadWriteTask 类中定义的 `_asyncResult` 字段。`__asyncResult` 属性的重要性在于,它保存了 `BeginReadAsync` 方法的返回类型,该类型就是 `FileStreamAsyncResult`。BeginReadAsync 方法有什么用?BeginReadAsync 负责启动异步操作。Task 如何知道异步操作何时完成?答案在于我们调用 BeginReadAsync 方法的参数之一:asyncCallback。等一下,asyncCallback 是从哪里来的,当我们没有传递任何回调方法时?这个回调方法是由框架本身创建的,其委托类型为 AsyncCallback。一旦异步操作完成,就会调用此方法。让我们看看框架定义的 BeginReadAsync 方法的声明:
fileStreamReadWriteTask._asyncResult = this.BeginReadAsync(buffer, offset, count, asyncCallback, fileStreamReadWriteTask);
所以这里的整个哲学是,async/awaits 使用 Task,Task 使用 ThreadPoolThread 或专用线程。有趣!!我们如何从文件流中获取结果?这是将回调方法(在本例中是 asyncCallback)向下传递到驱动程序级别(对于 IO 操作)的传统方法,一旦执行完成,就会调用回调方法。问题来了,如果我没有发送回调方法怎么办?ReadAsync 方法会为我们创建一个。简而言之,我们只得到一个包装了异步回调方法的 Task 对象。
taskawaiter = this.filestream.ReadAsync(result,0,Length).GetAwaiter().
正如我们开始讨论时,我们调用了 `ReadAsync` 方法返回类型的 `GetAwaiter` 方法。`ReadAsync` 方法的返回类型就是 Task(或其派生类)。事实上,我们调用了 Task 类中定义的 `GetAwaiter` 方法。根据 MSDN 上 GetAwaiter 方法的定义:
“GetAwaiter 方法获取用于等待任务的 awaiter。”
`GetAwaiter` 方法的定义足以提供调用此方法的必要性。如果我进一步阐述,`GetAwaiter` 返回 `TaskAwaiter` 类的实例(awaiter),该实例允许等待 `ReadAsync` 方法的完成。然而,`awaiter` 方法还有更多用途,我们将在下一节中探讨,现在,让我们先理解 `TaskAwaiter` 类的结构(`ReadAsync` 方法返回的实例就是这个类)。
第二个问题是:
this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, asyncDemo.<ReadData>d__0>(ref taskAwaiter, ref this);
上面的行很繁琐,难以理解。如果我用简单的术语重写上面的行,它会是这样的:
Builder.AwaitUnsafeOnCompleted(ref taskAwaiter, ref statemachine);
基本上,我们正在调用 builder 实例中定义的 `AwaitUnsafeOnCompleted` 方法,传递 `taskawaiter` 实例(来自 `this.filestream.ReadAsync(result, 0, Length).GetAwaiter()` 方法)和状态机。builder 结构中的 `AwaitUnsafeOnCompleted` 方法有什么用?在 `AwaiterUnsafeOnCompeleted` 方法中,我们调用 awaiter 对象中定义的 `UnsafeOnCompleted` 方法,传递回调方法(在状态机中定义)。将回调方法(在状态机中定义)传递给 `UnsafeOnCompleted`(在 `TaskAwaiter` 对象中定义)的原因是,在异步操作完成后恢复状态机。换句话说,awaiter 正在等待异步操作完成,一旦异步操作完成,状态机中定义的回调方法将被调用以恢复状态机并检索异步操作的结果。让我们看看 `AwaiUnsafeOnCompleted` 的实现。
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine
{
try
{
Action completionAction = this.m_coreState.GetCompletionAction<AsyncVoidMethodBuilder, TStateMachine>(ref this, ref stateMachine);
awaiter.UnsafeOnCompleted(completionAction);
}
catch (Exception exception)
{
AsyncMethodBuilderCore.ThrowAsync(exception, null);
}
}
根据状态,用简单的术语总结整个执行过程:
- `ReadAsync` 方法将启动异步操作并返回 Task 对象。
- 我们调用 Task 对象(来自步骤 1)中定义的 GetAwaiter 方法,该方法会等待直到异步操作完成。
- 稍后,我们调用 builder 结构中定义的 `AwaitUnsafeOnCompleted`,传递状态机和 awaiter 对象(在步骤 2 中返回)。
- 在 `AwaiterUnsafeOnCompleted` 方法中,将调用 awaiter 对象中定义的 `UnsafeOnCompleted` 方法,传递状态机中定义的回调方法。
- 在步骤 4 中,调用 awaiter 结构中的 `UnsafeOnCompleted` 方法,并传递回调方法。原因是它允许在步骤 1 中的异步操作完成后调用回调方法。
从上述讨论中另一个非常重要的问题是,为什么我们使用 awaiter 对象(从 GetAwaiter 方法返回)中定义的 UnSafeOnCompleted 方法来恢复状态机?为什么不使用 Task 对象的 ContinueWith 方法的变体?答案在于同步上下文。UnSafeOnCompleted 使用任务的当前执行上下文。
结论
在这篇文章中,我使用了 async 和 await 的简单示例,其中只有一个 async 和 await 方法。可能存在复杂的场景,即在单个 async 方法中调用多个 await 方法。在这种情况下,逆向工程代码可能包含对 ReadAsync/WriteAsync 和 AwaitUnsafeOnCompleted 方法的多个调用,而不是 if 和 else,我们可能会有 switch/case 或 goto 语句。但是 GetAwaiter、ReadSync、WriteSync 和 AwaitUnsafeOnCompleted 的处理仍然相同。核心逻辑和执行保持不变,根据状态变化,将调用 ReadAsync/WriteAsync、GetAwaiter 或 AwaitUnsafeOnCompleted 方法。
延伸阅读
有很多很好的文章解释了 Task 如何将异步请求分派到硬件,同步上下文的重要性。请参考参考文献部分以了解更多信息。
参考文献
- http://msdn.microsoft.com/en-us/library/system.threading.thread%28v=vs.110%29.aspx
- http://msdn.microsoft.com/en-us/library/ms973903.aspx
- https://codeproject.org.cn/Articles/31971/Understanding-SynchronizationContext-Part-I
- https://codeproject.org.cn/Articles/89284/Parallel-programming-in-NET-Internals
- http://whatis.techtarget.com/definition/state-machine