Для того чтобы понять, как работает асинхронность в контексте платформы .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> инкапсулирующий длительную операцию, которая завершится в какой-то момент в будущем. В других языках известен так же как future или promise.
Данный тип содержит методы и свойства, позволяющие программисту писать более гибкий асинхронный код.
Рассмотрим наиболее часто используемые методы типа Task.
Данный метод создает задачу, которая завершится после выполнения всех переданных задач.
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 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#.
Исключения не выбрасываются сразу, пока не будет вызван await
Task task1 = ReadAndCountWordAsync(null); // не узнаем об ошибке, пока не подождем результат
try
{
await task1; // ждем результат
}
catch (ArgumentNullException)
{
Console.WriteLine("Не указан путь к файлу");
throw;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
Проверки параметров рекомендуется производить до вызова асинхронного кода.
async Task ReadAndCountWordAsync(string? path_to_file)
{
ArgumentNullException.ThrowIfNull(path_to_file); // валидируем входящие данные до вызова await
Console.WriteLine("Читаю файл с диска...");
var content = await File.ReadAllTextAsync(path_to_file);
Console.WriteLine("Закончил читать файл");
Console.WriteLine("Узнаю, какое слово встречается чаще всего...");
var word = GetMostCommonWord(content);
return word;
}
Оборачивать асинхронные операции в try/catch, так как в I/O часто что-то идет не так.
async Task ReadAndCountWordAsync(string? path_to_file)
{
ArgumentNullException.ThrowIfNull(path_to_file);
var content = new StringBuilder();
// Оборачиваем в try/catch вызов асинхронного метода
try
{
Console.WriteLine("Читаю файл с диска...");
var file_content = await File.ReadAllTextAsync(path_to_file);
Console.WriteLine("Закончил читать файл");
content.Append(file_content);
}
catch (Exception e)
{
Console.WriteLine("Какая-то ошибка при чтении файла с диска! " + e.Message);
throw;
}
Console.WriteLine("Узнаю, какое слово встречается чаще всего...");
var word = GetMostCommonWord(content.ToString());
return word;
}