产量和等待如何在 .NET 中实现控制流



据我了解yield关键字,如果从迭代器块内部使用,它会将控制流返回到调用代码,当再次调用迭代器时,它会从中断的地方继续。

此外,await不仅等待被调用方,而且还将控制权返回给调用方,只是在调用方awaits方法时从中断的位置继续。

换句话说,没有线程,async 和 await 的"并发性"是由巧妙的控制流引起的错觉,其细节被语法所掩盖。

现在,我是一名前汇编程序员,我非常熟悉指令指针、堆栈等,我了解正常的控制流(子例程、递归、循环、分支)是如何工作的。 但是这些新的结构——我不明白。

当达到await时,运行时如何知道接下来应该执行哪段代码? 它如何知道何时可以从上次中断的地方恢复,以及如何记住在哪里? 当前调用堆栈会发生什么情况,它是否会以某种方式保存? 如果调用方法在await之前进行其他方法调用怎么办 - 为什么堆栈不会被覆盖? 在异常和堆栈展开的情况下,运行时究竟如何完成所有这些工作?

当达到yield时,运行时如何跟踪应该拾取东西的点? 如何保留迭代器状态?

我将在下面回答您的具体问题,但您可能最好简单地阅读我关于我们如何设计收益和等待的大量文章。

https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/

https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/

https://blogs.msdn.microsoft.com/ericlippert/tag/async/

其中一些文章现在已经过时了;生成的代码在很多方面都是不同的。但这些肯定会让你了解它是如何工作的。

此外,如果您不了解 lambda 是如何生成为闭包类的,请了解这一点。如果你没有 lambda 下来,你就不会做出异步的正面或反面。

当到达 await 时,运行时如何知道接下来应该执行哪段代码?

await生成为:

if (the task is not completed)
assign a delegate which executes the remainder of the method as the continuation of the task
return to the caller
else
execute the remainder of the method now

基本上就是这样。等待只是一个花哨的回归。

它如何知道何时可以从上次中断的地方恢复,以及如何记住在哪里?

那么,你如何在没有等待的情况下做到这一点? 当方法 foo 调用方法栏时,不知何故,我们记得如何回到 foo 的中间,无论 foo 激活的所有局部变量都完好无损,无论 bar 做什么。

你知道在汇编器中是如何完成的。 foo的激活记录被推送到堆栈上;它包含当地人的值。在调用时,foo中的返回地址被推送到堆栈上。当柱线完成后,堆栈指针和指令指针被重置到它们需要的位置,而 foo 从它停止的地方继续前进。

await 的延续完全相同,只是记录被放入堆中,原因很明显,激活序列不会形成堆栈

await 作为任务延续的委托包含 (1) 一个数字,该数字是查找表的输入,该查找表提供您接下来需要执行的指令指针,以及 (2) 局部变量和临时变量的所有值。

那里还有一些额外的设备;例如,在 .NET 中,分支到 try 块的中间是非法的,因此您不能简单地将 try 块中的代码地址粘贴到表中。但这些都是簿记细节。 从概念上讲,激活记录只是移动到堆上。

当前调用堆栈会发生什么情况,它是否会以某种方式保存?

