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

17. async в ASP.NET Core

О главе

Цель: писать async-код в ASP.NET Core: обработчики, токены, фоновые задачи, потоковые ответы, и видеть цифрами, чем плохи блокирующие обработчики.

Лабораторная: start/ — Minimal API с .Result и fire-and-forget. final/ — async-обработчики, RequestAborted, очередь на Channel и BackgroundService, IAsyncEnumerable. Оба проекта умеют сами себя нагружать: dotnet run -c Release --project start -- selftest (или final) печатает числа. Без аргументов запускается обычный сервер на http://localhost:5000.

Статус: ✅ проверено на стенде Ubuntu 26.04 (2 ядра, .NET 10.0.12, Kestrel), перепроверено на Windows 11 (8 ядер): выводы те же, числа другие (см. плашку после первого вывода selftest). Клиент нагрузки и сервер в selftest живут в одном процессе и делят один пул потоков: цифры показывают порядок, а не пропускную способность настоящего сервера.

17.1. Модель запроса

  • Kestrel принимает запрос, ставит обработку в пул, и действие контроллера или Minimal API выполняется на потоке пула.
  • На первом незавершённом await поток освобождается и возвращается в пул. Когда операция завершится, продолжение выполнит любой свободный поток.
  • Контекста синхронизации нет. ConfigureAwait(false) в коде приложения не нужен.
  • Масштабируемость по числу одновременных запросов достигается тем, что ждущие запросы не занимают потоки. Именно это «продаёт» async на сервере: не скорость одного запроса, а пропускную способность при I/O-нагрузке.
final -- selftest, пункт 6: обработчик /ctx
6) /ctx: контекста нет: True; поток до await 7, после 7; поток пула: True

Факт: без контекста продолжение «возвращается» не обязательно в другой поток

В /ctx (строки 28–34 final/Program.cs) поток до и после await Task.Delay(50) совпал (7 → 7, в другом запуске 12 → 12). Контекста синхронизации нет, поэтому продолжение уходит просто в пул, а пул при малой нагрузке может вернуть тот же поток. Рассчитывать на смену или сохранение потока нельзя ни в ту, ни в другую сторону.

17.2. Правила

  1. Обработчики возвращают Task/Task<T>/ValueTask; не async void.
  2. Принимайте CancellationToken в обработчике (подставляется RequestAborted) и передавайте вниз.
  3. Не блокируйте: нет .Result, .Wait(), Thread.Sleep, синхронного ввода-вывода (по умолчанию синхронный I/O в Kestrel запрещён).
  4. Не храните HttpContext и не используйте после завершения запроса. Нельзя запускать фоновую работу, использующую HttpContext, DbContext запроса или scoped-сервисы запроса.
  5. Фон: BackgroundService/IHostedService и очередь. Внутри создавайте свой scope (IServiceScopeFactory).
  6. Остановка: IHostApplicationLifetime.ApplicationStopping, stoppingToken в ExecuteAsync, HostOptions.ShutdownTimeout.
  7. Потоковые ответы: возврат IAsyncEnumerable<T>.

17.3. Что показывает selftest

Блокирующий обработчик против асинхронного

start/Program.cs
app.MapGet("/report", (ReportService reports) =>
{
    string report = reports.BuildAsync(CancellationToken.None).Result;
    return Results.Text(report);
});

app.MapPost("/orders/{id:int}", (int id, Mailer mailer) =>
{
    _ = Task.Run(() => mailer.SendAsync($"заказ {id} принят"));
    return Results.Accepted();
});
final/Program.cs
app.MapGet("/report", async (ReportService reports, CancellationToken ct) =>
    Results.Text(await reports.BuildAsync(ct)));

app.MapPost("/orders/{id:int}", async (int id, BackgroundQueue queue, CancellationToken ct) =>
{
    await queue.EnqueueAsync(new MailJob($"заказ {id} принят"), ct);   // при переполнении ждём (обратное давление)
    return Results.Accepted();
});

