Разделение задач, рабочие потоки и кластеризация API Express

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

В этой статье я постараюсь дать вам общее представление о том, что означает блокировка цикла событий Node.js, и мы познакомимся с тремя наиболее часто используемыми стратегиями для ее преодоления.

Постановка проблемы: пример блокировки цикла событий

Начнем с простого примера блокирующего приложения Express API. Взгляните на следующий код:

Этот простой API состоит из единственной конечной точки, которая вычисляет сумму квадратов первых N-1 целых чисел:

  • GET /calculate/2 даст нам { “result": 1 }
  • GET /calculate/5 даст нам { “result": 30 }
  • GET /calculate/10 даст нам { "result": 285 }

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

Теперь мы запускаем node index.js и node client.mjs. Вот результат:

node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 56 ms
finished request #2, result for n=5 is 30, computed in 36 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 37 ms
finished request #4, result for n=10 is 285, computed in 37 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 1357 ms
finished request #6, result for n=10000 is 333283335000, computed in 1362 ms

Обратите внимание на запрос №6, для n=10K. Это не должно было занять больше времени, чем потребовалось для n=1M (запрос № 3), так что же произошло?

Блокировка цикла событий

Когда в наш Express API поступает новый запрос, в цикле событий генерируется событие, и новый экземпляр обратного вызова помещается в стек обратного вызова. Этот экземпляр обратного вызова будет выполнять вычисление суммы квадратов. Поскольку одновременно может выполняться только один обратный вызов (по умолчанию Node.js имеет один поток выполнения), остальные ставятся в очередь и будут ждать завершения текущего.

Наша конечная точка выполняет задачу с высокой нагрузкой на ЦП (что не рекомендуется для обратных вызовов Node.js). Следовательно, экземпляр обратного вызова может занять много времени, как это происходит с запросом № 5. Пока выполняется запрос № 5, запрос № 6 ожидает следующей итерации цикла событий, которая никогда не происходит, пока экземпляр обратного вызова запроса № 5 не завершится.

На следующей диаграмме показано среднее время выполнения каждого запроса при использовании этого подхода после пятикратного запуска клиентского скрипта:

В реальном сценарии запрос № 6 может представлять собой критически важный API-клиент, который не должен долго ждать ответа. Итак, мы хотим минимизировать время выполнения каждого запроса, сделав их независимыми. Есть несколько стратегий, которым нужно следовать, чтобы достичь этого. Я остановлюсь на трех наиболее часто используемых.

1. Разделение задач

Когда N большое число, обратный вызов обработки запроса должен выполнить много работы, чтобы вычислить окончательный результат. Основной поток выполнения Node.js не может быть посвящен какой-либо другой задаче, когда этот расчет полностью занимает его. Однако подход с разделением задач позволяет нам разделить большую часть работы, интенсивно использующей ЦП, на набор рабочих блоков.

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

Ниже приведен фрагмент этой версии API. Мы рассмотрим подробнее, чтобы лучше понять, как это работает:

Функция calculateResultPartitioned возвращает Promise с окончательным результатом вычисления для заданного N. Он определяет функции computePartial и fn.

  • computePartial частично вычисляет результат, вычисляя 100 000 чисел в диапазоне [0, N).
  • fn — это просто функция планирования. Сначала он вызывает функцию computePartial, а затем планирует следующее выполнение (для вычисления следующего фрагмента) с помощью ловушки setImmediate. Как только все фрагменты вычислены (мы знаем это, глядя на значение переменной previous), он разрешает основной Promise.

Как вы могли догадаться, несмотря на то, что этот подход очень популярен, он наименее предпочтителен из-за сложности, которую он привносит в код. Однако выполнение этой версии API с использованием того же клиента возвращает следующий результат:

node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 59 ms
finished request #2, result for n=5 is 30, computed in 39 ms
finished request #4, result for n=10 is 285, computed in 40 ms
finished request #6, result for n=10000 is 333283335000, computed in 46 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 80 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 16437 ms

С одной стороны, запрос №6 выполняется и завершается намного раньше и быстрее, чем запрос №5, что желательно. С другой стороны, запрос № 5 теперь выполняется гораздо дольше, чем исходная версия. Обратите внимание, как это влияет на среднее время выполнения, как показано на следующей диаграмме:

Запрос № 5 выполняется так долго из-за количества рабочих блоков размером 100 КБ, содержащихся в N=1000000000 итерациях. Необходимость планировать, помещать в очередь и выталкивать обратный вызов на каждой итерации цикла добавляет дополнительные накладные расходы основному потоку, заставляя его работать намного медленнее. Обратите внимание, что вы могли бы получить более гладкие результаты, попробовав фрагмент работы другого размера (здесь я пробую 100 КБ).

2. Рабочие потоки

Node.js Worker Threads — это современная функция, позволяющая легко выполнять задачи параллельно. Мы можем использовать его, запуская новый поток каждый раз, когда делается запрос. Этот поток будет посвящен исключительно вычислению конечного результата для определенного значения N. Аналогичным образом можно использовать дочерние процессы Node.js. Здесь я предпочитаю рабочие потоки, поскольку они являются более простым и легким решением.

Чтобы использовать рабочие потоки, нам сначала нужно определить функцию solveOperationAsync, отвечающую за запуск каждого экземпляра потока worker. Вот как это выглядит:

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

  • при выполнении родительским процессом он создает новый экземпляр рабочего потока, отправляет значение N и разрешает Promise с окончательным результатом
  • при выполнении рабочим потоком он просто получает значение N и вычисляет результат, отправляя его обратно родителю перед завершением

Теперь наш Express API будет выглядеть следующим образом:

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

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

Этот подход работает намного быстрее, чем предыдущий. Взгляните на результаты, которые я получил:

node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 139 ms
finished request #2, result for n=5 is 30, computed in 119 ms
finished request #4, result for n=10 is 285, computed in 118 ms
finished request #6, result for n=10000 is 333283335000, computed in 119 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 124 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 1601 ms

Обратите внимание, что каждый запрос завершается в соответствии со своим значением N (чем меньше значение, тем раньше он завершается), но по какой-то причине теперь вычисление занимает больше времени, когда N равно 4, 5 или 10. Это может быть связано с дополнительной работой, необходимой для настройки каждого рабочего потока перед его выполнением.

3. Кластеризация Express API

Функция Кластер Node.js позволяет вам иметь несколько параллельных экземпляров одного и того же процесса Node.js, нагрузка на которые будет распределяться в соответствии с потребностями приложения. В отличие от рабочих потоков кластерные процессы изолированы друг от друга, поэтому у них есть собственные стеки вызовов, пространство в памяти и потоки. Это делает их более надежным и тяжелым решением, а также более быстрым.

Давайте добавим кластеризацию Node.js в наш API следующим образом:

Этот код очень похож на подход с рабочими потоками, поскольку у нас также есть два пути выполнения в зависимости от процесса, который его выполняет. Обратите внимание на строку cluster.schedulingPolicy = cluster.SCHED_RR , это было необходимо в моей Windows, так как сначала использовалась другая стратегия, что делало ее медленной. Это говорит движку Node.js использовать стратегию циклического перебора при балансировке нагрузки.

Эта версия API работает намного быстрее, чем другие. Взгляните на результаты:

node client.mjs
sending request #1 with n=4
sending request #2 with n=5
sending request #3 with n=1000000
sending request #4 with n=10
sending request #5 with n=1000000000
sending request #6 with n=10000
finished request #1, result for n=4 is 14, computed in 45 ms
finished request #2, result for n=5 is 30, computed in 27 ms
finished request #4, result for n=10 is 285, computed in 26 ms
finished request #6, result for n=10000 is 333283335000, computed in 27 ms
finished request #3, result for n=1000000 is 333332833333127550, computed in 33 ms
finished request #5, result for n=1000000000 is 3.333333328333552e+26, computed in 1490 ms

Как вы могли заметить, этот подход — именно то, что мы ищем:

  • запросы с низкими значениями N завершаются почти сразу, менее чем за 50 мс
  • запросы с большими значениями N, выполнение которых может занять больше времени, не блокировать последующие запросы, которые обрабатываются разными процессами

Заключение

При работе с Node.js максимально избегайте блокировки цикла событий в качестве первого рабочего подхода. Либо избегайте задач с высокой загрузкой ЦП, либо выполняйте их полностью асинхронно, используя рабочую среду. Это устранит необходимость принятия любого из этих шаблонов.

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

Подход с разделением задач кажется наиболее запутанным и бесполезным. Я рекомендую не использовать его, так как это затрудняет понимание. Используйте его, если вам не нужно/не хочется спамить больше дополнительных процессов, чем основной, а API вряд ли будет получать несколько запросов одновременно. Кроме того, примите во внимание проблемы со временем ответа, так как некоторые запросы могут выполняться намного дольше.

Надеемся, что эта статья прояснила некоторые из наиболее часто используемых стратегий работы с блокировкой цикла событий Node.js на конкретных практических примерах.

Спасибо за чтение.