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 назвали свой алгоритм также из-за некоторой схожести в «итеративном подходе с инкрементальным изменениями»

Комментарии (10)


  1. Vanirn
    31.07.2024 15:21

    Если вам всё-таки нужно запустить долгую задачу

    Под "долгой" задачей подразумевается задача выполняющаяся более 20 мс? Нужно ли учитывать время ожидания в асинхронных операциях, или же одна действительно асинхронная операция для ThreadPool представляет две операции до и после асинхронного ожидания?


    1. hikirangi Автор
      31.07.2024 15:21
      +3

      Спасибо за вопросы!

      Под "долгой" задачей подразумевается задача выполняющаяся более 20 мс?

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

      Нужно ли учитывать время ожидания в асинхронных операциях, или же одна действительно асинхронная операция для ThreadPool представляет две операции до и после асинхронного ожидания?

      Строго говоря, зависит.
      Если ваш поток можно отпустить между операциями await (например, у вас IO задача), то это будут "две" операции.
      Если поток отпустить нельзя, то это будет одна большая операция.

      Hidden text

      Можно проверить на небольшом примерчике:

      static Task Spin()
          {
              for (var i = 0; i < 1000; i++)
              {
                  
              }
      
              return Task.CompletedTask;
          }
          
          static async Task SomeLongOperationCantChange(int opId)
          {
              Console.WriteLine($"{opId}-{Environment.CurrentManagedThreadId}");
              await Spin();
              Console.WriteLine($"{opId}-{Environment.CurrentManagedThreadId}");
              await Spin();
              Console.WriteLine($"{opId}-{Environment.CurrentManagedThreadId}");
          }
      
          public static void Main()
          {
              SomeLongOperationCantChange(1);
              SomeLongOperationCantChange(2);
          }

      Здесь поток отпустить нельзя, поэтому для всех будет выведено последовательно 3 раза 1-1, потом 3 раза 2-2, несмотря на await'ы (похожее поведение будет и при Task.Run)

      Для проверки обратной ситуации можно использовать Task.Delay (он не совсем IO, но работает достаточно хитро для того, чтобы уметь отпустить поток)

      static async Task SomeLongOperation(int opId)
          {
              Console.WriteLine($"{opId}-{Environment.CurrentManagedThreadId}");
              await Task.Delay(10);
              Console.WriteLine($"{opId}-{Environment.CurrentManagedThreadId}");
              await Task.Delay(10);
              Console.WriteLine($"{opId}-{Environment.CurrentManagedThreadId}");
          }
          
          public static void Main()
          {
              Task.Run(()=>SomeLongOperation(1));
              Task.Run(()=>SomeLongOperation(2));
              Thread.Sleep(100000);
          }

      Скорее всего вы увидите, как операции начинают жонглировать тредами.

      Благодаря этому поведению в том же aspnet'e тредпул не начинает сходить с ума при долгих обращениях в базу, например


      1. vvdev
        31.07.2024 15:21

        чем задача дольше, тем хуже это может сказаться на перформансе

        А можно немного раскрыть мысль?


        1. hikirangi Автор
          31.07.2024 15:21

          Конечно, даже нужно, но, чтобы быть более объективным, я это сделаю в отдельной статье - можете подписаться, чтобы не пропустить ;)


          1. vvdev
            31.07.2024 15:21

            О. не туда ответил, оказывается. См. ниже.

            https://habr.com/ru/companies/skbkontur/articles/832742/comments/#comment_27113534


        1. ryanl
          31.07.2024 15:21

          Видимо, имелась в виду способность ThreadPool-а планировать свою загрузку, работа этого самого HillClimbing-а. У Дэвида Фаулера есть гайдлайн по async/await, где упоминается, что запускать таску с опцией TaskCreationOptions.LongRunning как бы бессмысленно, посколько при первом неблокирующем ожидании IO-операции этот самый поток, на создание которого было потрачено относительно много усилий, будет отпущен.

          В итоге в любом случае приходим к тому, что любой современный асинхронный код - это замысловатое переплетение коротких (и длинных) CPU-bound с IO-bound операциями. Что как бы отлично ложится на работу HillClimbing-а.


          1. vvdev
            31.07.2024 15:21

            Просто утверждение было слишком общее и (как минимум поэтому) слишком некорректное.

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

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

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

            Но, повторюсь: с точки зрения чистой производительности главное правило такое: всё, что можно собирать в пакет и запускать как единый таск - должно собираться в пакет, а уж потом нужно смотреть, что из важных характеристик пострадало и корректировать.


            1. hikirangi Автор
              31.07.2024 15:21

              Спасибо за более раскрытый комментарий!

              Мне кажется, что мы говорим об одном и том же, но немного по-разному

              Статья была обзорной, поэтому в ней (и после нее) я не мог не использовать такие общие утверждения. Получился generic ответ для generic ситуаций, поэтому получилось не очень объективно, о чем я выше и сказал

              Я говорил именно про долгие CPU-bound таски, которые тредпул плохо переваривает. Если там переплетение из IO и CPU тасок, то это нормально и хорошо укладывается в сценарии тредпула, поэтому такие "долгие" таски запускать на тредпуле нужно

              Это generic правило для большей части ситуаций, с которыми можно столкнуться при разработке современного приложения на дотнете (и именно таким инструментом и является тредпул дотнета, generic инструмент для решения большей части ситуаций)

              Но, конечно, не все ситуации одинаковые и где-то нужно конкретно смотреть, так что "запустить все долгие cpu-bound таски" тоже вполне себе может быть решением в конкретной ситуации


              1. vvdev
                31.07.2024 15:21

                Я говорил именно про долгие CPU-bound таски, которые тредпул плохо переваривает.

                Так а что имеется ввиду под "плохо переваривает долгие CPU-bound задачи"?

                Вообще, долгая CPU-bound - это идеальная задача с точки зрения производительности, часто недостижимая.


      1. Vanirn
        31.07.2024 15:21

        Спасибо) Всё-таки под асинхронным ожиданием я имел в виду общение со сторонними ресурсами с использованием "асинхронного драйвера", то есть второй пример.