Async/Await в C#. Часть 5. Функция-перечисление и цикл через рекурсию, асинхронный вызов без Async/Await

c684cf9eb74b53a35b2cd96a61f68a2c.jpg

В одной из предыдущих статей, посвященных анализу этого (все того же) Поста, я написал что «не возьмусь переписать всю эту хитрую логику, связанную с формированием последовательности вызовов сгенерированной функции на человеческом языке». Но подробно разбирая 5-ю главу того Поста у меня это вроде бы все-таки получилось сделать. Прошу вас оценить результаты этого труда.

Там где мне придется цитировать (назовем это так) содержание исходного текста в переводе, оно будет выделено подчеркнутым курсивом.В предыдущей статье я попробовал поэкспериментировать с особым форматированием, но это не всегда удобно читателю, на сколько я понимаю.

Итак, автор исходного Поста утверждает что, чтобы избавиться от callback-ов при асинхронных вызовах, callback-ов которые он также именует «continuation» или по нашему «продолжения» нам помогут итераторы, а точнее функциикоторые компилятор использует чтобы реализовать интерфейсыIEnumerable and/or an IEnumeratorконцепция которых появилась еще в C# 2.0. Пока просто заметим что интерфейсы не существуют без объектов, которые их реализуют, то есть такие функции неявно создают объект некоторого сгенерированного компилятором типа (класса)  который и реализует интерфейсы IEnumerable and/or an IEnumerator, в этом смысле такие функции являются функциями-объектами, так как на самом деле превращаются компилятором в объекты.

Очень полезно рассмотреть пример с реализацией итератора для вычисления элементов последовательности Фибоначи, который приводит автор Поста:

public static IEnumerable Fib()
{
    int prev = 0, next = 1;
    yield return prev;
    yield return next;

    while (true)
    {
        int sum = prev + next;
        yield return sum;
        prev = next;
        next = sum;
    }
}

А также способ вызова этой функции, такой:

foreach (int i in Fib())
{
    if (i > 100) break;
    Console.Write($"{i} ");
}

Или такой:

using IEnumerator e = Fib().GetEnumerator();
while (e.MoveNext())
{
    int i = e.Current;
    if (i > 100) break;
    Console.Write($"{i} ");
}

Мы видим, что для полноценного использования этой функции, для получения последовательности значений нам нужен цикл, foreach или while. На самом деле второй вариант использования более явный-показательный, там мы видим что функция используется не как обычная функция, а как более сложная сущность, которая сначала возвращает интерфейс к объекту неизвестного типа, который сохраняется в переменной «е», а самое интересное, что потом, мы многократно в цикле обращаемся к той же самой функции через метод этого интерфейса MoveNext().

Честно говоря, когда-то я был поражен тем, что кто-то догадался реализовать такую возможность в компиляторе. Если задуматься о том как выполняется такая функция, можно обратить внимание что она распадается на несколько функций, которые выполняются друг за другом, интересно отметить что в приведенном примере эта последовательность является бесконечной, но мы можем перечислить сколько функций мы получаем из этой одной исходной. Автор поста привел пример скомпилированной функции в виде стейт-машины с оператором switch-case, я же хочу показать альтернативный способ реализации такой функции, который четко повторяет части исходной (компилируемой) функции и поэтому кажется мне более наглядным, а значит более полезным для понимания того, что происходит с такой функцией при компиляции:

class FibFuncDataStorage
    {
        int prev;
        int next;
        int sum;
        public delegate int NextFunc();
        public NextFunc nextFunc;
        public FibFuncDataStorage()
        {
            nextFunc = func1;
        }
      private bool MoveNext()
      {//is it typo it is private? 
          nextFunc();
          return true;
      }
        int func1()
        {
            nextFunc = func2;
            prev = 0; next = 1;
            return prev;
        }
        int func2()
        {
            nextFunc = func3;
            return prev;
        }
        int func3()
        {
            nextFunc = func4;
            return prev;
        }
        int func4()
        {
            nextFunc = func5;
            sum = prev + next;
            return sum;
        }
        int func5()
        {
            //nextFunc = func5;
            prev = next;
            next = sum;
            sum = prev + next;
            return sum;
        }
    }

