递归和await/async关键字

33

我对await关键字的工作原理掌握得不够牢固,我想进一步了解它。

仍然让我晕头转向的问题是递归的使用。以下是一个例子:

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 异常。我大概能理解为什么它能工作,但更多地是直觉上的感觉(可能与代码如何排列以使用回调避免建立堆栈有关,但我无法将那种直觉转化为一种解释)

所以我有两个问题:

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

谢谢!

2个回答

18

任何函数中第一个await之前的部分都会同步运行。在第一个例子中,由于没有任何东西中断函数调用自身,因此它会遇到堆栈溢出。

第一个await(一般情况下不会立即完成)导致函数返回并放弃其堆栈空间!剩余的部分被排队作为延续处理。TPL确保延续不会嵌套得太深。如果有堆栈溢出的风险,则将延续排队到线程池中,重置堆栈(堆栈已经开始填充)。

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


那么,如果我用一个立即返回已完成的Task对象的函数替换了Task.Run(),这会重新引发堆栈溢出异常吗?(或者我刚刚提出了一些不可能的东西?) - riwalk
@Stargazer712,不仅可以实现你所描述的功能,而且确实会这样做。只需尝试await Task.FromResult<object>(null);。这就是为什么你应该避免在短时间内多次调用await的单个函数;你应该确保等待的任务要么操作时间相当长,要么任务数量有限,以便同步运行它们是可以的。 - Servy
5
是的!如果你将其替换为 Task.Yield()(这保证了需要发布一个 continuation),则可以确保不会出现堆栈溢出的问题(但要付出性能代价)。请注意,Yield 返回的是一个除 Task 之外的可等待对象。对于任务来说,要获得这种保证更加困难,因为你必须确保在 await 特性查询时它没有完成。 - usr

0
在您的第一个和第二个示例中,TestAsync仍在等待调用自身返回。区别在于递归在第二种方法中打印并返回线程到其他工作。因此,递归不足以导致堆栈溢出。然而,第一个任务仍在等待,最终计数将达到其最大整数大小或再次抛出堆栈溢出。重点是调用线程已经返回,但实际的异步方法已安排在同一线程上。基本上,TestAsync方法被遗忘,直到await完成,但它仍然保存在内存中。线程允许执行其他操作,直到await完成,然后该线程被记住并在await离开的地方完成。额外的await调用存储线程并再次忘记它,直到await再次完成。在所有等待完成之前,方法因此完成TaskAsync仍在内存中。所以,问题来了。如果我告诉一个方法去做某事,然后调用任务的await。我的其他代码仍在运行。当await完成时,代码会在那里重新开始并完成,然后回到它之前正在做的事情。在您的示例中,您的TaskAsync始终处于墓碑状态(可以这么说),直到最后一个调用完成并将调用返回到链中。

编辑:我一直在说存储线程或那个线程,但我指的是例子中的例程。它们都在同一个线程上,也就是主线程。如果我让你感到困惑,对不起。


1
但是这里没有使用堆栈,只有一系列在堆上分配的任务,它们相互指向并永远等待。 - Bogdan Mart

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