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

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

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

Это обзор только первой половины Поста, возможно по результатам анализа второй половины или по вашим замечаниям мне придётся пересмотреть какие-то мои выводы, я не претендую на абсолютное знание.

Дисклеймер 1

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


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

Причем здесь потоки (threads), дисклеймер 2

Почему-то никто не пишет, что Асинхронный вызов — это всегда использование концепции потоков (threads) реализованной Операционной Системой и этот Пост, к сожалению, не исключение. Поэтому позвольте мне начать с базовых вещей, которые, как мне кажется, очень важны для понимания темы. Любой отдельный поток (thread) является объектом операционной системы и, соответственно, объектом под контролем ОС. Поток создается и удаляется только через обращения к операционной системе, через системные вызовы. Вызов, асинхронный по отношению к коду (функции, объекту,…) который выполняет этот вызов, возможен только при наличии дополнительного потока, в котором этот асинхронный вызов будет выполняться. Тогда вызывающий код фактически сможет продолжить свое исполнение в своем исходном потоке параллельно с исполнением кода асинхронной функции в дополнительном потоке, в этом и есть смысл асинхронности, в основном.

В основном все признают, что трудно найти задачу сложнее чем проектирование работы многопоточного приложения (читай приложения с асинхронными вызовами), потому что для асинхронных вызовов порядок завершения этих вызовов не определен. Добавляя в ваше приложение асинхронные вызовы, вы, по сути, добавляете в ваше приложение то, что именуется Undefined Behavior (неопределенное поведение). Например, если вы запустили всего три асинхронных процедуры, вы в общем случае должны проанализировать не менее 3! = 6 вариантов порядка завершения этих процедур, и это без учета того, что в некоторых случаях разного рода наложения разных событий при параллельном (асинхронном) исполнении функций (операций, процедур, кусков кода, …) нужно считать особым случаем формирования такого порядка исполнения. То есть, вообще говоря, даже для двух асинхронных вызовов нельзя заранее с уверенностью сказать сколько же вариантов взаимодействия событий, связанных с этими асинхронными процедурами, нужно рассмотреть и, соответственно, обработать в коде. К сожалению, никакой синтаксис вам не поможет даже просто найти все эти варианты, которые вы должны обработать в коде, потому что эти варианты определяются в предметной области задачи. Это всегда вопрос того, насколько хорошо вы сформулировали задачу предметной области и насколько хорошо проработали ее решение.

С точки зрения программирования, то есть перевода найденного решения в код, очевидно проще всего (надежнее), будет отказаться от асинхронных вызовов до тех пор, пока необходимость асинхронной работы будет не просто обоснована некоторым предполагаемым улучшением производительности кода, например. Асинхронные вызовы не рекомендуется применять пока необходимость асинхронной работы не доказана с математической точностью (в том смысле что она необходима и достаточна - то есть точно решает определенную проблему и не создает других не понятных проблем И/ИЛИ что проблемы, которые она создает, тоже гарантированно решаются каким-то образом).

Я думаю, в развитие темы практического применения потоков (и связанной с ними асинхронности) может быть полезно почитать мои предыдущие статьи:

Многопоточность (Multithreading) для практического программирования. То, о чем «забыть-нельзя-вспоминать» придется

Многопоточность (Multithreading) для практического программирования

Плавно переходим к анализу содержания Поста

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

And “asynchronous” operations that complete synchronously are actually very common; they’re not “asynchronous” because they’re guaranteed to complete asynchronously but rather are just permitted to.

Типа: «Знаете ли, асинхронные операции могут, вообще-то, выполняться как синхронные и это как бы тоже проблема!», но как раз этой проблеме посвящена первая глава Поста, которая вроде бы посвящена APM паттерну, исходя из названия. По сути, первая глава просто констатирует наличие проблемы, связанной с тем, что асинхронные вызовы могут выполняться синхронно, в основном!

«stack dives» это же рекурсия?

