如何在.NET中产生并等待实现控制流?


105

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

同样,await不仅等待被调用方,而且将控制权返回给调用方,仅在调用方awaits方法时从中断的地方接管。

换句话说,没有线程,异步和等待的“并发性”是由聪明的控制流引起的错觉,其细节被语法隐藏了。

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

await到达,如何运行时知道什么是一段代码下一步应该执行?它如何知道何时可以从上次中断的地方恢复,以及如何记住在哪里?当前调用堆栈发生了什么,是否以某种方式保存了它?如果调用方法在此之前进行其他方法调用await怎么办?为什么堆栈不被覆盖?在异常和堆栈展开的情况下,运行时到底将如何处理所有这一切?

yield到达,如何运行跟踪那里的东西应该拿起点的?迭代器状态如何保存?


4
您可以在TryRoslyn在线编译器中查看生成的代码
xanatos

1
您可能需要查看Jon Skeet 撰写的Eduasync文章系列
Leonid Vasilev

Answers:


115

我将在下面回答您的特定问题,但是您可能会很容易阅读我有关如何设计产量和等待时间的大量文章。

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 生成为:

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调用方法bar时,无论如何执行bar,我们都以某种方式记得如何回到foo的中间,而激活foo的所有本机都保持不变。

您知道在汇编器中是如何完成的。foo的激活记录被压入堆栈;它包含本地人的值。在调用时,将foo中的返回地址压入堆栈。完成bar之后,堆栈指针和指令指针将重置为所需的位置,而foo将从其中断处继续运行。

等待的继续是完全相同的,除了将记录放到堆上是出于明显的原因,即激活序列没有形成堆栈

等待任务继续执行的委托包含(1)一个数字,该数字是查找表的输入,该查询表提供了接下来需要执行的指令指针,以及(2)所有本地和临时值。

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

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

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

呼叫者的激活记录未保存;请记住,等待可能会回到他们身边,所以他们将得到正常处理。

请注意,这是简化的继续等待传递样式与您在诸如Scheme之类的语言中看到的真正的当前通话继续结构之间的紧密区别。在这些语言中,整个延续(包括回到呼叫者的延续)由call-cc捕获。

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

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

在异常和堆栈展开的情况下,运行时到底如何处理所有这些问题?

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

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

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

同样的方法。当地人的状态被移到堆上,并且代表该指令的数字MoveNext在下一次被调用时应在该指令处重新存储。

同样,在迭代器块中还有很多工具可以确保正确处理异常。


1
由于问题作者的背景(汇编程序等),可能值得一提的是,如果没有托管内存,这两种构造都是不可能的。如果没有托管内存来尝试协调闭包的生存期,那么肯定会让您绊倒。
吉姆(Jim)

找不到所有页面链接(404)
Digital3D

您的所有文章现在都不可用。您可以重新发布吗?
米哈尔Turczyn

1
@MichałTurczyn:他们仍然在互联网上;Microsoft一直在移动博客存档。我将逐步将它们全部迁移到我的个人站点,并在有时间的时候尝试更新这些链接。
埃里克·利珀特

38

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由于任务awaited已经完成(例如,它可以同步返回),该方法将返回,然后该方法将继续遍历状态,否则将其自身设置为对等待者的回调。

究竟发生什么取决于等待者,包括触发回调的原因(例如异步I / O完成,在线程上运行的任务完成)以及对编组到特定线程或在线程池线程上运行有什么要求,可能需要也可能不需要原始调用中的上下文,依此类推。无论该等待者中的内容是什么,它都会调用MoveNext,它将继续进行下一个工作(直到下一个工作await),或者完成并返回,在这种情况下Task,实现的对象将完成。


您是否花了时间自己翻译?O_O uao。
CoffeDeveloper

4
@DarioOO第一个我可以很快完成的工作,yield在这样做有好处的情况下,已经完成了很多从手动翻译到手动翻译的过程(通常是一种优化,但是要确保起点接近编译器生成的位置)因此,通过错误的假设,一切都不会变得最优化)。第二个是在另一个答案中首次使用的,当时我的知识还存在一些空白,因此我在填补这些内容的同时,还通过手工反编译代码来提供答案,从而使自己受益匪浅。
乔恩·汉纳

13

这里已经有很多不错的答案;我将分享一些有助于形成心理模型的观点。

首先,async编译器将方法分为几部分;该await表达式是断裂点。(对于简单的方法,这是很容易想到的;带有循环和异常处理的更复杂的方法也可以通过添加更复杂的状态机来分解)。

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