Как видите стейт машину можно заменить простым делегатом, при этом исходная функция распадается на множество функций, представляющих отдельные куски этой исходной функции. Такой класс также можно оформить-унаследовать от правильных интерфейсов чтобы он также хорошо работал в foreach конструкциях или через итератор IEnumerator. Когда вы выполните такое оформление (что само по себе является очень полезной обучающей задачей) вы сможете сравнить сложность или наглядность-читаемость этого варианта с тем вариантом, который предлагает нам реальный C# компилятор, показанный автором Поста, ищите в исходном Посте код, который начинается строчкой:

public static IEnumerable Fib() => new d__0(-2);
...

Насколько я это понимаю каждая из реализаций имеет свои сильные и слабые стороны, то есть, я бы не взялся судить какая из них лучше в общем, это просто разные реализации одной и той же функциональности, которые подходят лучше или хуже для конкретных условий применения.

Теперь, когда мы досконально (я надеюсь) разобрались с функциями-объектами для перечисления мы можем попробовать также глубоко разобраться с примерами, которые автор Поста предоставил нам для реализации асинхронных вызовов. Я приведу их здесь в одном фрагменте кода, потому что их нельзя рассматривать в отрыве друг от друга:

    static internal class AyncAwait
    {
static Task IterateAsync(IEnumerable tasks)
{
    var tcs = new TaskCompletionSource();

    IEnumerator e = tasks.GetEnumerator();

    void Process()
    {
        try
        {
            if (e.MoveNext())
            {
                e.Current.ContinueWith(t => Process());
                return;
            }
        }
        catch (Exception e)
        {
            tcs.SetException(e);
            return;
        }
        tcs.SetResult();
    };
    Process();

    return tcs.Task;
}
static Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    return IterateAsync(Impl(source, destination));

    static IEnumerable Impl(Stream source, Stream destination)
    {
        var buffer = new byte[0x1000];
        while (true)
        {
            Task read = source.ReadAsync(buffer, 0, buffer.Length);	
             Console.WriteLine($"read {read.Result}");
            yield return read;
            int numRead = read.Result;
            if (numRead <= 0)
            {
                break;
            }

            Task write = destination.WriteAsync(buffer, 0, numRead);
             Console.WriteLine($"write {write.AsyncState}");
            yield return write;
            write.Wait();
        }
    }
}
  }

Как видите мне даже пришлось засунуть эти методы в специальный класс AyncAwait, чтобы их можно было скомпилировать. Также можно обратить внимание, что мне пришлось добавить пару строчек логов результат работы которых мы увидем ниже.

Мне кажется здесь особенно важно обратить внимание на то, что пропустил автор исходного поста. Если вы вспомните от чего отталкивается наш предыдущий анализ компиляции функций-объектов для перечисления вы поймете что нам не хватает кода который вызывает нашу функцию-пример асинхронной операции: CopyStreamToStreamAsync (). Мы должны такой код написать, например в таком виде:

static void Main(string[] args)
{
    string StartDirectory = @"c:\tmp";
    string filename = Directory.EnumerateFiles(StartDirectory).First();
    Stream source = File.Open(filename, FileMode.Open);
    Stream destination = File.Open(@"c:\tmp\test.bin", FileMode.OpenOrCreate);

    Task tsk = AyncAwait.CopyStreamToStreamAsync(source, destination);

    Console.WriteLine($"{tsk} was started");
    Console.WriteLine("was it asynch?"); 
…
}

И мы можем получить такой консольный вывод:

read 4096
write
System.Threading.Tasks.Task was started
was it asynch?
read 4096
write

который показывает нам что функция действительно выполнялась асинхронно, так как мы видим по добавленным в исходный пример логам

read 4096
write

что вызванная операция продолжает выполняться после возврата из вызванной для запуска этой операции функции!

Полноценный асинхронный вызов БЕЗ модификаторов async/await

Давайте разбираться как же у нас получился полноценный асинхронный вызов БЕЗ модификаторов async/await. Все начинается двумя вызовами:

IterateAsync(Impl(source, destination));

