Каков правильный способ использования 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/wait:
public async Task<string> 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);
}
Ответ 1
Хотя я должен сказать заранее, что намерение метода не совсем ясно для меня, переопределение его простым циклом довольно тривиально:
public async Task<string> 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
s), а также асинхронные сценарии, где, я буду честен, я даже не знаю, что произойдет, если вы слишком далеко продвинетесь (мой VS Test Explorer сработает всякий раз, когда я пытаюсь воспроизвести известные сценарии с помощью методов async
).
Ответы, такие как Рекурсия и ключевые слова await/async, предполагают, что StackOverflowException
меньше проблем с async
из-за того, как состояние async/await
машинные работы, но это не то, что я много изучил, поскольку я стараюсь избегать рекурсии, когда это возможно.
Ответ 2
Когда я добавляю код, чтобы сделать ваш пример более конкретным, я нахожу два возможных способа, чтобы рекурсия оказалась плохой. Оба они предполагают, что ваши данные довольно большие и требуют особых условий для запуска.
- Если
ProcessItem(string)
возвращает Task
, который завершается до того, как он будет await
ed on (или, я предполагаю, он завершится до того, как await
закончит вращение), продолжение будет выполняться синхронно. В моем коде ниже я смоделировал это, получив ProcessItem(string)
return Task.CompletedTask
. Когда я это делаю, программа очень быстро заканчивается с помощью StackOverflowException
. Это связано с тем, что .nets TPL " Релизы Zalgo" оппортунистически выполняя продолжения синхронно, независимо от того, сколько места доступно в текущем стеке. Это означает, что это усугубит проблему потенциального пространства стека, которую вы уже используете, используя рекурсивный алгоритм. Чтобы увидеть это, прокомментируйте await Task.Yield();
в моем примере кода ниже.
- Если вы используете некоторую технику для предотвращения асинхронности TPL (ниже я использую
Task.Yield()
), в конечном итоге у программы закончится память и умрет с помощью OutOfMemoryException
. Если я правильно понял, это не произошло бы, если return await
смог эмулировать оптимизацию хвостового вызова. Я полагаю, что то, что здесь происходит, - это каждый вызов, генерирующий что-то вроде бухгалтерского учета Task<string>
и продолжает генерировать их, даже если они могут быть объединены. Чтобы воспроизвести эту ошибку с примером ниже, убедитесь, что вы запускаете программу как 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<string> 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<int> Run(string[] args)
{
await new StreamProcessor().ProcessStream(
Convert.ToString(int.MaxValue));
return 0;
}
}
class Stream
{
public IList<string> Items { get; }
public string NextPosition { get; }
public Stream(
IList<string> items,
string nextPosition)
{
Items = items;
NextPosition = nextPosition;
}
}
Итак, я думаю, что мои две рекомендации:
- Используйте
Task.Yield()
, если вы уверены, что рост рекурсии в стеке будет прерван чем-то другим.
- Как уже было сказано, избегайте рекурсии, если она вообще не имеет смысла для вашей проблемы. И даже если он делает чистый алгоритм, избегайте его, если ваш размер проблемы неограничен.