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

Дочерние процессы, кластеризация и Worker Threads

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

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

Кластеризация, которая стала стабильной примерно с версии 4, позволяет нам упростить создание и управление дочерними процессами. Она великолепно работает в сочетании с PM2.

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

1. Многопоточность уже существует для задач ввода/вывода

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

2. Child Processes (Дочерние процессы)/Worker Threads работают только для синхронной логики JavaScript.

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

3. Создать один поток легко. Динамично управлять несколькими потоками сложно

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

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

Worker Pool

Модуль, с которым мы будем работать сегодня, называется Worker Pool. Созданный Джосом де Йонгом, Worker Pool предлагает простой способ создания пула воркеров для динамической разгрузки вычислений, а также для управления пулом выделенных воркеров. По сути, это менеджер пула потоков для Node JS, поддерживающий Worker Threads, дочерние процессы и Web Workers для браузерных имплементаций.

Чтобы использовать модуль Worker Pool в нашем приложении, необходимо выполнить следующие задачи:

  • Установить Worker Pool

Сначала нам нужно установить модуль Worker Pool - npm install workerpool

  • Инициализировать Worker Pool

Далее нам нужно будет инициализировать Worker Pool при запуске нашего приложения.

  • Создать Middleware Layer

Затем нам нужно будет создать Middleware Layer (промежуточный слой) между нашей сложной JavaScript-логикой и Worker Pool, который будет управлять ею.

  • Обновить существующую логику

Наконец, нам нужно обновить наше приложение, чтобы при необходимости передавать трудоемкие задачи Worker Pool.

Управление несколькими потоками с помощью Worker Pool

На данном этапе у вас есть 2 варианта: Использовать свое собственное приложение NodeJS (и установить модули workerpool и bcryptjs), или загрузить исходный код с GitHub по этому руководству и моей серии видео об оптимизации производительности NodeJS.

Если вы выберете последний вариант, файлы по данному руководству будут находиться в папке 06-multithreading. После загрузки войдите в корневую папку проекта и запустите npm install. Затем войдите в папку 06-multithreading, чтобы продолжить работу.

В папке worker-pool у нас есть 2 файла: один - это логика контроллера для Worker Pool (controller.js). Другой (файл) содержит функции, которые будут запускаться потоками... он же так называемый промежуточный слой, о котором я говорил ранее (thread-functions.js).

worker-pool/controller.js

'use strict'

const WorkerPool = require('workerpool')
const Path = require('path')

let poolProxy = null

// FUNCTIONS
const init = async (options) => {
  const pool = WorkerPool.pool(Path.join(__dirname, './thread-functions.js'), options)
  poolProxy = await pool.proxy()
  console.log(`Worker Threads Enabled - Min Workers: ${pool.minWorkers} - Max Workers: ${pool.maxWorkers} - Worker Type: ${pool.workerType}`)
}

const get = () => {
  return poolProxy
}

// EXPORTS
exports.init = init
exports.get = get

В файле controller.js мы используем модуль workerpool. У нас также есть 2 экспортируемые функции, которые называются init и get. Функция init будет выполняться один раз во время загрузки нашего приложения. Она инстанцирует Worker Pool с опциями, которые мы предоставим, и ссылкой на thread-functions.js. Она также создает прокси, который будет храниться в памяти до тех пор, пока работает наше приложение. Функция get просто возвращает прокси в памяти.

worker-pool/thread-functions.js

'use strict'

const WorkerPool = require('workerpool')
const Utilities = require('../2-utilities')

// MIDDLEWARE FUNCTIONS
const bcryptHash = (password) => {
  return Utilities.bcryptHash(password)
}

// CREATE WORKERS
WorkerPool.worker({
  bcryptHash
})

