ThreadPool – инъекция потоков

ThreadPool в дотнете часто воспринимается нами как данность. Надежно укрытый от глаз за простым интерфейсом async-await, он выполняет трудную работу по обеспечению эффективной работы с потоками.

ThreadPool — мощный и простой в использовании инструмент, но за эту мощность и простоту приходится платить потерей контроля: разработчик может довольно слабо повлиять на конкретные моменты его работы. «С большой силой приходит большая ответственность», и наша ответственность — понимать его устройство, чтобы понимать его ограничения. Два главных способа понять: чтение кода и, конечно, эксперименты с многопоточностью.

В этой статье мы начнем с небольшого погружения в код ThreadPool«a, а закончим интересным кейсом применения матанализа в одной из самых важных частей многопоточности в дотнете.

Рубрика э-э-э-эксперименты!

Одним из первых экспериментов с многопоточностью, который проводит, наверное, каждый — загрузка всех ядер процессора под 100%. Код на C# для этого выглядит довольно просто (не обращайте внимания, что тут создается куча бессмысленных Task«ов):

static void SpinningMethod()
{
   while (true)
   {
   }
}


public static void Main()
{
   while (true)
   {
       Task.Run(SpinningMethod);
   }
}

Если мы посмотрим на нагрузку процессора во время исполнения, мы ожидаемо  увидим там забитые «в полку» ядра процессора. Но теперь становится интересно, сколько из этих тасков выполняется параллельно?

Давайте добавим «идентификатор» выполняемой итерации:

static void SpinningMethod(long id)
{
   Console.WriteLine(id);
   while (true)
   {
   }
}


public static void Main()
{
   for (var i = 0L;; i++)
   {
       var j = i;
       Task.Run(() => SpinningMethod(j));
   }
}

На своей машине с процессором на 12 ядер я увидел в консоли перечисление от 0 до 11 в произвольном порядке, затем примерно 12 секунд ничего не происходило, после чего в консоли появилось число 12. Но почему так? Почему такая задержка и почему в обработку была взята новая итерация?

С этих вопросов и начинается наше небольшое путешествие по ThreadPool, пристегните ремни, мы… падаем?

Вниз по стектрейсу

Давайте провалимся вниз по стеку Task.Run и посмотрим, что за логика, отвечающая за такое поведение, там скрыта. Опуская некоторые вызовы, там происходит следующая цепочка: Task → ThreadPoolTaskScheduler → ThreadPoolWorkQueue → PortableThreadPool.RequestWorker.

PortableThreadPool.RequestWorker выглядит следующим образом:

internal void RequestWorker()
{
   Interlocked.Increment(ref _separated.numRequestedWorkers);
   WorkerThread.MaybeAddWorkingWorker(this);
   GateThread.EnsureRunning(this);
}

Именно в запускаемом GateThread скрывается поведение, которое мы увидели, когда выполняли приложение.

GateThread создается в единственном экземпляре на все приложение и выполняет некоторые периодические активности, одной из которых является контроль того, чтобы таски не лежали в очереди слишком долго:

private static bool SufficientDelaySinceLastDequeue(PortableThreadPool threadPoolInstance)
{
   uint delay = (uint)(Environment.TickCount - threadPoolInstance._separated.lastDequeueTime);
   uint minimumDelay;
   if (threadPoolInstance._cpuUtilization < CpuUtilizationLow)
   {
       minimumDelay = GateActivitiesPeriodMs;
   }
   else
   {
       minimumDelay = (uint)threadPoolInstance._separated.counts.NumThreadsGoal * DequeueDelayThresholdMs;
   }


   return delay > minimumDelay;
}

Если мы подставим туда все интересующие нас переменные, то мы получим те самые 12 секунд простоя (DequeueDelayThresholdMs задан как 0.5×2). Из этой же функции можно увидеть, что в случае, если на ThreadPool множество таких «заблокированных» задач, то minimumDelay будет только увеличиваться, так как будет увеличиваться NumThreadsGoal.

Мы ответили на изначальный вопрос и объяснили поведение ThreadPool«a, но давайте задержимся тут на подольше и осмотримся. Если мы посмотрим на код, который вызывается после прохождения условия SufficientDelaySinceLastDequeue, мы увидим там следующий вызов:

HillClimbing.ThreadPoolHillClimber.ForceChange(
   newNumThreadsGoal,
   HillClimbing.StateOrTransition.Starvation);

Вызов этого HillClimbing мы можем увидеть еще в нескольких местах тредпула. Что еще за HIllClimbing и чем он занимается? Для ответа на этот вопрос нам нужно разобраться с тем, как ThreadPool рулит потоками, и вернуться немного назад в историю.

История Thread Injection в ThreadPool

Управление количеством потоков сложная задача, которую можно описать как «баланс». Если потоков будет слишком мало, то мы не будем эффективно использовать ресурс процессора и наша очередь задач будет обрабатываться медленно. Если потоков будет слишком много, то у нас будет много переключений контекста (и наша очередь задач будет обрабатываться медленно) и большие затраты по памяти. Нахождение баланса между этими двумя крайностями и есть задача, которую пытается выполнить ThreadPool, своевременно добавляя или убавляя потоки, подстраиваясь под текущую нагрузку. Этот процесс и называется Thread Injection.

