Многопоточность в Node.js - Начало
На дворе 2023 год, а многие все еще считают, что в Node.js существует один единственный поток. Чтобы развеять этот миф вспомним, что Node использует библиотеку для асинхронных операций ввода/вывода под названием libuv. Каждый раз, когда вызывается API для асинхроного действия, создается параллельный поток, где сами эти действия и происходят (чтение файла, запрос по сети). К этим действиям привязываются callback-функции, которые должны отработать в конце, в главном потоке, где выполняется код на Javascript. Так еще раз: асинхронные операции выполняются параллельно, а callback в главном потоке, по завершению асинхронной операции.
libuv любезно дарит нам 4 дополнительных потока, а еще есть движок V8, который дарит нам еще два. Они нужны, например, для автоматической сборки мусора. В целом у нас получается 7 потоков. Теперь считаем по пальцам: 1 главный поток Javascript, 4 потока для асинхронщины и 2 достаются нам от движка.
Проверим, является ли бредом, то что я говорю. Для этого напишем программу, которая выполняет бесконечный цикл, чтобы создать долгий процесс.
Запускаем наш процесс в фоновом режиме
Далее прописываем команду (нужен Linux!), чтобы посмотреть процесс:
top -H -p 431И видим, что нашему процессу выделено 7 потоков.
Зачем больше потоков ?
Некоторые возразят, зачем мне вообще нужны дополнительные потоки. Благодаря асинхронности вроде можно избежать блока главного потока. Но ахилесова пята асинхронности в Javascript - это CPU-bound задачи. Это задачи, напрямую связанные с вычислительной сложностью (или простыми словами, задачи которые нагружают наш процессор). К ним могут относиться криптографические операции, сжатие каких-нибудь файлов и т.д. Пока эти задачи выполняются, наш главный поток будет заблокирован и это уже серьезная проблема. Просто воспроизведем кейс, как это может нам помешать.
Создаем простое серверное приложение на Node.js (без фреймворка), с двумя роутами message и sum-squared.
- message: просто возвращает сообщение, будем проверять с ним наш главный поток
- sum-squared: берет параметр в виде числа number и возвращает значение суммы квадратов с единицы до number
Пропустим всякий бойлерплейт, связанный с настройкой приложения и сразу перейдем к сути. Здесь у нас метод, который вычиляет сумму, но в основном потоке.
// cpu-intensive-task/controllers/sum-squared.js
const { SumSquared } = require('../procedure/sum-squared');
const sumSquaredController = (req, res) => {
const method = req.method;
switch(method) {
case "GET":
/// Вычисление без дополнительных потоков
SumSquared.getComputationWithThreads(req, res);
break;
default:
res.writeHead(405, {
'Content-Type': 'application/json'
});
res.write(JSON.stringify({detail: 'Method not allowed'}));
res.end();
}
}
module.exports = { sumSquaredController };Посмотрим на саму реализацию вычисления
// cpu-intensive-task/procedure/sum-squared.js
class SumSquared {
static #getParameterNumber(req) {
const params = req.url.split('/');
return +params[params.length-1];
}
static getComputationWithoutThreads(req, res) {
const number = this.#getParameterNumber(req);
const sum = {value: 0};
for (let i=0; i <= number; i++) {
sum.value+= i**2;
}
res.writeHead(200, { 'Content-Type': 'application/json'});
res.write(JSON.stringify({result: sum.value}));
res.end();
}
}
module.exports = { SumSquared };Теперь запустим наш нодовский сервер и сделаем запрос с очень большим числом в качестве параметра (20 миллиардов)
В этот же момент открываем вторую вкладку и делаем запрос на message
Видим, что не только sum-sqared, но и message подвис. Это потому что мы заняли наш единственный поток сложной вычислительной задачей и теперь никакие промисы нам не помогут убрать блок.
Есть ли выход?
Знакомьтесь, worker_threads.
Разработчики Node.js были обеспокоены однопоточной моделью выполнения кода. Начиная с 10 версии они выкатили модуль worker_threads, который реализует настоящее параллельное программирование на Javascript. Это как раз и было призвано решать проблемы, которые возникают из-за CPU-bound задач. С одной такой проблемой мы уже столкнулись - заблокированный главный поток и отсюда неотзывчивое приложение. Ладно, тут мы могли справиться просто создав параллельный поток и отдав все вычисления ему, тем самым освободив главный поток приложения. Это бы помогло быстро получать ответ со всех запросов, кроме связанных с CPU-bound задачами. Эти роуты все также будут медленными.
Множество потоков помогают быстро завершать тяжелые вычислительные задачи. Главный поток (назовем его master) будет делегировать между остальными потоками (которые уже будут slaves), дробя одну большую задачу на количество потоков. Посмотрим на примере.
Подключаем метод с потоками.
// cpu-intensive-task/controllers/sum-squared.js
const sumSquaredController = (req, res) => {
const method = req.method;
switch(method) {
case "GET":
// Заменяем на метод, использующий потоки
SumSquared.getComputationWithThreads(req, res);
break;
default:
res.writeHead(405, {
'Content-Type': 'application/json'
});
res.write(JSON.stringify({detail: 'Method not allowed'}));
res.end();
}
}Смотрим на саму реализацию. Тут главный поток создает четыре дополнительных потока и делегирует задачу между ними. Он это делает задав им начальное и конечное число для возведения в квадрат и суммирования. После параллельных вычислений, в конце суммируем результат со всех потоков и возвращаем пользователю. Тут есть некоторые важные детали, наши воркеры оборачиваются в Promise и отправляются в массив. Далее они запускаются через Promise.all, чтобы дождаться ответа со всех потоков. С точки зрения главного потока эти процессы выглядят асинхронно, но на самом деле мы реализуем параллельность.
// cpu-intensive-task/procedure/sum-squared.js
const { createWorker } = require('../utils/createWorker');
const thread_amount = 4;
class SumSquared {
static #getParameterNumber(req) {
const params = req.url.split('/');
return +params[params.length-1];
}
static async getComputationWithThreads(req, res) {
const number = this.#getParameterNumber(req);
const asyncWorkers = [];
for (let i=1; i <= thread_amount; i++) {
asyncWorkers.push(createWorker({
start_index: (number/thread_amount)*(i-1)+1,
end_index: (number/thread_amount)*i,
}));
}
const thread_results = await this.#getWorkerResults({asyncWorkers, res});
const sum = {
value: thread_results[0] + thread_results[1] + thread_results[2] + thread_results[3]
}
res.writeHead(200, { 'Content-Type': 'application/json'});
res.write(JSON.stringify({result: sum.value}));
res.end();
}
static async #getWorkerResults({asyncWorkers, res}) {
try {
return await Promise.all(asyncWorkers);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json'});
res.write(JSON.stringify({message: 'Error during computations in thread'}));
res.end();
}
}
}
module.exports = { SumSquared };Реализация создания потоков(через объявление Worker) скрыто в функции createWorkers:
// cpu-intensive-task/utils/createWorker.js
const threads = require('worker_threads');
const { Worker } = threads;
const createWorker = ({start_index, end_index}) => {
return new Promise((res, rej) => {
const worker = new Worker('./threads/worker.js', {
workerData: { start_index, end_index }
})
worker.on('message', (data) => {
res(data.sum.value);
});
worker.on('error', (msg) => {
rej(`Error message is ${msg}`);
});
})
}
module.exports = { createWorker };Ну и код, который выполняется в наших созданных потоках.
// cpu-intensive-task/threads/worker.js
const threads = require('worker_threads');
const start_index = threads.workerData.start_index;
const end_index = threads.workerData.end_index;
const sum = {value: 0};
for (let i = start_index; i<=end_index; i++) {
sum.value+= i**2;
}
threads.parentPort.postMessage({sum});Теперь пришло время проверить нашу теорию и измерить, реально ли 4 потока помогут быстрее завершить долгую вычислительную задачу. Пока видим, что наш поток не заблокирован и наше приложение спокойно может отвечать на остальные запросы.
Результаты
Приложение выдало ответ через 20 секунд, в то время как без четырех потоков этот запрос обрабатывался 1 минуту 15 секунд. Оптимизировано более, чем на 70%!
А тут мы видим, что в целом наше приложение использует на этих запросах до 11 потоков нашей операционной системы. Опять считаем по пальцам: 1 главный поток Javascript (master), 4 наших созданных потока (slaves), 4 потока от libuv и 2 потока от V8.
Вообще, лабораторные работы научили меня очень плохо писать выводы. Но тут я хочу от себя добавить, что многопоточность - это круто и это одна из тех вещей, которые делают Javascript универсальным языком программирования. Конечно, интерфейс работы с потоками реализован не так удобно как в Java или C#, но Node и другие рантаймы (Deno, Bun) все еще активно развиваются и мы в любом случае увидим много изменений в стандартах.