在递归方法中使用async / await的正确方法是什么?

在递归方法中使用async / await的正确方法是什么? 这是我的方法:

public string ProcessStream(string streamPosition) { var stream = GetStream(streamPosition); if (stream.Items.count == 0) return stream.NextPosition; foreach(var item in stream.Items) { ProcessItem(item); } return ProcessStream(stream.NextPosition) } 

以下是使用async / await的方法:

 public async Task ProcessStream(stringstreamPosition) { var stream = GetStream(streamPosition); if (stream.Items.count == 0) return stream.NextPosition; foreach(var item in stream.Items) { await ProcessItem(item); //ProcessItem() is now an async method } return await ProcessStream(stream.NextPosition); } 

虽然我必须提前说明方法的意图对我来说并不完全清楚,但用简单的循环重新实现它是非常简单的:

 public async Task ProcessStream(string streamPosition) { while (true) { var stream = GetStream(streamPosition); if (stream.Items.Count == 0) return stream.NextPosition; foreach (var item in stream.Items) { await ProcessItem(item); //ProcessItem() is now an async method } streamPosition = stream.NextPosition; } } 

递归不是堆栈友好的,如果您可以选择使用循环,那么在简单的同步场景(控制不良的递归最终导致StackOverflowException )以及异步场景中,这绝对是值得研究的,我会说实话,我甚至不知道如果你把事情推得太远会发生什么(每当我尝试使用async方法重现已知的堆栈溢出场景时,我的VS测试资源管理器崩溃)。

诸如Recursion和await / async关键字之类的答案表明,由于async/await状态机的工作方式, StackOverflowExceptionasync问题的影响较小,但这并不是我所探讨过的,因为我倾向于尽可能避免递归。

当我添加代码以使您的示例更具体时,我发现两种可能的方式使递归变得非常糟糕。 它们都假设您的数据非常大并且需要触发特定条件。

  1. 如果ProcessItem(string)返回一个在await ed之前完成的Task (或者,我认为它在await完成旋转之前完成),则continuation将同步执行。 在我下面的代码中,我通过让ProcessItem(string)返回Task.CompletedTask模拟这个。 当我这样做时,程序很快就会终止StackOverflowException 。 这是因为.net的TPL“通过机会性地同步执行延续来 释放 Zalgo ”,而不考虑当前堆栈中有多少可用空间。 这意味着它将通过使用递归算法加剧您已经拥有的潜在堆栈空间问题。 要查看此内容,请注释await Task.Yield(); 在我的代码示例中。
  2. 如果你使用一些技术来防止TPL异步连续(下面我使用Task.Yield() ),最终程序将耗尽内存并死掉OutOfMemoryException 。 如果我理解正确,如果return await能够模拟尾调用优化,则不会发生这种情况。 我想这里发生的事情就是每次调用都会产生类似于簿记Task并且即使它们可以合并,也会继续生成它们。 要使用下面的示例重现此错误,请确保以32位运行程序,禁用Console.WriteLine()调用(因为控制台非常慢),并确保取消注释await Task.Yield()
 using System; using System.Collections.Generic; using System.Threading.Tasks; // Be sure to run this 32-bit to avoid making your system unstable. class StreamProcessor { Stream GetStream(string streamPosition) { var parsedStreamPosition = Convert.ToInt32(streamPosition); return new Stream( // Terminate after we reach 0. parsedStreamPosition > 0 ? new[] { streamPosition, } : new string[] { }, Convert.ToString(parsedStreamPosition - 1)); } Task ProcessItem(string item) { // Comment out this next line to make things go faster. Console.WriteLine(item); // Simulate the Task represented by ProcessItem finishing in // time to make the await continue synchronously. return Task.CompletedTask; } public async Task ProcessStream(string streamPosition) { var stream = GetStream(streamPosition); if (stream.Items.Count == 0) return stream.NextPosition; foreach (var item in stream.Items) { await ProcessItem(item); //ProcessItem() is now an async method } // Without this yield (which prevents inline synchronous // continuations which quickly eat up the stack), // you get a StackOverflowException fairly quickly. // With it, you get an OutOfMemoryException eventually—I bet // that “return await” isn't able to tail-call properly at the Task // level or that TPL is incapable of collapsing a chain of Tasks // which are all set to resolve to the value that other tasks // resolve to? await Task.Yield(); return await ProcessStream(stream.NextPosition); } } class Program { static int Main(string[] args) => new Program().Run(args).Result; async Task Run(string[] args) { await new StreamProcessor().ProcessStream( Convert.ToString(int.MaxValue)); return 0; } } class Stream { public IList Items { get; } public string NextPosition { get; } public Stream( IList items, string nextPosition) { Items = items; NextPosition = nextPosition; } } 

所以,我想我的两条建议是:

  1. 如果您不确定递归的堆栈增长是否会被其他内容中断,请使用Task.Yield()
  2. 正如已经建议的那样 ,如果首先对你的问题没有任何意义,请避免递归。 即使它是一个干净的算法,如果您的问题大小无限制,请避免使用它。