惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Recent Commits to openclaw:main
Recent Commits to openclaw:main
MyScale Blog
MyScale Blog
A
About on SuperTechFans
爱范儿
爱范儿
L
LangChain Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
C
Check Point Blog
博客园 - Franky
Recent Announcements
Recent Announcements
Recorded Future
Recorded Future
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
大猫的无限游戏
大猫的无限游戏
U
Unit 42
雷峰网
雷峰网
Last Week in AI
Last Week in AI
Martin Fowler
Martin Fowler
博客园_首页
Engineering at Meta
Engineering at Meta
量子位
The Cloudflare Blog
B
Blog RSS Feed
N
Netflix TechBlog - Medium
罗磊的独立博客
Vercel News
Vercel News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
Visual Studio Blog
V
Vulnerabilities – Threatpost
P
Proofpoint News Feed
T
Threat Research - Cisco Blogs
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Cisco Talos Blog
Cisco Talos Blog
B
Blog
I
InfoQ
M
MIT News - Artificial intelligence
Microsoft Azure Blog
Microsoft Azure Blog
月光博客
月光博客
T
Tor Project blog
D
DataBreaches.Net
T
The Exploit Database - CXSecurity.com
D
Docker
C
Cyber Attacks, Cyber Crime and Cyber Security
阮一峰的网络日志
阮一峰的网络日志
G
Google Developers Blog
P
Proofpoint News Feed
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Blog — PlanetScale
Blog — PlanetScale
aimingoo的专栏
aimingoo的专栏
C
Cisco Blogs
MongoDB | Blog
MongoDB | Blog
Simon Willison's Weblog
Simon Willison's Weblog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Одна строчка .Result роняет ваш ASP.NET Core при CPU 8 %: разбор hill-climbing в .NET 9
Илья Бреговой · 2026-05-29 · via Все публикации подряд на Хабре

Сложный

12 мин

12K

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.

Если тема близка - много подобного про C# и .NET разбираю и делюсь кодом в своём Telegram-канале t.me/csharp_ci, подписывайтесь, будет интересно!

Репро: /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.cs

private const int WavePeriod              = 4;
private const int SamplesToMeasure        = WavePeriod * 8;   // 32
private 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 пула при этом снизится.

Чек-лист: что править в коде прежде всего

  1. Все .Result, .GetAwaiter().GetResult(), .Wait() в коде, который вызывается из request-pipeline. Кандидаты на удаление.

  2. Все Task.Run(() => SomethingBlocking()) в request-pipeline. Кандидаты на замену на bulkhead через Channel.

  3. Все обёртки вида public T Get() => GetAsync().Result; в репозиториях и сервисах. Типовая ловушка миграции, удалить.

  4. Старые ThreadPool.SetMinThreads в Program.cs, оставшиеся «от прошлой команды». Пересмотреть: они часто маскируют реальную проблему.

  5. Поднять метрики 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.ThreadCount EventCounter, который мы выше использовали для построения «лесенки».

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 в своём проде, и через что в итоге его увидели: метрики, дампы или жалобы клиентов.

Ндеюсюь, было полезно, критика в комментах приветсвуется"