当到达等待状态时,运行时如何知道接下来应该执行什么代码?

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

请注意,不会保存和恢复调用堆栈。回调直接被调用。如果I / O重叠,则直接从线程池中调用它们。

这些回调可以继续直接执行该方法,或者可以调度该方法在其他地方运行(例如,如果await捕获的UI SynchronizationContext和I / O在线程池上完成)。

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

全部都是回调。当一个awaitable完成时,它将调用其回调,然后恢复async已经await编辑过的所有方法。回调跳转到该方法的中间,并在范围内具有其局部变量。

回调运行特定线程,并且它们没有恢复其调用栈。

当前调用堆栈发生了什么,是否以某种方式保存了它?如果调用方法在等待之前进行其他方法调用怎么办-为什么堆栈不被覆盖?在异常和堆栈展开的情况下,运行时到底如何处理所有这些问题?

调用堆栈不会首先保存;没必要

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

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

所以,用同步码A呼叫B通话C,您的调用堆栈可能看起来像这样:

A:B:C

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

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

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

目前,效率很低。:)

它的工作原理与其他任何lambda一样-延长了变量生存期,并将引用放置在存在于堆栈中的状态对象中。有关所有深入细节的最佳资源是Jon Skeet的EduAsync系列


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()实现的内部是一堆状态机代码,它们将执行循环的“一步”,然后返回。因此,尽管您像简单循环一样在源代码中编写代码,但实际上并不是一个简单的循环。因此,编译器很棘手。如果想看自己,请拉出ILDASM或ILSpy或类似工具,然后查看生成的IL的外观。应该具有启发性。

asyncawait另一方面,又是另外一锅鱼。抽象来说,等待是一个同步原语。这是一种告诉系统“在完成此操作之前我无法继续”的方法。但是,正如您指出的,并不总是涉及线程。

什么涉及的是一种叫做同步上下文。总是有一个闲逛。他们同步上下文的工作是安排正在等待的任务及其继续。

当您说时await thisThing(),会发生一些事情。在异步方法中,编译器实际上将方法分成较小的块,每个块是“ await afore”之前的部分和“ await await”之后(或继续)的部分。当等待执行时,正在等待的任务以及随后的继续操作(换句话说,函数的其余部分)将传递到同步上下文。上下文负责安排任务,完成后,上下文将继续运行,并传递所需的任何返回值。

只要安排了内容,同步上下文就可以随意执行任何所需的操作。它可以使用线程池。它可以为每个任务创建一个线程。它可以同步运行它们。不同的环境(ASP.NET与WPF)提供了不同的同步上下文实现,这些实现根据适合其环境的最佳方式执行不同的操作。

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

同样,这也是很多编译器的难题。如果您查看生成的代码,它很复杂,但是您应该能够看到它在做什么。这些类型的转换很困难,但是是确定性的和数学的,这就是为什么编译器为我们完成这些转换非常好。

PS:默认同步上下文存在一个例外-控制台应用程序没有默认同步上下文。查看Stephen Toub的博客以获取更多信息。总体上来说async,这是一个查找信息的好地方await


1
“这是在告诉系统不要使用默认的同步上下文,而是使用默认的同步上下文,该上下文使用线程池”,您能弄清楚这是什么意思吗?“不使用默认值,使用默认值”
Kroltan

3
抱歉,混淆了我的术语,我将解决此问题。基本上,不要在您所处的环境中使用默认值,而在.NET(即线程池)中使用默认值。
克里斯·塔瓦雷斯

非常简单,能够理解,您得到了我的投票:)
Ehsan Sajjad

4

通常,我建议您查看CIL,但在这种情况下,情况很糟。

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

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

如果您进行了一些反思,则可以轻松地检查构造的状态机及其字段(对于状态和本地而言至少是一个)。如果更改字段,甚至可以重置它。

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

二者yieldawait具有分裂方法建立基于他们的次数,以形成一个状态机,与表示该方法的各部分的机器的每个分支。

您不应该以栈,线程等“低级”术语来考虑这些概念。它们是抽象的,它们的内部工作不需要CLR的任何支持,只是编译器发挥了作用。这与Lua的协程(确实具有运行时的支持)或C的longjmp(这简直是魔术)完全不同。


5
旁注await不必参加任务。任何东西INotifyCompletion GetAwaiter()都足够。有点类似于foreach不需要IEnumerable,只要有IEnumerator GetEnumerator()就足够了。
IllidanS4希望莫妮卡回到
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.