Во что компилируется ключевое слово await в C#?

Ответ

Ключевое слово await компилируется в конечный автомат (state machine), реализованный компилятором как структура, реализующая интерфейс IAsyncStateMachine. Этот автомат разбивает асинхронный метод на части и управляет возобновлением выполнения после завершения асинхронной операции.

Как это работает:

  1. Компилятор создаёт приватную вложенную структуру (<MethodName>d__X), которая хранит:
    • Состояние метода (state, обычно -1, 0, 1, 2...).
    • Локальные переменные исходного метода (превращаются в поля структуры).
    • TaskAwaiter (или другой awaiter) для ожидания задачи.
    • AsyncTaskMethodBuilder для управления созданием и завершением возвращаемой Task.
  2. Вся логика метода перемещается в метод MoveNext() этой структуры.
  3. При встрече await:
    • Если задача уже завершена (IsCompleted == true), выполнение продолжается синхронно.
    • Если задача не завершена, состояние (state) сохраняется, метод подписывается на уведомление о завершении задачи через builder.AwaitUnsafeOnCompleted(...), и управление возвращается вызывающему коду.
    • При завершении задачи вызывается MoveNext() снова, восстанавливается контекст и выполняется код после await.

Пример преобразования: Исходный код:

public async Task<int> GetDataAsync()
{
    Console.WriteLine("Start");
    int result = await FetchFromNetworkAsync();
    Console.WriteLine("Result: " + result);
    return result + 1;
}

Упрощённый вид сгенерированного кода (концептуально):

private struct <GetDataAsync>d__0 : IAsyncStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder<int> builder;
    private int result; // Локальная переменная стала полем
    private TaskAwaiter<int> awaiter;

    void MoveNext()
    {
        int finalResult;
        try
        {
            if (state == 0) // Первый вход в метод
            {
                Console.WriteLine("Start");
                awaiter = FetchFromNetworkAsync().GetAwaiter();
                if (!awaiter.IsCompleted)
                {
                    state = 1; // Устанавливаем состояние для точки возобновления
                    builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return; // Выход, управление возвращается вызывающему
                }
            }
            // Сюда попадаем при возобновлении (state == 1) или если задача завершилась синхронно
            result = awaiter.GetResult(); // Получаем результат асинхронной операции
            Console.WriteLine("Result: " + result);
            finalResult = result + 1;
        }
        catch (Exception ex)
        {
            builder.SetException(ex); // Проброс исключения в возвращаемую Task
            return;
        }
        builder.SetResult(finalResult); // Успешное завершение Task
    }
}

Почему это важно понимать? Это объясняет накладные расходы на создание объекта состояния, важность использования ConfigureAwait(false) в библиотечном коде (чтобы избежать лишних захватов контекста синхронизации) и то, почему async/await — это не потоки, а модель на основе продолжений (continuations).

Ответ 18+ 🔞

А, ну это же классика, про которую каждый второй думает, что там магия какая-то, а на деле — обычный конечный автомат, который компилятор из тебя вытворяет. Представь себе, ты написал красивый async метод, а компилятор смотрит на это и такой: «О, блядь, опять разгребать...» — и начинает генерировать тебе под капотом структуру, которая всё это хозяйство и крутит.

Вот смотри, ключевое слово await — это не волшебная палочка. Это команда для компилятора: «Слушай, а сделай-ка тут точку остановки, подпишись на завершение задачи, а потом, когда она сделается, продолжи отсюда, да не забудь контекст восстановить, а то я тут локальные переменные использую, ёпта!».

И компилятор, сука, послушно создаёт эту самую структуру, которая реализует IAsyncStateMachine. Название-то какое, блядь, серьёзное — «Машина состояний». А по сути — просто переключатель state, который говорит: «Мы сейчас на шаге 0, ждём первую задачу» или «Мы на шаге 1, ждём вторую».

