【问题标题】:Recursion and the await / async Keywords递归和 await / async 关键字
【发布时间】:2012-11-28 07:00:54
【问题描述】:

我对@9​​87654321@ 关键字的工作原理有一个脆弱的了解,我想稍微扩展一下对它的理解。

仍然让我头晕目眩的问题是递归的使用。这是一个例子:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TestingAwaitOverflow
{
    class Program
    {
        static void Main(string[] args)
        {
            var task = TestAsync(0);
            System.Threading.Thread.Sleep(100000);
        }

        static async Task TestAsync(int count)
        {
            Console.WriteLine(count);
            await TestAsync(count + 1);
        }
    }
}

这个显然抛出了StackOverflowException

我的理解是因为代码实际上是同步运行的,直到第一个异步操作,之后它返回一个 Task 对象,其中包含有关异步操作的信息。在这种情况下,没有异步操作,因此它只是在它最终会返回一个Task 的虚假承诺下不断递归。

现在稍微改变一下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TestingAwaitOverflow
{
    class Program
    {
        static void Main(string[] args)
        {
            var task = TestAsync(0);
            System.Threading.Thread.Sleep(100000);
        }

        static async Task TestAsync(int count)
        {
            await Task.Run(() => Console.WriteLine(count));
            await TestAsync(count + 1);
        }
    }
}

这个不会抛出StackOverflowException。我可以 sortof 了解它为什么起作用,但我会称它为一种直觉(它可能涉及如何安排代码使用回调来避免构建堆栈,但我无法翻译那种直觉的解释)

所以我有两个问题:

  • 第二批代码如何避免StackOverflowException
  • 第二批代码会不会浪费其他资源? (例如,它是否在堆上分配了大量的 Task 对象?)

谢谢!

【问题讨论】:

    标签: c# recursion async-await


    【解决方案1】:

    任何函数中直到第一个等待的部分都是同步运行的。在第一种情况下,它会因此而陷入堆栈溢出 - 没有任何东西会中断函数调用自身。

    第一次等待(它不会立即完成 - 这很可能是您的情况)导致函数返回(并放弃其堆栈空间!)。它将其余部分作为延续进行排队。 TPL 确保延续永远不会嵌套太深。如果存在堆栈溢出的风险,则继续排队到线程池,重置堆栈(开始填满)。

    第二个例子还是会溢出!如果Task.Run任务总是立即完成怎么办? (这不太可能,但可以通过正确的操作系统线程调度来实现)。然后,异步函数将永远不会被中断(导致它返回并释放所有堆栈空间),并且会产生与情况 1 相同的行为。

    【讨论】:

    • 那么如果我将Task.Run() 替换为立即返回已完成的Task 对象的函数,它会重新引入堆栈溢出异常吗? (或者我只是提出了一些不可能的事情?)
    • @Stargazer712 不仅有可能,而且确实可以做到您所描述的。试试await Task.FromResult<object>(null); 这就是为什么你应该避免在短时间内调用await 的单个函数;您应该确保等待的任务运行时间相当长,或者它们的数量有限,以便同步运行它们是可以的。
    • @Stargazer712 是的!如果您将其替换为Task.Yield()(保证需要发布延续),您将获得保证免于堆栈溢出(以性能为代价)。请注意,Yield 返回一个可等待的对象,而不是 Task。从任务中获得这种保证要困难得多,因为您必须确保在等待功能查询时它没有完成。
    【解决方案2】:

    在您的第一个和第二个示例中,TestAsync 仍在等待对自身的调用返回。不同之处在于递归是在第二种方法中打印并将线程返回到其他工作。因此递归不够快,不足以成为堆栈溢出。但是,第一个任务仍在等待,最终计数将达到其最大整数大小,否则将再次引发堆栈溢出。关键是调用线程被返回,但实际的异步方法被安排在同一个线程上。基本上,TestAsync 方法在等待完成之前会被遗忘,但它仍保留在内存中。允许线程做其他事情,直到等待完成,然后该线程被记住并在等待停止的地方完成。额外的等待调用存储线程并再次忘记它,直到等待再次完成。直到所有等待都完成并且方法因此完成,TaskAsync 仍在内存中。所以,事情就是这样。如果我告诉一个方法做某事,然后调用等待任务。我在其他地方的其余代码继续运行。当 await 完成时,代码会重新回到那里并完成,然后回到它之前的那个时间正在做的事情。在您的示例中,您的 TaskAsync 始终处于墓碑状态(可以这么说),直到最后一次调用完成并将调用返回到链中。

    编辑:我一直说存储线程或那个线程,我的意思是例程。它们都在同一个线程上,这是您示例中的主线程。对不起,如果我让你感到困惑。

    【讨论】:

    • 但是这里没有使用堆栈,只有堆分配的任务链,它们相互指向,并且永远等待。
    猜你喜欢
    • 2013-05-14
    • 2015-05-04
    • 2012-06-29
    • 1970-01-01
    • 2017-11-08
    • 2018-02-09
    • 1970-01-01
    • 1970-01-01
    • 2017-05-13
    相关资源
    最近更新 更多