Перейти к содержанию

Асинхронные операции сервера

Асинхронное I/O позволяет ожидать внешнюю операцию без блокировки потока приложения на всё время ожидания. Это полезно для файла, базы или другого HTTP API. Слово async само по себе не ускоряет любую функцию: механизм имеет смысл, когда зависимая операция действительно умеет отдавать управление на период ожидания. В этом уроке добавим чтение небольшой заметки каталога из локального файла.

Начальное состояние уже передаёт CancellationToken через асинхронный контракт repository, но источник курсов остаётся памятью. В lesson-13/after появится отдельный CatalogNoteReader и GET /api/catalog-note. Четыре курса и число 60 сохраняются. Это учебная заметка с постоянным путём, а не выдача произвольного пользовательского файла. Приложение и измерения при подготовке не запускались.

Источник ожидания

Файл Data/catalog-note.txt содержит одну строку:

Четыре учебных курса, всего 60 уроков.

В Infrastructure/CatalogNoteReader.cs находится полный сервис чтения:

namespace CatalogApi;
public sealed class CatalogNoteReader(IWebHostEnvironment environment)
{
    public Task<string> ReadAsync(CancellationToken token) =>
        File.ReadAllTextAsync(Path.Combine(environment.ContentRootPath,
            "Data", "catalog-note.txt"), token);
}

IWebHostEnvironment.ContentRootPath определяет корень содержимого приложения. Мы соединяем его с фиксированными сегментами Data и catalog-note.txt. Путь не приходит из query и не строится из имени, введённого пользователем. Это сохраняет задачу узкой: получить известную заметку, не открыть произвольную часть файловой системы.

File.ReadAllTextAsync возвращает task результата и принимает token. Метод сервиса может вернуть этот task напрямую: ему не нужно добавить async/await, если после чтения нет собственных действий и обработки. Асинхронный контракт от этого не исчезает. Справка Microsoft о ReadAllTextAsync описывает соответствующие перегрузки и форму результата.

Сервис зарегистрирован как singleton. Он хранит только ссылку на окружение и вычисляет фиксированный путь при обращении, не сохраняет открытый поток и не накапливает данные запроса. Несколько чтений не используют один изменяемый reader. Это отличается от singleton, который удерживает общий открытый stream с текущей позицией чтения.

Ожидание в обработчике

В файл endpoints добавлена операция:

app.MapGet("/api/catalog-note", async (CatalogNoteReader reader, CancellationToken token) =>
    Results.Text(await reader.ReadAsync(token), "text/plain; charset=utf-8"));

Handler получает сервис и token, ожидает строку и возвращает текстовый HTTP-результат. Клиенту не выдаётся JSON-объект курса, поэтому явно указан текстовый media type. Выбор ответа согласуется с данными: это маленькая заметка, а не модель каталога. Адрес находится вне группы /api/courses, поскольку обозначает другой ресурс.

При настоящем выполнении GET /api/catalog-note ожидаемо возвращает 200 и строку файла с переносом строки. Пустой файл дал бы пустой текстовый ответ, однако отсутствующий файл означает ошибку источника, а не допустимую пустую заметку. Наш сервис не перехватывает исключение и не подменяет его пустой строкой; общий обработчик ошибок затем формирует неожиданный сбой.

В момент await метод может приостановить собственное выполнение, пока I/O не завершится. Это не означает, что один выделенный поток сидит и ждёт файл всё время. После завершения продолжение получает результат и формирует ответ. Иногда операция завершится быстро и продолжение выполнится практически сразу; точное поведение зависит от реализации и состояния ресурса.

Почему не нужен Task.Run

Обёртка Task.Run(() => File.ReadAllText(path)) переносит блокирующее чтение на другой поток, но не делает само чтение неблокирующим API. Для серверного приложения это лишь меняет, какой поток занят ожиданием. Если доступен подходящий асинхронный метод, лучше использовать его непосредственно.

Аналогично .Result или .Wait() у task возвращают блокирующее ожидание в handler. Даже если нижний вызов асинхронен, верхняя граница снова удерживает поток. Предпочтительная цепочка — async-операция, переданный token, await в обработчике и дальнейшее асинхронное выполнение зависимостей. Это не запрещает все синхронные вычисления: фильтрация четырёх объектов в памяти остаётся короткой обычной работой.

Рекомендации Microsoft для ASP.NET Core связывают избегание блокирующего ожидания с обслуживанием запросов. В этой статье мы не заявляем измеренный прирост производительности. Маленький файл выбран для наблюдаемого механизма; оценка нагрузки и пропускной способности потребует отдельного воспроизводимого эксперимента.

CPU-работа имеет другую природу. Если handler долго считает сложный результат, добавление async перед методом не снижает объём вычислений. Task.Run внутри веб-запроса тоже не создаёт бесконечный запас процессоров. Для большой CPU-задачи нужно оценить ограничение параллелизма, очередь и возможность вынести работу за время жизни запроса.

Размер данных и ответственность ресурса

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

Сервис каждый раз читает файл, а не кеширует строку навсегда. Поэтому последующее обращение может увидеть новое содержимое, если читатель изменит заметку в своей песочнице. Это не обещание атомарного согласования при одновременной перезаписи: способ записи файла и время чтения являются отдельными условиями. Для нашей постоянной учебной строки конкурентного редактирования нет.

Текст также должен быть согласован с содержимым. Если изменить числа курсов, но забыть заметку, сервер корректно выполнит I/O и всё равно выдаст устаревшее человеческое сообщение. Техническая успешность чтения не проверяет смысл файла. Здесь 60 закреплено общим контрактом серии; в настоящем приложении такую сводку лучше вычислять из одного источника данных.

Token передан именно файловой операции. При отмене обращения reader получает возможность прекратить ожидание, но server не гарантирует доставку специального ответа уже отключившемуся клиенту. Отмена не является причиной удалять файл или изменять каталог: чтение и предметное действие остаются разными понятиями.

Обработка маленького файла не требует хранить его строку в поле singleton между обращениями. Такой кеш изменил бы учебный результат: новое содержимое перестало бы появляться без отдельного сброса. Если кеш понадобится, следует определить срок жизни и условие обновления, а не добавлять поле ради сокращения одного чтения. Асинхронность, кеширование и потоковая выдача — самостоятельные свойства; наличие одного не подразумевает остальные.

После урока у нас есть маленький пример настоящего I/O и непрерывная асинхронная цепочка. В следующем уроке применим её к PostgreSQL: создадим repository, который получает записи из отдельной учебной базы, а endpoint продолжит использовать знакомый контракт.