Идентифицированная нами проблема выливается в проблему со стеком (стек — это понятие, жестко связанное с потоком-thread, не забываем про связь с потоками). Автор применил специальный термин для нее: «stack dives». Что такое stack dives? это же широко известное давно используемое понятие, это рекурсия! Проверьте меня, это всего лишь на всего рекурсия! Зачем вместо широко известного термина «рекурсия» изобретать какое-то вычурное «погружение в стек»? У меня есть одно предположение.

Есть много мастеров программирования которые пропагандируют методы построения алгоритмов, основанные на рекурсии. Все эти методы игнорируют простой медицинский факт, что неконтролируемая рекурсия ведет к переполнению стека, которое провоцирует аварийное завершение приложения. Я не видел ни одного алгоритма, основанного на рекурсии в котором бы была прописана или анализировалась допустимая глубина используемой рекурсии для безопасного выполнения этого алгоритма на ПК (на вычислительной машине). Самое противное при аварийном завершении приложения по причине переполнения стека это то, что во многих системах переполнение стека ведет еще и к разрушению базы отладочной информации и вам придется приложить немало усилий, чтобы понять почему же ваше приложение (служба, библиотека, …) упала (обычно в самый не подходящий момент), а виной всему будет рекурсия. До сих пор никто не вводил стандартных методов предупреждения возможного переполнения стека при очередном вызове функции, и на сколько я знаю, никто не собирается такой метод изобрести. Поэтому неконтролируемая рекурсия остается миной замедленного действия в любой программе.

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

SynchronizationContext контекст синхронизации

Асинхронный вызов возможен либо через создание нового потока для выполнения вызываемой функции асинхронно, либо через отправку сообщения некоторому существующему потоку. Назовем такой поток вторым потоком. Второй поток должен выполнить эту функцию, взятую из сообщения, с параметрами, которые могут прийти в том же сообщении либо как-то еще. С любым и каждым потоком связан контекст этого потока, который включает в себя как минимум стек потока и атрибуты потока как системного объекта, Пост знакомит нас с другим понятием: SynchronizationContext контекст синхронизации, которое принадлежит некоторому планировщику (scheduler), в качестве которого может выступать опять же системный поток, а может некоторая абстрактная операция, которая иногда асинхронная, а иногда нет. В принципе этот новый класс контекста нужен (моя догадка) в том числе чтобы разрешить эту добавленную неопределенность относительно того является ли данная операция асинхронной (выполняемой в стороннем потоке – в контексте стороннего потока ИЛИ она выполняется в текущем потоке, а значит выполняется как синхронная).

Чтобы обосновать свою догадку я процитирую какой планировщик может представлять SynchronizationContext в примере от автора Поста:

The base implementation of SynchronizationContext, for example, just represents the ThreadPool, and so the base implementation of SynchronizationContext.Post simply delegates to ThreadPool.QueueUserWorkItem, which is used to ask the ThreadPool to invoke the supplied callback with the associated state on one the pool’s threads.

Здесь написано, что SynchronizationContext может представлять ThreadPool, который в свою очередь выделяет поток для выполнения функции callback при передаче этой функции в SynchronizationContext через метод SynchronizationContext.Post. Но насколько я понимаю, в конечном итоге SynchronizationContext будет представлять конкретный поток, который будет выделен для исполнения операции.

Насколько я могу судить, основной смысл главы

Event-Based Asynchronous Pattern

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

Если не верите переведите вот эту цитату:

However, it did add one notable advance that the APM pattern didn’t factor in at all, and that has endured into the models we embrace today: SynchronizationContext.

continuation Функция которая должна выполняться при завершении асинхронной операции.

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

Дело в том, что эта функция, это «продолжение» играет ключевую роль в том, во что компилируется Async/Await (играет ключевую роль в построении реализации конструкций на основе Async/Await). И это действительно достаточно интересная техника. Но для начала давайте посмотрим, что автор Поста выделяет относительно типа Task из .NET Framework 4.0, или что мне показалось заслуживающим внимания по поводу этого типа Task у автора Поста.