当前激活记录中的相关信息永远不会放在堆栈上;它从一开始就被从堆中分配出来。(好吧,形式参数通常在堆栈或寄存器中传递,然后在方法开始时复制到堆位置。

调用者的激活记录不会被存储;记住,等待可能会返回给他们,所以它们将被正常处理。

请注意,这是 await 的简化延续传递样式与您在 Scheme 等语言中看到的真正当前继续调用结构之间的密切区别。在这些语言中,整个延续,包括返回调用方的延续,都由 call-cc 捕获。

如果调用方法

在等待之前进行其他方法调用怎么办 - 为什么堆栈没有被覆盖?

这些方法调用返回,因此它们的激活记录在等待点不再位于堆栈上。

在异常和堆栈展开的情况下,运行时究竟如何完成所有这些工作?

如果发生未捕获的异常,则会捕获异常,将其存储在任务中,并在获取任务结果时重新引发异常。

还记得我之前提到的所有簿记吗?让我告诉你,获得正确的异常语义是一个巨大的痛苦。

当达到产量时,运行时如何跟踪应该拾取东西的点?如何保留迭代器状态?

同样的方式。局部变量的状态被移动到堆上,并且表示下次调用MoveNext时应恢复的指令的数字与局部变量一起存储。

同样,迭代器块中有一堆设备来确保正确处理异常。

>yield是两者中比较容易的,所以让我们检查一下。

假设我们有:

public IEnumerable<int> CountToTen()
{
for (int i = 1; i <= 10; ++i)
{
yield return i;
}
}

这有点像我们写的:

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
private int _i;
private int _current;
private int _state;
private int _initialThreadId = CurrentManagedThreadId;
public IEnumerator<CountToTen> GetEnumerator()
{
// Use self if never ran and same thread (so safe)
// otherwise create a new object.
if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
{
return new <CountToTen>();
}
_state = 1;
return this;
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public int Current => _current;
object IEnumerator.Current => Current;
public bool MoveNext()
{
switch(_state)
{
case 1:
_i = 1;
_current = i;
_state = 2;
return true;
case 2:
++_i;
if (_i <= 10)
{
_current = _i;
return true;
}
break;
}
_state = -1;
return false;
}
public void Dispose()
{
// if the yield-using method had a `using` it would
// be translated into something happening here.
}
public void Reset()
{
throw new NotSupportedException();
}
}

因此,不如手写的IEnumerable<int>IEnumerator<int>实现高效(例如,在这种情况下,我们可能不会浪费单独的_state_i_current),但还不错(在安全的情况下重用自身而不是创建新对象的技巧很好),并且可以扩展以处理非常复杂的yield使用方法。

当然,从

foreach(var a in b)
{
DoSomething(a);
}

与以下相同:

using(var en = b.GetEnumerator())
{
while(en.MoveNext())
{
var a = en.Current;
DoSomething(a);
}
}

然后重复调用生成的MoveNext()

async的情况原理几乎相同,但有点额外的复杂性。重用另一个答案中的示例 代码如下:

private async Task LoopAsync()
{
int count = 0;
while(count < 5)
{
await SomeNetworkCallAsync();
count++;
}
}

生成如下代码:

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
public int _state;
public AsyncTaskMethodBuilder _builder;
public TestAsync _this;
public int _count;
private TaskAwaiter _awaiter;
void IAsyncStateMachine.MoveNext()
{
try
{
if (_state != 0)
{
_count = 0;
goto afterSetup;
}
TaskAwaiter awaiter = _awaiter;
_awaiter = default(TaskAwaiter);
_state = -1;
loopBack:
awaiter.GetResult();
awaiter = default(TaskAwaiter);
_count++;
afterSetup:
if (_count < 5)
{
awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
if (!awaiter.IsCompleted)
{
_state = 0;
_awaiter = awaiter;
_builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
return;
}
goto loopBack;
}
_state = -2;
_builder.SetResult();
}
catch (Exception exception)
{
_state = -2;
_builder.SetException(exception);
return;
}
}
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
{
_builder.SetStateMachine(param0);
}
}
public Task LoopAsync()
{
LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
stateMachine._this = this;
AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
stateMachine._builder = builder;
stateMachine._state = -1;
builder.Start(ref stateMachine);
return builder.Task;
}

它更复杂,但基本原理非常相似。主要的额外复杂性是现在正在使用GetAwaiter()。如果选中任何时间awaiter.IsCompleted它都会返回true因为 edawait的任务已经完成(例如,它可以同步返回的情况),那么该方法会继续在状态中移动,但除此之外,它会将自己设置为对等待者的回调。

发生这种情况取决于等待者,触发回调的原因(例如异步 I/O 完成,在线程完成上运行的任务)以及编组到特定线程或在线程池线程上运行的要求,可能需要或不需要原始调用的上下文等等。不管是什么,尽管等待者中的某些东西会调用MoveNext,它要么继续下一项工作(直到下一个await),要么完成并返回,在这种情况下,它正在实现的Task就完成了。

这里已经有很多很棒的答案;我只是要分享一些可以帮助形成心智模型的观点。

首先,编译器将async方法分解为若干部分;await表达式是断开点。(对于简单的方法来说,这很容易想象;带有循环和异常处理的更复杂的方法也会被分解,并添加更复杂的状态机)。

其次,await被翻译成一个相当简单的序列;我喜欢 Lucian 的描述,用文字来说几乎是"如果等待已经完成,得到结果并继续执行此方法;否则,保存此方法的状态并返回"。(我在async介绍中使用了非常相似的术语)。

当到达 await 时,运行时如何知道接下来应该执行哪段代码?

该方法的其余部分作为可等待的回调存在(对于任务,这些回调是延续)。当可等待对象完成时,它会调用其回调。

请注意,不会保存和还原调用堆栈;直接调用回调。在重叠 I/O 的情况下,直接从线程池调用它们。

这些回调可以继续直接执行该方法,也可以安排它在其他位置运行(例如,如果await捕获了 UISynchronizationContext并且 I/O 在线程池上完成)。

它如何知道何时可以从上次中断的地方恢复,以及如何记住在哪里?

这一切都只是回调。当一个可等待对象完成时,它会调用其回调,并且任何已经awaitasync方法都会恢复。回调跳转到该方法的中间,并在范围内具有其局部变量。

回调不会运行特定线程,并且不会还原其调用堆栈。

当前调用堆栈会发生什么情况,它是否会以某种方式保存?如果调用方法在等待之前进行其他方法调用怎么办 - 为什么堆栈没有被覆盖?在异常和堆栈展开的情况下,运行时究竟如何完成所有这些工作?

调用堆栈首先没有保存;它不是必需的。

使用同步代码,最终可以得到一个包含所有调用方的调用堆栈,并且运行时知道使用该堆栈返回的位置。

使用异步代码,您最终可能会得到一堆回调指针 - 根植于完成其任务的某个 I/O 操作,该操作可以恢复完成其任务的async方法,可以恢复完成其任务的async方法,等等。

