Async/Await в C# это синтаксический сахар для конечного автомата

от автора

Конечный автомат и его внутреннее устройство

Примечание переводчика:

  • State Machine, конечный автомат это преобразованный async метод. Компилятор преобразует метод в тип, реализующий конечный автомат (наследуется от IAsyncStateMachine). Благодаря такому механизму, при достижении первого оператора await поток, начавший метод, может возвращаться без «физического» оператора return метода, тем самым, продолжая выполнение основной программы.

    В математике, конечный автомат это некоторая система, которая может находится только в одном состоянии.

    (Возможные) состояния конечного автомата:

    • -1 — Начальное состояние (Initial State): Это состояние до начала выполнения метода. Когда выполнение только начинается, автомат находится именно в этой точке.

    • 0, 1, 2... — Промежуточные состояния (Intermediate States): Каждому ключевому слову await в вашем методе присваивается уникальное числовое состояние (начиная с 0). Когда выполнение доходит до ожидания и метод приостанавливается, автомат запоминает это число. Как только ожидаемая операция завершается, он «просыпается» и, глядя на это число, точно знает, в каком месте кода нужно продолжить выполнение и какие локальные переменные восстановить .

    • -2 — Конечное состояние (Final State): Это состояние сигнализирует о том, что метод полностью завершил свою работу. Неважно, успешно ли он выполнился или выбросил исключение — после того, как работа закончена, состояние устанавливается в -2 .

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

    Аналогия: пицца — асинхронная операция. Оператор await — ожидать пиццу. Объект ожидания awaiter — доставщик пиццы.

  • Построитель (например, AsyncTaskMethodBuilder<T>) — это внутренний механизм компилятора, который конструирует и управляет жизненным циклом объекта Task для асинхронного метода

Простыми словами, async/await это своего рода синтаксический сахар. Каждый асинхронный метод будет преобразован в StateMachine, и затем вызывающий метод использует ее для выполнения бизнес‑логики.

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

Несколько терминов, которые будут использоваться в статье:

  • WorkerFunction: метод, который будет выполнять фактическую асинхронную работу.

  • CallingFunction: метод, который будет вызывать WorkerFunction.

  • FirstCall: первый вызов метода MoveNext у конечного автомата (синхронный поток выполнения).

  • WakeUpCall: момент, когда результаты операции await становятся доступными, и код продолжает выполнение с того места, где он остановился. Своего рода callback (обратный вызов).

Что происходит при компиляции кода (кратко — теория)

Мы берем наш фрагмент кода и вставляем его на sharplab, а затем он генерирует скомпилированный код для этого фрагмента. Вот несколько вещей, которые компилятор сгенерирует для нашего асинхронного кода:

  1. Компилятор сгенерирует код конечного автомата (реализующийIAsyncStateMachine) для WorkerFunction.

  2. Перенесет фактическую логику WorkerFunction в функцию MoveNext.

  3. Создаст внутри конечного автомата переменные, необходимые для его работы.

  4. Изменит CallingFunction так, чтобы он создавал новый экземпляр StateMachine.

  5. Вызовет Start у одного из генераторов задач (TaskMethodGenerator) конечного автомата внутри CallingFunction (подробнее ниже).

Теперь посмотрим на код

Возьмем очень простой фрагмент кода, в котором используются ключевые слова async/await. Я намеренно сохраняю сложность кода минимальной, так как нам нужно понять работу async/await, а не возможные варианты применения асинхронности. Это заслуживает отдельной статьи.