Факт: на Windows с 8 ядрами числа другие, картина та же

Windows 11, 8 ядер, 10.0.12, один прогон start -- selftest: пункт 1 — 6 303 мс, потоков до 20 (минимум 8); пункт 3 — писем отправлено 0, упало 10. final (два запуска): пункт 1 — 575–578 мс, потоков до 9; пункты 2, 3, 5, 6 и 7 совпали с Linux (отменён, 2 письма, HTTP 500, нет контекста и поток пула, 0 из 40); пункт 4 — первые данные через 226–230 мс, всё через 1 070–1 072 мс (на Linux 1 019–1 030): тот же шаг таймера ≈ 15,6 мс на Windows. Время ожидания в start зависит от MinThreads (равен числу ядер), поэтому на 2 ядрах оно в разы больше.

start -- selftest (три запуска)
1) 64 одновременных /report: 22723–25722 мс, потоков в пуле до 28–32 (минимум 2)
2) клиент ушёл через 100 мс: сервер довёл отчёт до конца = 1, отменил = 0
3) 10 заказов: писем отправлено 0, упало 10
final -- selftest (три запуска)
1) 64 одновременных /report: 553–581 мс, потоков в пуле до 3 (минимум 2)
2) клиент ушёл через 100 мс: сервер довёл отчёт до конца = 0, отменил = 1
3) 10 заказов: писем отправлено 2, упало 0
4) /stream: первые данные через 214–226 мс, всё через 1019–1030 мс
5) /sync (Response.Body.Write): HTTP 500
6) /ctx: контекста нет: True; поток до await 7, после 7; поток пула: True
7) 40 заказов поставлены, хост остановлен сразу: отправлено 0 из 40 (остальные потеряны вместе с очередью в памяти)

Факт: 64 запроса по 500 мс — 0,56 с против 23–26 с

В start обработчик блокирует поток на .Result (строка 17), 500 мс «БД» превращаются в 500 мс занятого потока. Пул растёт до 28–32 потоков с задержкой (глава 13), и 64 запроса обслуживаются 23–26 секунд. В final те же 64 запроса укладываются в 0,56 с на 2–3 потоках: пока запросы ждут таймер, потоков им не нужно. Это та самая пропускная способность, ради которой async на сервере и нужен.

Факт: токен запроса действительно отменяет работу

Когда клиент отключился через 100 мс, final отменил отчёт (отменил = 1), а start довёл его до конца (довёл = 1): токен передан как CancellationToken.None. На сервере с реальной БД это ещё одна впустую выполненная операция.

Fire-and-forget с scoped-сервисом

В start (строка 23) письмо отправляется в Task.Run с Mailer, созданным в scope запроса. Запрос завершается за миллисекунды, scope освобождается и вызывает Mailer.Dispose(), а письмо отправляется через секунду:

3) 10 заказов: писем отправлено 0, упало 10

Все десять писем упали с ObjectDisposedException — и никто об этом не узнал: исключение внутри Task.Run никем не наблюдается (глава 8). Клиент получил 202 Accepted.

Очередь и BackgroundService

final/Program.cs: очередь
sealed class BackgroundQueue
{
    private readonly Channel<MailJob> _channel = Channel.CreateBounded<MailJob>(100);

