在.NET中,yield和await如何实现控制流?

108
据我所理解,如果在迭代器块内使用yield关键字,它会将控制流返回给调用代码,并且当再次调用迭代器时,它会从上次离开的地方继续执行。
此外,await不仅等待被调用者,还会将控制返回给调用者,只有在调用者await方法时才会继续执行之前离开的地方。
换句话说——没有线程,异步和等待的“并发性”是由巧妙的控制流引起的幻觉,其细节被语法所隐藏。
现在,我曾经是一个汇编程序员,非常熟悉指令指针、堆栈等,了解正常的控制流程(子例程、递归、循环、分支)如何工作。但是这些新的构造——我不懂它们。
当遇到await时,运行时如何知道下一个应该执行的代码段?它如何知道何时恢复之前离开的地方,以及如何记住它?当前调用堆栈会发生什么情况,它会被保存吗?如果调用方法在await之前进行其他方法调用,为什么堆栈不会被覆盖?如果异常和堆栈展开出现,运行时将如何处理这些情况?
当遇到yield时,运行时如何跟踪可以恢复执行的位置?如何保留迭代器状态?

4
您可以在TryRoslyn在线编译器中查看生成的代码。 - xanatos
1
你可能想查看Jon Skeet的Eduasync文章系列 - Leonid Vasilev
相关有趣阅读:https://dev59.com/jloU5IYBdhLWcg3we24R?rq=1 - Jason C
5个回答

116

我将在下面回答你的具体问题,但你最好仍然阅读我们如何设计yield和await的详细文章。

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/

Some of these articles are out of date now; the code generated is different in a lot of ways. But these will certainly give you the idea of how it works.
Also, if you do not understand how lambdas are generated as closure classes, understand that first. You won't make heads or tails of async if you don't have lambdas down.
When an await is reached, how does the runtime know what piece of code should execute next?
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

基本上就是这样。Await只是一个花哨的返回。

它如何知道在哪里可以恢复之前的操作,以及如何记住位置呢?

那么,如果没有await,你怎么做到这一点呢?当方法foo调用方法bar时,我们以某种方式记住了如何回到foo的中间,并保持foo激活时的所有本地内容不变,无论bar做了什么。

你知道汇编语言是如何做到这一点的。为foo创建一个激活记录并将其推入堆栈;它包含本地内容的值。在调用点,foo中的返回地址被推入堆栈。当bar完成后,堆栈指针和指令指针被重置到需要的位置,并且foo从离开的地方继续执行。

等待的继续与此完全相同,只是该记录被放置在堆上,原因很明显,即“激活序列的顺序不形成堆栈”。

await作为任务给出的委托包含(1)一个数字,它是查找表的输入,该表给出您需要执行的下一个指令指针,以及(2)所有本地变量和临时变量的值。

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

当前调用栈会发生什么,它会以某种方式保存吗?

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

调用者的激活记录不会被存储;请记住,await可能会返回给它们,因此它们将按正常方式处理。

请注意,这是await简化的延续传递样式和真正的带有当前延续结构之间的一个相关区别,您可以在像Scheme这样的语言中看到整个包括返回调用者的延续的call-cc

如果调用方法在等待之前进行其他方法调用,为什么堆栈不会被覆盖?
那些方法调用返回后,它们的激活记录在等待点不再存在于堆栈上。
如果发生未捕获的异常,异常会被捕获并存储在任务中,在获取任务结果时重新抛出。
记住我之前提到的所有簿记吗?正确处理异常语义是非常困难的。
当到达 yield 时,运行时如何跟踪应该恢复的点?迭代器状态如何保留?
同样的方式。局部变量的状态被移动到堆上,并存储一个数字表示 MoveNext 下一次被调用时应恢复的指令与局部变量一起。
而且,有一堆迭代器块中的工具来确保正确处理异常。