因此,使用同步代码A调用B调用C,您的调用堆栈可能如下所示:

A:B:C

而异步代码使用回调(指针):

A <- B <- C <- (I/O operation)

当达到产量时,运行时如何跟踪应该拾取东西的点?如何保留迭代器状态?

目前,效率相当低下。 :)

它的工作方式与任何其他 lambda 类似 - 变量生存期得到延长,引用被放置在堆栈上的状态对象中。所有深层细节的最佳资源是Jon Skeet的EduAsync系列。

yieldawait是两个完全不同的东西,虽然两者都处理流量控制。所以我将分别处理它们。

yield的目标是使构建惰性序列变得更加容易。当您编写包含yield语句的枚举器循环时,编译器会生成大量您看不到的新代码。在引擎盖下,它实际上生成了一个全新的类。该类包含跟踪循环状态的成员,以及 IEnumerable 的实现,以便每次调用MoveNext时,它都会再次遍历该循环。因此,当您执行如下 foreach 循环时:

foreach(var item in mything.items()) {
dosomething(item);
}

生成的代码如下所示:

var i = mything.items();
while(i.MoveNext()) {
dosomething(i.Current);
}

mything.items() 的实现中是一堆状态机代码,它们将执行循环的一个"步骤"然后返回。因此,虽然你像一个简单的循环一样在源代码中编写它,但在引擎盖下它不是一个简单的循环。所以编译器的诡计。如果你想看看你自己,拿出ILDASM或ILSpy或类似的工具,看看生成的IL是什么样子的。它应该是有启发性的。

另一方面,asyncawait是另一壶鱼。抽象地说,Await 是一个同步原语。这是一种告诉系统"在完成此操作之前我无法继续"的方法。但是,正如您所指出的,并不总是涉及线程。

所涉及的内容称为同步上下文。总有一个在身边徘徊。同步上下文的工作是计划正在等待的任务及其延续。

当你说await thisThing()时,会发生几件事。在异步方法中,编译器实际上将方法切成较小的块,每个块是"等待之前"部分和"等待之后"(或延续)部分。执行 await 时,正在等待的任务后续延续(换句话说,函数的其余部分)将传递到同步上下文。上下文负责调度任务,完成后,上下文将运行延续,传递所需的任何返回值。

同步上下文可以自由地做任何它想做的事情,只要它计划内容。它可以使用线程池。它可以为每个任务创建一个线程。它可以同步运行它们。不同的环境(ASP.NET 与 WPF)提供不同的同步上下文实现,这些实现根据最适合其环境的内容执行不同的操作。

(奖励:有没有想过.ConfigurateAwait(false)是做什么的?它告诉系统不要使用当前的同步上下文(通常基于项目类型 - 例如 WPF 与 ASP.NET),而是使用默认的上下文,它使用线程池)。

所以再一次,这是很多编译器的诡计。如果你看一下生成的代码,它很复杂,但你应该能够看到它在做什么。这些转换很难,但具有确定性和数学性,这就是为什么编译器为我们做这些转换是件好事。

附言存在默认同步上下文有一个例外 - 控制台应用没有默认同步上下文。查看Stephen Toub的博客以获取更多信息。这是查找有关asyncawait信息的好地方。

通常,我会重新评论 CIL,但在这些情况下,它是一团糟。

这两种语言结构在工作上相似,但实现方式略有不同。基本上,它只是编译器魔法的语法糖,在汇编级别没有什么疯狂/不安全的。让我们简要地看一下它们。

yield是一个较旧、更简单的语句,它是基本状态机的语法糖。返回IEnumerable<T>IEnumerator<T>的方法可能包含一个yield,然后该方法转换为状态机工厂。您应该注意的一件事是,如果内部有yield,则在调用该方法时不会运行该方法中的任何代码。原因是您编写的代码被转移到IEnumerator<T>.MoveNext方法,该方法会检查它所处的状态并运行代码的正确部分。 然后将yield return x;转换为类似于this.Current = x; return true;的东西

如果进行一些反射,则可以轻松检查构造的状态机及其字段(至少一个用于状态和局部变量)。如果您更改字段,您甚至可以重置它。

await需要类型库的一些支持,并且工作方式略有不同。它需要一个TaskTask<T>参数,然后在任务完成时结果到其值,或者通过Task.GetAwaiter().OnCompleted注册延续。async/await系统的完整实现需要很长时间才能解释,但它也不是那么神秘。它还创建一个状态机,并将其沿延续传递到OnComplete。如果任务完成,则在继续中使用其结果。等待程序的实现决定如何调用延续。通常,它使用调用线程的同步上下文。

yieldawait都必须根据方法的发生情况拆分方法,以形成状态机,其中机器的每个分支代表方法的每个部分。

您不应该在"较低级别"术语(如堆栈、线程等)中考虑这些概念。这些是抽象的,它们的内部工作不需要CLR的任何支持,它只是编译器在施展魔法。这与Lua的协程(确实有运行时的支持)或C的longjmp(只是黑魔法)截然不同。

最新更新