Pull to refresh

Async/Await из C#. Головоломка для разработчиков компилятора и для нас

Level of difficultyHard
Reading time11 min
Views11K

Я рискну все таки продолжить изложение своего понимания Поста: How Async/Await Really Works in C#, которое в предыдущей статье получило название “ортогональный взгляд”. Также, недавно мы познакомились (возможно несколько преждевременно) с изначальным определением концепции SynchronizationContext на которую ссылается автор этого Поста.

Это не перевод. Это изложение содержания Поста на разных уровнях раскрытия сущностей и их взаимодействия по мере развития (эволюции) моего понимания тех мыслей и идей, которые, как мне кажется, хотел донести до читателя автор Поста Stephen Toub.

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

В этот раз попробуем сформулировать задачу, которую решает компилятор, то есть решили те разработчики, которые позволили нам пользоваться конструкциями Async/Await в C#.


В этой статье мы попробуем рассмотреть исходный материал с точки зрения того какие задачи решаются компилятором, когда он встречает конструкции Async/Await в C#. Но начнем пожалуй с того что можно назвать:

Извилистый путь в мир async / await

Мне кажется, что одна из основных проблем с пониманием Поста происходит из того, что в начале автор сильно отклоняется от заявленной уже в названии цели разобраться в том, как и во что компилируются конструкции Async/Await в C#. Дело в том, что автор начинает свое изложение с анализа конструкций, которые использовались изначально для решения проблем, связанных с асинхронными вызовами и асинхронным взаимодействием в коде. Но изначально, решение этих проблем не предполагалось переложить на плечи компилятора, изначально эти решения существовали как некоторая техника или шаблон решения (шаблон проектирования этого решения) который надо было самостоятельно реализовать в коде используя некоторые предопределенные интерфейсы и/или классы, которые были предопределены-реализованы в библиотеках C#, как раз с целью поддержки при реализации некоторых предопределенных (хотя и достаточно гибких) решений по достаточно гибким шаблонам.

Как пишет автор Поста в другой своей работе, Understanding the Whys, Whats, and Whens of ValueTask (там, кстати, можно найти интересные примеры, которые наглядно демонстрируют функции, которые ведут себя как асинхронные и как синхронные в разных вызовах):

This is, after all, how we write synchronous code (e.g. Result result = SomeOperation();), and it naturally translates to the world of async / await.

По моим ощущениям автор Поста выбрал все таки слишком извилистый путь в "мир async/await". Сначала мы как бы вспоминаем о том, какие задачи мы должны были решать в старой парадигме, а потом мы как-то незаметно переходим к тому, как эти задачи теперь могут быть решены БЕЗ нашего участия на уровне компиляции и компилятора. Но к моменту, когда мы переходим к анализу решения для компилятора, мы успеваем переключиться на анализ решения как на анализ очередного шаблона проектирования, и нам уже приходится приложить усилие чтобы правильно и с нужного ракурса воспринимать логику, которую разворачивает перед нами автор. По крайней мере у меня было именно так, я несколько раз возвращался к началу изложения, чтобы наконец понять, что в изложении произошла смена экспозиции на предмет рассмотрения, так сказать, что мы изначально рассматривали задачу как бы снаружи, а потом начали рассматривать ее изнутри, с точки зрения анализа реализации сгенерированного компилятором кода.

При этом мы на самом деле пишем реализацию решения, которая УЖЕ существует внутри компилятора. Только внутри компилятора существует полноценная всеобъемлющая реализация, а мы пишем очень упрощенную версию, как говорит автор Поста, в педагогических целях:

... is only being done in the name of pedagogy ...

... we’ll do the pedagogical thing and just implement a simple version. ...

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

Я просто перечислю классы и методы из ядра и библиотек .Net фреймворка, замену которых предлагает написать и проанализировать автор Поста в своей работе:

  • class MyTask вместо стандартного(предопределенного) класса Task

  • методы:

public void ContinueWith(Action<MyTask> action)
public void SetResult() => Complete(null);
public void SetException(Exception error) => Complete(error);
private void Complete(Exception? error)

