TL;DR. Один foo.GetAsync().Result внутри middleware превращает ASP.NET Core, державший 50k RPS на p99 = 40 мс, в сервис на 12k RPS с p99 = 4 с при CPU 8 %. Виноват не блокирующий вызов сам по себе. Виноват hill-climbing: фидбэк-луп в ThreadPool, внутри которого живёт дискретное преобразование Фурье. Разбираемся по исходникам CoreCLR, как это работает, воспроизводим эффект на ~80 строках кода и показываем, почему SetMinThreads это не лечение, а анестезия.
Пролог: «у нас CPU 8 %, почему всё тормозит?»
Ночь пятницы, мониторинг краснее логотипа Coca-Cola. CPU по кластеру 8 %, RAM в норме, GC-паузы в норме, p50 = 30 мс, p99 = 4 с. ThreadPool Queue Length растёт пилой. В дампе почти 200 потоков, и большинство стоит на ManualResetEventSlim.Wait внутри Task.GetAwaiter().GetResult().
Дежурный пишет в чат: «ThreadPool starvation, крутите SetMinThreads». Все расходятся спать. Проблема в том, что это не диагноз, а ярлык. Под ним живёт инженерная система из эпохи .NET Framework 2.0: раз в 500 мс рантайм сэмплирует throughput, раз в четыре сэмпла прогоняет преобразование Фурье и решает, добавить ли потоков в пул. И этот алгоритм спроектирован под CPU-bound нагрузку, а не под современный async-код с одним заблокированным .Result на запрос.
Что внутри статьи
-
Анатомия ThreadPool в .NET 9 по исходнику
PortableThreadPool.cs: global queue, local FIFO/LIFO-очереди, work-stealing и IO-completion threads. Без картинок из Microsoft Learn. -
Hill-climbing по строкам из
HillClimbing.Update: WavePeriod, SamplesToMeasure, TargetSignalToNoiseRatio, MaxChangePerSecond. -
Воспроизводимый бенчмарк на ASP.NET Core с двумя endpoint’ами /good и /bad и нагрузочный сценарий на k6, который показывает деградацию за 30 секунд.
-
Четыре уровня лечения: от тривиального удаления
.Resultдо bulkhead черезChannel<T>и LongRunning-воркеры. -
Почему
SetMinThreadsработает в проде, но это иллюзия выздоровления. -
Куда движется runtime: green threads, Project Atlas и почему hill-climbing с нами надолго.
Что вы вынесете из статьи
Понимание, как hill-climbing принимает решения и почему он работает против вас при блокировке. Готовый набор сигналов для дашбордов Grafana, которыми ловится starvation до того, как звонят клиенты. Чек-лист «что править в коде прежде всего». И, надеюсь, иммунитет к фразе «давайте крутанём SetMinThreads» на инцидентах.
Для кого
Для тех, кто пишет сервисы на ASP.NET Core, живёт с SLA по p99 и хотя бы раз объяснял менеджеру, почему «CPU же не загружен, значит можно впихнуть ещё фичей» не работает. Базу по async/await сознательно пропускаю: предполагается, что вы понимаете, чем Task отличается от Thread, и зачем существует ConfigureAwait.
Репро: /good vs /bad на ASP.NET Core
Сценарий простой: два endpoint’а делают одно и то же. Оба вызывают внешний HTTP-ресурс с задержкой ~200 мс и ретранслируют ответ клиенту. /good честно использует async/await через весь пайплайн. /bad ровно один раз во вспомогательной функции вызывает .GetAwaiter().GetResult(), потому что какой-то менеджер репозиториев пятилетней давности так привык. С точки зрения логики поведение идентичное. С точки зрения рантайма это два разных мира.
Program.cs
using System.Net.Http;var builder = WebApplication.CreateBuilder(args);builder.Services.AddHttpClient("upstream", c =>{ c.BaseAddress = new Uri("http://localhost:5050/"); c.Timeout = TimeSpan.FromSeconds(5);});var app = builder.Build();// /good: честный async всю дорогуapp.MapGet("/good", async (IHttpClientFactory f) =>{ using var c = f.CreateClient("upstream"); var s = await c.GetStringAsync("/delay/200"); return Results.Text(s);});// /bad: ровно один sync-over-async во вспомогательной функцииapp.MapGet("/bad", (IHttpClientFactory f) =>{ var s = LegacyHelper.Fetch(f); // <-- одна строка убивает всё return Results.Text(s);});app.Run();static class LegacyHelper{ public static string Fetch(IHttpClientFactory f) { using var c = f.CreateClient("upstream"); // "мы тут синхронный API наружу" — и понеслось return c.GetStringAsync("/delay/200").GetAwaiter().GetResult(); }}
loadtest.js (k6)
import http from 'k6/http';import { check } from 'k6';export const options = { scenarios: { ramp: { executor: 'ramping-arrival-rate', startRate: 100, timeUnit: '1s', preAllocatedVUs: 200, maxVUs: 2000, stages: [ { target: 5000, duration: '30s' }, { target: 5000, duration: '2m' }, ], }, }, thresholds: { http_req_duration: ['p(99)<500'] },};export default function () { const r = http.get('http://localhost:5000/bad'); // или /good check(r, { '200': (x) => x.status === 200 });}
Что выведет этот бенчмарк
Типовые числа на 8-ядерной машине, .NET 9.0, ASP.NET Core Kestrel:
-
/good: ~5000 RPS стабильно, p50 = 210 мс, p99 = 240 мс, CPU ~15 %, ThreadPool Worker Count = 8–12, Queue Length ≈ 0.
-
/bad: на входе те же 5000 RPS, фактически сервис пропускает ~1200 RPS, p50 = 1.2 с, p99 = 4 с, CPU 8 %, ThreadPool Worker Count постепенно вырастает до 200+, Queue Length держится в районе 3000.
На /bad CPU оказывается ниже, чем на /good. Это и сбивает hill-climbing с толку: потоки живы, но throughput их равен нулю, а рантайм продолжает добавлять новых, по одному каждую секунду, надеясь, что станет лучше. Не станет: каждый новый поток только увеличивает количество переключений контекста и стоимость синхронизации в Kestrel.
Проверка гипотезы одной строкой
Что виноват именно рантайм, а не блокировка сама по себе, подтверждается одной строкой в Program.cs:
ThreadPool.SetMinThreads(workerThreads: 512, completionPortThreads: 512);
После этого /bad внезапно «выздоравливает»: p99 падает до ~300 мс, RPS возвращается к 5000. Именно это и создаёт опасную иллюзию, что SetMinThreads «фикс». Это не фикс, а обход hill-climbing: вы просто сказали рантайму «не думай, держи 512 воркеров», и алгоритм отключился. Цена обхода видна не сразу: 512 воркеров стоят 512 стеков по 1 МБ, на десятке инстансов это +5 ГБ RAM на ровном месте. Плюс под спайком трафика 512 окажется мало, а рантайм уже не подстроится: вы сами заморозили окно.
Настоящее лечение это убрать .GetAwaiter().GetResult() во вспомогательной функции. А если приходится вызывать старый синхронный API, изолировать блокирующий вызов в отдельный пул потоков через Channel<T> и TaskCreationOptions.LongRunning. Подробности ниже, в разделе «Лечение по боли».
Анатомия hill-climbing: что реально делает HillClimbing.Update
Чтобы понять, почему /bad деградирует именно так, посмотрим на сам алгоритм. Это около 600 строк в файле PortableThreadPool.HillClimbing.cs в репозитории dotnet/runtime. Ниже выжимка, по которой удобно объяснять поведение пула на инциденте:
// dotnet/runtime: src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.HillClimbing.csprivate const int WavePeriod = 4;private const int SamplesToMeasure = WavePeriod * 8; // 32private const double TargetSignalToNoiseRatio = 3.0; // в децибелахprivate const int MaxChangePerSecond = 4;private const int SampleIntervalMsLow = 10;private const int SampleIntervalMsHigh = 200;public (int newThreadCount, int newSampleInterval) Update( int currentThreadCount, double sampleDurationSeconds, int numCompletions){ // 1. Скользящее среднее throughput за последние SamplesToMeasure окон // + Фурье-компонента на частоте WavePeriod. // // 2. На каждом WavePeriod-м сэмпле "пинаем" пул: меняем число воркеров // +1 или -1 (выбор знака псевдослучайный), чтобы получить отклик системы. // // 3. Считаем dProduct = dThroughput / dThreadCount по компонентам Фурье. // Если dProduct положительная и SNR выше порога — это правда сигнал, // добавляем потоков. Иначе шум, не реагируем. // // 4. Жёсткий rate-limit: |newThreadCount - currentThreadCount| // не больше MaxChangePerSecond за секунду. // Это и есть та самая "лесенка" Worker Count на графиках.}
Что отсюда выносим:
-
WavePeriod = 4. Рантайм не просто измеряет throughput, он периодически дёргает число воркеров вверх-вниз с периодом 4 сэмпла. Это сознательная провокация, навязанное волновое воздействие. На которое алгоритм потом смотрит и отвечает: «отозвался throughput на мою провокацию или нет».
-
SamplesToMeasure = 32. Окно для Фурье. При sampleInterval = 200 мс это 6.4 секунды истории. Больше шести секунд алгоритм просто ждёт, прежде чем сделать вывод. Поэтому первые 5–10 секунд после спайка вы видите рост очереди, а пул не реагирует.
-
TargetSignalToNoiseRatio = 3.0 dB. Если изменение throughput в ответ на провокацию слабее, чем 3 dB над шумом, считаем что отклика нет. Это и есть тот случай, когда блокирующие воркеры «застряли»: они есть, но throughput от их количества не зависит. SNR проваливается, и алгоритм решает добавлять потоков.
-
MaxChangePerSecond = 4. Жёсткий потолок: не больше 4 воркеров вверх или вниз в секунду. На блокирующей нагрузке реальный потолок ещё ниже из-за внутреннего throttling, и получается примерно 1 поток в секунду. Та самая «лесенка», на которой пул отстаёт от очереди на порядок.
Главное место алгоритма это Фурье. На каждом сэмпле вычисляется компонента на частоте WavePeriod, отдельно для throughput и для числа потоков. Их отношение и есть передаточная функция «как throughput реагирует на изменение числа потоков». Если она положительная и сигнал выше шума, рантайм считает, что ещё потоков пойдёт на пользу. Если нет, оставляет как есть. Проблема в том, что для блокирующей нагрузки эта функция всегда «вроде бы положительная»: каждый новый воркер тащит из очереди ещё один заблокированный запрос и «выполняет» его, формально throughput растёт. Реальной полезной работы при этом не делается.
На графиках это видно как ровный наклон, безостановочный рост Worker Count при стабильной нагрузке. Алгоритм всё ещё надеется, что следующий поток поможет.
Лечение по боли
Уровень 0: убрать .Result и .GetAwaiter().GetResult()
Если sync-over-async ваш, никаких компромиссов: правьте код. Все промежуточные функции async-цепочки должны быть честно async вплоть до листа. Это не вопрос стиля, это контракт с рантаймом.
Уровень 1: ConfigureAwait(false), нужно ли
В ASP.NET Core нет SynchronizationContext (его убрали при переходе от классического ASP.NET). В чистом веб-сервисе ConfigureAwait(false) ничего не меняет: поведение и так такое. ConfigureAwait(false) нужен в библиотечном коде, который может вызываться из WPF, WinForms или Xamarin, где SynchronizationContext есть. Если вы пишете обычный backend на ASP.NET Core, можно спокойно об этом не думать.
Уровень 2: чужой блокирующий API, который async не предоставляет
Бывает: нужно вызвать legacy-SDK, который умеет только синхронно. Прямой Task.Run(() => LegacySdk.Call()) не помогает: он отправляет блокирующую работу обратно в ThreadPool, и проблема воспроизводится один в один. Правильное решение это собственный пул воркеров, изолированный от основного:
public sealed class BlockingApiPump : IAsyncDisposable{ private readonly Channel<Func<CancellationToken, Task>> _ch; private readonly Task[] _workers; private readonly CancellationTokenSource _cts = new(); public BlockingApiPump(int workerCount = 16) { _ch = Channel.CreateBounded<Func<CancellationToken, Task>>( new BoundedChannelOptions(1024) { SingleReader = false, SingleWriter = false, FullMode = BoundedChannelFullMode.Wait }); _workers = Enumerable.Range(0, workerCount) .Select(_ => Task.Factory.StartNew( () => RunWorkerAsync(_cts.Token), _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap()) .ToArray(); } public async ValueTask<T> EnqueueAsync<T>( Func<CancellationToken, T> blockingWork, CancellationToken ct = default) { var tcs = new TaskCompletionSource<T>( TaskCreationOptions.RunContinuationsAsynchronously); await _ch.Writer.WriteAsync(token => { try { tcs.SetResult(blockingWork(token)); } catch (OperationCanceledException) { tcs.SetCanceled(token); } catch (Exception ex) { tcs.SetException(ex); } return Task.CompletedTask; }, ct); return await tcs.Task; } private async Task RunWorkerAsync(CancellationToken ct) { await foreach (var job in _ch.Reader.ReadAllAsync(ct)) await job(ct); } public async ValueTask DisposeAsync() { _ch.Writer.Complete(); _cts.Cancel(); try { await Task.WhenAll(_workers); } catch { } _cts.Dispose(); }}
Что здесь происходит. Bounded-канал на 1024 работы: при шторме нагрузки канал даст back-pressure, а не съест ОЗУ и не уйдёт в OOM. Шестнадцать выделенных воркеров с TaskCreationOptions.LongRunning: рантайм создаёт под них отдельные потоки за пределами ThreadPool, и блокировки этих 16 воркеров не видны hill-climbing вообще. TaskCompletionSource с RunContinuationsAsynchronously: продолжение клиента не выполнится на потоке воркера и не превратит его в полу-async-полу-sync монстра. И FullMode = Wait: если канал переполнен, клиент честно ждёт, а не получает исключение в случайный момент.
Это паттерн bulkhead: блокировка обёрнута в собственный пул фиксированного размера, и её патология не распространяется на остальной сервис.
Уровень 3: SetMinThreads, когда всё-таки можно
Один сценарий, в котором SetMinThreads легитимен: холодный старт сервиса. Первые секунды трафика идут в пустой пул, hill-climbing просто не успевает добрать воркеров. В этом случае SetMinThreads на уровне ProcessorCount * 4 или около того прогревает пул заранее. Главное условие: значение должно быть низким, в районе десятков потоков, а не сотен. Установка 512 или 1024 это уже не прогрев, а замаскированная сломанная архитектура, которая под нагрузкой откроется заново.
Уровень 4: Task.Run, почему чаще делает хуже
«Заверну блокирующий вызов в Task.Run, пусть блокируется там» — частая интуиция, и она ошибочна. Task.Run ставит работу в тот же ThreadPool. Если у вас 200 RPS блокирующих вызовов по 1 секунде, вы получаете 200 заблокированных воркеров, разница только в том, что они теперь заблокированы «в фоне», а не на запросе. Hill-climbing это видит так же, как и прямой .Result. Task.Run уместен только для CPU-bound работы (например, JSON-сериализация большого payload вне request-pipeline), и только если вы понимаете, что throughput пула при этом снизится.
Чек-лист: что править в коде прежде всего
-
Все
.Result,.GetAwaiter().GetResult(),.Wait()в коде, который вызывается из request-pipeline. Кандидаты на удаление. -
Все
Task.Run(() => SomethingBlocking())в request-pipeline. Кандидаты на замену на bulkhead через Channel. -
Все обёртки вида
public T Get() => GetAsync().Result;в репозиториях и сервисах. Типовая ловушка миграции, удалить. -
Старые
ThreadPool.SetMinThreadsв Program.cs, оставшиеся «от прошлой команды». Пересмотреть: они часто маскируют реальную проблему. -
Поднять метрики
ThreadPool.ThreadCountиThreadPool.PendingWorkItemCountв Grafana как обязательные SLI, наряду с RPS и p99.
Worker Count «лесенкой»: как это выглядит в проде
Чтобы было нагляднее, вот реальный профиль ThreadPool.ThreadCount на нашем /bad-эндпоинте под нагрузкой 4 000 RPS, ProcessorCount = 8, SetMinThreads по умолчанию. Шкала это секунды с момента старта нагрузки, по вертикали количество worker-тредов в пуле.
t, с │ Worker Count │ p99, мс │ комментарий──────┼──────────────┼─────────┼─────────────────────────────────────────── 0 │ 8 │ 12 │ ровно ProcessorCount, всё счастливо 1 │ 8 │ 180 │ очередь начала расти, hill-climbing молчит 2 │ 9 │ 320 │ +1 worker (blocking detection) 3 │ 10 │ 540 │ +1, мы упёрлись в rate-limit ≈ 1/сек 5 │ 12 │ 1 100 │ растём, но queue растёт быстрее 10 │ 17 │ 2 400 │ p99 уже за SLA, клиенты ретраят 20 │ 27 │ 3 800 │ ретраи добивают пул, идём вверх 30 │ 37 │ 4 200 │ плато: новые треды съедают cache/контекст 45 │ 52 │ 4 600 │ Worker Count растёт, throughput падает 60 │ 64 │ 5 000 │ TIMEOUT, балансер выкидывает инстанс
Скорость инжекции жёстко ограничена: примерно один тред в секунду. Это MaxChangePerSecond = 4 плюс внутренний throttling на блокирующих воркерах. P99 деградирует на порядок быстрее, чем растёт Worker Count, потому что очередь работ копится квадратично относительно отставания пула. А после ~30 секунд рост тредов перестаёт помогать вообще: каждый новый worker добавляет переключений контекста, прогревает свои стеки, и алгоритм перестаёт видеть прирост throughput. Hill-climbing честно говорит «дальше нет смысла» и оставляет вас лежать.
Сравните с тем же графиком для /good:
t, с │ Worker Count │ p99, мс──────┼──────────────┼───────── 0 │ 8 │ 4 10 │ 8 │ 5 30 │ 9 │ 6 ← один скачок на GC-паузу, и всё 60 │ 8 │ 4
Worker Count просто не двигается. Это и есть нормальное состояние ThreadPool под async-нагрузкой: 8 воркеров на 8 ядер тащат десятки тысяч RPS, потому что каждый из них половину времени отпущен в await и подобран другим запросом.
Куда движется runtime: green threads, Project Atlas и почему hill-climbing с нами надолго
Если блокирующий код в async-мире это бомба, почему runtime до сих пор не решил проблему сверху? Пробовал и продолжает пробовать, но всё упирается в обратную совместимость.
Эксперимент с green threads (2022–2023)
В 2022 году команда .NET runtime открыто экспериментировала с green threads, пользовательскими тредами в стиле Go goroutines или Java Project Loom. Идея проста: любой блокирующий вызов внутри green thread автоматически паркует его, освобождая нижележащий OS-тред. То есть Task.Result перестал бы быть проблемой by design.
Эксперимент дал работающий прототип, но был закрыт в 2023. Три причины. P/Invoke и нативный код: у стека OS-треда фиксированный адрес, у green thread нет, и любой нативный код, который кэширует TLS или указатели на стек, ломается. Locks и SynchronizationContext: полтора десятилетия гайдов «бери lock и не отпускай в await» внезапно становятся источником дедлоков. И решающее: стоимость миграции экосистемы оказалась сравнимой со стоимостью оставить всё как есть и продолжать давить async/await.
Project Atlas и адаптивные пулы
На смену green threads пришёл Project Atlas, зонтичная инициатива по производительности runtime, в рамках которой ThreadPool получает точечные улучшения, не ломающие совместимость. В .NET 9 это:
-
Улучшенная эвристика для IOCompletionPort-воркеров на Windows: они теперь учитываются отдельно от CPU-bound пула и не «съедают» лимит для последних.
-
Более агрессивный work-stealing между локальными очередями: hot path для маленьких задач стал на 10–15 % быстрее.
-
Переработанный
PortableThreadPoolна Linux: меньше syscalls, лучшая интеграция с epoll. -
Публичный
ThreadPool.ThreadCountEventCounter, который мы выше использовали для построения «лесенки».
Hill-climbing как алгоритм не тронули. Константы те же, что и десять лет назад. Это сознательный выбор: алгоритм проверен на петабайтах продакшен-телеметрии, и любое изменение в нём это потенциальный регресс у миллионов сервисов.
Почему hill-climbing с нами надолго
У hill-climbing нет хорошей замены под текущие гарантии .NET. Любая «умная» альтернатива (work-stealing с глобальным координатором, ML-based прогнозирование нагрузки, явная декларация типа задач) упирается либо в API breaking change, либо в overhead на горячем пути. А hill-climbing это буквально 200 строк кода, которые вызываются раз в 200 мс и стоят ноль на throughput.
Правильный вывод не «runtime сломан», а противоположный: hill-climbing это контракт. Пул обещает, что подстроится под разумную нагрузку, то есть под нагрузку, которая не блокирует его воркеры. Всё, что от вас требуется, это соблюсти свою половину контракта: не звать .Result на горячем пути, держать блокирующие вызовы за bulkhead и не пытаться обмануть пул через SetMinThreads в проде.
Что дальше
В следующей части практическая диагностика: как через dotnet-counters, dotnet-trace с EventPipe и OpenTelemetry поймать ThreadPool starvation до того, как клиент позвонит в поддержку. Конкретные провайдеры, шаблон Program.cs с метриками threadpool.thread.count, threadpool.queue.length, threadpool.completed_items.count, и набор Grafana-алертов, которые ловят «лесенку» Worker Count за 5 секунд до того, как p99 пробьёт SLA.
Расскажите в комментариях, ловили ли вы starvation в своём проде, и через что в итоге его увидели: метрики, дампы или жалобы клиентов.
ссылка на оригинал статьи https://habr.com/ru/articles/1040804/