Спасибо за чёткое дополнение. Про Task.WhenAll вместо PLINQ — абсолютно верно, async + SemaphoreSlim для ограничения параллельности это правильный паттерн. PLINQ спроектирован под CPU-bound задачи, он не знает про async-операции внутри и блокирует потоки ThreadPool.
Интересный кейс! Про время: разбирал составные по ходу написания — за дней 10, но основная часть через прокопку исходников собралась быстро.
Про ваш кейс — iakimovdev правильно объяснил. Hangfire использует свой JobActivator с собственным SynchronizationContext, поэтому await без ConfigureAwait(false) возвращался на его поток и дожидался в очереди — отсюда зависание. СConfigureAwait(false) продолжения шли на любом ThreadPool-потоке — время сошлось. Это именно тот случай где ConfigureAwait(false) помогает в ASP.NET Core-подобном хосте с кастомным SynchronizationContext (как раз о Hangfire речь и идёт). Спасибо за конкретный пример.
Точно, это один из немногих легальных случаев - после await Task.WhenAll задача гарантированно завершена, блокировки не будет. .Result здесь выступает только как извлечение уже готового значения, а не как sync-over-async. Для полноты: аналогичные безопасные кейсы — чтение через task.Result в продолжении ContinueWith (IsCompleted == true), и чтение уже завершённой Task в объектах с ValueTask.
Да, это сознательное проектирование hill-climbing — он именно не даёт пулу помереть, а постепенно добавляет воркеров. С точки зрения надёжности самого рантайма это хорошо. Плохо для SLA сервиса: p99 пробивает через 10-30 секунд после спайка, а не сразу. Про «кстати почему» — обычно унаследованные обёртки от старого SDK без async API или неправильно портированный код из .NET Framework.
Nito.AsyncEx AsyncContext.Run действительно не кладёт задачу в ThreadPool — он создаёт свой собственный single-threaded SynchronizationContext и прокручивает event loop на текущем потоке. Если коллеги его применяли для sync-over-async в рамках ASP.NET Core — это работало, но не решало старвейшн: блокирующий поток просто переносился с ThreadPool-воркера на дедикированный поток. Тоесть воркер пула освобождался — отсюда и падали таймауты. Правильное решение — либо убрать sync-over-async, либо bulkhead через Channel + LongRunning как в статье.
Ключевое отличие от простого сценария async vs sync: в легаси с низким RPS (скажем, 50-200 реквестов в секунду) пул действительно держит, потому что hill-climbing успевает нагнать потоки заранее, чем приходит нагрузка. Реально проблема проявляется тогда, когда нагрузка резко растёт (спайк, релиз фича, ретраи от upstream) — за 10-60 секунд пул просто не успевает наращивать воркеров (по 1/сек), очередь растёт быстрее чем пул, p99 пробивает SLA.
В легаси проектах часто всё выглядит нормально до первого серьёзного спайка — именно поэтому баг тяжело поймать: CPU норм, RAM норм, и вдруг p99 = 4с при 8% CPU. Хорошая ссылка для дополнительного чтения — dotnet/runtime исходник PortableThreadPool.HillClimbing.cs, там всё подробно.
Спасибо за чёткое дополнение. Про Task.WhenAll вместо PLINQ — абсолютно верно, async + SemaphoreSlim для ограничения параллельности это правильный паттерн. PLINQ спроектирован под CPU-bound задачи, он не знает про async-операции внутри и блокирует потоки ThreadPool.
Интересный кейс! Про время: разбирал составные по ходу написания — за дней 10, но основная часть через прокопку исходников собралась быстро.
Про ваш кейс — iakimovdev правильно объяснил. Hangfire использует свой JobActivator с собственным SynchronizationContext, поэтому await без ConfigureAwait(false) возвращался на его поток и дожидался в очереди — отсюда зависание. СConfigureAwait(false) продолжения шли на любом ThreadPool-потоке — время сошлось. Это именно тот случай где ConfigureAwait(false) помогает в ASP.NET Core-подобном хосте с кастомным SynchronizationContext (как раз о Hangfire речь и идёт). Спасибо за конкретный пример.
Точно, это один из немногих легальных случаев - после await Task.WhenAll задача гарантированно завершена, блокировки не будет. .Result здесь выступает только как извлечение уже готового значения, а не как sync-over-async. Для полноты: аналогичные безопасные кейсы — чтение через task.Result в продолжении ContinueWith (IsCompleted == true), и чтение уже завершённой Task в объектах с ValueTask.
Да, это сознательное проектирование hill-climbing — он именно не даёт пулу помереть, а постепенно добавляет воркеров. С точки зрения надёжности самого рантайма это хорошо. Плохо для SLA сервиса: p99 пробивает через 10-30 секунд после спайка, а не сразу. Про «кстати почему» — обычно унаследованные обёртки от старого SDK без async API или неправильно портированный код из .NET Framework.
Nito.AsyncEx AsyncContext.Run действительно не кладёт задачу в ThreadPool — он создаёт свой собственный single-threaded SynchronizationContext и прокручивает event loop на текущем потоке. Если коллеги его применяли для sync-over-async в рамках ASP.NET Core — это работало, но не решало старвейшн: блокирующий поток просто переносился с ThreadPool-воркера на дедикированный поток. Тоесть воркер пула освобождался — отсюда и падали таймауты. Правильное решение — либо убрать sync-over-async, либо bulkhead через Channel + LongRunning как в статье.
Ключевое отличие от простого сценария async vs sync: в легаси с низким RPS (скажем, 50-200 реквестов в секунду) пул действительно держит, потому что hill-climbing успевает нагнать потоки заранее, чем приходит нагрузка. Реально проблема проявляется тогда, когда нагрузка резко растёт (спайк, релиз фича, ретраи от upstream) — за 10-60 секунд пул просто не успевает наращивать воркеров (по 1/сек), очередь растёт быстрее чем пул, p99 пробивает SLA.
В легаси проектах часто всё выглядит нормально до первого серьёзного спайка — именно поэтому баг тяжело поймать: CPU норм, RAM норм, и вдруг p99 = 4с при 8% CPU. Хорошая ссылка для дополнительного чтения — dotnet/runtime исходник PortableThreadPool.HillClimbing.cs, там всё подробно.