В файле thread-functions.js создадим воркер-функции, которые будут управляться Worker Pool. В нашем примере применим BcryptJS для хэширования паролей. Это обычно занимает около 10 миллисекунд, в зависимости от скорости работы используемой машины, и является хорошим решением для трудоемких задач. Внутри файла utilities.js находится функция и логика, которая хэширует пароль. Все, что мы делаем в функциях потока, заключается в выполнении этого bcryptHash через функцию workerpool. Таким образом, мы сохраняем код централизованным и избегаем дублирования или путаницы в том, где существуют определенные операции.

2-utilities.js

'use strict'

const BCrypt = require('bcryptjs')

const bcryptHash = async (password) => {
  return await BCrypt.hash(password, 8)
}

exports.bcryptHash = bcryptHash

.env

NODE_ENV="production"
PORT=6000
WORKER_POOL_ENABLED="1"

Файл .env содержит номер порта и устанавливает переменную NODE_ENV на "production". Здесь же мы указываем, хотим ли мы включить или отключить Worker Pool, устанавливая WORKER_POOL_ENABLED в "1" или "0".

1-app.js

'use strict'

require('dotenv').config()

const Express = require('express')
const App = Express()
const HTTP = require('http')
const Utilities = require('./2-utilities')
const WorkerCon = require('./worker-pool/controller')

// Router Setup
App.get('/bcrypt', async (req, res) => {
  const password = 'This is a long password'
  let result = null
  let workerPool = null

  if (process.env.WORKER_POOL_ENABLED === '1') {
    workerPool = WorkerCon.get()
    result = await workerPool.bcryptHash(password)
  } else {
    result = await Utilities.bcryptHash(password)
  }

  res.send(result)
})

// Server Setup
const port = process.env.PORT
const server = HTTP.createServer(App)

;(async () => {
  // Init Worker Pool
  if (process.env.WORKER_POOL_ENABLED === '1') {
    const options = { minWorkers: 'max' }
    await WorkerCon.init(options)
  }

  // Start Server
  server.listen(port, () => {
    console.log('NodeJS Performance Optimizations listening on: ', port)
  })
})()

И последнее, файл 1-app.js содержит код, который будет выполняться при запуске нашего приложения. Сначала инициализируем переменные в файле .env. Затем настроим сервер Express и создадим маршрут под названием /bcrypt. При запуске этого маршрута проверим, включен ли Worker Pool. Если да, то получим управление прокси Worker Pool и выполним функцию bcryptHash, которую мы объявили в файле thread-functions.js. Она, в свою очередь, выполнит функцию bcryptHash в Utilities и вернет нам результат. Если Worker Pool отключен, тогда просто выполним функцию bcryptHash непосредственно в Utilities.

В конце нашего файла 1-app.js находится функция, вызывающая саму себя. Это сделано для поддержки async/await, которую мы используем при взаимодействии с Worker Pool. Далее инициализируем Worker Pool, если он включен. Единственная конфигурация, которую надо переопределить - установка minWorkers на "max". Это гарантирует, что Worker Pool породит столько потоков, сколько логических ядер есть на нашей машине, за исключением 1 логического ядра, которое используется для главного потока. В моем случае имеется 6 физических ядер с гиперпоточностью, это означает, что у меня в распоряжении 12 логических ядер. Поэтому при значении minWorkers равном "max", Worker Pool будет создавать и управлять 11 потоками. Наконец, последний фрагмент кода - это запуск нашего сервера и прослушивание порта 6000.

Тестирование Worker Pool

Тестировать Worker Pool очень просто: запустите приложение и во время его работы выполните запрос get на http://localhost:6000/bcrypt. Если у вас есть инструментарий нагрузочного тестирования, такой как AutoCannon, вы можете посмотреть разницу в производительности при включении/выключении Worker Pool. AutoCannon очень прост в использовании.

Заключение

Я надеюсь, что это руководство дало вам представление об управлении несколькими потоками в вашем приложении Node. Вложенное видео в начале статьи наглядно демонстрирует процесс тестирования приложения Node.


Перевод подготовлен в рамках курса "Node.js Developer". Если интересно узнать о курсе больше, регистрируйтесь на день открытых дверей.