Pull to refresh
8K+
9
Илья Бреговой@Csharponelove

t.me/csharp_ci — net разработчик!

38
Rating
6
Subscribers
Send message

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

Information

Rating
236-th
Registered
Activity

Specialization

Бэкенд разработчик, Инженер встраиваемых систем
Git
SQL
Python
PostgreSQL
Linux
Docker
Английский язык
Redis
MySQL
ООП