Конечный автомат и его внутреннее устройство
Примечание переводчика:
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, а затем он генерирует скомпилированный код для этого фрагмента. Вот несколько вещей, которые компилятор сгенерирует для нашего асинхронного кода:
-
Компилятор сгенерирует код конечного автомата (реализующий
IAsyncStateMachine) дляWorkerFunction. -
Перенесет фактическую логику
WorkerFunctionв функциюMoveNext. -
Создаст внутри конечного автомата переменные, необходимые для его работы.
-
Изменит
CallingFunctionтак, чтобы он создавал новый экземплярStateMachine. -
Вызовет
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#»
Автор: daryaKarman