static Task IterateAsync(IEnumerable<Task> tasks)
  • Структура (тип для использования по значению, а не по ссылке как класс): public struct MyTaskMethodBuilder вместо стандартного типа TaskMethodBuilder

  • class MyThreadPool вместо стандартного класса ThreadPool

Задача для компилятора

Учитывая ту проблему ракурса, с которого происходит рассмотрение некоторого решения (шаблона, шаблона проектирования, техники, … назовите, как угодно) автор Поста по сути оставил за кадром формулировку задачи для компилятора решение которой он, собственно, и разъясняет. Но автор любезно предоставил нам код, который все-таки позволит нам воспроизвести эту формулировку, этот код вы найдете в самом начале Поста:

// Asynchronously copy all data from source to destination.
public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

И я, наверно, тоже не очень ясно изъясняюсь пока, поэтому спешу исправиться.

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

Очень важно обратить внимание на то, что мы не видим из этого кода. Автор Поста не приводит нам код с вызовом (вызовами) этой функции, и в общем можно понять почему автор Поста не удосужился даже вспомнить о том, что эта функция, вообще-то, где-то, должна быть вызвана, иначе этот код не имеет смысла. Дело в том, что вызов функции помеченной async никак не отличается в исходном коде от вызова функции без такого модификатора, а вот сгенерированный компилятором код для такого вызова отличается кардинально! Тем не менее, далее, автор Поста всё-таки написал код, который вызывает, вообще говоря, любую функцию помеченную async (в том числе может вызвать и эту функцию) и мы, конечно, до этого кода тоже доберемся. Более того, наверно большая часть работы How Async/Await Really Works in C# посвящена именно компиляции вызовов для функций помеченных async, если я в состоянии правильно оценить соотношение объема кода который поддерживает вызов к объему кода, который реализует структуру внутри функции после компиляции.

Как C# итераторы спасают нас от колбеков, но оставляют в неизвестности о способе многократных вызовов

В исходном изложении присутствует целый параграф, который называется: C# Iterators to the Rescue.

Название можно перевести как: C# итераторы пришли нам на помощь (спасли нас). Подразумевается, что итераторы, то есть логика или способ компиляции итерируемых функций спасают нас от необходимости использовать callbacks (колбеки) для реализации решений с асинхронными вызовами. В предыдущей статье Async/Await в C#. Уроки по асинхронному программированию мы уже обратили внимание в разделе «Во что компилируется Async/Await», что логика компилятора «для преобразования функций использующих yield return для генерации IEnumerable<> списка» используется и для компиляции конструкций Async/Await.

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

Теперь вернемся к нашей исходной теме задачи для компилятора и, к примеру функции, которую компилятор должен скомпилировать. Очевидно, что компилятор должен преобразовать нашу функцию из исходного кода в класс со стейт машиной, который позволяет многократно вызывать эту функцию, пока она не дойдет до своего завершения. Автор Поста демонстрирует нам, как может выглядеть преобразованная компилятором функция в стиле «yield return для генерации IEnumerable<> списка»:

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

            Task write = destination.WriteAsync(buffer, 0, numRead);
            yield return write;
            write.Wait();
        }
    }

Я думаю, идея всем понятна, но я сформулирую ее на всякий случай. Суть идеи в том, что куски кода до первого yield return и далее до каждого следующего yield return при компиляции преобразуются в соответствующие блоки case X-STATE: внутри оператора switch заменяющего последовательный код тела функции.

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

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

    // ...
}

Функция Impl() возвращает управление на каждом yield return, и поэтому требует многократных вызовов, как если бы она применялась из конструкции на основе foreach. Но для вызова асинхронных вызовов не применяется foreach! В нашем случае это значит, что мы имеем в исходном коде только один вызов CopyStreamToStreamAsync(), который приводит к единственному вызову Impl(), и это значит что компилятор должен применить какой то трюк, чтобы все таки обеспечить все необходимые последующие вызовы функции Impl().

