Асинхронность в .NET ― ключевое слово await и обработка ошибок в асинхронных методах

Для того чтобы понять, как работает асинхронность в контексте платформы .NET, разберемся, что такое синхронность и асинхронность в общем смысле.

Понятие синхронности

Синхронность

Синхронность — это временная зависимость состояний двух или более объектов.

Два объекта можно считать синхронными, если их состояния находятся в функциональной зависимости от одних и тех же (или согласованных) моментов времени.

Это может выражаться:

Таким образом, синхронность не требует совпадения событий во времени, а лишь устойчивой закономерности между их временными характеристиками.

Одновременность - частный случай синхронности

Примеры из жизни

Эти примеры показывают, что синхронность может проявляться как одновременность действий, согласованность состояний или регламентированная последовательность событий — всё это формы временной зависимости между объектами.

Асинхронность

Как вы уже догадались — асинхронность это противоположное понятие.

Асинхронность означает, что состояния объектов не связаны общей временной закономерностью.

Синхронность в программировании

В контексте программирования синхронность — это зависимость выполнения по времени операторов (процедур).

Операторы выполняются строго по очереди один за другим: время начала выполнения оператора зависит от времени окончания предыдущего.

Синхронное выполнение операций в программировании логично и интуитивно понятно человеку. Оно отражает привычную модель мышления: линейный, причинно временной ход событий.

var a = 2;
var b = 40;
var c = a + b;
Console.WriteLine("Ответ на ваш вопрос: " + c);
        

Минус синхронного программирования в том, что пока программа ждёт ответ от внешнего ресурса (файл на диске, сетевой запрос, запрос к базе данных), процессор просто простаивает. Вынужден ждать результата выполнения этих операций, а мог бы выполнять другую работу.

Console.WriteLine("Прочтем великое произведение...");
var content = File.ReadAllText("Толстой Л. Н. - Война и мир.txt");
Console.WriteLine("Это заняло некоторое время.");
        

Что если наше приложение выполняет настолько полезную работу, что часто обращается к внешним источникам?

Console.WriteLine("Укажите путь к произведению");
var path = Console.ReadLine();
Console.WriteLine("Читаю файл с диска...");
var content = File.ReadAllText(path);
Console.WriteLine("Закончил читать файл");
Console.WriteLine("Узнаю, какое слово встречается чаще всего...");
var word = GetMostCommonWord(content);
Console.WriteLine("Чаще всего встречается слово \"" + word + "\"");
return;

string GetMostCommonWord(string content)
{
    var result = string.Empty;
    // result = ... считаем слова в content
    return result;
}

Получается так, что при множестве одновременных запросов, наше приложение не сможет обслужить запросы многих пользователей и будет выдавать ошибку (503 — Сервис временно недоступен).

Тут нам и поможет модель асинхронного программирования на основе задач.

Асинхронное программирование

Асинхронное программирование служит целям повышения производительности программы: увеличения количества обрабатываемых запросов пользователей.

Достигается это за счет вызова методов неблокирующих поток выполнения программы.

Поток — это набор команд программы, которые выполняются процессором одна за другой.

Ожидая внешний ресурс поток может вернуться и обработать другой запрос, если мы внедрим асинхронный код.

Ключевое слово await помогает "дожидаться" асинхронной операции.

Заменим наш вызов чтения файла на его асинхронную версию:

/// ...
var content = await File.ReadAllTextAsync(path);
/// ...
        

Обратите внимание, что по соглашению принятому Microsoft - асинхронные методы имеют в названии суффикс Async.

Теперь процессор может выполнять другу работу пока идет операции чтения с диска.

Таким образом увеличивается количество одновременных запросов, которые может обработать наше приложение.

В языке C# существует ограничение — ключевое слово await может использоваться только в методах, помеченных модификатором async.

Чтобы продемонстрировать это — сделаем небольшой рефакторинг — выделим основной код нашего приложения в отдельный асинхронный метод и вызовем его.

Console.WriteLine("Укажите путь к произведению");
var path = Console.ReadLine();
await ReadAndCountWordAsync(path);

return;

async Task ReadAndCountWordAsync(string path_to_file)
{
    Console.WriteLine("Читаю файл с диска...");
    var content = await File.ReadAllTextAsync(path_to_file);
    Console.WriteLine("Закончил читать файл");
    Console.WriteLine("Узнаю, какое слово встречается чаще всего...");
    var word = GetMostCommonWord(content);
    Console.WriteLine("Чаще всего встречается слово \"" + word + "\"");
}
        

Отмена асинхронных операций

Иногда необходимо прерывать (отменять) асинхронную операцию по причине ненужности операции. Например, пользователь закрыл окно, отменился веб-запрос.

В таких случаях необходимо корректно остановить выполнение "дорогой" операции ради экономии ресурсов. Для этого в .NET существует механизм CancellationToken.

Фактически каждый асинхронный метод, реализованный на платформе .NET имеет параметр для отмены операции.

Нам как разработчикам на C# необходимо пробрасывать CancellationToken в методы, написанные нами и передавать далее в методы сторонних библиотек.

Ниже показан пример отмены операции для консольного приложения.

var token =  GetCancellationToken(); // получаем токен отмены операции
Console.WriteLine("Укажите путь к произведению");
var path = Console.ReadLine();
await ReadAndCountWordAsync(path, token);

return;

