«Хороший сайт делается для людей, а поисковики приходят следом»

— из принципов редакции SeoLiga.ru
Главная » Статьи » .NET и C# » Асинхронный код в C#: async и await на практике

Асинхронный код в C#: async и await на практике

Асинхронное программирование в 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 и всегда думайте об отмене. Начните с небольшого метода, который загружает данные из сети, прогоните его в отладчике и посмотрите, в каких потоках выполняется каждый кусочек. После этого модель встанет на место, и вы перестанете бояться зависаний.

ПО
Павел Орлов
Автор SeoLiga.ru. Пишет о практике: что работает на реальных проектах, а что лучше обойти стороной. Хотите предложить свою статью? Загляните в раздел «Авторам».