При разработке алгоритма Thread Injection можно использовать разные подходы. Старые реализации в дотнете использовали метрику максимизации Cpu Utilization — добавляли как можно больше потоков, чтобы как можно сильнее увеличить Cpu Utilization. Данная метрика хорошо работает, когда у нас малое количество длинных задач. Однако в случае большого количества коротких задач данная метрика начинает давать ложные срабатывания, а это на самом деле и есть наиболее частый сценарий использования CPU-bound ThreadPool«a (например, запрос в aspnet — серия очень коротких cpu-bound задач перетекающих в длинные IO-bound задачи). 

Из-за этого в .net core и далее была выбрана другая метрика — максимизация Throughput (количество выполненных задач за единицу времени). Эта метрика является более отзывчивой — если добавление потоков не привело к увеличению Througput, то потоки можно убрать. К сожалению, оказалось, что Throughput — достаточно шумная метрика, и зависит не только от количества активных потоков, но и от множества других факторов. И здесь нам на помощь приходят метод скользящего среднего и, неожиданно, функциональный анализ.

Я, когда в моей карьере «перекладывателя жсонов» встретился матанализ

Я, когда в моей карьере «перекладывателя жсонов» встретился матанализ

Текущий метод Thread Injection

Так как Throughput является метрикой «количество за единицу времени», то самый распространенный вариант — это считать некоторое скользящее среднее. В дотнете пошли немного дальше и результат этого скользящего среднего переиспользуют в последующих измерениях. Это помогает скорректировать метрику на дистанции, получая более сглаженные измерения (что очень похоже на cumulative average, но немного другое).

Метод скользящего среднего

Метод скользящего среднего

Но, к сожалению, этого недостаточно, так как мы все еще снимаем измерения из очень шумного окружения в очень малые промежутки времени (хоть усреднение аккумулированных значений и уменьшает эту проблему).

Здесь авторы алгоритма применили интересный переход. Если посмотреть на нашу метрику Throughput, то заметим, что на самом деле это дискретная функции в пространстве времени. Если бы мы могли разложить эту дискретную функцию на составляющие, то могли бы выделить в этих составляющих влияние функции количества потоков, таким образом точнее корректировать работу алгоритма.

И у нас есть такая возможность — мы можем применить дискретное преобразование Фурье. Теперь нам остается только найти синусоиду, соответствующую влиянию функции количества потоков. Зная амплитуду и фазу функции количества потоков, мы можем это сделать. А мы как раз их знаем, так как мы сами точно их задаем — это контролируемая метрика без шума.

Таким образом мы можем находить влияние количества потоков на пропускную способность, главным следствием чего является более консистентное выделение числа потоков очень близкое к тому, чтобы эффективно выдавать максимальный throughput.

Пример разложения Фурье – выделение частот, составляющих исходную функцию

Пример разложения Фурье — выделение частот, составляющих исходную функцию

Алгоритм получился не без минусов. Например, ему нужно больше времени на изменение количества потоков, так как окно скользящего среднего должно напитаться новыми значениями, что складывается с некоторой медлительностью самого ThreadPool«a в достижении поставленного алгоритмом количества потоков. Но он выполняет свою главную задачу: более корректно подстраивается под большое количество коротких задач.

Что касается запуска длинных задач — адаптация происходит дольше и количество потоков будет отличаться от наиболее оптимального, так как даже аккумулированный throughput будет достаточно маленьким, независимо от количества потоков (алгоритм хорошо справляется даже с задачами по 250ms, но наилучший результат показывает при задачах длительностью <10ms)

Старый алгоритм дотнета против нового – из исследовательской статьи про создание нового алгоритма

Старый алгоритм дотнета против нового — из исследовательской статьи про создание нового алгоритма

Какие-то выводы

В конце обычно хочется увидеть какие-то простые ответы на сложные вопросы. Данная статья является скорее обзорной, поэтому дать какие-то конкретные рекомендации в ней сложно, но совсем с пустыми руками я вас не отпущу. Вы неоднократно слышали, что ThreadPool не предназначен для запуска на нём долгих задач. Причин этому множество, но теперь вы точно знаете одну из них.

Если вам всё-таки нужно запустить долгую задачу, для этого существует TaskCreationOptions.LongRunning (который на самом деле просто большой сахар над самостоятельным запуском треда вне ThreadPool)

Спойлер

new Thread(s => ((Task)s).ExecuteEntryUnsafe(threadPoolThread: null) ){ IsBackground = true }.UnsafeStart(task);

У нас остаётся ещё множество неотвеченных вопросов, для которых в этой статье уже не хватает места, например, как же ThreadPool будет вести себя на при разной нагрузке — разное количество задач, разное время исполнения, разный contention.

(Возможно, это станет темой другой статьи. Всё как мы любим: графики, таблицы, пояснения. ;))

Весь этот экскурс был призван показать обширность и сложность темы ThreadPool«a, а также вдохновить читателя на самостоятельное изучение. Больше экспериментируйте, не бойтесь заглядывать в исходники, иногда там можно увидеть очень интересные и неожиданные решения. Чем больше вы будете узнавать свой инструмент, тем увереннее и ответственнее будете им пользоваться.

P.S.: Для тех, кто вдохновился посмотреть алгоритм — велкам. В своем описании я опустил множество хаков, коррекций ошибок и особенностей в реализации алгоритмов.

P.P. S.: Не пытайтесь смаппить статью из Википедии под названием Hill climbing на этот алгоритм — это два разных Hill climbing«а. Возможно, Microsoft назвали свой алгоритм также из-за некоторой схожести в «итеративном подходе с инкрементальным изменениями»

© Habrahabr.ru