На самом деле тема асинхронного кода довольно обширная и сложная, относится она не только к ASP.NET Core, а ко всей платформе .NET в целом. Но тем не менее постараюсь рассмотреть данный вопрос с практической точки зрения, применительно к ASP.NET Core приложениям. Сначала будет немного теории, разберем основные понятия, как работает синхронный код и как асинхронный, и далее на примере веб-приложения, преобразуем его из синхронного в асинхронное.
Рассмотрим схему, как вообще происходит обработка любого входящего запроса.
Веб-сервер предоставляет пул потоков (thread pool) для обработки входящих запросов к веб-приложению. Для простоты понимания можно представить каждый поток (thread) как рабочую единицу, а пул – как хранилище для таких единиц. Каждый поток (thread) в единицу времени может обрабатывать (сопровождать) один веб-запрос. Количество потоков в пуле ограничено. Для начала разберем синхронное выполнение.
Синхронная обработка запросов
Например, у нас имеется в наличии два потока. На веб-сервер от браузера пользователя приходит запрос. Из пула выделяется один поток для сопровождения этого запроса. Пока что проблем нет.
Следом за первым (или одновременно) приходят еще два (10, 100) запроса. Из пула выделяется второй и последний доступный поток, он принимает в обработку второй запрос.
На обработку третьего запроса уже не осталось потоков. Поэтому третий запрос встает в очередь и ждет. Однако такая очередь не бесконечная. Если она будет переполнена, то веб-сервер будет отдавать клиенту ошибку под номером 503 (Service Unavailable).
Проблема здесь в том, что какой-нибудь запрос может обрабатываться достаточно долго, например, когда идет работа с базой данных, обращение к стороннему веб-сервису за данными, чтение/запись в файловую систему на сервере и т.д. При синхронной обработке запросов поток блокируется на все время выполнения запроса.
Асинхронная обработка запросов
При асинхронной обработке запросов поток временно освобождается и может уделить время на другие запросы, пока происходит какая-то длительная операция в том запросе, который он принял изначально. После завершения такой операции обработка этого запроса вновь продолжается свободным потоком (не обязательно этим же). Выгода здесь очевидна. При неизменном (ограниченном) количестве потоков мы можем обработать большее количество запросов в единицу времени.
Отсюда следует вывод: при асинхронном программировании мы не ускоряем обработку одного любого запроса. Вместо этого мы масштабируем наше веб-приложение (более рациональное распределение ресурсов сервера, и, как следствие, повышение быстродействия веб-приложения).
Ключевые слова async/await
Для работы с асинхронным кодом мы будем использовать ключевые слова async и await. Важно понимать, что:
async модификатор:
- позволяет использовать внутри метода ключевое слово await;
- превращает метод в конечный автомат (генерируется компилятором);
- еще не делает ваш метод асинхронным.
await оператор:
- сообщает компилятору, что async-метод не может быть продолжен до тех пор, пока не завершится асинхронный await-процесс;
- возвращает контроль над управлением наверх, вызвавшему async-метод коду, вплоть до освобождения потока и возвращения его в thread pool;
- если в методе не представлен оператор await, то метод выполняется обычным синхронным образом, но при этом все также создается конечный автомат, что влечет бессмысленную нагрузку на производительность.
Что возвращает async-метод:
В качестве возвращаемого значения из асинхронного метода выступает класс Task. Можно дать определение:
Task<T> - возвращает тип T (Task<Book> - возвращает объект класса Book).
Task – не возвращает ничего (void).
void – не следует использовать (тяжело тестировать, отлавливать исключения, проверять состояние операции).
Через Task можно узнать о состоянии данной await-операции (в процессе, завершена, ошибка завершения).
Таски управляются конечным автоматом, который был создан компилятором, когда мы пометили метод как async.
Общие правила при написании асинхронного кода
- Построение асинхронного кода следует начинать с самого нижнего уровня и далее вверх, по структуре приложения (не с контроллера);
- Для асинхронных методов в название метода добавляется приписка Async();
- При работе с Entity Framework следует везде, где это возможно, использовать асинхронные аналоги методов вместо обычных.
Перейдем к практике. Допустим, у нас есть такой метод, который выбирает из базы данных все книги.
public IEnumerable<Book> GetBooks()
{
IEnumerable<Book> result = context.Books.ToList();
return result;
}
Преобразуем его в асинхронный аналог, учитывая ранее описанные правила.
public async Task<IEnumerable<Book>> GetBooksAsync()
{
IEnumerable<Book> result = await context.Books.ToListAsync();
return result;
}
В данной реализации метода появились ключевые слова async/await, также был изменен тип возвращаемого из метода значения. Метод ToList(); был заменен на свой асинхронный аналог .ToListAsync();.
Поднимаемся на уровень выше в структуре веб-приложения. Предположим, что синхронный метод GetBooks() использовался в действии контроллера.
public IActionResult Index()
{
IEnumerable<Book> model = booksRepository.GetBooks();
return View("Index", model);
}
Однако теперь, чтобы была возможность использовать асинхронный аналог метода GetBooks(), нам следует так же обновить код в контроллере.
public async Task<IActionResult> Index()
{
IEnumerable<Book> model = await booksRepository.GetBooksAsync();
return View("Index", model);
}
Теперь действие Index() соответствует всем требованиям и готово работать с методом .GetBooksAsync();
Еще один пример. В ASP.NET Core существует стандартный класс HttpClient для отправки/получения http-запросов. В нем используются только асинхронные методы для работы с запросами. Приведя свой код в асинхронный вид, мы сможем воспользоваться функционалом данного класса. Например, обратимся к стороннему сайту и получим ответ в виде строки.
public async Task<string> GetHttpRequestData()
{
HttpClient httpClient = HttpClientFactory.Create();
string data = await httpClient.GetStringAsync("https://google.com/");
return data;
}
Можно легко проверить как происходит переключение потоков в приложении, пока выполняется await-операция. В следующем коде мы записываем ID текущего потока дважды – до асинхронной операции и после. Если сравнить полученные айдишники, то велика вероятность что они будут отличаться. То есть данный запрос обслуживался разными потоками в разные моменты времени.
public async Task<IActionResult> Index()
{
int idBefore = Thread.CurrentThread.ManagedThreadId; //id == 8
IEnumerable<Book> model = await booksRepository.GetBooksAsync();
int idAfter= Thread.CurrentThread.ManagedThreadId; //id == 11
return View("Index", model);
}
Когда следует использовать асинхронный код
- Input/Output-операции: файловая система, база данных, сеть;
- сложные расчеты (алгоритмы), сильно нагружающие CPU.