async Task ReadAndCountWordAsync(string path_to_file, CancellationToken cancellationToken) // пробрасываем токен в наш метод
{
    Console.WriteLine("Читаю файл с диска...");
    var content = await File.ReadAllTextAsync(path_to_file, cancellationToken); // пробрасываем токен в метод .NET
    Console.WriteLine("Закончил читать файл");
    Console.WriteLine("Узнаю, какое слово встречается чаще всего...");
    var word = GetMostCommonWord(content);
    Console.WriteLine("Чаще всего встречается слово \"" + word + "\"");
}

static CancellationToken GetCancellationToken() // Создаем токен отмены
{
    var cts = new CancellationTokenSource();

    Console.CancelKeyPress += (s, e) =>
    {
        e.Cancel = true;
        Console.WriteLine("Ctrl+C → отмена");
        cts.Cancel();
    };

    AppDomain.CurrentDomain.ProcessExit += (s, e) =>
    {
        Console.WriteLine("ProcessExit → отмена");
        cts.Cancel();
    };
    return cts.Token;
}
        

Как отменяются операции на вебе? В каждый контроллер платформа .NET внедряет CancellationToken на основании жизненного цикла http-запроса. Нам остается только указать его в качестве параметра метода и пробросить ниже по стеку вызова, вплоть до методов реализующих вызовы к базе данных, чтению файлов, обращению к внешним интернет-сервисам и т.п.

[ApiController]
[Route("api/[controller]")]
public class UsersController(AppDb db) : ControllerBase
{
    [HttpGet("{id:guid}")]
    public async Task GetUser(Guid id, CancellationToken ct)
    {
        var user = await db.Users
        .AsNoTracking()
        .SingleOrDefaultAsync(
            u => u.Id == id,
            ct // передаем CancellationToken в EntityFramework
        );

        if (user is null) return NotFound();
        return Ok(user);
    }
}

        

Запуск нескольких задач

Как уже упоминалось ранее, асинхронный код позволяет не только не блокировать поток для операций ввода/вывода, но и ускорять работу нашего приложения.

Предположим мы хотим считать наиболее часто встречающиеся слова сразу в нескольких произведениях. Но при этом делать это "одновременно", а точнее конкурентно.

Console.WriteLine("Укажите путь к произведению 1:");
var path1 = Console.ReadLine();

Console.WriteLine("Укажите путь к произведению 2:");
var path2 = Console.ReadLine();

Console.WriteLine("Укажите путь к произведению 3:");
var path3 = Console.ReadLine();

// Запускаем 3 задачи сразу
Task task1 = ReadAndCountWordAsync(path1); // 10 сек
Task task2 = ReadAndCountWordAsync(path2); // 5 сек
Task task3 = ReadAndCountWordAsync(path3); // 15 сек

// Ждём и печатаем по очереди (время общее ≈ 15 сек):
Console.WriteLine($"Чаще всего в произведении 1 встречается слово \"{await task1}\""); // ждём ~10с
Console.WriteLine($"Чаще всего в произведении 2 встречается слово \"{await task2}\""); // мгновенно, уже готово
Console.WriteLine($"Чаще всего в произведении 3 встречается слово \"{await task3}\""); // ждём еще ~5с

return;
        

Более высокая скорость в этом примере достигается за счет того что задачи task1, task2, task3 начинают выполняться сразу.

Внимательный студент заметит, что кроме async/await в качестве возвращаемого значения метода ReadAndCountWordAsync указаны типы Task и Task<string>. Этот объект и является основным компонентом моделирования асинхронных операций на языке C#.

Тип Task и Task<T>

Асинхронный метод возвращает объект типа Task или Task<T> инкапсулирующий длительную операцию, которая завершится в какой-то момент в будущем. В других языках известен так же как future или promise.

Данный тип содержит методы и свойства, позволяющие программисту писать более гибкий асинхронный код.

Рассмотрим наиболее часто используемые методы типа Task.

Task.WhenAll

Данный метод создает задачу, которая завершится после выполнения всех переданных задач.

Console.WriteLine("Укажите путь к произведению 1:");
var path1 = Console.ReadLine();

Console.WriteLine("Укажите путь к произведению 2:");
var path2 = Console.ReadLine();

Console.WriteLine("Укажите путь к произведению 3:");
var path3 = Console.ReadLine();

Task task1 = ReadAndCountWordAsync(path1); // 10 сек
Task task2 = ReadAndCountWordAsync(path2); // 5 сек
Task task3 = ReadAndCountWordAsync(path3); // 15 сек

var all = await Task.WhenAll(task1, task2, task3); // завершится через ~15с

Console.WriteLine("Все результаты готовы!");
Console.WriteLine("Чаще всего в произведении 1 встречается слово \"" + all[0] + "\"");
Console.WriteLine("Чаще всего в произведении 2 встречается слово \"" + all[1] + "\"");
Console.WriteLine("Чаще всего в произведении 3 встречается слово \"" + all[2] + "\"");

return;
        

Task.WhenAny

Создает задачу, которая завершится после завершения любой из предоставленных задач.

// Запускаем сразу три задачи
Task task1 = ReadAndCountWordAsync(path1); // 10 сек
Task task2 = ReadAndCountWordAsync(path2); // 5 сек
Task task3 = ReadAndCountWordAsync(path3); // 15 сек

// Ожидаем, пока завершится хотя бы одна задача
Task firstFinished = await Task.WhenAny(task1, task2, task3);

// Получаем результат первой завершившейся
string result = await firstFinished;

Console.WriteLine("Первой завершилась задача с результатом:");
Console.WriteLine($"\"{result}\"");
        

Обработка ошибок в асинхронных методах

Исключения в асинхронном коде в общем работают так же как и в синхронном, но вот что полезно знать при работе с исключениями в асинхронном программировании на C#.