    public ValueTask EnqueueAsync(MailJob job, CancellationToken ct) => _channel.Writer.WriteAsync(job, ct);
    public IAsyncEnumerable<MailJob> ReadAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct);
}
final/Program.cs: воркер
sealed class QueueWorker(BackgroundQueue queue, IServiceScopeFactory scopes, ILogger<QueueWorker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            await foreach (var job in queue.ReadAllAsync(stoppingToken))
            {
                try
                {
                    await using var scope = scopes.CreateAsyncScope();          // свой scope, не scope запроса
                    var mailer = scope.ServiceProvider.GetRequiredService<Mailer>();
                    await mailer.SendAsync(job.Text, stoppingToken);
                }
                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { throw; }
                catch (Exception ex) { log.LogError(ex, "задание не выполнено"); }   // не уронить цикл
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // штатная остановка хоста
        }
    }
}
  • Строки 66–72: ограниченная очередь (CreateBounded(100)) даёт обратное давление: если воркер не справляется, EnqueueAsync в обработчике начинает ждать, а не копит память (глава 11).
  • Строка 84: у каждого задания свой scope (CreateAsyncScope), а не scope запроса.
  • Строки 88–89: ошибка одного задания логируется и не убивает цикл (по умолчанию исключение из ExecuteAsync останавливает хост); отмена при остановке пробрасывается и выходит из цикла (строка 88 и блок catch в строках 92–95).

Факт: один воркер отправляет одно письмо в секунду

В пункте 3 final за 2,5 секунды из 10 писем ушло 2 (каждое занимает 1 с и воркер один). Очередь не делает обработку параллельной: для параллелизма нужны несколько воркеров или Parallel.ForEachAsync внутри воркера (глава 10).

Факт: очередь в памяти теряет задания при остановке

Пункт 7: 40 заказов поставлены в очередь, хост остановлен сразу: отправлено 0 из 40. Токен остановки прервал воркер, остальное осталось в Channel, а Channel живёт только в памяти процесса. Если письма нельзя терять, очередь должна быть внешней (брокер, таблица-outbox), а Channel — только буфер перед ней.

Потоковые ответы и синхронный ввод-вывод

Факт: IAsyncEnumerable отдаёт первый элемент сразу

/stream (строки 36 и 55–62 final/Program.cs) отдаёт пять элементов по 200 мс. Первые данные пришли через ~215 мс, а не через секунду: Kestrel пишет ответ по мере появления элементов.

Факт: синхронная запись в ответ — HTTP 500

Response.Body.Write(...) (строка 40) в Kestrel по умолчанию завершается InvalidOperationException (синхронный ввод-вывод запрещён, AllowSynchronousIO = false), клиент получает 500. Не включайте AllowSynchronousIO, чтобы «заставить работать» старую библиотеку: ровно этим вы и вернёте блокирующий I/O на потоки пула.

17.4. Запуск async при старте

В top-level Program.cs можно использовать await. Для инициализации с I/O (прогрев, миграции) используйте IHostedService.StartAsync или код перед app.Run(), не блокируя .Result.

Итоги

  • На сервере async экономит потоки, а не время одного запроса: 64 запроса по 500 мс — 0,56 с против 23–26 с с .Result.
  • Токен запроса надо передавать вниз: иначе работа продолжается после ухода клиента.
  • Fire-and-forget с scoped-сервисом запроса падает молча; фоновую работу делайте через BackgroundService и очередь с собственным scope.
  • Очередь на Channel даёт обратное давление, но не переживает остановку процесса и обрабатывает задания в одном воркере последовательно.

Код лабораторной

Запуск из папки главы: dotnet run -c Release --project start или --project final.

start/Program.cs
// Глава 17. Minimal API с типичными ошибками.
// Задание: найдите и исправьте проблемы (README, §17.2):
//   - блокирующее ожидание в обработчике;
//   - нет CancellationToken;
//   - fire-and-forget, использующий scoped-сервис запроса после завершения запроса.
// Запуск сервера:  dotnet run -c Release --project start        (затем GET http://localhost:5000/report, POST http://localhost:5000/orders/42)
// Самопроверка:    dotnet run -c Release --project start -- selftest   (сервер сам нагружает себя и печатает числа)

var builder = WebApplication.CreateBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<Mailer>();
var app = builder.Build();

app.MapGet("/report", (ReportService reports) =>
{
    string report = reports.BuildAsync(CancellationToken.None).Result;
    return Results.Text(report);
});