Здесь надо помнить что функция Impl() это функция-объект для перечисления, при каждом обращении к ней она возвращает этот самый объект для перечисления. Это значит здесь мы создали объект-перечисление и передали его в функцию IterateAsync(), больше ничего!

Дальше разбираем что происходит в функции IterateAsync(). А там у нас 4 (четыре) строчки кода, если не считать определение функции Process (). Мы создали:

1.     создали объект TaskCompletionSource  который будет представлять нашу асинхронную операцию CopyStreamToStreamAsync() в виде задачи,

2.     получили итератор к нашему объекту-перечислению;

3.     вызвали функцию Process ();

4.     вернули задачу представляющую созданную асинхронную операцию.

Дело в том что функция Process () рекурсивная, рекурсия в этом случае является способом организации цикла, который, в свою очередь, необходим чтобы пройти по перечислению из функции-объекта для перечисления Impl(). Замыкание функции Process () на переменную с итератором к нашему объекту-перечислению я думаю пояснять не надо. И мы помним что MoveNext() это тоже обращение к функции-перечислению Impl() в этом случае, только к отдельным частям этой функции, каждый раз к следующей части.

Идея состоит в том, что когда мы вызвали операцию из Мейна, мы запустили цикл в котором последовательные задачи этой операции последовательно вызывают друг друга с помощью функции Process (), которая организует цикл рекурсии. Этот цикл далее существует-выполняется параллельно с выполнением последующего кода после вызова из функции Мейн, в нашем случае. Задачи выполняются асинхронно, поэтому и вся операция (вызов функции CopyStreamToStreamAsync) происходит асинхронно. Рекурсия не приводит к погружению в стек, так как каждый раз новый рекурсивный вызов функции Process () выполняется в контексте очередной задачи, то есть функция Process () не выполняет сама себя непосредственно, она отдает себя очередной задаче для вызова по завершению этой задачи. Кстати, тут уместно вспомнить про понятие continuation-продолжение в очередной раз! Эта функция Process () как раз и является таким продолжением в данном случае, по сути это функция, которая позволяет нам выстроить наши задачи в последовательность и выполнять их в цикле, в соответствии с порядком из этой последовательности.

Есть еще один нюанс, который я тоже не сразу понял, не сразу обратил внимание. У нас есть последовательность задач, которые генерирует нам наша функция-перечисление, это задачи, которые эта функция возвращает нам с yield return, кстати, полезно заметить, что в нашем примере они вообще говоря разных типов: Task-просто и Task. Но у того, кто вызвал нашу операцию, должен остаться объект-задача, который-которая представляет эту одну большую операцию, состоящую из этой последовательности задач разного типа и такой объект создается в самом начале:

var tcs = new TaskCompletionSource();

 это обертка для получения задачи, через которую вызывающий код может контролировать, завершилась ли асинхронная операция, которую этот вызывающий код запустил когда-то раннее, если конечно ему нужно это контролировать. Обратите внимание, этот объект обертки используется чтобы создать и вернуть задачу представляющюю текущюю сложную-составную асинхронную операцию-задачу, сразу при запуске этой операции задачи:

return tcs.Task;

И только в самом конце этой сложной операции, ей будет присвоен результат полученный по результатам цепочки вызовов функции Process ():

            tcs.SetException(e);
///////// или ////////////////////
        tcs.SetResult();

Заключение

В заключение повторю что автор поста напоминает нам, что это решение было реализовано и использовалось еще до того, как, async/await ворвались на сцену (по выражению автора Поста):

In fact, some enterprising developers used iterators in this fashion for asynchronous programming before async/await hit the scene.

Насколько я понимаю основная идея этого решения и этой изложенной реализации лежит в основе метода компиляции конструкций c async/await в C#. И далее исходный Пост погружает нас уже в детали реализации методов компиляции конструкций использующих async/await в C#.

Я надеюсь моя интерпретация примеров кода из исходного Поста поможет кому-то глубже понять способ компиляции конструкций c async/await в C#, ну и просто будет интересна как еще одно описание достаточно сложной техники (может даже шаблона проектирования для которого я не знаю названия) которую можно применять где-то самостоятельно.

PS: надо все-таки заставить себя вчитаться в окончание исходного Поста, может найдется еще что-то интересное.

© Habrahabr.ru