async/await под капотом

Вопрос-крючок с реального собеседования: «Что выведет этот код? И, кстати, сколько потоков он создаст?»

async/await — это не «выполнить в другом потоке». Это способ написать метод, который умеет приостанавливаться: компилятор режет его на куски по точкам await и собирает из них машину состояний, которая сама себя возобновляет, когда ожидаемая операция завершилась.

Вопрос: что выведет этот код?

Нумерация в строках — это то, что утверждает кандидат. Прежде чем нажимать «Запустить», проговорите порядок вслух.

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("1. Main: до вызова WorkAsync");
        Task work = WorkAsync();
        Console.WriteLine("3. Main: WorkAsync уже вернул управление, но ещё не завершён");
        await work;
        Console.WriteLine("5. Main: await дождался результата");
    }

    static async Task WorkAsync()
    {
        Console.WriteLine("2. WorkAsync: начало — тот же поток, что и у Main");
        await Task.Delay(100);
        Console.WriteLine("4. WorkAsync: продолжение после Task.Delay");
    }
}

Результат:

1. Main: до вызова WorkAsync
2. WorkAsync: начало — тот же поток, что и у Main
3. Main: WorkAsync уже вернул управление, но ещё не завершён
4. WorkAsync: продолжение после Task.Delay
5. Main: await дождался результата

Главное здесь — строки 2 и 3. Вызов WorkAsync() не «запускает задачу где-то в фоне»: тело метода начинает выполняться прямо здесь и сейчас, в вызывающем потоке, синхронно, до первого await. И только когда дело доходит до await Task.Delay(100) — операции, которая ещё не завершилась, — метод возвращает управление вызывающему коду, отдавая ему незавершённый Task как расписку: «допишу позже».

Отсюда следует вывод, который многие произносят с трудом: слово async само по себе не создаёт ни одного потока. Оно вообще не является частью сигнатуры для вызывающего кода — это лишь разрешение компилятору использовать await внутри тела.

Как это работает под капотом

Компилятор берёт ваш WorkAsync и переписывает его в структуру, реализующую IAsyncStateMachine. Тело метода превращается в один метод MoveNext() с гигантским switch по номеру состояния, а все локальные переменные — в поля этой структуры (чтобы пережить приостановку). Упрощённо это выглядит так:

// примерно во что компилятор превращает WorkAsync
struct WorkAsyncStateMachine : IAsyncStateMachine
{
    public int state;                       // -1 — ещё не входили, 0 — ждём первый await
    public AsyncTaskMethodBuilder builder;  // строит и завершает внешний Task
    private TaskAwaiter awaiter;            // то, чего мы ждём

    public void MoveNext()
    {
        switch (state)
        {
            case -1:
                Console.WriteLine("2. WorkAsync: начало...");
                awaiter = Task.Delay(100).GetAwaiter();

                if (!awaiter.IsCompleted)    // операция ещё не готова?
                {
                    state = 0;
                    // подписались на завершение и ВЫШЛИ из метода
                    builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;
                }
                goto case 0;                 // готова — продолжаем синхронно, без переключений

            case 0:
                awaiter.GetResult();         // здесь «выстрелит» исключение, если оно было
                Console.WriteLine("4. WorkAsync: продолжение...");
                break;
        }

        builder.SetResult();                 // помечаем внешний Task завершённым
    }
}

Три детали, которые ценят на собеседовании

  • Быстрый путь. Если awaiter.IsCompleted == true (задача уже готова — например, значение лежит в кэше), продолжение выполняется синхронно, тем же потоком. Никакого возврата, никаких переключений. Именно поэтому await дешёвый: в горячем пути он почти бесплатен.
  • Машина состояний — struct. Пока метод не приостановился, она живёт на стеке и не нагружает сборщик мусора. Boxing на кучу происходит только при первой настоящей приостановке. Отсюда практическое следствие: async-метод, который на самом деле почти всегда завершается синхронно, почти ничего не аллоцирует.
  • Кто вызывает MoveNext() потом? Тот, кто завершил ожидаемую задачу. Для Task.Delay это поток таймера, который ставит продолжение в очередь пула потоков (или в контекст синхронизации — см. ниже). Никто не «держал» поток все 100 миллисекунд.

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