app.MapPost("/orders/{id:int}", (int id, Mailer mailer) =>
{
    _ = Task.Run(() => mailer.SendAsync($"заказ {id} принят"));
    return Results.Accepted();
});

if (args.Contains("selftest"))
{
    app.Urls.Add("http://127.0.0.1:5057");
    await app.StartAsync();
    await SelfTest.RunAsync("http://127.0.0.1:5057");
    await app.StopAsync();
}
else
{
    app.Run("http://localhost:5000");
}

sealed class ReportService
{
    public async Task<string> BuildAsync(CancellationToken ct)
    {
        try
        {
            await Task.Delay(500, ct);                 // имитация запроса к БД
            Stats.ReportsCompleted++;
            return $"отчёт на {DateTime.Now:T}";
        }
        catch (OperationCanceledException)
        {
            Stats.ReportsCancelled++;
            throw;
        }
    }
}

sealed class Mailer(ILogger<Mailer> log) : IDisposable
{
    private bool _disposed;

    public async Task SendAsync(string text)
    {
        await Task.Delay(1000);
        if (_disposed)
        {
            Interlocked.Increment(ref Stats.MailsFailed);
            throw new ObjectDisposedException(nameof(Mailer), "scope запроса уже закрыт");
        }
        Interlocked.Increment(ref Stats.MailsSent);
        log.LogInformation("письмо отправлено: {Text}", text);
    }

    public void Dispose() => _disposed = true;
}

static class Stats
{
    public static int ReportsCompleted, ReportsCancelled, MailsSent, MailsFailed;
}
start/SelfTest.cs
using System.Diagnostics;

// Сервер нагружает сам себя и печатает числа, чтобы не нужен был внешний генератор нагрузки.
static class SelfTest
{
    public static async Task RunAsync(string baseUrl)
    {
        using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(60) };
        await http.GetStringAsync("/report");                                   // прогрев
        Stats.ReportsCompleted = 0;

        // 1. 64 одновременных запроса к /report (каждый «ждёт БД» 500 мс).
        int peakThreads = 0;
        using var stop = new CancellationTokenSource();
        var sampler = Task.Run(async () =>
        {
            while (!stop.IsCancellationRequested)
            {
                peakThreads = Math.Max(peakThreads, ThreadPool.ThreadCount);
                await Task.Delay(50);
            }
        });
        var sw = Stopwatch.StartNew();
        await Task.WhenAll(Enumerable.Range(0, 64).Select(_ => http.GetStringAsync("/report")));
        Console.WriteLine($"1) 64 одновременных /report: {sw.ElapsedMilliseconds} мс, потоков в пуле до {peakThreads} (минимум {Environment.ProcessorCount})");
        stop.Cancel();
        await sampler;

        // 2. Клиент отваливается через 100 мс: останавливает ли сервер работу?
        Stats.ReportsCompleted = 0; Stats.ReportsCancelled = 0;
        using (var cts = new CancellationTokenSource(100))
        {
            try { await http.GetStringAsync("/report", cts.Token); } catch (OperationCanceledException) { }
        }
        await Task.Delay(1000);
        Console.WriteLine($"2) клиент ушёл через 100 мс: сервер довёл отчёт до конца = {Stats.ReportsCompleted}, отменил = {Stats.ReportsCancelled}");

