Еще раз про асинхронную машину состояний и где именно там аллокации

f110c3a4956146d27e8410de761d8978

Несмотря на то, что про async/await уже было сказано много слов и записано множество докладов, тем не менее, в своей практике преподавания и наставничества, я часто сталкиваюсь с недопониманием устройства async/await даже у разработчиков уровня Middle+.

Как известно, при компиляции асинхронного метода компилятор преобразует код, разбивая его на отдельные шаги. Потом, во время выполнения каждый шаг прерывается асинхронной операцией. Когда она завершается, надо точно понимать, куда вернуть управление — в какой конкретно шаг. Поэтому все шаги нумеруются и компилятор очень строго следит за тем откуда куда можно перейти. В computer science такое решение называется машиной состояний. Еще, по-русски её называют конечный автомат. Далее, для краткости, я буду использовать сокращение SM (state machine).

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

«Высокуровневый» C#

Сначала рассмотрим пример простого кода на обычном («высокуровневом») C#.

using System;
using System.Threading.Tasks;
using System.IO;

public class Program {
    private string _fileContent;
    
    public async Task Main() {
        await Task.Delay(100);
        
        int delay = int.Parse(Console.ReadLine());
        await Task.Delay(delay);
        
        _fileContent = await File.ReadAllTextAsync("file1");
        
        await Task.Delay(delay);
    }
}

Код сначала ожидает 100 мс, затем считывает из консоли сколько еще ожидать, еще ожидает, считыват данные из файла и еще ожидает. Логики в последовательности этих вызовов искать не стоит, для нас здесь главное, что это просто понятные асинхронные вызовы.

«Низкоуровневый» C#

Далее следует код, который генерирует компилятор из «высокоуровневого» (обычного) C#.
Сразу скажу, что оригинальный код, сгенерированный компилятором, выглядит так, как будто разработчики кодо-генератора делали все для того, чтобы человеку было непонятно ничего. Тем не менее, кому интересно, оригинальный код можно посмотреть на sharplab.io.

В общем, я попросил нейросеть провести небольшой рефакторинг, чтобы сделать код более читаемым, а также сопроводил значимые и неочевидные места подробными комментариями. Вот что получилось (содержимое класса Program):

// Машина состояний (SM)
private sealed class AsyncStateMachine : IAsyncStateMachine
{
    // Определение состояний для машины состояний
    public enum State
    {
        NotStarted,               // Машина состояний не запущена - начальное состояние
        WaitingAfterInitialDelay, // Ожидание после начальной задержки
        WaitingForFileRead,       // Ожидание чтения файла
        WaitingAfterFinalDelay,   // Ожидание после последней задержки
        Finished                  // Завершено
    }

    public State CurrentState; // Текущее состояние машины состояний

    public AsyncTaskMethodBuilder Builder; // Строитель задачи асинхронного метода

    public Program Instance; // Экземпляр программы (оригинального класса)

    private int DelayDuration; // Длительность задержки (переменная delay стала полем машины состояний)

    private string FileContentTemp; // Временное хранение содержимого файла

    private TaskAwaiter DelayAwaiter; // Ожидатель задержки

    private TaskAwaiter ReadFileAwaiter; // Ожидатель чтения файла

