在.NET中如何产生和等待实现控制stream?
正如我所理解的yield
关键字,如果从一个迭代器块内部使用,它会将控制stream返回给调用代码,并且当再次调用迭代器时,它会从中断处继续。
另外,不仅等待被叫方等待,而且还向主叫方返回控制权,只有当主叫方awaits
方法时,才能从中断的位置继续。
换句话说,没有线程 ,asynchronous和等待的“并发”是由控制的巧妙stream动造成的错觉,其细节被语法掩盖。
现在,我是一个前汇编程序员,我非常熟悉指令指针,堆栈等,我得到正常的控制stream(子例程,recursion,循环,分支)如何工作。 但是这些新的结构 – 我没有得到它们。
当await
到达时,运行时如何知道下一步应该执行哪一段代码? 它是如何知道什么时候可以恢复的呢?它又如何记住它在哪里? 目前的调用堆栈发生了什么,它以某种方式得到保存? 如果调用方法在进行其他方法调用之前await
什么?为什么堆栈不被覆盖? 那么在这种情况下,运行时如何解决这个问题呢?
当达到yield
时,运行时如何跟踪应该提取事物的点? 如何保存迭代器状态?
我会在下面回答你的具体问题,但你可能会好好简单地阅读我关于如何devise产量和等待的广泛的文章。
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/
其中一些文章现在已经过时了; 生成的代码在很多方面都有所不同。 但是这些肯定会让你知道它是如何工作的。
此外,如果你不明白如何生成闭包类的lambdas, 首先要了解它。 如果你没有使用lambdaexpression式,你将不会做出asynchronous的头或尾。
当等待到达时,运行时如何知道下一步应该执行哪一段代码?
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的激活完好无损,不pipe是什么吧。
你知道这是如何在汇编程序中完成的。 foo的激活logging被压入堆栈; 它包含了当地人的价值观。 在调用的时候,foo中的返回地址被压入栈中。 当bar完成时,堆栈指针和指令指针被重置到他们需要的地方,并且foo从它离开的地方继续前进。
await的继续是完全相同的,除了logging放在堆上,原因是激活的顺序不构成堆栈 。
等待给出任务的委托包含(1)一个数字,它是一个查找表的input,它给出你需要执行的指令指针,以及(2)所有的本地值和临时值。
那里有一些额外的装备; 例如,在.NET中,分支到try块的中间是非法的,所以你不能简单地把try块内的代码地址粘贴到表中。 但这些是簿记的细节。 从概念上讲,激活logging只是简单的移到堆上。
目前的调用堆栈会发生什么变化?
当前激活logging中的相关信息从不放在首位; 它从一开始就被分配到堆中。 (好吧,forms参数通常在堆栈或寄存器中传递,然后在方法开始时复制到堆中。)
呼叫者的激活logging不被存储; 等待可能会回到他们,记住,所以他们会得到正常处理。
请注意,这是在简化的延续传球风格之间的一个密切的区别,就像您在Scheme中这样的语言中看到的真正的呼叫延续结构。 在这些语言中, call-cc捕获整个延续,包括延续回呼者。
如果调用方法在等待之前进行其他方法调用,为什么堆栈不被覆盖?
这些方法调用返回,所以他们的激活logging不再在等待点上的堆栈上。
那么在这种情况下,运行时如何解决这个问题呢?
在发生未捕获exception的情况下,exception将被捕获,存储在任务中,并在获取任务结果时重新抛出。
记得我之前提到的所有簿记吗? 获得exception语义正确是一个巨大的痛苦,让我告诉你。
当达到收益率时,运行时如何跟踪应该提取事物的点? 如何保存迭代器状态?
同样的方式。 当地人的状态被移到堆上,一个代表下一次MoveNext
应该继续的指令的数字与当地人一起存储。
再次,迭代器模块中有一堆装备,以确保正确处理exception。
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
),但并不坏(重用本身在安全的情况下这样做,而不是创build一个新的对象是好的),可扩展的处理非常复杂的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
因为await
的任务已经完成(例如,它可以同步返回的情况),那么方法一直在状态中移动,否则它将自己设置为回叫到awaiter。
只是发生了什么取决于awaiter,就什么触发callback(例如,asynchronousI / O完成,一个线程上运行的任务完成)以及什么要求编组到特定的线程或在线程池线程上运行,来自原始呼叫的上下文可能需要也可能不需要,等等。 不pipe是什么东西,尽pipe这个服务器中的东西都会调用MoveNext
,它将继续下一个工作(直到下一个await
),或者结束并返回,在这种情况下,正在执行的Task
将完成。
这里已经有很多很好的答案了。 我只是想分享一些可以帮助形成心理模型的观点。
首先,一个async
方法被编译器分成几块, await
expression是断裂点。 (这对于简单的方法来说很容易理解,带有循环和exception处理的更复杂的方法也被打破,增加了一个更复杂的状态机)。
其次, await
被翻译成相当简单的序列; 我喜欢Lucian的描述 ,这几乎是“如果awaitable已经完成了,得到结果并继续执行这个方法;否则,保存这个方法的状态并返回”。 (我在async
介绍中使用非常类似的术语)。
当等待到达时,运行时如何知道下一步应该执行哪一段代码?
该方法的其余部分作为该等待的callback存在(在任务的情况下,这些callback是继续的)。 当等待完成时,它调用它的callback。
请注意,调用堆栈不会被保存和恢复; callback直接调用。 在重叠I / O的情况下,它们直接从线程池中调用。
这些callback可以直接继续执行方法,或者可以安排它在别处运行(例如,如果await
捕获了UI SynchronizationContext
和在线程池上完成的I / O)。
它是如何知道什么时候可以恢复的呢?它又如何记住它在哪里?
这只是callback。 当awaitable完成时,它调用它的callback函数,任何已经await
async
方法都会被恢复。 callback跳转到该方法的中间,并在其范围内具有局部variables。
callback没有运行一个特定的线程,他们没有恢复他们的调用堆栈。
目前的调用堆栈会发生什么变化? 如果调用方法在等待之前进行其他方法调用,为什么堆栈不被覆盖? 那么在这种情况下,运行时如何解决这个问题呢?
调用堆栈不是保存在第一位; 这是没有必要的。
使用同步代码,您可以最终获得包含所有调用者的调用堆栈,并且运行时知道使用该调用返回的位置。
使用asynchronous代码,你可以得到一堆callback指针 – 根植于一些完成任务的I / O操作,它可以恢复一个async
方法来完成它的任务,这可以恢复一个完成任务的async
方法,等等。
所以,用同步代码A
调用B
调用C
,你的调用堆栈可能看起来像这样:
A:B:C
而asynchronous代码使用callback(指针):
A <- B <- C <- (I/O operation)
当达到收益率时,运行时如何跟踪应该提取事物的点? 如何保存迭代器状态?
目前,效率相当低下。 🙂
它的工作方式与其他任何lambda类似 – 扩展了variables生命周期,并将引用放置在堆栈上的状态对象中。 所有深层细节的最佳资源是Jon Skeet的EduAsync系列 。
yield
和await
都是,而两者在处理stream量控制方面,两个完全不同的东西。 所以我会分开处理它们。
yield
的目标是使build立惰性序列变得更容易。 当你写一个带有yield
语句的枚举器循环时,编译器会产生大量你看不到的新代码。 在引擎盖下,它实际上产生了一个全新的课程。 该类包含跟踪循环状态的成员,以及IEnumerable的实现,这样每次调用MoveNext
都会通过该循环再次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是什么样的。 这应该是有益的。
async
和await
,另一方面,是一个完整的其他水壶。 等待是抽象的同步原语。 这是一种告诉系统的方式:“在这样做之前我不能继续。” 但是,正如你所指出的那样,并不总是涉及到一个线程。
涉及到的是一个叫做同步上下文的东西。 总是有一个挂着。 他们的同步上下文的工作是安排正在等待的任务及其延续。
当你说await thisThing()
,会发生一些事情。 在asynchronous方法中,编译器实际上将方法切成较小的块,每个块是“在等待”部分之前和“在等待”(或延续)部分之后。 当await执行时,正在等待的任务, 以及下面的延续 – 换句话说,函数的其余部分 – 传递给同步上下文。 上下文负责调度任务,当它完成时,上下文将运行延续,传递任何想要的返回值。
同步上下文可以自由的做任何事情,只要它调度的东西。 它可以使用线程池。 它可以为每个任务创build一个线程。 它可以同步运行它们。 不同的环境(ASP.NET与WPF)提供了不同的同步上下文实现,它们根据环境的最佳做法做不同的事情。
(奖金:曾经想知道什么.ConfigurateAwait(false)
呢?它告诉系统不要使用当前的同步上下文(通常基于你的项目types – 例如WPF和ASP.NET),而是使用默认的上下文线程池)。
所以再次,这是很多编译器的诡计。 如果你看看生成的代码,它的复杂性,但你应该能够看到它在做什么。 这些转换是艰难的,但确定性的和math的,这就是为什么编译器为我们做这些是很棒的。
PS存在默认同步上下文有一个例外 – 控制台应用程序没有默认的同步上下文。 查看Stephen Toub的博客了解更多信息。 一般来说,这是一个寻找async
信息的好地方。
通常情况下,我会推荐看CIL,但在这种情况下,这是一团糟。
这两种语言结构在工作上是相似的,但实现方式有点不同。 基本上,这只是一个编译魔术的语法糖,没有什么疯狂/不安全的程序集。 让我们简单地看一下。
yield
是一个陈旧而简单的陈述,它是一个基本状态机的语法糖。 返回IEnumerable<T>
或IEnumerator<T>
可能包含yield
,然后将该方法转换为状态机工厂。 有一件事你应该注意的是,如果你在调用它的时候,方法中没有代码运行,如果里面有yield
。 原因是您编写的代码易位到IEnumerator<T>.MoveNext
方法,该方法检查它所在的状态并运行代码的正确部分。 yield return x;
然后转换成类似于this.Current = x; return true;
this.Current = x; return true;
如果你做一些反思,你可以很容易地检查构build的状态机及其字段(至less为状态和本地状态)。 如果更改字段,甚至可以重置它。
await
需要来自types库的一点支持,并且工作方式有所不同。 它接受Task
或Task<T>
参数,然后在任务完成时结果为其值,或通过Task.GetAwaiter().OnCompleted
注册延续Task.GetAwaiter().OnCompleted
。 async
/ await
系统的完整实现将花费太长时间来解释,但这也不是神秘的。 它还会创build一个状态机并将其沿着OnCompleted传递。 如果任务完成,则将其结果用于继续。 服务员的执行决定如何调用延续。 通常它使用调用线程的同步上下文。
yield
和await
都必须根据它们的出现来分解方法,以形成一个状态机,机器的每个分支代表方法的每个部分。
你不应该在堆栈,线程等“低级”术语中考虑这些概念。这些是抽象的,它们的内部工作不需要CLR的任何支持,只是编译器才能做到这一点。 这与Lua的运行时支持的协同程序或C的longjmp完全不同 ,后者只是黑魔法。