1
由于问题作者的背景(汇编语言等),可能值得一提的是,这两个结构都不可能在没有托管内存的情况下实现。如果没有托管内存来协调闭包的生命周期,那么你肯定会被绊倒在自己的靴带上。 - Jim
所有页面链接都未找到(404)。 - Digital3D
你的所有文章现在都无法访问。你能重新发布一下吗? - Michał Turczyn
1
@MichałTurczyn:它们仍然在互联网上;微软不断地迁移博客存档的位置。我将逐步将它们全部迁移到我的个人网站,并尽可能更新这些链接,当我有时间时。 - Eric Lippert

39

yield是两者中较简单的,让我们来看看它。

假设我们有:

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

这将被编译,就像我们写了:

这个bit会被编译。

// 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 ,因为待await的任务已经完成 (例如可以同步地返回的情况),那么该方法将继续移动到下一状态,否则它会将自己设置为 awaiter 的回调函数。

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


你有没有花时间自己做翻译?O_O uao。 - CoffeDeveloper
4
@DarioOO 我可以很快完成第一个任务,因为我已经经常将yield翻译成手动编写的代码,通常是作为优化的一部分,但我想确保起点接近于编译器生成的代码,以避免由于错误的假设而导致不优化。第二个问题最初在另一个答案中使用,当时我的知识有些欠缺,因此我通过手动反编译代码来填补空白,从而在提供答案的同时受益。 - Jon Hanna

13
这里已经有很多好的答案了,我只想分享一些观点来帮助形成一个心理模型。
首先,编译器会将async方法分解为几个部分;await表达式是断裂点。(对于简单的方法,这很容易想象;更复杂的方法包括循环和异常处理也被分解,加上更复杂的状态机)。
其次,await被转换为一个相当简单的序列;我喜欢Lucian's description,用语言描述就是"如果可等待对象已经完成,则获取结果并继续执行此方法;否则,保存此方法的状态并返回"。(我在我的async intro中使用非常类似的术语)。
当到达await时,运行时如何知道下一个应该执行的代码片段?
该方法的剩余部分作为回调函数存在于可等待对象中(在任务的情况下,这些回调被称为续集)。当可等待对象完成时,它会调用其回调函数。
请注意,调用堆栈未保存和恢复;回调直接调用。在重叠 I/O 的情况下,它们直接从线程池中调用。
这些回调可以直接继续执行该方法,也可以安排在其他地方运行(例如,如果 await 捕获了 UI 同步上下文,并且 I/O 完成于线程池上)。
“它如何知道何时可以恢复到离开的地方,如何记住何处离开?” 这都只是回调函数。当可等待对象完成时,它会调用其回调函数,已经 await 该对象的任何 async 方法都会得到恢复。回调跳入该方法的中间并拥有其局部变量的范围。
这些回调不在特定的线程上运行,也不会恢复其调用堆栈。
当发生什么时,当前调用栈会被保存吗?如果调用方法在等待之前进行其他方法调用,为什么堆栈不会被覆盖?如果发生异常和堆栈展开的情况下,运行时如何处理所有这些? 首先,调用栈并没有被保存,也没有必要。对于同步代码,您可能会得到一个包括所有调用者的调用栈,运行时知道从哪里返回。对于异步代码,您可能会得到一堆回调指针 - 以某个完成任务的I/O操作为根,可以恢复完成其任务的async方法,可以恢复完成其任务的async方法等。因此,对于同步代码A调用B调用C,您的调用栈可能如下所示:
A:B:C

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

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

当达到 yield 时,运行时如何跟踪应该恢复的位置?如何保留迭代器状态?
目前,效率相对较低。:)
它像任何其他 lambda 一样工作 - 变量生命周期被延长,并且引用被放置到存储在堆栈上的状态对象中。有关所有深层细节的最佳资源是 Jon Skeet 的 EduAsync 系列。链接:Jon Skeet's EduAsync series

