Каков правильный способ использования async/wait в рекурсивном методе?

Каков правильный способ использования 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(), если вы уверены, что рост рекурсии в стеке будет прерван чем-то другим.
  • Как уже было сказано, избегайте рекурсии, если она вообще не имеет смысла для вашей проблемы. И даже если он делает чистый алгоритм, избегайте его, если ваш размер проблемы неограничен.