Комментарии 19
Вот это Торт - полезно и доходчиво, а не очередное "Топ 10 бесплатных нейросетей для генерации котиков, которые мы впарим вам за деньги". Спасибо!
А можно деталей или ссылок где почитать, почему это проблема? Во всех легаси проектах такого полно, RPS-ы они как то держат, про проблему услышал впервые. Вроде дотнет более-менее знаю, асинхронный апи писал и дебажил, но не понял почему проблема появляется.
Ближайшая аналогия, которая пришла в голову - это стандартное сравнение, которое было при появлении async-await. Что-то на тему "у вас 4 ядра, и на 100 рпс вы упретесь в них, т.к. синхронно ждёте ответа. А на async-await потоки будут переиспользоваться и упретесь вы на ~150". Это я понимаю, но там нет никаких Hill-climbing, там механически "свободные" потоки имеются и цпу действительно свободен.
ПС: я понимаю, что на больших RPS это действительно может быть проблемой, просто не сталкивался и хочется почитать ещё.
И внезапно, есть "магическое" решение для таких ситуаций, если вместо Task.Run использовать Nito.AsyncEx с его AsyncContext.Run, т.к. он тоже не создаст задачку в тредпуле (как я помню).
Была какая то проблема с таймаутами в нагрузке, которую так коллеги порешали. Я пытался узнать, за счёт чего это работает, но мне не смогли ответить (либо я не нашел тех кто был в курсе). Вполне возможно, что у статьи ровно та проблема, которую так решили =)
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, там всё подробно.
Разбор хороший, спасибо. Так же весьма поучителен пример борьбы с `.Result` через увеличение потоков.
Так то ничего нового: в рядовом асинхронном коде не должно быть Result/GetAwaiter. Если у нас блокирующая операция (кстати почему), то это кандидат на отдельный поток или тредпул.
Меня удивило, что тредпул не помер, не за лочился и даже мог вытянуть подобную ошибку, хоть и с просадкой производительности.
await Task.WhenAll(tasks);
foreach (var task in tasks) {
if (task.IsCompletedSuccessfully){
var res = task.Result;
}
}Иногда можно
Согласен, но я бы делал через `var result = await task;`
Точно, это один из немногих легальных случаев - после 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.
Интересно, сколько времени заняло все это выяснить?
В чистом веб-сервисе
ConfigureAwait(false)ничего не меняет: поведение и так такое.ConfigureAwait(false)нужен в библиотечном коде, который может вызываться из WPF, WinForms или Xamarin, где SynchronizationContext есть. Если вы пишете обычный backend на ASP.NET Core, можно спокойно об этом не думать
И вот тут вот, у меня был кейс где помогло. Я глубоко не раскапывал как так вышло. Aspnet core приложение, Hangfire вызывает джобу, внутри джобы обращение к внешней системе с использованием Linq parallel.
Без этой настройки stop watch внутри каждого вызова показывали приемлемое время в сумме, типа 150-250мс * 10. Но общий stop watch на всю джобу выдавал значительно больше. Будто все висело где-то.
Добавил ConfigureAwait(false) и общий stop watch стал почти совпадать с суммой вложенных, что изначально и ожидалось.
суть в том, что ConfigureAwait(false) разрешил твоему коду не возвращаться в исходно захваченный контекст/планировщик, а продолжить выполнение на любом доступном потоке из ThreadPool. Эти «лишние» миллисекунды и есть continuation после него. И сейчас то, что общее время всё равно немного не совпадает с суммой внутренних замеров, нормально, так как планирование задач, переключение потоков и накладные расходы параллелизма никуда полностью не исчезают. Не знаю как там устроено у вас, возможно, лучше не мешать это с PLINQ/Parallel, а собрать задачи и выполнить через Task.WhenAll? И при необходимости ограничить параллельность через SemaphoreSlim. Так все должно быть быстрее и время на часах сойдется в итоге. Да и будет, наверное получше.
Да, я тот момент понял про пул потоков и возврат в исходный, просто тогда это означает то, что пересказывают в интернетах про ConfigureAwait и ASP.NET Core не совсем точная инфа. И вот дальше копать уже особо времени не было.
@Csharponeloveуже дополнил ниже про Hangfire.
Без этой настройки stop watch внутри каждого вызова показывали приемлемое время в сумме, типа 150-250мс * 10. Но общий stop watch на всю джобу выдавал значительно больше. Будто все висело где-то.
А есть возможность заменить на Task.WhenAll с SemaphoreSlim и посчитать время вызова и общее время исполнения? Очень интересно на сколько улучшится. Так как правильно заметил @CsharponelovePLINQ сделан под CPU-bound задачи, а не под I/O ( внутри джобы обращение к внешней системе). Ещё было бы интересно посмотреть метрики до и после по ThreadPool: количество занятых потоков, доступных потоков и очередь, если она есть.
Спасибо за чёткое дополнение. Про 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 речь и идёт). Спасибо за конкретный пример.
Не совсем понял зачем было создавать таски с LongRunning. По идее LongRunning просит создать дополнительный Thread. Всё бы хорошо, но если в Channel не будет элементов работы, он асинхронно будет ожидать, соответственно поток будет освобождён, а продолжение будет выполнено в потоке из пулла потоков. Может тогда лучше использовать BlockingCollection с ручным созданием и утилизацией Thread, но также позволить вызывающей стороне ожидать завершения через TaskCompletionSource? Если я заблуждаюсь, поправьте пожалуйста

Одна строчка .Result роняет ваш ASP.NET Core при CPU 8 %: разбор hill-climbing в .NET 9