.NET 4.8中的异步等待递归会导致堆栈溢出异常(但在.NET Core 3.1中不会!)

6
为什么下面的代码在.Net4.8中只进行17层递归就会导致StackOverflowException,但在NetCore 3.1中不会发生这种情况(我可以将计数设置为10,000并且仍然可以正常工作)?
class Program
{
  static async Task Main(string[] args)
  {
    try
    {
      await TestAsync(17);
    }
    catch(Exception e)
    {
      Console.WriteLine("Exception caught: " + e);
    }
  }

  static async Task TestAsync(int count)
  {
    await Task.Run(() =>
    {
      if (count <= 0)
        throw new Exception("ex");
    });

    Console.WriteLine(count);
    await TestAsync2(count);
  }

  static async Task TestAsync2(int count) => await TestAsync3(count);
  static async Task TestAsync3(int count) => await TestAsync4(count);
  static async Task TestAsync4(int count) => await TestAsync5(count);
  static async Task TestAsync5(int count) => await TestAsync6(count);
  static async Task TestAsync6(int count) => await TestAsync(count - 1);
}

这是.Net 4.8中已知的bug吗?我期望在这样的函数中有超过17层递归... 这是否意味着使用async/await编写递归不被推荐?

更新:简化版本

class Program
{
  // needs to be compiled as AnyCpu Prefer 64-bit
  static async Task Main(string[] args)
  {
    try
    {
      await TestAsync(97); // 96 still works
    }
    catch(Exception e)
    {
      Console.WriteLine("Exception caught: " + e);
    }
  }

  static async Task TestAsync(int count)
  {
    await Task.Run(() =>
    {
      if (count <= 0)
        throw new Exception("ex");
    });

    Console.WriteLine(count);
    await TestAsync(count-1);
  }
}

如果选择 任意CPU 且禁用 32位,则只会发生这么快的情况, 但在多台机器上(Windows 1903和1909)的多个.NET版本(.NET 4.7.2和.NET 4.8)中都可以重现此问题。


count < 0 时,它会抛出异常。但在 TestAsync6 中,您使用 count-1 调用了 TestAsync。无论您从17或2或其他数字开始,迟早都会使用 -1 调用它,然后就会出现异常。我认为这是可以预料的。 - Wiktor Zychla
是的,当 count <= 0 时会抛出异常,如果使用 2 调用它,则会抛出异常并且异常将被捕获,一切都很好。但是,当使用 17 或更高 的数字调用时,异常将被抛出,但反过来会导致堆栈溢出异常,这是我没有预料到的... - Inspyro
我真的不明白为什么不同PC上运行时会有不同的递归限制?如果能知道这是在运行时还是编译期间发生的差异就好了,但是如果某些机器上的递归限制低至17(甚至更低?),那么这对我来说并不利,并且暗示着这可能是一个bug。 - Inspyro
没有错,两台机器都是64位的,我使用默认设置进行编译。 IlSpy显示:// 架构:AnyCPU(64位优先) 但是我关于操作系统版本的判断是错误的,两台机器具有完全相同的版本:Windows 1903(OS Build 18362.720)。 - Inspyro
gofile.io 版本在我的操作系统 - Windows 1909 上崩溃了。 - D.R.
显示剩余5条评论
1个回答

5
我猜测你看到的是关于完整性的Stack Overflow,也就是说,每个数字都会被打印出来,一直到1,然后才会显示Stack Overflow消息。
我猜测这种行为是由于await使用同步续体。有代码可以防止同步续体溢出堆栈,但它是启发式的,并且不总是起作用。
我怀疑这种行为在.NET Core上不会发生,因为大量的优化工作已经涉及到了.NET Core的async支持,很可能意味着该平台上的续体占用更少的堆栈空间,从而使启发式检查起作用。也有可能启发式本身已经在.NET Core中修复。无论哪种方式,我不会指望.NET Framework得到这些更新。

我希望在这样的函数中能期望到更多的递归层数超过17层...

并不是真正的17层递归。您有102个递归级别(17 * 6)。为了测量实际占用的堆栈空间,它将是17 * 6 * (要恢复继续的堆栈数)。在我的机器上,17可以工作; 它会在200次以上失败(深度达到1200次)。
请记住,这只发生在长序列的尾部递归异步函数中——即,在他们的await之后没有更多的异步工作要做。如果您更改其中任何一个函数以在其递归await之后有其他异步工作,那么就可以避免堆栈溢出。
static async Task TestAsync(int count)
{
  await Task.Run(() =>
  {
    if (count <= 0)
      throw new Exception("ex");
  });

  Console.WriteLine(count);
  try
  {
    await TestAsync2(count);
  }
  finally
  {
    await Task.Yield(); // some other async work
  }
}

是的,你是正确的。我更新了我的问题,提供了一个更简单的示例,展示了97个直接递归级别是关键点。但是,只有在禁用首选32位时,编译为任何CPU时才会这么低,所以肯定还有其他的问题。我也测试了yield版本,并且它确实解决了问题,但是在我们的真实场景中(访客者),我们有一些不需要处理的visit方法... 所以我仍然认为这是一个bug。 - Inspyro
我同意这是一个bug,但我不认为它会被修复。 - Stephen Cleary
2
我们将在未来几周内与微软开始一次支持电话,我会在第一时间更新此问题的进展。 - Inspyro
1
它能在.NET Core上运行,因为有#23152的支持。 - Paulo Morgado

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