В этом посте я собираюсь показать вам, как потенциально утроить производительность вашего приложения 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". Если интересно узнать о курсе больше, регистрируйтесь на день открытых дверей.