using System;using System.Diagnostics;using System.Net.Http;using System.Threading.Tasks;namespace Scenario{    class Program    {        static void Main(string[] args)        {            try            {                AsyncDownload().GetAwaiter().GetResult();                Console.ReadLine();            }            catch (Exception e)            {                Console.WriteLine(e);                throw;            }        }        static async Task<string> AsyncDownload()        {            HttpClient client = new HttpClient();            //Асинхронно скачиваем контент веб-страницы            return await client.GetStringAsync("https://msdn.microsoft.com");        }    }}

Я вставил этот код на sharplab и скомпилировал его в режиме Debug. Полученный сгенерированный результат представлен ниже:

using System;using System.Diagnostics;using System.Net.Http;using System.Reflection;using System.Runtime.CompilerServices;using System.Security;using System.Security.Permissions;using System.Threading.Tasks;[assembly: CompilationRelaxations(8)][assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)][assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)][assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)][assembly: AssemblyVersion("0.0.0.0")][module: UnverifiableCode]namespace Scenario{    internal class Program    {        // Создан конечный автомат для представления асинхронного метода загрузки         // с использованием HTTP-клиента        [CompilerGenerated]        private sealed class <AsyncDownload>d__1 : IAsyncStateMachine        {            // Переменная для поддержания текущего состояния выполнения конечного автомата            // FirstCall: Начальное значение -1             public int <>1__state;            // Построитель для создания новой асинхронной задачи, которая будет выполнять этот код конечного автомата            public AsyncTaskMethodBuilder<string> <>t__builder;            // HTTP-клиент, используемый методом для загрузки содержимого удаленного URL-адреса            private HttpClient <client>5__1;            // Переменная для хранения результатов вызова HttpClient            private string <>s__2;            // Awaiter задачи по загрузке содержимого с помощью HTTP-клиента            private TaskAwaiter<string> <>u__1;            // Это раздел, где находится фактическая логика исходного метода            // Содержит логику выполнения кода до оператора await            // Также настраивает параметры вызова функции пробуждения             // После завершения выполнения асинхронного метода            private void MoveNext()            {                // Копирует текущее состояние в локальную переменную                // Начальное значение состояния будет -1                int num = <>1__state;                // Переменная для сохранения результата HTTP-запроса                string result;                try                {                    // Переменная для сохранения объекта ожидания для новой задачи                    TaskAwaiter<string> awaiter;                    // В первый раз num будет -1                    if (num != 0)                    {                        //FirstCall: мы окажемся здесь по первичному вызову                        <client>5__1 = new HttpClient();                        //FirstCall: Сохранит объект ожидания для HTTP-вызова в переменную                        awaiter = <client>5__1.GetStringAsync("https://msdn.microsoft.com").GetAwaiter();                        //FirstCall: Скорее всего, мы перейдем к этому блоку при первом вызове                        //FirstCall: Этот блок предназначен для оптимизации в случае, если задача уже выполнена                                       //FirstCall: Мы пропускаем планирование вызова пробуждения или продолжения                        if (!awaiter.IsCompleted)                        {                            //FirstCall: Мы устанавливаем переменную состояния в 0                            //FirstCall: Чтобы при вызове функции пробуждения или обратном вызове мы вообще не входили в этот блок.                            num = (<>1__state = 0);                            <>u__1 = awaiter;                            <AsyncDownload>d__1 stateMachine = this;                            //FirstCall: Именно здесь происходит большая часть магии при вызове AwaitUnsafeOnCompleted                            //FirstCall: На этом шаге мы регистрируем конечный автомат как продолжение задачи, вызывая AwaitUnsafeOnCompleted                            //Но как это делается?                            //builder.AwaitUnsafeOnCompleted выполняет несколько действий в фоновом режиме                            //FirstCall: 1. TaskMethodBuilder захватывает контекст выполнения                            //FirstCall: 2. Создает MoveNextAction, используя контекст выполнения                            //FirstCall: 3. Этот MoveNextAction вызовет MoveNext конечного автомата и предоставит контекст выполнения                            //FirstCall: 4. Устанавливает MoveNextAction в качестве обратного вызова для объекта ожидания при завершении, используя awaiter.UnsafeOnCompleted(action)                            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine)                            //FirstCall: Освободить процессор или поток для вызывающей стороны                            //FirstCall: Начинается работа "ожидания"                            return;                        }                    }                    else                    {                        //WakeupCall: Мы получаем объект ожидания из контекста выполнения                        awaiter = <>u__1;                        //WakeupCall: Установить значение объекта ожидания в null для освобождения памяти                        <>u__1 = default(TaskAwaiter<string>);                        //WakeupCall: Временно установить переменную состояния в значение -1 (начальное состояние)                        num = (<>1__state = -1);                    }                    //WakeupCall: Получить результат от объекта ожидания                    //WakeupCall: Объект ожидания должен завершиться, как только мы получим WakeUpCall                    <>s__2 = awaiter.GetResult();                    result = <>s__2;                }                catch (Exception exception)                {                    <>1__state = -2;                    <>t__builder.SetException(exception);                    return;                }                //WakeUpCall: Установить состояние на конечное, на этом всё                <>1__state = -2;                //WakeUpCall: Построитель завершает задачу и устанавливает её результат                <>t__builder.SetResult(result);            }            void IAsyncStateMachine.MoveNext()            {                //ILSpy сгенерировал эту явную реализацию интерфейса из директивы .override в MoveNext                this.MoveNext();            }            [DebuggerHidden]            private void SetStateMachine(IAsyncStateMachine stateMachine)            {            }            void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)            {                //ILSpy сгенерировал эту явную реализацию интерфейса из директивы .override в SetStateMachine                this.SetStateMachine(stateMachine);            }        }                // Далее, мы будем называть функцию Main как "CallingFunction"        private static void Main(string[] args)        {            try            {                AsyncDownload().GetAwaiter().GetResult();                Console.ReadLine();            }            catch (Exception value)            {                Console.WriteLine(value);                throw;            }        }        // Мы будем называть этот метод "WorkerMethod"        [AsyncStateMachine(typeof(<AsyncDownload>d__1))]        [DebuggerStepThrough]        private static Task<string> AsyncDownload()        {            //FirstCall: Создать новый экземпляр конечного автомата            <AsyncDownload>d__1 stateMachine = new <AsyncDownload>d__1();            //FirstCall: Создать новый экземпляр AsyncTaskMethodBuilder и настроить его для конечного автомата            stateMachine.<>t__builder = AsyncTaskMethodBuilder<string>.Create();            //FirstCall: Установить состояние конечного автомата в -1 (начальное)            stateMachine.<>1__state = -1;            AsyncTaskMethodBuilder<string> <>t__builder = stateMachine.<>t__builder;            //FirstCall: Вызовет метод Start в построителе, который приведет к вызову StateMachine.MoveNext();            <>t__builder.Start(ref stateMachine);            //FirstCall: Возвращает задачу из построителя от WorkerMethod            return stateMachine.<>t__builder.Task;        }    }}

Пояснение к приведенному выше коду

Если вы уже ознакомились с приведенным выше примером кода, и вам все еще что-то непонятно, предлагаю изучить мою схему. Это может помочь лучше понять ход выполнения кода.

Я использую цветовые паттерны для лучшего понимания.

  • Все блоки с красной рамкой будут выполнены как при первом вызове FirstCall, так и при вызове пробуждения WakeUpCall.

  • Синие блоки будут выполнены только при FirstCall.

  • Зеленые блоки могут быть выполнены при FirstCall, если объект ожидания уже завершил операцию, но это крайне маловероятно, поскольку в этом процессе нет оптимизаций.

  • Зеленые блоки будут выполнены при вызове WakeUpCall в случае отсутствия ошибок или исключений.

Алгоритм работы конечного автомата во время выполнения метода

Алгоритм работы конечного автомата во время выполнения метода

Также по этой теме рекомендую к прочтению статью «Другой способ понять, как работает async/await в C#»

ссылка на оригинал статьи https://habr.com/ru/articles/1035442/