Введение по Таскам(Tasks) от автора поста

Так вот, тут автор Поста, естественно, пишет, я бы даже сказал восторженно пишет, что Таски(Tasks) гораздо лучше, чем IAsyncResult. Я вполне могу согласится с его восторгом, только он совсем не способствует пониманию темы, в данном случае, а скорее отвлекает, поэтому мы опустим эти детали.

Как ни странно, содержательная часть этого введения по Таскам(Tasks) опять же связана с continuation или продолжением! Автор Поста пишет, что Таски позволяют определять «продолжение» не только ДО вызова (для Тасков больше подходит: создание) асинхронных операций, но и во время их исполнения и даже после завершения, цитата:

As noted, one of the fundamental advances in Task over previous models was the ability to supply the continuation work (the callback) after the operation was initiated.

Тут стоит отметить, я думаю, последовательную линию автора Поста по поиску того что может заменить то, что он называет callback-based asynchrony или callback-based approaches, цитата из первой главы:

this isn’t really a criticism of the APM pattern. Rather, it’s a critique of callback-based asynchrony in general. We’re all so used to the power and simplicity that control flow constructs in modern languages provide us with, and callback-based approaches typically run afoul of such constructs once any reasonable amount of complexity is introduced.

Цитата из раздела "And ValueTasks":

So, we have TaskTask<TResult>ValueTask, and ValueTask<TResult>. We’re able to interact with them in various ways, representing arbitrary asynchronous operations and hooking up continuations to handle the completion of those asynchronous operations. And yes, we can do so before or after the operation completes.

But… those continuations are still callbacks!

...

How can we fix that????

Тут хочется заметить еще, по поводу определения Тасков сформулированного автором. Я не могу согласиться с тем, что Таски представляют только «возможное завершение какой-либо асинхронной операции» (the eventual completion of some asynchronous operation). Мне кажется они определены там таким образом, только для того чтобы написать далее про аналогию с типами из других Фреймворков: a “promise” or a “future”.

Мне все-таки кажется (то есть я уверен, потому что это очевидно, по-моему) что Таски(Tasks) представляют всю целую задачу или операцию вместе с ее результатом, когда она завершилась, и с ее состоянием в любой момент времени.

Во что компилируется Async/Await

 Начнем, пожалуй, сразу с ответа. Автор поста в конце главы

C# Iterators to the Rescue

пишет (почти дословный перевод): что-то около 95% логики для поддержки итераторов (как возвращаемое значение функции которая возвращает IEnumerable<Т> ) используется также и для реализации конструкций на базе Async/Await.

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

public static IEnumerable<int> 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;
    }
}

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

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

static Task IterateAsync(IEnumerable<Task> tasks)
{
    var tcs = new TaskCompletionSource();

    IEnumerator<Task> 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;
}

Обратите внимание на метод ContinueWith() в строчке:

e.Current.ContinueWith(t => Process());

Как можно видеть продолжение играет немаловажную роль после преобразования исходного C# кода в промежуточный исполняемый код. Вот почему ранее автор столько внимания уделил continuation – функции, которая должна выполняться при завершении асинхронной операции, как мне кажется. Эта callback-функция незаменима при компиляции конструкций Async/Await.

Здесь стоит перевести цитату с пояснениями:

Different syntax, different types involved, but fundamentally the same transform. Squint at the yield returns, and you can almost see awaits in their stead.

=

Другой синтаксис (Impl() вместо MoveNext(), await вместо yield return), задействованы другие типы (Task вместо IEnumerable<>), но по сути одно и то же преобразование (преобразование в стейт машину компилятором!). Присмотритесь к yield return, и вы почти можете увидеть await вместо них (вот мы, вроде как, и присмотрелись!).

На этом пока все.

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

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

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

С уважением,

Сёргий