Когда вы пишете await task, awaiter перед приостановкой запоминает SynchronizationContext.Current — объект, который умеет «вернуть» продолжение туда, где положено. В WinForms/WPF это UI-контекст: он загоняет продолжение в очередь сообщений главного потока, поэтому после await вы спокойно трогаете label.Text. В классическом ASP.NET (не Core) это контекст запроса. В консольном приложении и в ASP.NET Core контекста нет (null) — продолжение просто отправляется в пул потоков.

Проверим на живом коде, что до и после await поток может быть разным:

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

class Program
{
    static async Task Main()
    {
        Console.WriteLine($"Контекст синхронизации: {SynchronizationContext.Current?.ToString() ?? "null"}");
        Console.WriteLine($"До await:    поток {Environment.CurrentManagedThreadId}, из пула = {Thread.CurrentThread.IsThreadPoolThread}");

        await Task.Delay(50);

        Console.WriteLine($"После await: поток {Environment.CurrentManagedThreadId}, из пула = {Thread.CurrentThread.IsThreadPoolThread}");
    }
}

Результат (номера потоков у вас будут другими, важны null и True):

Контекст синхронизации: null
До await:    поток 1, из пула = False
После await: поток 4, из пула = True

Вот и ответ на добивающий вопрос: async-метод не создал ни одного потока, но продолжение подхватил свободный поток из пула. Разница принципиальная — поток не создавался под задачу, он был переиспользован уже существующим.

Частые ошибки на собеседовании

  • «async запускает метод в фоновом потоке». Нет. До первого await всё выполняется синхронно в вызывающем потоке. Фоновым выполнение делает Task.Run, а не async.
  • «await блокирует поток, пока ждёт». Наоборот: await — это единственный способ не блокировать. Блокирует .Result и .Wait().
  • async void. У такого метода нет Task, а значит его невозможно дождаться, а исключение из него не «сядет» в задачу — оно улетит прямо в SynchronizationContext и уронит процесс. Допустим async void ровно в одном месте — обработчики событий UI:
    // единственный легальный async void — обработчик события
    private async void OnSaveClick(object sender, EventArgs e)
    {
        try
        {
            await _repository.SaveAsync(_model);   // внутри — только async Task
        }
        catch (Exception ex)                       // ловим ЗДЕСЬ, выше поймать некому
        {
            ShowError(ex.Message);
        }
    }
  • Забытый await. Вызов SaveAsync(model); без await компилируется (максимум предупреждение CS4014), метод стартует — и метод-родитель уезжает дальше, не дожидаясь. Исключение внутри задачи никто не увидит: оно тихо осядет в брошенном Task.
  • «async-методы всегда медленнее». Если ожидаемая задача уже завершена, машина состояний идёт быстрым путём — без аллокаций и переключений.

Итоги-шпаргалка

  • async — это указание компилятору переписать метод в машину состояний (IAsyncStateMachine + MoveNext), а не команда «создать поток».
  • Тело async-метода выполняется синхронно до первого await над незавершённой задачей.
  • Если ожидаемое уже готово — продолжение идёт синхронно, тем же потоком (быстрый путь).
  • Если нет — метод возвращает незавершённый Task, а продолжение позже вызовет тот, кто завершил операцию.
  • await захватывает SynchronizationContext.Current и возвращает продолжение туда (UI-поток, контекст запроса) — либо в пул потоков, если контекста нет.
  • async void — только для обработчиков событий, исключения ловите внутри.
Проверьте себя
1. Вызывается async-метод, первая строка которого — Console.WriteLine, а вторая — await Task.Delay(1000). В каком потоке выполнится Console.WriteLine?
AВ новом потоке, созданном ключевым словом async
BВ том же потоке, который вызвал метод — синхронно
CВ потоке из пула, задача сразу ставится в очередь
DЗависит от настроек TaskScheduler
2. Что произойдёт, если await применён к задаче, которая УЖЕ завершена (IsCompleted == true)?
AМетод всё равно приостановится и вернёт управление вызывающему коду
BПродолжение выполнится синхронно, тем же потоком, без приостановки
CБудет выброшено исключение InvalidOperationException
DПродолжение всегда уйдёт в пул потоков