Как этот цирк работает:

  1. Твой метод компилятор разрывает на куски, как тупую курицу. Всё, что до первого await — один кусок. Всё, что после него — другой. И так далее.
  2. Все твои милые локальные переменные — int result — превращаются в поля этой сгенерированной структуры. Потому что между вызовами MoveNext() их же где-то хранить надо, а в стеке они не живут вечно.
  3. Когда ты делаешь await FetchFromNetworkAsync(), компилятор не тупо ждёт. Он сначала спрашивает: «А задача-то уже может и готова?» (IsCompleted). Если готова — ну, ебать, красота, просто забираем результат и идём дальше, синхронно, без всяких подписок.
  4. А если не готова — вот тут начинается настоящий пиздец. Состояние (state) выставляется в «1», чтобы при возобновлении понять, откуда продолжать. Потом через этого самого builder.AwaitUnsafeOnCompleted метод говорит: «Эй, задача, как только ты там ебёшься — дай мне знать, я продолжу». И тут же, сука, возвращает управление тому, кто вызвал твой асинхронный метод! Вот откуда эта «неблокируемость» берётся.
  5. Потом, когда сеть там всё скачала, твой MoveNext() вызывается снова, состояние проверяется, контекст восстанавливается, и выполнение прыгает сразу на код после того самого await.

Вот смотри, как твой красивый код на самом деле выглядит под капотом (упрощённо, конечно, а то там ещё дохуя всего):

Было у тебя:

public async Task<int> GetDataAsync()
{
    Console.WriteLine("Start");
    int result = await FetchFromNetworkAsync();
    Console.WriteLine("Result: " + result);
    return result + 1;
}

А стало примерно вот это нечто (я тебе дух, а не букву передаю):

private struct <GetDataAsync>d__0 : IAsyncStateMachine
{
    public int state; // -1, 0, 1, 2... Где мы, блядь, находимся?
    public AsyncTaskMethodBuilder<int> builder; // Этот чувак таску строит и завершает
    private int result; // Твоя локальная переменная теперь тут живёт
    private TaskAwaiter<int> awaiter; // А это — тот, кто ждёт завершения

    void MoveNext()
    {
        int finalResult;
        try
        {
            if (state == 0) // Первый заход в метод
            {
                Console.WriteLine("Start");
                awaiter = FetchFromNetworkAsync().GetAwaiter();
                if (!awaiter.IsCompleted) // А задача-то ещё не готова!
                {
                    state = 1; // Запомним, что надо будет продолжить с шага 1
                    builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // Подписались, пошли нахуй
                    return; // Выходим, управление — вызывающему коду!
                }
            }
            // Сюда попадём либо если задача сходу была готова, либо при возобновлении (state == 1)
            result = awaiter.GetResult(); // Вот тут уже безопасно забираем результат
            Console.WriteLine("Result: " + result);
            finalResult = result + 1;
        }
        catch (Exception ex)
        {
            builder.SetException(ex); // Если что-то пошло не так — пробрасываем исключение в таску
            return;
        }
        builder.SetResult(finalResult); // Всё, пизда, таска успешно завершена с результатом
    }
}

А зачем это всё, спрашивается, понимать? Да затем, чувак!

  • Накладные расходы: Теперь ты видишь, что создаётся объект (структура, которую всё равно боксят), поля, awaiter. Это не бесплатно. Для горячего цикла — может быть важно.
  • ConfigureAwait(false): Ты теперь понимаешь, откуда ноги растут у этого совета. Этот самый builder.AwaitUnsafeOnCompleted может захватывать контекст синхронизации (например, контекст UI-потока), чтобы потом в нём же и возобновиться. Если ты пишешь библиотечный код, которому похуй на контекст, ConfigureAwait(false) говорит компилятору: «Не захватывай контекст, возобновляйся где попало». Это может здорово сэкономить производительность и избежать дедлоков.
  • Не потоки: Это самое главное. Здесь нет никаких дополнительных потоков! Это модель на основе продолжений (continuations). Поток освобождается, когда ждёт I/O, а потом, когда I/O готов, продолжение (твой MoveNext) может быть выполнено на любом свободном потоке из пула (или на том же самом, если контекст был захвачен). Вся магия — в умной подписке и колбэках, а не в создании тысяч потоков.

Короче, async/await — это не магия, а просто очень, блядь, умная и удобная синтаксическая надстройка над старыми-добрыми задачами и колбэками. Компилятор — тот ещё извращенец, который превращает твой линейный код в машину состояний. Но когда понимаешь, как оно работает, сразу меньше страха и больше уважения к технологии. И меньше шансов накосячить с дедлоками, потому что ты уже представляешь, что там под капотом творится.