Большинство медленных операций в программе это ожидания: пока веб-сервер ответит, база данных вернёт строки, файл прочитается. async и await позволяют методу приостановиться на время такого ожидания, не держа поток в заложниках, а затем продолжить с того же места. Код при этом по-прежнему читается сверху вниз, как обычный.
Первый асинхронный метод
Метод async возвращает Task (без результата) или Task<T> (результат типа T). Внутри него await ожидает другую задачу и отдаёт её результат.
Вывод:
Tea 2.50, cake 4.00, total 6.50
GetPriceAsync пишет return 2.50m, а компилятор оборачивает это в Task<decimal>, который возвращает метод. await снова его распаковывает. Task.Delay это асинхронная версия Thread.Sleep: он завершается по истечении времени, не блокируя поток в промежутке.
Начиная с C# 7.1 сам Main может быть асинхронным, и именно так эту программу написали бы в современном .NET:
static async Task Main()
{
decimal tea = await GetPriceAsync("tea");
Console.WriteLine(tea);
}
Примеры на этой странице, которые можно запустить, вызывают асинхронный метод RunAsync из обычного Main через .GetAwaiter().GetResult(), и именно это генерирует компилятор для асинхронного Main. В консольном приложении это безопасно; почему тот же вызов опасен в коде интерфейса, объясняет раздел о взаимоблокировках.
Что происходит на await
Асинхронный метод выполняется синхронно до первого await на незавершённой задаче. В этот момент он возвращает вызывающему Task, а остаток метода становится продолжением, которое выполнится, когда ожидаемая задача завершится.
Вывод:
Calling DownloadAsync
Download: starting
DownloadAsync returned a task; doing other work
Download: finished
Download awaited
«Download: starting» печатается до возврата из DownloadAsync, потому что всё до первого await выполняется в потоке вызывающего. Затем метод отдаёт незавершённую задачу, вызывающий продолжает работу, а «Download: finished» появляется, только когда задержка закончится. Вызов асинхронного метода запускает его; ожидание через await это способ дождаться результата.
async не создаёт поток. Пока метод приостановлен, его вообще не ждёт ни один поток, поэтому сервер может держать тысячи запросов в ожидании базы данных всего с несколькими потоками. Для работы, нагружающей процессор, которую нужно выполнить в другом потоке, используйте Task.Run, описанный на странице задачи.
Одновременный запуск операций через Task.WhenAll
Ожидание вызовов один за другим последовательно: каждый начинается только после завершения предыдущего. Когда операции не зависят друг от друга, запустите их все, а затем ожидайте вместе через Task.WhenAll:
Пример вывода:
Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms
Последовательная версия занимает сумму трёх ожиданий, одновременная примерно столько же, сколько самое долгое. Task.WhenAll возвращает результаты в том же порядке, что и переданные задачи, независимо от того, какая завершилась первой. Он работает и со списком, и это обычная форма для «получить каждый элемент»: await Task.WhenAll(ids.Select(id => FetchAsync(id))).
Task.WhenAny это парный метод, который завершается, как только завершилась первая задача; он полезен для тайм-аутов и сценария «побеждает первый ответ».
Исключения в асинхронном коде
Исключение, выброшенное внутри асинхронного метода, сохраняется в возвращённой задаче и выбрасывается повторно, когда задачу ожидают, поэтому обычный try/catch вокруг await работает:
Вывод:
Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7
Вызов LoadProfileAsync(-1) не выбросил исключение: оно хранится в задаче, пока её кто-нибудь не начнёт ожидать. Задача, которую никто никогда не ожидает, полностью скрывает своё исключение, и это ещё одна причина всегда ожидать то, что вы запустили. С WhenAll оператор await выбрасывает первое исключение, а свойство Exception объединённой задачи (AggregateException) хранит их все. Чтение ok.Result там нормально, потому что ok уже завершилась.
async void: только для обработчиков событий
Асинхронный метод может также возвращать void. Избегайте этого везде, кроме обработчиков событий:
// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
await db.InsertAsync(order); // if this throws, the process may crash
}
// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
await db.InsertAsync(order);
}
// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
try { await SaveAsync(currentOrder); }
catch (Exception ex) { ShowError(ex); }
}
С async void нет задачи, которую можно ожидать, поэтому вызывающий идёт дальше до завершения работы, а исключение, выброшенное внутри, выбрасывается прямо в контексте синхронизации (или в пуле потоков), где его не достанет ни один try/catch вызывающего. В консольном или серверном приложении это обычно завершает процесс. Внутри асинхронного обработчика событий async void перехватывайте всё сами.
Родственная ошибка это вызов асинхронного метода без await. Компилятор предупреждает (CS4014), а метод выполняется в фоне, и никто не наблюдает ни за его результатом, ни за его ошибками.
Взаимоблокировки из-за .Result и .Wait()
Блокировка на задаче через .Result или .Wait() это место, где асинхронный код ломается чаще всего. В приложении с контекстом синхронизации (WinForms, WPF, MAUI, классический ASP.NET) последовательность такая:
- Поток интерфейса вызывает
GetDataAsync().Resultи блокируется в ожидании задачи. - Внутри
GetDataAsyncзавершаетсяawait. По умолчанию его продолжение должно выполниться в том контексте, где он начался: в потоке интерфейса. - Поток интерфейса заблокирован на шаге 1, поэтому продолжение никогда не выполняется, поэтому задача никогда не завершается, поэтому шаг 1 никогда не заканчивается.
Приложение зависает без всякого исключения. У консольных приложений и ASP.NET Core такого контекста нет, поэтому тот же код работает в тестовой программе и зависает в настольном приложении. Исправления:
- Используйте
awaitна всём пути вверх по цепочке вызовов вместо блокировки («async до самого верха»). Обработчики событий для этого могут бытьasync void. - В коде библиотек, которому не нужно возвращаться в контекст вызывающего, пишите
await SomethingAsync().ConfigureAwait(false);. Тогда продолжение выполняется в пуле потоков, а не в захваченном контексте, и библиотека пропускает ненужное переключение потока. Оно защищает блокирующего вызывающего, только если так делает каждыйawaitв цепочке, поэтому считайте это хорошей гигиеной библиотек, а не исправлением.Result.
Коду приложений в ASP.NET Core ConfigureAwait(false) не нужен, поскольку возвращаться некуда.
Частые ошибки
- Ожидание независимых вызовов по одному. Запустите их, затем
await Task.WhenAll(...). - Методы
async void. ВозвращайтеTask; оставьтеasync voidдля обработчиков событий. .Resultи.Wait()в коде интерфейса или классического ASP.NET. Они вызывают взаимоблокировку; используйте await.- Забытый
await. Работа выполняется без наблюдения, а её исключения пропадают. - Обёртывание ввода-вывода в
Task.Run. Асинхронный API вродеFile.ReadAllTextAsyncилиHttpClient.GetStringAsyncуже освобождает поток;Task.Runлишь добавляет переход в пул потоков. - Предположение, что
asyncозначает «выполняется в другом потоке». Оно означает «может приостановиться без блокировки». Для работы, нагружающей процессор, используйтеTask.Run.
Часто задаваемые вопросы
Как работают async и await в C#?
Пометка метода async позволяет использовать в нём await. Когда метод доходит до await на незавершённой задаче, он сразу возвращает управление вызывающему, отдавая Task, который представляет оставшуюся работу. Когда ожидаемая операция завершается, метод продолжает выполнение после await. Никакой поток не простаивает в блокировке во время ожидания.
Чем Task отличается от Task<T>?
Task представляет операцию, которая не возвращает значения, асинхронную версию метода void. Task<T> представляет операцию, которая возвращает T: await на Task<int> даёт вам int. Асинхронный метод, объявленный как async Task<int>, просто пишет return 42;, а компилятор оборачивает значение в задачу.
Как запустить несколько асинхронных операций одновременно?
Сначала запустите их все, затем ожидайте вместе: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. Запись await GetUserAsync(); await GetOrdersAsync(); выполняет их одну за другой, поэтому общее время равно сумме, а не самой долгой операции.
Чем плох async void в C#?
Метод async void нельзя ожидать, поэтому вызывающий не знает, когда он завершится, а выброшенное внутри исключение вызывающий не может перехватить: оно выбрасывается в контексте синхронизации и обычно обрушивает процесс. Возвращайте Task. async void существует только для обработчиков событий, сигнатура которых требует void.
Почему .Result или .Wait() вызывает взаимоблокировку?
В приложении с контекстом синхронизации (WinForms, WPF, классический ASP.NET) .Result блокирует поток интерфейса или запроса, а продолжение ожидаемого метода ждёт возможности выполниться в этом же потоке. Каждый бесконечно ждёт другого. Используйте await на всём пути вверх вместо блокировки, а в коде библиотек используйте ConfigureAwait(false).
Может ли Main быть асинхронным в C#?
Да, начиная с C# 7.1: static async Task Main() или static async Task<int> Main(). Операторы верхнего уровня (C# 9) могут использовать await напрямую. В старом коде эквивалент это вызов MainAsync().GetAwaiter().GetResult() из обычного Main, что там безопасно, потому что у консольного приложения нет контекста синхронизации.