据我理解yield
关键字,如果从迭代器块内部使用它,它会将控制流返回给调用代码,当再次调用迭代器时,它会从中断处继续。
此外,await
不仅等待被叫者,而且还将控制权返回给调用者,只有当调用者awaits
方法时,它才会从中断处继续。
换句话说 - there is no thread,async和await的“并发”是由巧妙的控制流引起的错觉,其细节被语法隐藏。
现在,我是一名前汇编程序员,我对指令指针,堆栈等非常熟悉,并且我得到了正常的控制流(子程序,递归,循环,分支)的工作方式。但是这些新结构 - 我没有得到它们。
当达到await
时,运行时如何知道接下来应该执行哪一段代码?它是如何知道什么时候可以从它停止的地方恢复的,它如何记住在哪里?当前的调用堆栈会发生什么,它会以某种方式保存吗?如果调用方法在await
之前进行其他方法调用 - 为什么堆栈不会被覆盖呢?在异常情况和堆栈展开的情况下,运行时如何才能完成这一切?
当达到yield
时,运行时如何跟踪应该拾取事物的点?如何保留迭代器状态?
答案 0 :(得分:109)
我将在下面回答您的具体问题,但您可能只需阅读我关于我们如何设计收益并等待的大量文章。
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作为闭包类,请首先理解 。如果没有lambdas,你不会做出异步的正面或反面。
当达到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的激活的所有本地完整,无论是什么吧。
你知道汇编程序是如何完成的。 foo的激活记录被压入堆栈;它包含本地人的价值观。在调用时,foo中的返回地址被压入堆栈。当bar完成时,堆栈指针和指令指针被重置到它们需要的位置,并且foo从它离开的地方继续前进。
await的延续完全相同,只是记录被放到堆上,原因很明显激活序列没有形成堆栈。
await给出的委托作为任务的继续包含(1)一个数字,它是查找表的输入,提供下一个需要执行的指令指针,以及(2)本地和的所有值临时工。
那里还有一些额外的装备;例如,在.NET中,分支到try块的中间是非法的,因此您不能简单地将try块中的代码地址粘贴到表中。但这些都是簿记细节。从概念上讲,激活记录只是移动到堆上。
当前调用堆栈会发生什么,是否以某种方式保存?
当前激活记录中的相关信息从不首先放在堆栈中;它从一开始就从堆中分配出来。 (好的,正式参数通常在堆栈或寄存器中传递,然后在方法开始时复制到堆位置。)
不存储来电者的激活记录;记得,await可能会回归他们,所以他们会被正常处理。
请注意,这是await的简化延续传递样式与您在Scheme等语言中看到的真实的call-with-current-continuation结构之间的密切差异。在这些语言中,call-cc捕获了包括继续回调用者的整个延续。
如果调用方法在等待之前进行其他方法调用怎么办?为什么堆栈不会被覆盖?
那些方法调用返回,因此它们的激活记录在等待点不再位于堆栈上。
在异常情况和堆栈展开的情况下,运行时如何运行所有这些?
如果发生未捕获的异常,则会捕获异常,将其存储在任务中,并在获取任务结果时重新抛出异常。
还记得我之前提到的所有簿记吗?正确地获取异常语义是一个巨大的痛苦,让我告诉你。
当达到产量时,运行时如何跟踪应该拾取的点?如何保留迭代器状态?
同样的方式。本地的状态被移动到堆上,并且表示MoveNext
下次调用时应该恢复的指令的数字与本地一起存储。
同样,迭代器块中有一堆齿轮,以确保正确处理异常。
答案 1 :(得分:36)
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
和{{ 1}}在这种情况下)但不坏(在安全的情况下重新使用自身而不是创建新对象的技巧),并且可以扩展以处理非常复杂的_current
- 使用方法。 / p>
当然,
yield
与:
相同foreach(var a in b)
{
DoSomething(a);
}
然后重复调用生成的using(var en = b.GetEnumerator())
{
while(en.MoveNext())
{
var a = en.Current;
DoSomething(a);
}
}
。
MoveNext()
案例几乎是相同的原则,但有一点额外的复杂性。要重用another answer代码中的示例,例如:
async
生成如下代码:
private async Task LoopAsync()
{
int count = 0;
while(count < 5)
{
await SomeNetworkCallAsync();
count++;
}
}
它更复杂,但却是一个非常相似的基本原则。主要的额外复杂因素是现在正在使用GetAwaiter()
。如果选中任何时间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;
}
,则返回awaiter.IsCompleted
,因为任务true
已经完成(例如,它可以同步返回的情况)然后该方法继续通过状态,但是否则设置本身就是对awaiter的回调。
发生什么事情取决于awaiter,就触发回调的内容而言(例如,异步I / O完成,在线程完成时运行的任务)以及对编组到特定线程或运行的要求有哪些要求线程池线程,原始调用的上下文可能需要也可能不需要等等。无论是什么,虽然awaiter中的某些东西会调用await
,它将继续下一段工作(直至下一个MoveNext
)或完成并返回{{1}正在实施的工作已经完成。
答案 2 :(得分:12)
这里有很多很棒的答案;我将分享一些有助于形成心理模型的观点。
首先,编译器将async
方法分成几部分; await
表达式是断裂点。 (这对于简单的方法很容易设想;带有循环和异常处理的更复杂的方法也会被分解,添加更复杂的状态机)。
其次,await
被翻译成一个相当简单的序列;我喜欢Lucian's description,其中几乎是单词&#34;如果等待已经完成,则获取结果并继续执行此方法;否则,保存此方法的状态并返回&#34;。 (我在async
intro中使用了非常相似的术语。)
当达到await时,运行时如何知道接下来应该执行哪一段代码?
该方法的其余部分作为可回收的回调存在(在任务的情况下,这些回调是连续的)。当等待完成时,它会调用它的回调。
请注意,调用堆栈未已保存并恢复;回调是直接调用的。在重叠I / O的情况下,它们直接从线程池中调用。
这些回调可以继续直接执行该方法,或者可以将其安排在其他地方运行(例如,如果await
捕获了UI SynchronizationContext
并且在线程池上完成了I / O)。
它如何知道何时可以从中断的地方恢复,以及它如何记住?
这只是回调。当等待完成时,它会调用其回调,并且已经async
已经恢复的任何await
方法都会恢复。回调跳转到该方法的中间,并且其局部变量在范围内。
回调是不运行特定线程,并且他们不已恢复其callstack。
当前调用堆栈会发生什么,是否以某种方式保存?如果调用方法在等待之前进行其他方法调用会怎样 - 为什么不覆盖堆栈?在异常情况和堆栈展开的情况下,运行时如何才能完成这一切?
首先没有保存callstack;这没有必要。
使用同步代码,您最终可以得到一个包含所有调用者的调用堆栈,运行时知道使用该调用返回的位置。
使用异步代码,你可以得到一堆回调指针 - 根据完成其任务的一些I / O操作,可以恢复完成其任务的async
方法,该方法可以恢复{ {1}}完成任务的方法等
因此,同步代码async
调用A
调用B
,您的调用堆可能如下所示:
C
而异步代码使用回调(指针):
A:B:C
当达到产量时,运行时如何跟踪应该拾取的点?如何保留迭代器状态?
目前,相当低效。 :)
它的工作方式与任何其他lambda一样 - 扩展了变量生命周期,并将引用放入堆栈中的状态对象。所有深层次详细信息的最佳资源是Jon Skeet's EduAsync series。
答案 3 :(得分:7)
yield
和await
在处理流量控制时都是两个完全不同的东西。所以我会分开处理它们。
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是什么样的。它应该是有益的。
另一方面, async
和await
是另一个鱼的水壶。摘要中,Await是一个同步原语。这是一种告诉系统的方法“在完成之前我不能继续”。但是,正如你所指出的那样,并不总是涉及一个线程。
所涉及的 是一种称为同步上下文的东西。总有一个闲逛。他们同步上下文的工作是安排正在等待的任务及其继续。
当你说await thisThing()
时,会发生一些事情。在异步方法中,编译器实际上将方法切换为较小的块,每个块是“在await之前”部分和“在等待之后”(或继续)部分。当await执行时,正在等待的任务,和以下的继续 - 换句话说,函数的其余部分 - 被传递给同步上下文。上下文负责调度任务,当它完成上下文时,然后运行continuation,传递它想要的任何返回值。
只要计划内容,同步上下文就可以随心所欲地执行任何操作。它可以使用线程池。它可以为每个任务创建一个线程。它可以同步运行它们。不同的环境(ASP.NET与WPF)提供了不同的同步上下文实现,这些实现根据对环境最佳的内容执行不同的操作。
(Bonus:曾经想知道.ConfigurateAwait(false)
做了什么?它告诉系统不要使用当前的同步上下文(通常基于你的项目类型 - 例如WPF vs ASP.NET),而是使用默认的,它使用线程池。)
再次,这是很多编译器的诡计。如果你看看生成的代码很复杂,但你应该能够看到它正在做什么。这些类型的转换很难,但确定性和数学转换,这就是编译器为我们做这些转换的原因。
P.S。默认同步上下文的存在有一个例外 - 控制台应用程序没有默认的同步上下文。查看Stephen Toub's blog以获取更多信息。这是一般查找async
和await
信息的好地方。
答案 4 :(得分:3)
通常情况下,我建议您查看CIL,但就这些情况而言,这是一团糟。
这两种语言结构在工作方面类似,但实现方式略有不同。基本上,它只是编译器魔术的语法糖,在汇编级别没有任何疯狂/不安全的东西。让我们简要地看一下它们。
yield
是一个更古老,更简单的陈述,它是基本状态机的语法糖。返回IEnumerable<T>
或IEnumerator<T>
的方法可能包含yield
,然后将该方法转换为状态机工厂。您应注意的一件事是,如果内部有yield
,则在调用方法时,方法中的代码不会运行。原因是您编写的代码被转移到IEnumerator<T>.MoveNext
方法,该方法检查它所处的状态并运行代码的正确部分。然后将yield return x;
转换为类似于this.Current = x; return true;
如果进行一些反射,您可以轻松检查构建的状态机及其字段(至少一个用于状态和本地)。如果更改字段,甚至可以重置它。
await
需要类型库的一些支持,并且工作方式有所不同。它需要Task
或Task<T>
参数,然后在任务完成时结果为其值,或者通过Task.GetAwaiter().OnCompleted
注册延续。 async
/ await
系统的全面实施需要很长时间才能解释,但它也不是神秘的。它还会创建一个状态机并将其沿着延续传递给 OnCompleted 。如果任务完成,则在延续中使用其结果。等待者的实现决定了如何调用延续。通常它使用调用线程的同步上下文。
yield
和await
都必须根据它们的出现来分割方法,以形成状态机,机器的每个分支代表方法的每个部分。
你不应该在&#34;较低级别&#34;中考虑这些概念。像堆栈,线程等术语这些是抽象,它们的内部工作不需要CLR的任何支持,它只是执行魔术的编译器。这与Lua的协同程序有很大的不同,Lua的协同程序确实有运行时支持,或者C&#39> longjmp ,这只是黑魔法。