7

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()实现内部,有一堆状态机代码,会执行"step"循环然后返回。所以虽然你在源代码中写它像一个简单的循环,但在底层它并不是一个简单的循环,而是编译器的技巧。如果你想看到自己,可以使用ILDASM或ILSpy等工具,并查看生成的IL的样子,这应该是有教益的。
另一方面,async和await则完全是另一种情况。抽象地说,await是同步原语。它是告诉系统“我在这个完成之前不能继续进行”。但正如你所指出的,有时并不涉及线程。
涉及的是称为同步上下文的东西。始终有一个同步上下文存在。同步上下文的工作是安排正在等待和它们的继续任务。
当你使用await thisThing()时,会发生一些事情。在异步方法中,编译器实际上将该方法分成更小的块,每个块都是“await之前”部分和“await之后”(或续集)部分。当await执行时,被等待的任务和接下来的续集-换句话说,函数的其余部分-都会传递给同步上下文。上下文负责安排任务,当任务完成后,上下文运行续集,并传递任何它想要的返回值。
同步上下文可以自由地做任何它想做的事情,只要它安排了东西。它可以使用线程池。它可以为每个任务创建一个线程。它可以同步运行它们。不同的环境(ASP.NET vs. WPF)提供了不同的同步上下文实现,根据最适合它们环境的内容进行不同的处理。
(奖励:曾经想过.ConfigurateAwait(false)是什么意思吗?它告诉系统不要使用当前的同步上下文(通常基于您的项目类型-WPF vs ASP.NET),而是使用默认的同步上下文,该上下文使用线程池)。

因此,这需要很多编译器的技巧。如果您查看生成的代码,它是复杂的,但您应该能够看到它在做什么。这些类型的转换很难,但是是确定性和数学的,这就是为什么幸运的是编译器正在为我们完成它们。

附注:存在默认同步上下文的唯一例外是控制台应用程序没有默认同步上下文。请查看Stephen Toub的博客以获取有关asyncawait的更多信息。这是一个非常好的地方,可以了解一般信息。


1
“这是告诉系统不要使用默认的同步上下文,而是使用默认的线程池上下文。” 你能解释一下你的意思吗?“不要使用默认,使用默认”。 - Kroltan
3
抱歉,我术语搞混了,我会修改帖子。基本上,不要使用你所在环境的默认选项,而是使用.NET的默认选项(即线程池)。 - Chris Tavares
非常简单,易于理解,你得到了我的投票 :) - Ehsan Sajjad

4
通常来说,我会建议查看CIL(公共中间语言),但在这种情况下,它有点混乱。
这两个语言结构的工作方式相似,但实现方式有些不同。基本上,它只是一种编译器魔法的语法糖,在汇编级别没有什么疯狂或不安全的地方。让我们简要地看一下它们。
yield是一个较旧且更简单的语句,它是一个基本状态机的语法糖。返回IEnumerable或IEnumerator的方法可能包含一个yield,然后将该方法转换为状态机工厂。您应该注意到的一件事是,如果存在yield,当您调用它时,方法中没有任何代码被运行。原因是您编写的代码被转移到IEnumerator.MoveNext方法中,该方法检查其所处的状态并运行正确的代码部分。yield return x;然后转换为类似于this.Current = x; return true;的内容。
如果进行一些反射,您可以轻松地检查构造的状态机及其字段(至少有一个用于状态和局部变量)。如果更改字段,则甚至可以重置它。
await需要从类型库获得一些支持,并且工作方式有所不同。它接受一个Task或Task参数,如果任务已完成,则返回其值;否则,通过Task.GetAwaiter().OnCompleted注册一个继续。async/await系统的完整实现要解释的太长,但它也不是那么神秘。它还创建一个状态机,并将其传递给OnCompleted的继续。如果任务已完成,则在继续中使用其结果。awaiter的实现决定如何调用继续。通常,它使用调用线程的同步上下文。
无论是yield还是await都必须根据发生的位置将方法分成若干部分以形成状态机,其中机器的每个分支代表方法的每个部分。
您不应该像堆栈、线程等“低级”的术语来考虑这些概念。这些都是抽象的,它们的内部工作不需要CLR的任何支持,只有编译器才会做魔法。这与Lua的协同程序大不相同,后者确实具有运行时的支持,或者C的longjmp,后者只是黑魔法。

5
: await 不一定只能接受 Task。任何具有 INotifyCompletion GetAwaiter() 方法的对象都可以使用。这有点类似于 foreach 不需要 IEnumerable,任何具有 IEnumerator GetEnumerator() 方法的对象都可以使用。 - IS4

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接