Цель: писать 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 живут в одном процессе и делят один пул потоков: цифры показывают порядок, а не пропускную способность настоящего сервера.
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). Контекста синхронизации нет, поэтому продолжение уходит просто в пул, а пул при малой нагрузке может вернуть тот же поток. Рассчитывать на смену или сохранение потока нельзя ни в ту, ни в другую сторону.
Обработчики возвращают Task/Task<T>/ValueTask; не async void.
Принимайте CancellationToken в обработчике (подставляется RequestAborted) и передавайте вниз.
Не блокируйте: нет .Result, .Wait(), Thread.Sleep, синхронного ввода-вывода (по умолчанию синхронный I/O в Kestrel запрещён).
Не храните HttpContext и не используйте после завершения запроса. Нельзя запускать фоновую работу, использующую HttpContext, DbContext запроса или scoped-сервисы запроса.
Фон: BackgroundService/IHostedService и очередь. Внутри создавайте свой scope (IServiceScopeFactory).
Остановка: IHostApplicationLifetime.ApplicationStopping, stoppingToken в ExecuteAsync, HostOptions.ShutdownTimeout.
app.MapGet("/report",async(ReportServicereports,CancellationTokenct)=>Results.Text(awaitreports.BuildAsync(ct)));app.MapPost("/orders/{id:int}",async(intid,BackgroundQueuequeue,CancellationTokenct)=>{awaitqueue.EnqueueAsync(newMailJob($"заказ {id} принят"),ct);// при переполнении ждём (обратное давление)returnResults.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. На сервере с реальной БД это ещё одна впустую выполненная операция.
В start (строка 23) письмо отправляется в Task.Run с Mailer, созданным в scope запроса. Запрос завершается за миллисекунды, scope освобождается и вызывает Mailer.Dispose(), а письмо отправляется через секунду:
3) 10 заказов: писем отправлено 0, упало 10
Все десять писем упали с ObjectDisposedException — и никто об этом не узнал: исключение внутри Task.Run никем не наблюдается (глава 8). Клиент получил 202 Accepted.
sealedclassQueueWorker(BackgroundQueuequeue,IServiceScopeFactoryscopes,ILogger<QueueWorker>log):BackgroundService{protectedoverrideasyncTaskExecuteAsync(CancellationTokenstoppingToken){try{awaitforeach(varjobinqueue.ReadAllAsync(stoppingToken)){try{awaitusingvarscope=scopes.CreateAsyncScope();// свой scope, не scope запросаvarmailer=scope.ServiceProvider.GetRequiredService<Mailer>();awaitmailer.SendAsync(job.Text,stoppingToken);}catch(OperationCanceledException)when(stoppingToken.IsCancellationRequested){throw;}catch(Exceptionex){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 на потоки пула.
В top-level Program.cs можно использовать await. Для инициализации с I/O (прогрев, миграции) используйте IHostedService.StartAsync или код перед app.Run(), не блокируя .Result.
// Глава 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 (сервер сам нагружает себя и печатает числа)varbuilder=WebApplication.CreateBuilder(args);builder.Logging.SetMinimumLevel(LogLevel.Warning);builder.Services.AddScoped<ReportService>();builder.Services.AddScoped<Mailer>();varapp=builder.Build();app.MapGet("/report",(ReportServicereports)=>{stringreport=reports.BuildAsync(CancellationToken.None).Result;returnResults.Text(report);});app.MapPost("/orders/{id:int}",(intid,Mailermailer)=>{_=Task.Run(()=>mailer.SendAsync($"заказ {id} принят"));returnResults.Accepted();});if(args.Contains("selftest")){app.Urls.Add("http://127.0.0.1:5057");awaitapp.StartAsync();awaitSelfTest.RunAsync("http://127.0.0.1:5057");awaitapp.StopAsync();}else{app.Run("http://localhost:5000");}sealedclassReportService{publicasyncTask<string>BuildAsync(CancellationTokenct){try{awaitTask.Delay(500,ct);// имитация запроса к БДStats.ReportsCompleted++;return$"отчёт на {DateTime.Now:T}";}catch(OperationCanceledException){Stats.ReportsCancelled++;throw;}}}sealedclassMailer(ILogger<Mailer>log):IDisposable{privatebool_disposed;publicasyncTaskSendAsync(stringtext){awaitTask.Delay(1000);if(_disposed){Interlocked.Increment(refStats.MailsFailed);thrownewObjectDisposedException(nameof(Mailer),"scope запроса уже закрыт");}Interlocked.Increment(refStats.MailsSent);log.LogInformation("письмо отправлено: {Text}",text);}publicvoidDispose()=>_disposed=true;}staticclassStats{publicstaticintReportsCompleted,ReportsCancelled,MailsSent,MailsFailed;}
usingSystem.Diagnostics;// Сервер нагружает сам себя и печатает числа, чтобы не нужен был внешний генератор нагрузки.staticclassSelfTest{publicstaticasyncTaskRunAsync(stringbaseUrl){usingvarhttp=newHttpClient{BaseAddress=newUri(baseUrl),Timeout=TimeSpan.FromSeconds(60)};awaithttp.GetStringAsync("/report");// прогревStats.ReportsCompleted=0;// 1. 64 одновременных запроса к /report (каждый «ждёт БД» 500 мс).intpeakThreads=0;usingvarstop=newCancellationTokenSource();varsampler=Task.Run(async()=>{while(!stop.IsCancellationRequested){peakThreads=Math.Max(peakThreads,ThreadPool.ThreadCount);awaitTask.Delay(50);}});varsw=Stopwatch.StartNew();awaitTask.WhenAll(Enumerable.Range(0,64).Select(_=>http.GetStringAsync("/report")));Console.WriteLine($"1) 64 одновременных /report: {sw.ElapsedMilliseconds} мс, потоков в пуле до {peakThreads} (минимум {Environment.ProcessorCount})");stop.Cancel();awaitsampler;// 2. Клиент отваливается через 100 мс: останавливает ли сервер работу?Stats.ReportsCompleted=0;Stats.ReportsCancelled=0;using(varcts=newCancellationTokenSource(100)){try{awaithttp.GetStringAsync("/report",cts.Token);}catch(OperationCanceledException){}}awaitTask.Delay(1000);Console.WriteLine($"2) клиент ушёл через 100 мс: сервер довёл отчёт до конца = {Stats.ReportsCompleted}, отменил = {Stats.ReportsCancelled}");// 3. Письма в fire-and-forget с scoped-сервисом запроса.for(inti=0;i<10;i++)awaithttp.PostAsync($"/orders/{i}",null);awaitTask.Delay(2000);Console.WriteLine($"3) 10 заказов: писем отправлено {Stats.MailsSent}, упало {Stats.MailsFailed}");}}
// Глава 17, итог.// - обработчики async, принимают CancellationToken (это HttpContext.RequestAborted);// - фоновая работа идёт через ограниченную очередь на Channel и BackgroundService;// - воркер создаёт свой scope на каждое задание и не падает от ошибки одного задания.// - потоковый ответ через IAsyncEnumerable; синхронный ввод-вывод в ответ запрещён.// Запуск сервера: dotnet run -c Release --project final// Самопроверка: dotnet run -c Release --project final -- selftestusingSystem.Threading.Channels;varbuilder=WebApplication.CreateBuilder(args);builder.Logging.SetMinimumLevel(LogLevel.Warning);if(args.Contains("selftest"))builder.Logging.AddFilter("Microsoft.AspNetCore",LogLevel.None);// не засорять вывод ожидаемой ошибкой /syncbuilder.Services.AddScoped<ReportService>();builder.Services.AddScoped<Mailer>();builder.Services.AddSingleton<BackgroundQueue>();builder.Services.AddHostedService<QueueWorker>();varapp=builder.Build();app.MapGet("/report",async(ReportServicereports,CancellationTokenct)=>Results.Text(awaitreports.BuildAsync(ct)));app.MapPost("/orders/{id:int}",async(intid,BackgroundQueuequeue,CancellationTokenct)=>{awaitqueue.EnqueueAsync(newMailJob($"заказ {id} принят"),ct);// при переполнении ждём (обратное давление)returnResults.Accepted();});app.MapGet("/ctx",async()=>{boolbefore=SynchronizationContext.Currentisnull;intthreadBefore=Environment.CurrentManagedThreadId;awaitTask.Delay(50);returnResults.Text($"контекста нет: {before}; поток до await {threadBefore}, после {Environment.CurrentManagedThreadId}; поток пула: {Thread.CurrentThread.IsThreadPoolThread}");});app.MapGet("/stream",()=>Numbers());// клиент получает элементы по мере готовностиapp.MapGet("/sync",(HttpContextctx)=>{ctx.Response.Body.Write("синхронная запись"u8);// запрещено по умолчанию (AllowSynchronousIO = false)returnResults.Empty;});if(args.Contains("selftest")){app.Urls.Add("http://127.0.0.1:5058");awaitapp.StartAsync();awaitSelfTest.RunAsync("http://127.0.0.1:5058",app);}else{app.Run("http://localhost:5000");}staticasyncIAsyncEnumerable<int>Numbers(){for(inti=1;i<=5;i++){awaitTask.Delay(200);yieldreturni;}}sealedrecordMailJob(stringText);sealedclassBackgroundQueue{privatereadonlyChannel<MailJob>_channel=Channel.CreateBounded<MailJob>(100);publicValueTaskEnqueueAsync(MailJobjob,CancellationTokenct)=>_channel.Writer.WriteAsync(job,ct);publicIAsyncEnumerable<MailJob>ReadAllAsync(CancellationTokenct)=>_channel.Reader.ReadAllAsync(ct);}sealedclassQueueWorker(BackgroundQueuequeue,IServiceScopeFactoryscopes,ILogger<QueueWorker>log):BackgroundService{protectedoverrideasyncTaskExecuteAsync(CancellationTokenstoppingToken){try{awaitforeach(varjobinqueue.ReadAllAsync(stoppingToken)){try{awaitusingvarscope=scopes.CreateAsyncScope();// свой scope, не scope запросаvarmailer=scope.ServiceProvider.GetRequiredService<Mailer>();awaitmailer.SendAsync(job.Text,stoppingToken);}catch(OperationCanceledException)when(stoppingToken.IsCancellationRequested){throw;}catch(Exceptionex){log.LogError(ex,"задание не выполнено");}// не уронить цикл}}catch(OperationCanceledException)when(stoppingToken.IsCancellationRequested){// штатная остановка хоста}}}sealedclassReportService{publicasyncTask<string>BuildAsync(CancellationTokenct){try{awaitTask.Delay(500,ct);Stats.ReportsCompleted++;return$"отчёт на {DateTime.Now:T}";}catch(OperationCanceledException){Stats.ReportsCancelled++;throw;}}}sealedclassMailer(ILogger<Mailer>log):IDisposable{privatebool_disposed;publicasyncTaskSendAsync(stringtext,CancellationTokenct){awaitTask.Delay(1000,ct);if(_disposed)thrownewObjectDisposedException(nameof(Mailer));Interlocked.Increment(refStats.MailsSent);log.LogInformation("письмо отправлено: {Text}",text);}publicvoidDispose()=>_disposed=true;}staticclassStats{publicstaticintReportsCompleted,ReportsCancelled,MailsSent,MailsFailed;}
usingSystem.Diagnostics;// Сервер нагружает сам себя и печатает числа, чтобы не нужен был внешний генератор нагрузки.staticclassSelfTest{publicstaticasyncTaskRunAsync(stringbaseUrl,WebApplicationapp){usingvarhttp=newHttpClient{BaseAddress=newUri(baseUrl),Timeout=TimeSpan.FromSeconds(60)};awaithttp.GetStringAsync("/report");// прогревStats.ReportsCompleted=0;// 1. 64 одновременных запроса к /report (каждый «ждёт БД» 500 мс).intpeakThreads=0;usingvarstop=newCancellationTokenSource();varsampler=Task.Run(async()=>{while(!stop.IsCancellationRequested){peakThreads=Math.Max(peakThreads,ThreadPool.ThreadCount);awaitTask.Delay(50);}});varsw=Stopwatch.StartNew();awaitTask.WhenAll(Enumerable.Range(0,64).Select(_=>http.GetStringAsync("/report")));Console.WriteLine($"1) 64 одновременных /report: {sw.ElapsedMilliseconds} мс, потоков в пуле до {peakThreads} (минимум {Environment.ProcessorCount})");stop.Cancel();awaitsampler;// 2. Клиент отваливается через 100 мс: останавливает ли сервер работу?Stats.ReportsCompleted=0;Stats.ReportsCancelled=0;using(varcts=newCancellationTokenSource(100)){try{awaithttp.GetStringAsync("/report",cts.Token);}catch(OperationCanceledException){}}awaitTask.Delay(1000);Console.WriteLine($"2) клиент ушёл через 100 мс: сервер довёл отчёт до конца = {Stats.ReportsCompleted}, отменил = {Stats.ReportsCancelled}");// 3. Письма через очередь и BackgroundService.for(inti=0;i<10;i++)awaithttp.PostAsync($"/orders/{i}",null);awaitTask.Delay(2500);Console.WriteLine($"3) 10 заказов: писем отправлено {Stats.MailsSent}, упало {Stats.MailsFailed}");// 4. Потоковый ответ: когда приходит первый элемент и когда всё.sw.Restart();usingvarresp=awaithttp.GetAsync("/stream",HttpCompletionOption.ResponseHeadersRead);usingvarstream=awaitresp.Content.ReadAsStreamAsync();varbuf=newbyte[256];intn=awaitstream.ReadAsync(buf);longfirst=sw.ElapsedMilliseconds;while(awaitstream.ReadAsync(buf)>0){}Console.WriteLine($"4) /stream: первые данные через {first} мс, всё через {sw.ElapsedMilliseconds} мс");// 5. Синхронная запись в ответ.varr=awaithttp.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(inti=0;i<40;i++)awaithttp.PostAsync($"/orders/{100 + i}",null);awaitapp.StopAsync();Console.WriteLine($"7) 40 заказов поставлены, хост остановлен сразу: отправлено {Stats.MailsSent} из 40 (остальные потеряны вместе с очередью в памяти)");}}