接《async/await 在 C# 语言中是如何工作的?(上)》,明天咱们持续介绍 C# 迭代器和 async/await under the covers。
C# 迭代器
这个解决方案的伏笔实际上是在 Task 呈现的几年前,即 C# 2.0,过后它减少了对迭代器的反对。
迭代器容许你编写一个办法,而后由编译器用来实现 IEnumerable<T> 和 / 或 IEnumerator<T>。例如,如果我想创立一个产生斐波那契数列的枚举数,我能够这样写:
public static IEnumerable<int> Fib(){
int prev = 0, next = 1;
yield return prev;
yield return next;
while (true)
{
int sum = prev + next;
yield return sum;
prev = next;
next = sum;
}}
而后我能够用 foreach 枚举它:
foreach (int i in Fib()){if (i > 100) break;
Console.Write($"{i}");}
我能够通过像 System.Linq.Enumerable 上的组合器将它与其余 IEnumerable<T> 进行组合:
foreach (int i in Fib().Take(12)){Console.Write($"{i}");}
或者我能够间接通过 IEnumerator<T> 来手动枚举它:
using IEnumerator<int> e = Fib().GetEnumerator();while (e.MoveNext()){
int i = e.Current;
if (i > 100) break;
Console.Write($"{i}");}
以上所有的后果是这样的输入:0 1 1 2 3 5 8 13 21 34 55 89
真正乏味的是,为了实现上述指标,咱们须要可能屡次进入和退出 Fib 办法。咱们调用 MoveNext,它进入办法,而后该办法执行,直到它遇到 yield return,此时对 MoveNext 的调用须要返回 true,随后对 Current 的拜访须要返回 yield value。而后咱们再次调用 MoveNext,咱们须要可能在 Fib 中从咱们上次进行的中央开始,并且放弃上次调用的所有状态不变。迭代器实际上是由 C# 语言 / 编译器提供的协程,编译器将 Fib 迭代器扩大为一个成熟的状态机。
所有对于 Fib 的逻辑当初都在 MoveNext 办法中,然而作为跳转表的一部分,它容许实现分支到它上次来到的地位,这在枚举器类型上生成的状态字段中被跟踪。而我写的局部变量,如 prev、next 和 sum,曾经被 “ 晋升 “ 为枚举器上的字段,这样它们就能够在调用 MoveNext 时继续存在。
在我之前的例子中,我展现的最初一种枚举模式波及手动应用 IEnumerator<T>。在那个层面上,咱们手动调用 MoveNext(),决定何时是从新进入循环程序的适当机会。然而,如果不这样调用它,而是让 MoveNext 的下一次调用理论成为异步操作实现时执行的连续工作的一部分呢?如果我能够 yield 返回一些代表异步操作的货色,并让耗费代码将 continuation 连贯到该 yield 对象,而后在该 continuation 执行 MoveNext 时会怎么样?应用这种办法,我能够编写一个辅助办法:
static Task IterateAsync(IEnumerable<Task> tasks){var tcs = new TaskCompletionSource();
IEnumerator<Task> e = tasks.GetEnumerator();
void Process()
{
try
{if (e.MoveNext())
{e.Current.ContinueWith(t => Process());
return;
}
}
catch (Exception e)
{tcs.SetException(e);
return;
}
tcs.SetResult();};
Process();
return tcs.Task;}
当初变得乏味了。咱们失去了一个可迭代的工作列表。每次咱们 MoveNext 到下一个 Task 并取得一个时,咱们将该工作的 continuation 连接起来;当这个 Task 实现时,它只会回过头来调用执行 MoveNext、获取下一个 Task 的雷同逻辑,以此类推。这是建设在将 Task 作为任何异步操作的繁多示意的思维之上的,所以咱们输出的枚举表能够是一个任何异步操作的序列。这样的序列是从哪里来的呢? 当然是通过迭代器。还记得咱们之前的 CopyStreamToStream 例子吗?考虑一下这个:
static Task CopyStreamToStreamAsync(Stream source, Stream destination){return IterateAsync(Impl(source, destination));
static IEnumerable<Task> Impl(Stream source, Stream destination)
{var buffer = new byte[0x1000];
while (true)
{Task<int> read = source.ReadAsync(buffer, 0, buffer.Length);
yield return read;
int numRead = read.Result;
if (numRead <= 0)
{break;}
Task write = destination.WriteAsync(buffer, 0, numRead);
yield return write;
write.Wait();}
}}
咱们正在调用那个 IterateAsync 助手,而咱们提供给它的枚举表是由一个解决所有控制流的迭代器产生的。它调用 Stream.ReadAsync 而后 yield 返回 Task;yield task 在调用 MoveNext 之后会被传递给 IterateAsync,而 IterateAsync 会将一个 continuation 挂接到那个 task 上,当它实现时,它会回调 MoveNext 并在 yield 之后回到这个迭代器。此时,Impl 逻辑取得办法的后果,调用 WriteAsync,并再次生成它生成的 Task。以此类推。
这就是 C# 和 .NET 中 async/await 的开始。在 C# 编译器中反对迭代器和 async/await 的逻辑中,大概有 95% 左右的逻辑是共享的。不同的语法,不同的类型,但实质上是雷同的转换。
事实上,在 async/await 呈现之前,一些开发人员就以这种形式应用迭代器进行异步编程。在实验性的 Axum 编程语言中也有相似的转换原型,这是 C# 反对异步的要害灵感起源。Axum 提供了一个能够放在办法上的 async 关键字,就像 C# 中的 async 一样。
Task 还不广泛,所以在异步办法中,Axum 编译器启发式地将同步办法调用与 APM 对应的办法相匹配,例如,如果它看到你调用 stream.Read,它会找到并利用相应的 stream.BeginRead 和 stream.EndRead 办法,合成适当的委托传递给 Begin 办法,同时还为定义为可组合的 async 办法生成残缺的 APM 实现。它甚至还集成了 SynchronizationContext!尽管 Axum 最终被搁置,但它为 C# 中的 async/await 提供了一个很棒的原型。
async/await under the covers
当初咱们晓得了咱们是如何做到这一点的,让咱们深入研究它实际上是如何工作的。作为参考,上面是咱们的同步办法示例:
public void CopyStreamToStream(Stream source, Stream destination){var buffer = new byte[0x1000];
int numRead;
while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
{destination.Write(buffer, 0, numRead);
}}
上面是 async/await 对应的办法:
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);
}}
签名从 void 变成了 async Task,咱们别离调用了 ReadAsync 和 WriteAsync,而不是 Read 和 Write,这两个操作都带 await 前缀。编译器和外围库接管了其余部分,从根本上扭转了代码理论执行的形式。让咱们深刻理解一下是如何做到的。
▌编译器转换
咱们曾经看到,和迭代器一样,编译器基于状态机重写了 async 办法。咱们依然有一个与开发人员写的签名雷同的办法(public Task CopyStreamToStreamAsync(Stream source, Stream destination)),但该办法的主体齐全不同:
[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))]public Task CopyStreamToStreamAsync(Stream source, Stream destination){
<CopyStreamToStreamAsync>d__0 stateMachine = default;
stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
stateMachine.source = source;
stateMachine.destination = destination;
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;}
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;
...}
留神,与开发人员所写的签名的惟一区别是短少 async 关键字自身。Async 实际上不是办法签名的一部分;就像 unsafe 一样,当你把它放在办法签名中,你是在表达方法的实现细节,而不是作为契约的一部分理论公开出来的货色。应用 async/await 实现 task -return 办法是实现细节。
编译器曾经生成了一个名为 <CopyStreamToStreamAsync>d__0 的构造体,并且它在堆栈上对该构造体的实例进行了零初始化。重要的是,如果异步办法同步实现,该状态机将永远不会来到堆栈。这意味着没有与状态机相干的调配,除非该办法须要异步实现,也就是说它须要期待一些尚未实现的工作。稍后会有更多对于这方面的内容。
该构造体是办法的状态机,不仅蕴含开发人员编写的所有转换逻辑,还蕴含用于跟踪该办法中以后地位的字段,以及编译器从办法中提取的所有“本地”状态,这些状态须要在 MoveNext 调用之间生存。它在逻辑上等价于迭代器中的 IEnumerable<T>/IEnumerator<T> 实现。(请留神,我展现的代码来自公布版本;在调试构建中,C# 编译器将理论生成这些状态机类型作为类,因为这样做能够帮忙某些调试工作)。
在初始化状态机之后,咱们看到对 AsyncTaskMethodBuilder.Create() 的调用。尽管咱们目前关注的是 Tasks,但 C# 语言和编译器容许从异步办法返回任意类型(“task-like”类型),例如,我能够编写一个办法 public async MyTask CopyStreamToStreamAsync,只有咱们以适当的形式扩大咱们后面定义的 MyTask,它就能顺利编译。这种适当性包含申明一个相干的“builder”类型,并通过 AsyncMethodBuilder 属性将其与该类型关联起来:
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]public class MyTask{...}
public struct MyTaskMethodBuilder{public static MyTaskMethodBuilder Create() {...}
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine {...}
public void SetStateMachine(IAsyncStateMachine stateMachine) {...}
public void SetResult() { ...}
public void SetException(Exception exception) {...}
public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine {...}
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine {...}
public MyTask Task {get { ...} }}
在这种状况下,这样的“builder”晓得如何创立该类型的实例 (Task 属性),如何胜利实现并在适当的状况下有后果(SetResult) 或有异样(SetException),以及如何解决连贯期待尚未实现的事务的连续(AwaitOnCompleted/AwaitUnsafeOnCompleted)。在 System.Threading.Tasks.Task 的状况下,它默认与 AsyncTaskMethodBuilder 相关联。通常状况下,这种关联是通过利用在类型上的 [AsyncMethodBuilder(…)] 属性提供的,但在 C# 中,Task 是已知的,因而实际上没有该属性。因而,编译器曾经让构建器应用这个异步办法,并应用模式中的 Create 办法构建它的实例。请留神,与状态机一样,AsyncTaskMethodBuilder 也是一个构造体,因而这里也没有内存调配。
而后用这个入口点办法的参数填充状态机。这些参数须要可能被挪动到 MoveNext 中的办法体拜访,因而这些参数须要存储在状态机中,以便后续调用 MoveNext 时代码能够援用它们。该状态机也被初始化为初始 - 1 状态。如果 MoveNext 被调用且状态为 -1,那么逻辑上咱们将从办法的开始处开始。
当初是最不显眼但最重要的一行:调用构建器的 Start 办法。这是模式的另一部分,必须在 async 办法的返回地位所应用的类型上公开,它用于在状态机上执行初始的 MoveNext。构建器的 Start 办法实际上是这样的:
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{stateMachine.MoveNext();}
例如,调用 stateMachine.<>t__builder.Start(ref stateMachine); 实际上只是调用 stateMachine.MoveNext()。在这种状况下,为什么编译器不间接收回这个信号呢?为什么还要有 Start 呢?答案是,Start 的内容比我所说的要多一点。但为此,咱们须要简略地理解一下 ExecutionContext。
❖ ExecutionContext
咱们都相熟在办法之间传递状态。调用一个办法,如果该办法指定了形参,就应用实参调用该办法,以便将该数据传递给被调用方。这是显式传递数据。但还有其余更荫蔽的办法。例如,办法能够是无参数的,但能够指定在调用办法之前填充某些特定的动态字段,而后从那里获取状态。这个办法的签名中没有任何货色表明它接管参数,因为它的确没有:只是调用者和被调用者之间有一个隐含的约定,即调用者可能填充某些内存地位,而被调用者可能读取这些内存地位。被调用者和调用者甚至可能没有意识到它的产生,如果他们是中介,办法 A 可能填充动态信息,而后调用 B, B 调用 C, C 调用 D,最终调用 E,读取这些动态信息的值。这通常被称为“环境”数据:它不是通过参数传递给你的,而是挂在那里,如果需要的话,你能够应用。
咱们能够更进一步,应用线程部分状态。线程部分状态,在 .NET 中是通过属性为 [ThreadStatic] 的动态字段或通过 ThreadLocal<T> 类型实现的,能够以雷同的形式应用,但数据仅限于以后执行的线程,每个线程都可能领有这些字段的本人的隔离正本。这样,您就能够填充线程动态,进行办法调用,而后在办法实现后将更改还原到线程动态,从而启用这种隐式传递数据的齐全隔离模式。
如果咱们进行异步办法调用,而异步办法中的逻辑想要拜访环境数据,它会怎么做? 如果数据存储在惯例动态中,异步办法将可能拜访它,但一次只能有一个这样的办法在运行,因为多个调用者在写入这些共享动态字段时可能会笼罩彼此的状态。如果数据存储在线程动态信息中,异步办法将可能拜访它,但只有在调用线程进行同步运行之前;如果它将 continuation 连贯到它发动的某个操作,并且该 continuation 最终在某个其余线程上运行,那么它将不再可能拜访线程动态信息。即便它碰巧运行在同一个线程上,无论是偶尔的还是因为调度器的强制,在它这样做的时候,数据可能曾经被该线程发动的其余操作删除和 / 或笼罩。对于异步,咱们须要一种机制,容许任意环境数据在这些异步点上流动,这样在 async 办法的整个逻辑中,无论何时何地运行,它都能够拜访雷同的数据。
输出 ExecutionContext。ExecutionContext 类型是异步操作和异步操作之间传递环境数据的媒介。它存在于一个 [ThreadStatic] 中,然而当某些异步操作启动时,它被“捕捉”(从该线程动态中读取正本的一种奇异的形式),存储,而后当该异步操作的连续被运行时,ExecutionContext 首先被复原到行将运行该操作的线程中的 [ThreadStatic] 中。ExecutionContext 是实现 AsyncLocal<T> 的机制(事实上,在 .NET Core 中,ExecutionContext 齐全是对于 AsyncLocal<T> 的,仅此而已),例如,如果你将一个值存储到 AsyncLocal<T> 中,而后例如队列一个工作项在 ThreadPool 上运行,该值将在该 AsyncLocal<T> 中可见,在该工作项上运行:
var number = new AsyncLocal<int>();
number.Value = 42;ThreadPool.QueueUserWorkItem(_ => Console.WriteLine(number.Value));
number.Value = 0;
Console.ReadLine();
这段代码每次运行时都会打印 42。在咱们对委托进行排队之后,咱们将 AsyncLocal<int> 的值重置为 0,这无关紧要,因为 ExecutionContext 是作为 QueueUserWorkItem 调用的一部分被捕捉的,而该捕捉蕴含了过后 AsyncLocal<int> 的状态。
❖ Back To Start
当我在写 AsyncTaskMethodBuilder.Start 的实现时,咱们绕道探讨了 ExecutionContext,我说这是无效的:
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{stateMachine.MoveNext();}
而后倡议我简化一下。这种简化疏忽了一个事实,即该办法实际上须要将 ExecutionContext 思考在内,因而更像是这样:
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine{ExecutionContext previous = Thread.CurrentThread._executionContext; // [ThreadStatic] field
try
{stateMachine.MoveNext();
}
finally
{ExecutionContext.Restore(previous); // internal helper
}}
这里不像我之前倡议的那样只调用 statemmachine .MoveNext(),而是在这里做了一个动作:获取以后的 ExecutionContext,再调用 MoveNext,而后在它实现时将以后上下文重置为调用 MoveNext 之前的状态。
这样做的起因是为了避免异步办法将环境数据泄露给调用者。一个示例办法阐明了为什么这很重要:
async Task ElevateAsAdminAndRunAsync(){using (WindowsIdentity identity = LoginAdmin())
{using (WindowsImpersonationContext impersonatedUser = identity.Impersonate())
{await DoSensitiveWorkAsync();
}
}}
“假冒”是将以后用户的环境信息改为其他人的;这让代码能够代表其他人,应用他们的特权和拜访权限。在 .NET 中,这种模仿跨异步操作流动,这意味着它是 ExecutionContext 的一部分。当初设想一下,如果 Start 没有复原之前的上下文,思考上面的代码:
Task t = ElevateAsAdminAndRunAsync();PrintUser();await t;
这段代码能够发现,ElevateAsAdminAndRunAsync 中批改的 ExecutionContext 在 ElevateAsAdminAndRunAsync 返回到它的同步调用者之后依然存在(这产生在该办法第一次期待尚未实现的内容时)。这是因为在调用 Impersonate 之后,咱们调用了 DoSensitiveWorkAsync 并期待它返回的工作。假如工作没有实现,它将导致对 ElevateAsAdminAndRunAsync 的调用 yield 并返回到调用者,模仿依然在以后线程上无效。这不是咱们想要的。因而,Start 设置了这个爱护机制,以确保对 ExecutionContext 的任何批改都不会从同步办法调用中流出,而只会随着办法执行的任何后续工作一起流出。
❖ 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 执行什么其余工作,当所有工作实现后,它都有责任实现 async Task 办法返回的工作。如果 try 代码块的主体抛出了未解决的异样,那么工作就会抛出该异样。如果 async 办法胜利达到它的起点(相当于同步办法返回),它将胜利实现返回的工作。在任何一种状况下,它都将设置状态机的状态以示意实现。(我有时听到开发人员从实践上说,当波及到异样时,在第一个 await 之前抛出的异样和在第一个 await 之后抛出的异样是有区别的……基于上述,应该分明状况并非如此。任何未在 async 办法中解决的异样,不论它在办法的什么地位,也不论办法是否产生了后果,都会在下面的 catch 块中完结,而后被捕捉的异样会存储在 async 办法返回的工作中。)
还要留神,这个实现过程是通过构建器实现的,应用它的 SetException 和 SetResult 办法,这是编译器预期的构建器模式的一部分。如果 async 办法之前曾经挂起了,那么构建器将不得不再挂起解决中创立一个 Task (稍后咱们会看到如何以及在哪里执行),在这种状况下,调用 SetException/SetResult 将实现该工作。然而,如果 async 办法之前没有挂起,那么咱们还没有创立工作或向调用者返回任何货色,因而构建器在生成工作时有更大的灵活性。如果你还记得之前在入口点办法中,它做的最初一件事是将工作返回给调用者,它通过拜访构建器的 Task 属性返回后果:
public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
...
return stateMachine.<>t__builder.Task;
}
构建器晓得该办法是否挂起过,如果挂起了,它就会返回曾经创立的工作。如果办法从未挂起,而且构建器还没有工作,那么它能够在这里创立一个实现的工作。在胜利实现的状况下,它能够间接应用 Task.CompletedTask 而不是调配一个新的工作,防止任何调配。如果是个别的工作 <TResult>,构建者能够间接应用 Task.FromResult<TResult>(TResult result)。
构建器还能够对它创立的对象进行任何它认为适合的转换。例如,Task 实际上有三种可能的最终状态: 胜利、失败和勾销。AsyncTaskMethodBuilder 的 SetException 办法解决非凡状况 OperationCanceledException,将工作转换为 TaskStatus。如果提供的异样是 OperationCanceledException 或源自 OperationCanceledException,则将工作转换为 TaskStatus.Canceled 最终状态;否则,工作以 TaskStatus.Faulted 完结;这种区别在应用代码时往往不显著;因为无论异样被标记为勾销还是故障,都会被存储到 Task 中,期待该工作的代码将无奈察看到状态之间的区别(无论哪种状况,原始异样都会被流传)…… 它只影响与工作间接交互的代码,例如通过 ContinueWith,它具备重载,容许仅为实现状态的子集调用 continuation。
当初咱们理解了生命周期方面的内容,上面是在 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 同步运行,因而从开始到入口点都同步运行,这意味着开发者的代码调用了 CopyStreamToStreamAsync,它依然在同步执行,还没有返回一个 Task 来示意这个办法的最终实现。
咱们调用 Stream.ReadAsync,从中失去一个 Task<int>。读取可能是同步实现的,也可能是异步实现的,但速度快到当初曾经实现,也可能还没有实现。不管怎么说,咱们有一个示意最终实现的 Task<int>,编译器收回的代码会查看该 Task<int> 以决定如何持续:如果该 Task<int> 的确曾经实现(不论它是同步实现还是只是在咱们查看时实现),那么这个办法的代码就能够持续同步运行 …… 当咱们能够在这里持续运行时,没有必要花不必要的开销排队解决该办法执行的残余局部。然而为了解决 Task<int> 还没有实现的状况,编译器须要收回代码来为 Task 挂上一个连续。因而,它须要收回代码,询问工作 “ 你实现了吗?” 它是否是间接与工作对话来问这个问题?
如果你在 C# 中惟一能够期待的货色是 System.Threading.Tasks.Task,这将是一种限度。同样地,如果 C# 编译器必须晓得每一种可能被期待的类型,那也是一种限度。相同,C# 在这种状况下通常会做的是:它采纳了一种 api 模式。代码能够期待任何公开适当模式 (“awaiter”模式) 的货色(就像您能够期待任何提供适当的“可枚举”模式的货色一样)。例如,咱们能够加强后面写的 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();
}
}
如果一个类型公开了 getwaiter() 办法,就能够期待它,Task 就是这样做的。这个办法须要返回一些内容,而这些内容又公开了几个成员,包含一个 IsCompleted 属性,用于在调用 IsCompleted 时查看操作是否曾经实现。你能够看到正在产生的事件:在 IL_008b,从 ReadAsync 返回的工作曾经调用了 getwaiter,而后在 struct awaiter 实例上实现拜访。如果 IsCompleted 返回 true,那么最终会执行到 IL_00f0,在这里代码会调用 awaiter 的另一个成员:GetResult()。如果操作失败,GetResult() 负责抛出异样,以便将其流传到 async 办法中的 await 之外;否则,GetResult() 负责返回操作的后果。在 ReadAsync 的例子中,如果后果为 0,那么咱们跳出读写循环,到办法的开端调用 SetResult,就实现了。
不过,回过头来看一下,真正乏味的局部是,如果 IsCompleted 查看实际上返回 false,会产生什么。如果它返回 true,咱们就持续解决循环,相似于在 APM 模式中 completedsynchronized 返回 true,Begin 办法的调用者负责继续执行,而不是回调函数。然而如果 IsCompleted 返回 false,咱们须要暂停 async 办法的执行,直到 await 操作实现。这意味着从 MoveNext 中返回,因为这是 Start 的一部分,咱们依然在入口点办法中,这意味着将工作返回给调用者。但在产生任何事件之前,咱们须要将 continuation 连贯到正在期待的工作(留神,为了防止像在 APM 状况中那样的 stack dives,如果异步操作在 IsCompleted 返回 false 后实现,但在咱们连贯 continuation 之前,continuation 依然须要从调用线程异步调用,因而它将进入队列)。因为咱们能够期待任何货色,咱们不能间接与工作实例对话;相同,咱们须要通过一些基于模式的办法来执行此操作。
Awaiter 公开了一个办法来连贯 continuation。编译器能够间接应用它,除了一个十分要害的问题:continuation 到底应该是什么? 更重要的是,它应该与什么对象相关联? 请记住,状态机构造体在栈上,咱们以后运行的 MoveNext 调用是对该实例的办法调用。咱们须要保留状态机,以便在复原时咱们领有所有正确的状态,这意味着状态机不能始终存在于栈中;它须要被复制到堆上的某个中央,因为栈最终将被用于该线程执行的其余后续的、无关的工作。而后,连续须要在堆上的状态机正本上调用 MoveNext 办法。
此外,ExecutionContext 也与此相关。状态机须要确保存储在 ExecutionContext 中的任何环境数据在暂停时被捕捉,而后在复原时被利用,这意味着连续也须要合并该 ExecutionContext。因而,仅仅在状态机上创立一个指向 MoveNext 的委托是不够的。这也是咱们不想要的开销。如果当咱们挂起时,咱们在状态机上创立了一个指向 MoveNext 的委托,那么每次这样做咱们都要对状态机构造进行装箱 (即便它曾经作为其余对象的一部分在堆上) 并调配一个额定的委托(委托的这个对象援用将是该构造体的一个新装箱的正本)。因而,咱们须要做一个简单的动作,即确保咱们只在办法第一次暂停执行时将该构造从堆栈中晋升到堆中,而在其余时候都应用雷同的堆对象作为 MoveNext 的指标,并在这个过程中确保咱们捕捉了正确的上下文,并在复原时确保咱们应用捕捉的上下文来调用该操作。
你能够在 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 实现了 iccriticalnotifycompletion;状态机解决流动的 ExecutionContext,所以咱们不须要 awaiter,正如后面提到的,这样做只会带来反复和不必要的开销。)
AwaitUnsafeOnCompleted 办法的实现太简单了,不能在这里详述,所以我将总结它在 .NET Framework 上的作用:
1. 它应用 ExecutionContext.Capture() 来获取以后上下文。
2. 而后它调配一个 MoveNextRunner 对象来包装捕捉的上下文和装箱的状态机(如果这是该办法第一次挂起,咱们还没有状态机,所以咱们只应用 null 作为占位符)。
3. 而后,它创立一个操作委托给该 MoveNextRunner 上的 Run 办法;这就是它如何可能取得一个委托,该委托将在捕捉的 ExecutionContext 的上下文中调用状态机的 MoveNext。
4. 如果这是该办法第一次挂起,咱们还没有装箱的状态机,所以此时它会将其装箱,通过将实例存储到本地类型的 IAsyncStateMachine 接口中,在堆上创立一个正本。而后,这个盒子会被存储到已调配的 MoveNextRunner 中。
5. 当初到了一个有些令人费解的步骤。如果您查看状态机构造体的定义,它蕴含构建器,public AsyncTaskMethodBuilder <>t__builder;,如果你查看构建器的定义,它蕴含外部的 IAsyncStateMachine m_stateMachine;。构建器须要援用装箱的状态机,以便在后续的挂起中它能够看到它曾经装箱了状态机,并且不须要再次这样做。然而咱们只是装箱了状态机,并且该状态机蕴含一个 m_stateMachine 字段为 null 的构建器。咱们须要扭转装箱状态机的构建器的 m_stateMachine 指向它的父容器。为了实现这一点,编译器生成的状态机构造体实现了 IAsyncStateMachine 接口,其中包含一个 void SetStateMachine(IAsyncStateMachine stateMachine) ; 办法,该状态机构造体蕴含了该接口办法的实现:
private void SetStateMachine(IAsyncStateMachine stateMachine) =>
<>t__builder.SetStateMachine(stateMachine);
因而,构建器对状态机进行装箱,而后将装箱传递给装箱的 SetStateMachine 办法,该办法会调用构建器的 SetStateMachine 办法,将装箱存储到字段中。
6. 最初,咱们有一个示意 continuation 的 Action,它被传递给 awaiter 的 UnsafeOnCompleted 办法。在 TaskAwaiter 的状况下,工作将将该操作存储到工作的 continuation 列表中,这样当工作实现时,它将调用该操作,通过 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 Object Allocation Tracking profiler 运行它,后果如下:
那是很多的调配!让咱们来钻研一下它们的起源。
ExecutionContext。有超过一百万个这样的内容被调配。为什么? 因为在 .NET Framework 中,ExecutionContext 是一个可变的数据结构。因为咱们心愿流转一个异步操作被 fork 时的数据,并且咱们不心愿它在 fork 之后看到执行的变更,咱们须要复制 ExecutionContext。每个独自的 fork 操作都须要这样的正本,因而有 1000 次对 SomeMethodAsync 的调用,每个调用都会暂停 / 复原 1000 次,咱们有 100 万个 ExecutionContext 实例。
Action。相似地,每次咱们期待尚未实现的工作时(咱们的百万个 await Task.Yield()s 就是这种状况),咱们最终调配一个新的操作委托来传递给 awaiter 的 UnsafeOnCompleted 办法。
MoveNextRunner。同样的,有一百万个这样的例子,因为在后面的步骤纲要中,每次咱们暂停时,咱们都要调配一个新的 MoveNextRunner 来存储 Action 和 ExecutionContext,以便应用后者来执行前者。
LogicalCallContext。这些是 .NET Framework 上 AsyncLocal<T> 的实现细节;AsyncLocal<T> 将其数据存储到 ExecutionContext 的“逻辑调用上下文”中,这是示意与 ExecutionContext 一起流动的个别状态的一种奇异形式。如果咱们要复制一百万个 ExecutionContext,咱们也会复制一百万个 LogicalCallContext。
QueueUserWorkItemCallback。每个 Task.Yield() 都将一个工作项排队到线程池中,导致调配了 100 万个工作项对象用于示意这 100 万个操作。
Task< VoidResult >。这里有一千个这样的,所以至多咱们脱离了 ” 百万 ” 俱乐部。每个异步实现的异步工作调用都须要调配一个新的 Task 实例来示意该调用的最终实现。
< SomeMethodAsync > d__1。这是编译器生成的状态机构造的盒子。1000 个办法挂起,1000 个盒子呈现。
QueueSegment / IThreadPoolWorkItem[]。有几千个这样的办法,从技术上讲,它们与具体的异步办法无关,而是与线程池中的队列工作无关。在 .NET 框架中,线程池的队列是一个非循环段的链表。这些段不会被重用;对于长度为 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 中,模仿将作为 SecurityContext 的一部分流动,而 SecurityContext 是 ExecutionContext 的一部分;在 .NET Core 中,模仿通过 AsyncLocal<SafeAccessTokenHandle> 流动,它应用 valueChangedHandler 来对以后线程进行适当的更改。
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 还是,为这个挂起生成的 IL 都是雷同的。然而,扭转的是 AwaitUnsafeOnCompleted 办法的实现,在 .NET Core 中有很大的不同:
1. 事件的开始是一样的:该办法调用 ExecutionContext.Capture() 来获取以后执行上下文。
2. 而后,事件偏离了 .NET Framework。.NET Core 中的 builder 只有一个字段:
public struct AsyncTaskMethodBuilder
{
private Task<VoidTaskResult>? m_task;
...
}
在捕捉 ExecutionContext 之后,它查看 m_task 字段是否蕴含一个 AsyncStateMachineBox<TStateMachine> 的实例,其中 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;
请留神源正文为“重要”的那一行。这取代了 .NET Framework 中简单的 SetStateMachine,使得 SetStateMachine 在 .NET Core 中基本没有应用。你看到的 taskField 有一个指向 AsyncTaskMethodBuilder 的 m_task 字段的 ref。咱们调配 AsyncStateMachineBox< tstatemachinebox >,而后通过 taskField 将对象存储到构建器的 m_task 中 (这是在栈上的状态机构造中的构建器),而后将基于堆栈的状态机(当初曾经蕴含对盒子的援用) 复制到基于堆的 AsyncStateMachineBox< tstatemachinebox > 中,这样 AsyncStateMachineBox<TStateMachine> 适当地并递归地完结援用本人。这依然是令人费解的,但却是一种更无效的费解。
3. 而后,咱们能够对这个 Action 上的一个办法进行操作,该办法将调用其 MoveNext 办法,该办法将在调用 StateMachine 的 MoveNext 之前执行适当的 ExecutionContext 复原。该 Action 能够缓存到 _moveNextAction 字段中,以便任何后续应用都能够重用雷同的 Action。而后,该 Action 被传递给 awaiter 的 UnsafeOnCompleted 来连贯 continuation。
它解释了为什么剩下的大部分调配都没有了:<SomeMethodAsync>d__1 没有被装箱,而是作为工作自身的一个字段存在,MoveNextRunner 不再须要,因为它的存在只是为了存储 Action 和 ExecutionContext。然而,依据这个解释,咱们依然应该看到 1000 个操作调配,每个办法调用一个,但咱们没有。为什么? 还有那些 QueueUserWorkItemCallback 对象呢? 咱们依然在 Task.Yield() 中进行排队,为什么它们没有呈现呢?
正如我所提到的,将实现细节推入外围库的益处之一是,它能够随着工夫的推移改良实现,咱们曾经看到了它是如何从 .NET Framework 倒退到 .NET Core 的。它在最后为 .NET Core 重写的根底上进一步倒退,减少了额定的优化,这得益于对系统要害组件的外部拜访。特地是,异步基础设施晓得 Task 和 TaskAwaiter 等外围类型。而且因为它晓得它们并具备外部拜访权限,所以它不用遵循公开定义的规定。C# 语言遵循的 awaiter 模式要求 awaiter 具备 AwaitOnCompleted 或 AwaitUnsafeOnCompleted 办法,这两个办法都将 continuation 作为一个操作,这意味着根底构造须要可能创立一个操作来示意 continuation,以便与根底构造不晓得的任意 awaiter 一起工作。然而,如果基础设施遇到它晓得的 awaiter,它没有任务采取雷同的代码门路。对于 System.Private 中定义的所有外围 awaiter。因而,CoreLib 的基础设施能够遵循更简洁的门路,齐全不须要操作。这些 awaiter 都晓得 IAsyncStateMachineBoxes,并且可能将 box 对象自身作为 continuation。例如,Task 返回的 YieldAwaitable.Yield 可能将 IAsyncStateMachineBox 自身作为工作项间接放入 ThreadPool 中,而期待工作时应用的 TaskAwaiter 可能将 IAsyncStateMachineBox 自身间接存储到工作的连续列表中。不须要操作,也不须要 QueueUserWorkItemCallback。
因而,在十分常见的状况下,async 办法只期待 System.Private.CoreLib (Task, Task<TResult>,ValueTask, ValueTask<TResult>,YieldAwaitable,以及它们的 ConfigureAwait 变体),最坏的状况下,只有一次开销调配与 async 办法的整个生命周期相干:如果这个办法挂起了,它会调配一个繁多的 Task-derived 类型来存储所有其余须要的状态,如果这个办法素来没有挂起,就不会产生额定的调配。
如果违心,咱们也能够去掉最初一个调配,至多以平摊的形式。如所示,有一个默认构建器与 Task(AsyncTaskMethodBuilder) 相关联,相似地,有一个默认构建器与工作 <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 and C# 10 引入了一个办法能够笼罩一一办法应用的构建器的能力,并为 ValueTask/ValueTask<TResult> 引入了几个专门的构建器,它们可能池化 IValueTaskSource/IValueTaskSource<TResult> 对象来示意最终的实现,而不是应用 Tasks。
咱们能够在咱们的样本中看到这一点的影响。略微调整一下之前剖析的 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 类型。要害的区别是,当办法第一次挂起时,它不是执行新的 AsyncStateMachineBox<TStateMachine>(),而是执行 StateMachineBox<TStateMachine>. rentfromcache(),并且在 async 办法 (SomeMethodAsync) 实现并期待返回的 ValueTask 实现时,租用的盒子会被返回到缓存中。这意味着(平摊) 零调配:
这个缓存自身有点意思。对象池可能是一个好主见,也可能是一个坏主意。创立一个对象的老本越高,共享它们的价值就越大;因而,例如,对十分大的数组进行池化比对十分小的数组进行池化更有价值,因为更大的数组不仅须要更多的 CPU 周期和内存拜访为零,它们还会给垃圾收集器带来更大的压力,使其更频繁地收集垃圾。然而,对于十分小的对象,将它们池化可能会带来负面影响。池只是内存分配器,GC 也是,所以当您应用池时,您是在衡量与一个分配器相干的老本与另一个分配器相干的老本,并且 GC 在解决大量渺小的、生存期短的对象方面十分高效。如果你在对象的构造函数中做了很多工作,防止这些工作能够使分配器自身的开销黯然失色,从而使池变得有价值。然而,如果您在对象的构造函数中简直没有做任何工作,并且将其进行池化,则您将打赌您的分配器 (您的池) 就所采纳的拜访模式而言比 GC 更无效,而这通常是一个蹩脚的赌注。还波及其余老本,在某些状况下,您可能最终会无效地反抗 GC 的启发式办法;例如,垃圾回收是基于一个前提进行优化的,即从较高代 (如 gen2) 对象到较低代 (如 gen0) 对象的援用绝对较少,但池化对象能够使这些前提生效。
咱们明天为大家介绍了 C# 迭代器和 async/await under the covers,下期文章,咱们将持续介绍 SynchronizationContext 和 ConfigureAwait,欢送继续关注。
点我浏览原博客~