        // 3. Письма в fire-and-forget с scoped-сервисом запроса.
        for (int i = 0; i < 10; i++) await http.PostAsync($"/orders/{i}", null);
        await Task.Delay(2000);
        Console.WriteLine($"3) 10 заказов: писем отправлено {Stats.MailsSent}, упало {Stats.MailsFailed}");
    }
}
final/Program.cs
// Глава 17, итог.
//   - обработчики async, принимают CancellationToken (это HttpContext.RequestAborted);
//   - фоновая работа идёт через ограниченную очередь на Channel и BackgroundService;
//   - воркер создаёт свой scope на каждое задание и не падает от ошибки одного задания.
//   - потоковый ответ через IAsyncEnumerable; синхронный ввод-вывод в ответ запрещён.
// Запуск сервера:  dotnet run -c Release --project final
// Самопроверка:    dotnet run -c Release --project final -- selftest
using System.Threading.Channels;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
if (args.Contains("selftest")) builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.None);   // не засорять вывод ожидаемой ошибкой /sync
builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<Mailer>();
builder.Services.AddSingleton<BackgroundQueue>();
builder.Services.AddHostedService<QueueWorker>();
var app = builder.Build();

app.MapGet("/report", async (ReportService reports, CancellationToken ct) =>
    Results.Text(await reports.BuildAsync(ct)));

app.MapPost("/orders/{id:int}", async (int id, BackgroundQueue queue, CancellationToken ct) =>
{
    await queue.EnqueueAsync(new MailJob($"заказ {id} принят"), ct);   // при переполнении ждём (обратное давление)
    return Results.Accepted();
});

app.MapGet("/ctx", async () =>
{
    bool before = SynchronizationContext.Current is null;
    int threadBefore = Environment.CurrentManagedThreadId;
    await Task.Delay(50);
    return Results.Text($"контекста нет: {before}; поток до await {threadBefore}, после {Environment.CurrentManagedThreadId}; поток пула: {Thread.CurrentThread.IsThreadPoolThread}");
});

app.MapGet("/stream", () => Numbers());                      // клиент получает элементы по мере готовности

app.MapGet("/sync", (HttpContext ctx) =>
{
    ctx.Response.Body.Write("синхронная запись"u8);           // запрещено по умолчанию (AllowSynchronousIO = false)
    return Results.Empty;
});

if (args.Contains("selftest"))
{
    app.Urls.Add("http://127.0.0.1:5058");
    await app.StartAsync();
    await SelfTest.RunAsync("http://127.0.0.1:5058", app);
}
else
{
    app.Run("http://localhost:5000");
}

static async IAsyncEnumerable<int> Numbers()
{
    for (int i = 1; i <= 5; i++)
    {
        await Task.Delay(200);
        yield return i;
    }
}

sealed record MailJob(string Text);

sealed class BackgroundQueue
{
    private readonly Channel<MailJob> _channel = Channel.CreateBounded<MailJob>(100);

    public ValueTask EnqueueAsync(MailJob job, CancellationToken ct) => _channel.Writer.WriteAsync(job, ct);
    public IAsyncEnumerable<MailJob> ReadAllAsync(CancellationToken ct) => _channel.Reader.ReadAllAsync(ct);
}

sealed class QueueWorker(BackgroundQueue queue, IServiceScopeFactory scopes, ILogger<QueueWorker> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            await foreach (var job in queue.ReadAllAsync(stoppingToken))
            {
                try
                {
                    await using var scope = scopes.CreateAsyncScope();          // свой scope, не scope запроса
                    var mailer = scope.ServiceProvider.GetRequiredService<Mailer>();
                    await mailer.SendAsync(job.Text, stoppingToken);
                }
                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { throw; }
                catch (Exception ex) { log.LogError(ex, "задание не выполнено"); }   // не уронить цикл
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // штатная остановка хоста
        }
    }
}

sealed class ReportService
{
    public async Task<string> BuildAsync(CancellationToken ct)
    {
        try
        {
            await Task.Delay(500, ct);
            Stats.ReportsCompleted++;
            return $"отчёт на {DateTime.Now:T}";
        }
        catch (OperationCanceledException)
        {
            Stats.ReportsCancelled++;
            throw;
        }
    }
}

sealed class Mailer(ILogger<Mailer> log) : IDisposable
{
    private bool _disposed;

