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— только для обработчиков событий, исключения ловите внутри.