Асинхронное программирование в C# давно перестало быть экзотикой. Любое приложение, которое ходит в сеть, читает файлы или обращается к базе данных, выигрывает от неблокирующего кода. При этом ключевые слова async и await окружены мифами: кто-то считает, что они запускают новый поток, кто-то боится ими пользоваться из-за таинственных «зависаний». Давайте разберёмся, как это устроено, и пройдём по практическим сценариям.
Зачем нужна асинхронность
Представьте обработчик нажатия кнопки в настольном приложении. Он скачивает файл по сети и занимает на это пару секунд. Если делать это синхронно, поток интерфейса блокируется, окно не перерисовывается и не реагирует на мышь. Пользователь видит «Не отвечает».
В серверном приложении проблема другая. Каждый запрос занимает поток из пула. Если поток простаивает в ожидании ответа базы данных, он не может обслуживать других клиентов. Асинхронный код освобождает поток на время ожидания, и сервер выдерживает намного больше одновременных запросов при тех же ресурсах.
Ключевая мысль: асинхронность нужна для операций, где вы ждёте (ввод-вывод), а не для тяжёлых вычислений, где процессор занят вашим кодом.
Как работают async и await
Метод, помеченный как async, может содержать выражения await. Когда выполнение доходит до await, а операция ещё не завершена, метод возвращает управление вызывающему коду, а остаток метода превращается в продолжение. Когда операция заканчивается, продолжение выполняется дальше.
public async Task<string> LoadPageAsync(string url)
{
using var client = new HttpClient();
string text = await client.GetStringAsync(url);
return text.Trim();
}
Здесь компилятор строит конечный автомат. Вам не нужно думать о коллбэках: код читается сверху вниз, как обычный синхронный. Но выполнение под капотом разбивается на кусочки.
Важно понимать: await не создаёт поток. Пока идёт сетевой запрос, ни один поток вашего приложения его не обслуживает; ожидание отдаётся операционной системе. Поток снова понадобится лишь тогда, когда нужно выполнить продолжение.
Task и возвращаемые типы
Асинхронные методы возвращают один из нескольких типов:
- Task — операция без результата, аналог void.
- Task<T> — операция, которая в итоге выдаст значение типа T.
- ValueTask<T> — облегчённый вариант для случаев, когда результат часто доступен сразу, без реального ожидания.
- void — допустим почти исключительно для обработчиков событий.
Task — это «обещание» результата. Его можно передавать, хранить, объединять с другими задачами. Это и есть сила модели: асинхронная операция становится обычным объектом.
Параллельное выполнение нескольких операций
Если вам нужно выполнить несколько независимых запросов, не ждите их по очереди. Запустите все, а затем дождитесь вместе.
var t1 = LoadPageAsync(urlA);
var t2 = LoadPageAsync(urlB);
var t3 = LoadPageAsync(urlC);
string[] pages = await Task.WhenAll(t1, t2, t3);
Три запроса идут одновременно, а общее время примерно равно самому медленному, а не сумме. Для ситуации «достаточно первого ответа» есть Task.WhenAny. С его помощью удобно делать таймауты или запрашивать данные из нескольких зеркал, выбирая самый быстрый.
Типичные ошибки
Блокировка через Result и Wait
Самая известная ловушка. Вы вызываете асинхронный метод и дожидаетесь его синхронно:
var text = LoadPageAsync(url).Result; // опасно
В приложении с контекстом синхронизации, например в оконном интерфейсе, это приводит к взаимной блокировке. Поток интерфейса ждёт завершения задачи, а задача для продолжения хочет вернуться именно в этот поток, который занят ожиданием. Получается deadlock. Решение простое: распространяйте async по всей цепочке вызовов, не блокируйтесь. Принцип называют «async all the way».
async void
Метод async void нельзя ожидать, а исключения из него нельзя перехватить в вызывающем коде: они выбрасываются прямо в контекст и могут уронить процесс. Используйте его только для обработчиков событий, а во всех остальных случаях возвращайте Task.
Забытый await
Если вызвать асинхронный метод и не дождаться, он выполнится «в стороне», а исключение потеряется. Компилятор предупреждает об этом, не игнорируйте предупреждения.
async без нужды
Не делайте метод асинхронным, если в нём нет await. Если метод просто возвращает чужой Task, можно вернуть его напрямую без обёртки. Это экономит ресурсы, хотя нужно помнить об обработке исключений и освобождении ресурсов в using.
ConfigureAwait: когда он нужен
По умолчанию после await выполнение возвращается в исходный контекст, то есть в поток интерфейса или в контекст запроса. Это удобно в приложении с окнами: после загрузки данных вы можете сразу обновлять элементы управления. Но в библиотечном коде возвращаться в тот же контекст обычно незачем. Вызов ConfigureAwait(false) говорит: продолжай в любом потоке пула.
string text = await client.GetStringAsync(url).ConfigureAwait(false);
В библиотеках это почти всегда правильный выбор: он снижает риск взаимных блокировок и немного ускоряет работу. В коде самого приложения, где вам нужен интерфейсный контекст, оставляйте поведение по умолчанию. В современных серверных средах контекста синхронизации часто нет вообще, и эффект от ConfigureAwait скромнее, но привычка писать его в библиотеках остаётся полезной.
Отмена и таймауты
Долгая операция должна уметь останавливаться. Для этого служит CancellationToken. Его принимают в параметрах асинхронных методов и передают дальше по цепочке.
public async Task<string> LoadWithTimeoutAsync(string url)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var client = new HttpClient();
try
{
return await client.GetStringAsync(url, cts.Token);
}
catch (OperationCanceledException)
{
return null;
}
}
Отмена в .NET кооперативная: операция сама проверяет токен и завершается. Хорошая практика — добавлять токен последним необязательным параметром во все публичные асинхронные методы, чтобы вызывающий мог при необходимости прервать работу.
Асинхронность и вычисления
Если у вас тяжёлый расчёт, который занимает процессор, await сам по себе не поможет. Здесь нужен Task.Run, который выполнит код в потоке пула и освободит поток интерфейса.
var result = await Task.Run(() => HeavyCalculation(data));
Но на сервере Task.Run для обработки запроса обычно бессмыслен: вы просто перекладываете работу из одного потока пула в другой и теряете время. Правило такое: для ввода-вывода используйте настоящие асинхронные API, для вычислений в оконном приложении используйте Task.Run, а на сервере вычисления выполняйте как есть.
Обработка исключений
Исключения в асинхронных методах работают привычно: try и catch вокруг await перехватывают ошибку так же, как в синхронном коде. Нюанс возникает с Task.WhenAll: если упало несколько задач, await выбросит только первое исключение, а остальные доступны через свойство Exception у самой задачи. Если важно знать обо всех ошибках, сохраните объект задачи и проверьте его после ожидания.
Чек-лист хорошего асинхронного кода
- Имена асинхронных методов заканчиваются на Async.
- Нигде нет Result и Wait в коде, который может работать в контексте.
- Методы возвращают Task, а не void, кроме обработчиков событий.
- Независимые операции запускаются параллельно через WhenAll.
- Долгие операции принимают CancellationToken.
- В библиотеках используется ConfigureAwait(false).
Заключение
Async и await делают асинхронный код таким же читаемым, как синхронный, но не отменяют необходимость понимать, что происходит под капотом. Запомните главное: асинхронность освобождает поток во время ожидания, а не создаёт новые потоки. Избегайте блокирующих вызовов, не злоупотребляйте async void и всегда думайте об отмене. Начните с небольшого метода, который загружает данные из сети, прогоните его в отладчике и посмотрите, в каких потоках выполняется каждый кусочек. После этого модель встанет на место, и вы перестанете бояться зависаний.