异步递归。我的记忆实际上去了哪里?

Async recursion. Where is my memory actually going?

问这个问题更多是出于好奇,而不是针对任何现实世界的问题。

考虑以下代码:

void Main()
{
    FAsync().Wait();
}

async Task FAsync()
{
    await Task.Yield();
    await FAsync();
}

在同步世界中,这最终会导致计算器溢出。

在异步世界中,这只会消耗大量内存(我认为这与我可能松散地称为 "asynchronous stack" 的东西有关?)

这些数据到底是什么,它是如何保存的?

好问题。

堆栈是continuation的具体化。简单来说,继续就是关于程序接下来要做什么的信息。在传统的非异步环境中,这表示为堆栈上的 return 地址;当方法 returns 它查看堆栈并分支到 return 地址。堆栈上还有关于局部变量的值在延续开始时的信息。

在异步情况下,所有信息都存储在堆上。任务包含一个在任务完成时调用的委托。委托绑定到 "closure" class 的实例,其中包含任何局部变量或其他状态的字段。当然任务本身就是堆对象。

您可能想知道:如果延续是在任务完成时调用的委托,那么完成任务的代码怎么不在调用堆栈上 在执行完成的地方?任务可以选择通过发布windows消息来调用延续委托,当消息循环处理消息时,它会进行调用。因此,调用位于堆栈的 "top" 处,消息循环通常位于此处。 (用于延续的调用策略的精确细节取决于创建任务的上下文;有关详细信息,请参阅任务并行库的更高级指南。)

可以在此处找到关于这一切如何工作的很好的介绍性文章:

https://msdn.microsoft.com/en-us/magazine/hh456403.aspx

自 Mads 撰写那篇文章以来,一些细节发生了变化,但想法是正确的。 (i3arnon 的回答说明了这是如何演变的;在 Mads 的文章中,一切都在堆上,但在某些情况下这会产生过多的垃圾。更复杂的代码生成器允许我们在堆栈上保留一些信息。理解这种区别并不重要有必要了解延续是如何在逻辑上表示的。)

执行您的程序并实际绘制出所有创建的委托和任务,以及它们之间的引用是什么,这是一项有趣且富有启发性的练习。试一试!

编译器将您的异步方法转换为状态机结构。该结构首先在堆栈上创建。当您等待未完成的任务时(否则它会继续 运行 同步并导致堆栈溢出)状态机被装箱并移动到堆中。

例如这个方法:

public async Task M()
{
}

变成这个状态机:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    void IAsyncStateMachine.MoveNext()
    {
        try
        {
        }
        catch (Exception exception)
        {
            this.<>1__state = -2;
            this.<>t__builder.SetException(exception);
            return;
        }
        this.<>1__state = -2;
        this.<>t__builder.SetResult();
    }
    [DebuggerHidden]
    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
    {
        this.<>t__builder.SetStateMachine(stateMachine);
    }
}

因此,在 "traditional" 递归中,每次迭代的状态都存储在堆栈中,因此过多的迭代会溢出内存。在异步方法中,状态存储在堆上,它也可能溢出(尽管它通常要大得多)。