    public async Task SendAsync(string text, CancellationToken ct)
    {
        await Task.Delay(1000, ct);
        if (_disposed) throw new ObjectDisposedException(nameof(Mailer));
        Interlocked.Increment(ref Stats.MailsSent);
        log.LogInformation("письмо отправлено: {Text}", text);
    }

    public void Dispose() => _disposed = true;
}

static class Stats
{
    public static int ReportsCompleted, ReportsCancelled, MailsSent, MailsFailed;
}
final/SelfTest.cs
using System.Diagnostics;

// Сервер нагружает сам себя и печатает числа, чтобы не нужен был внешний генератор нагрузки.
static class SelfTest
{
    public static async Task RunAsync(string baseUrl, WebApplication app)
    {
        using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(60) };
        await http.GetStringAsync("/report");                                   // прогрев
        Stats.ReportsCompleted = 0;

        // 1. 64 одновременных запроса к /report (каждый «ждёт БД» 500 мс).
        int peakThreads = 0;
        using var stop = new CancellationTokenSource();
        var sampler = Task.Run(async () =>
        {
            while (!stop.IsCancellationRequested)
            {
                peakThreads = Math.Max(peakThreads, ThreadPool.ThreadCount);
                await Task.Delay(50);
            }
        });
        var sw = Stopwatch.StartNew();
        await Task.WhenAll(Enumerable.Range(0, 64).Select(_ => http.GetStringAsync("/report")));
        Console.WriteLine($"1) 64 одновременных /report: {sw.ElapsedMilliseconds} мс, потоков в пуле до {peakThreads} (минимум {Environment.ProcessorCount})");
        stop.Cancel();
        await sampler;

        // 2. Клиент отваливается через 100 мс: останавливает ли сервер работу?
        Stats.ReportsCompleted = 0; Stats.ReportsCancelled = 0;
        using (var cts = new CancellationTokenSource(100))
        {
            try { await http.GetStringAsync("/report", cts.Token); } catch (OperationCanceledException) { }
        }
        await Task.Delay(1000);
        Console.WriteLine($"2) клиент ушёл через 100 мс: сервер довёл отчёт до конца = {Stats.ReportsCompleted}, отменил = {Stats.ReportsCancelled}");

        // 3. Письма через очередь и BackgroundService.
        for (int i = 0; i < 10; i++) await http.PostAsync($"/orders/{i}", null);
        await Task.Delay(2500);
        Console.WriteLine($"3) 10 заказов: писем отправлено {Stats.MailsSent}, упало {Stats.MailsFailed}");

        // 4. Потоковый ответ: когда приходит первый элемент и когда всё.
        sw.Restart();
        using var resp = await http.GetAsync("/stream", HttpCompletionOption.ResponseHeadersRead);
        using var stream = await resp.Content.ReadAsStreamAsync();
        var buf = new byte[256];
        int n = await stream.ReadAsync(buf);
        long first = sw.ElapsedMilliseconds;
        while (await stream.ReadAsync(buf) > 0) { }
        Console.WriteLine($"4) /stream: первые данные через {first} мс, всё через {sw.ElapsedMilliseconds} мс");

        // 5. Синхронная запись в ответ.
        var r = await http.GetAsync("/sync");
        Console.WriteLine($"5) /sync (Response.Body.Write): HTTP {(int)r.StatusCode}");

        // 6. Контекст синхронизации в обработчике.
        Console.WriteLine($"6) /ctx: {await http.GetStringAsync("/ctx")}");

        // 7. Остановка хоста с ещё не обработанной очередью: что происходит с заданиями?
        Stats.MailsSent = 0;
        for (int i = 0; i < 40; i++) await http.PostAsync($"/orders/{100 + i}", null);
        await app.StopAsync();
        Console.WriteLine($"7) 40 заказов поставлены, хост остановлен сразу: отправлено {Stats.MailsSent} из 40 (остальные потеряны вместе с очередью в памяти)");
    }
}