    private void MoveNext()
    {
        try
        {
            switch (CurrentState)
            {
                case State.NotStarted:
                    // Запуск начальной задержки
                    DelayAwaiter = Task.Delay(100).GetAwaiter();
                    if (DelayAwaiter.IsCompleted)
                    {
                        /* 
                            В случае если таска сразу после запуска завершилась, произойдет переход к выполнению следующего этапа машины состояний (WaitingAfterInitialDelay)
                            Такое бывает, например, когда в методе с модификатором async нет асинхронных вызовов, либо если мы эвэйтим уже завершенную таску.
                        */
                        goto case State.WaitingAfterInitialDelay;
                    }
                    // Конкретно в этом кейсе, исполнение не зайдет в if, который выше, а выполнит две нижние строки
                    CurrentState = State.WaitingAfterInitialDelay;
                    Builder.AwaitUnsafeOnCompleted(ref DelayAwaiter, ref this);
                    /* 
                        AwaitUnsafeOnCompleted запланирует, что указанная машина состояний (ref this) будет продвинута вперед после завершения работы указанного awaiter'а (будет вызван метод MoveNext).
                        По смыслу это похоже на ContinueWith.
                        [ссылка на исходник под кодом] *
                    */
                    break;

                case State.WaitingAfterInitialDelay:
                    DelayAwaiter.GetResult();
                    /*
                        В случае если в асинхронном методе случился эксепшн, тогда он будет выброшен при вызове GetResult и мы сразу попадем в блок catch.
                    */

                    DelayDuration = int.Parse(Console.ReadLine());
                    DelayAwaiter = Task.Delay(DelayDuration).GetAwaiter();
                    if (DelayAwaiter.IsCompleted)
                    {
                        goto case State.WaitingForFileRead;
                    }
                    CurrentState = State.WaitingForFileRead;
                    Builder.AwaitUnsafeOnCompleted(ref DelayAwaiter, ref this);
                    break;

                case State.WaitingForFileRead:
                    /*
                        Важно, что если выполнение идет по реально асинхронному сценарию (т. е. мы попадаем сюда не из goto case), и используется контекст синхронизации по умолчанию, либо он не задан (что по умолчанию в запросах ASP.NET Core, например), то метод MoveNext() будет вызван из какого-то потока пула потоков. То есть, разные состояния SM могут быть запущены разными потоками.
                        Обычно, нам, программистам, эта особенность не мешает. Но есть редкие кейсы, где это может быть важно - как, например, кейс в одной из задачек на самопроверку ниже в статье.
                    */
                    DelayAwaiter.GetResult();
                    ReadFileAwaiter = File.ReadAllTextAsync("file1").GetAwaiter();
                    if (ReadFileAwaiter.IsCompleted)
                    {
                        goto case State.WaitingAfterFinalDelay;
                    }
                    CurrentState = State.WaitingAfterFinalDelay;
                    Builder.AwaitUnsafeOnCompleted(ref ReadFileAwaiter, ref this);
                    break;

                case State.WaitingAfterFinalDelay:
                    // Завершение чтения файла и установка результата
                    FileContentTemp = ReadFileAwaiter.GetResult();
                    Instance._fileContent = FileContentTemp;
                    FileContentTemp = null;
                    DelayAwaiter = Task.Delay(DelayDuration).GetAwaiter();
                    if (DelayAwaiter.IsCompleted)
                    {
                        CurrentState = State.Finished;
                        return;
                    }
                    CurrentState = State.Finished;
                    Builder.AwaitUnsafeOnCompleted(ref DelayAwaiter, ref this);
                    break;
            }
        }
        catch (Exception exception)
        {
            CurrentState = State.Finished;
            Builder.SetException(exception);
        }
    }
}

private string _fileContent; // Содержимое файла

[AsyncStateMachine(typeof(AsyncStateMachine))]
public Task Main()
{
    AsyncStateMachine stateMachine = new AsyncStateMachine();
    stateMachine.Builder = AsyncTaskMethodBuilder.Create();
    stateMachine.Instance = this;
    stateMachine.CurrentState = AsyncStateMachine.State.NotStarted;
    stateMachine.Builder.Start(ref stateMachine);
    /* 
        Первый вызов MoveNext происходит прямо в stateMachine.Builder.Start. Т. е. первое состояние нашей SM фактически выполняется синхронно (и далее до первого реального асинхронного вызова).
        Исходник **
    */
    return stateMachine.Builder.Task;
}

* Исходный код метода AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted доступен по ссылке
** Исходный код этого метода AsyncStateMachine.Builder.Start доступен по ссылке.

Из кода выше видно, что, фактически, на каждое использование ключевого слова await компилятор генерирует дополнительное состояние для машины состояний (SM). Кроме того, важно отметить, что саму машину состояний компилятор сгенерирует в случае если в определении типа метода используется модификатор async.

Кстати, этот код не запустится, потому что в нем нет некоторых вспомогательных методов, которые генерирует компилятор. Но он позволяет понять, как работает асинхронность в C#.

Разбираемся с аллокациями

Интересно, что в Debug режиме AsyncStateMachine для тасок представлен в виде класса, а в Release — в виде структуры (struct). Но хоть это и структура, если выполнение пойдет действительно по асинхронному сценарию, под капотом в Runtime все-таки произойдет аллокация для AsyncStateMachine.

Когда выполнение идет по асинхронному сценарию (вызов DelayAwaiter.IsCompleted возвращает false), CLR’у машину состояний необходимо переместить из стека в управляемую кучу, для этого она упаковывается (boxing) в AsyncStateMachineBox рантаймом.
Для Task это происходит внутри AsyncTaskMethodBuilder.GetStateMachineBox.
Для ValueTask это происходит внутри цепочки (AsyncValueTaskMethodBuilder.AwaitUnsafeOnCompleted → AsyncTaskMethodBuilder.AwaitUnsafeOnCompleted → AsyncTaskMethodBuilder.GetStateMachineBox).

Пулинг