И тут мы подходим к вопросу о том, как же компилируется вызов async функции. Если сравнить с тем что мы имеем для «функций использующих yield return для генерации IEnumerable<> списка» по аналогии с которой мы пытаемся реализовать логику использования async функций, то окажется, что ее нельзя использовать, потому что она требует особенной конструкции с циклом для вызова по месту вызова. Но нам надо чтобы вызовы async функций не отличались от вызовов обычных функций, и это является настоящей головоломкой для тех кто берется осмыслить как работает этот синтаксис на уровне компилятора, также как это было головоломкой для разработчиков расширения синтаксиса с ключевыми словами Async/Await. Только автор поста напоминает нам, что эта головоломка была давно решена и это решение использовалось еще до того, как, async/await ворвались на сцену (по выражению автора Поста):

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

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

Анализ последовательности вызовов async функции

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

Stream source =GetSourceSomeHow(); 
Stream destination =GetDestinationSomeHow();   
...
CopyStreamToStreamAsync(source, destination);
...

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

Здесь мы должны вспомнить термин continuation-продолжение, вокруг которого крутится повествование на протяжении всего Поста. Таким продолжением компилятор, каждый раз, определяет нашу сгенерированную функцию с оператором switch. Собственно трансформация компилятором кода исходной функции с await-ами в функцию с оператором switch и позволяет компилятору выполнять такую функцию по частям, выбирая очередную часть этой функции с помощью переменной состояния.

Но мы не ответили на вопрос: кто же всё-таки вызывает повторно эту функцию со switch-ом? И есть еще один не очевидный, но более важный вопрос: где и кем сохраняются данные необходимые для этого вызова?

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

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

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

Самые содержательные функции из класса MyTask на мой взгляд:

static Task CopyStreamToStreamAsync(Stream source, Stream destination)
  
static Task IterateAsync(IEnumerable<Task> tasks)

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

[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))]
public Task CopyStreamToStreamAsync(Stream source, Stream destination)

который использует вспомогательные типы (вспомогательный код как раз для организации хранения необходимых данных и для очередного вызова функции «продолжения», после того как текущая await операция будет завершена), вот эти типы:

public struct MyTaskMethodBuilder

class MyThreadPool

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

   static MyThreadPool()
    {
        for (int i = 0; i < Environment.ProcessorCount; i++)
        {
            new Thread(() =>
            {
                while (true)
                {
                    (Action action, ExecutionContext? ec) = s_workItems.Take();
                    if (ec is null)
                    {
                        action();
                    }
                    else
                    {
                        ExecutionContext.Run(ec, s => ((Action)s!)(), action);
                    }
                }
            })
            { IsBackground = true }.UnsafeStart();
        }
    }

Здесь action это делегат, в котором сохранена наша сгенерированная компилятором функция с оператором switch, функция-продолжение. Как видим в этом случае ее вызывает наша учебная версия класса ThreadPool – MyThreadPool.

Чтобы эта функция-продолжение была вызвана, текущая задача, которая формируется в месте помеченном await должна завершиться и вызвать функцию:

private void Complete(Exception? error)
{
    lock (this)
    {
        if (_completed)
        {
            throw new InvalidOperationException("Already completed");
        }

        _error = error;
        _completed = true;

        if (_continuation is not null)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                if (_ec is not null)
                {
                    ExecutionContext.Run(_ec, _ => _continuation(this), null);
                }
                else
                {
                    _continuation(this);
                }
            });
        }
    }
}

которая и поставит функцию-продолжение в очередь на исполнения в MyThreadPool. То есть нужно внимательно разобрать код учебной реализации класса Task – MyTask, который, кстати, приведен в исходном тексте кусками, что тоже, вполне может ввести читателя в заблуждение при первом прочтении соответствующего параграфа, по крайней мере я не сразу понял, что отдельно приведенные функции являются методами класса MyTask. Заметьте, кстати, здесь круг замыкается, так как я отсылаю вас обратно к анализу класса MyTask, с которого я предлагал начинать этот анализ.

На этом я, пожалуй, пока закончу пересказ того что мне показалось интересным в работе : How Async/Await Really Works in C#, и в том что я из нее понял.

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

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

Надеюсь на вашу поддержку.

Сёргий.

Tags:
Hubs:
Total votes 7: ↑6 and ↓1+5
Comments6

Articles