Еще интересно, что в CLR предусмотрена возможность пулинга* AsyncStateMachineBox для минимизации аллокаций (метод StateMachineBox RentFromCache ()).

* пулинг (pooling) в данном контексте — это техника повторного использования данных для экономии места в куче приложения и ресурсов GC. В случае пулинга машины состояний, CLR сможет повторно ее использовать для ValueTask, чтобы сэкономить на аллокациях в куче. Хотя, даже Стивен Тауб сомневается в реальной эффективности такого подхода.

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

Ну и финальное замечание — если присмотрется внимательно что стало с переменной delay, то мы увидим, что она была захвачена и перенесена в поле машины состояний (DelayDuration в очищенном коде и 5__2 в коде от компилятора). Соответственно, важно понимать, что если выполнение идет по асинхронному сценарию (что довольно часто), value type переменные забоксятся вместе с машиной состояний и будут перемещены в управляемую кучу (heap) — поэтому Spanы запрещено использовать в методах с модификатором async.

Что еще почитать по теме

Например, целый гайд от Стивена Тауба «How Async/Await Really Works in C#» («Как на самом деле работает Async/Await в C#»). Переводы также доступны на хабре.

Или статью Prefer ValueTask to Task, always; and don’t await twice, которая описывает не только особенности ValueTask, но и то, как реализовать даже асинхронную логику через IValueTaskSource или ManualResetValueTaskSourceCore, минимизируя кол-во выделений памяти в куче.

Стоит упомянуть, подход из этой статьи похож на то, что еще в 2017 году делал Сергей Тепляков @SergeyT в своей статье. Отличие в том, что в моем примере SM сохранена более аккуратно в первозданном виде, еще сразу внутри SM код сопроважден комментариями, разобран вопрос с аллокациями и приведены разные кейсы.

Упражнения для самопроверки

Упражнения для самопроверки под спойлером

  1. Что выведет программа?

void Main()
{
    RunAsync(); //"fire-and-forget"
    Console.WriteLine("Main");
    Thread.Sleep(1500);
}

async Task RunAsync()
{
    Console.WriteLine("RunAsync 1");
    await Task.Delay(1000);
    Console.WriteLine("RunAsync 2");
}
Ответ

Т. к. первое состояние точно выполнится синхронно (его выполняет вызывающий поток), то сразу при вызове RunAsync в консоль будет выведено «RunAsync 1», затем после запуска Task.Delay(1000) вызывающий поток сразу продолжит выполнение и перейдет к выполнению Console.WriteLine("Main"), после чего он переключится в состояние WaitSleepJoin (Wait:ExecutionDelay) и уснет, затем примерно через секунду другой поток (thread pool worker) напишет в консоль «RunAsync 2». В итоге, получим вывод:

RunAsync 1
Main
RunAsync 2
  1. Сколько состояний SM будет сгенерировано для такого кода?

async Task Delay1()
{
    await Task.Delay(1);
}
Ответ
  • первое состояние — это этап, на котором запускается таска Task.Delay(1)

  • второе состояние — продолжение (continuation) с вызовом GetResult, которое выполнится после завершения ранее запущенной таски.

  • Итого: 2 состояния.

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

  1. А для такого?

async Task MultiDelay()
{
    var task = Task.Delay(1);
    await task;
    await task;
    await task;
}
Ответ
  • 1 до всех эвейтов на запуск Task.Delay

  • 1 continiation с вызовом GetResult после первого await’a

  • затем 2 доп. состояния, делающих фактически синхронное потребление уже завершенной таски (они выполняются по цепочке через goto)

  • Итого: 4 состояния.

  1. И финальная задача. Сколько состояний SM будет сгенерировано для такого кода?

Task Delay1()
{
    return Task.Delay(1);
}
Ответ

Машина состояний для этого кода вообще не будет сгенерирована, т. к. отсутствует модификатор async в объявлении метода Delay1.

  1. Бонус: Вопрос, который иногда задают на собеседованиях о том, почему запрещено использовать await внутри lock. Чтобы ответить на него, достаточно концептуально (упрощенно) воссоздать код, в который разворачивается ключевое слово lock:

object _syncObj = new();

async Task DelayLocked()
{
    Monitor.Enter(_syncObj);
    await Task.Delay(1);
    Monitor.Exit(_syncObj);
}
Ответ

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

Материал актуален для версии .NET 8.0.1. С удовольствием отвечу на ваши вопросы в комментариях.

Заканчивая, хочу поблагодарить Марка Шевченко @markshevchenko и Евгения Пешкова @epeshk за ревью этой статьи. А еще, в будущих версиях .NET async/await может сильно преобразиться.

© Habrahabr.ru