Node.js: Heroes of Worker Threads ― C++ Addon

Kate

Administrator
Команда форума

Эпиграф​

Ни один дракон не может устоять перед загадками и потерянным временем для их решения. ― Дж.Р.Р. Толкин

Проблема​

Сегодня мы попытаемся раскрыть пути решения одной из самых больших проблем JavaScript ― CPU-bound заданий. Сделаем мы это в 2 частях. В части I мы поговорим и попробуем использование исключительно CPU-bound заданий. В части II ― модули, которые используются для frontend, CPU+I/O-bound задания и тому подобное.

Что у нас есть?​

Node.js имеет несколько способов исполнения CPU intensive заданий:

  1. Просто запустить CPU-bound задачу в одном процессе, блокируя event loop. Кто-то может возразить, что это совсем не вариант, но если этот процесс был специально создан для этой задачи, то почему бы и нет. Правда не у всех есть пара дополнительных ядер.
  2. Создать отдельные процессы (Child Processes), распределить между ними задания.
  3. Создать cluster и заставить работать отдельные процессы в нем.
  4. Использовать Worker Threads и создать несколько дополнительных потоков исполнения.
  5. Попросить C++ разработчика написать C++ Addon, который загадочным образом выполняет CPU-bound задания. В конце концов, думаю все слышали старинные легенды про компилируемые языки программирования и о том, что “нативная” реализация ― это всегда успех (на этой фразе где-то в мире должен заплакать React Native разработчик, смотря на перформанс своего приложения).
В этой статье мы не будем обсуждать реализацию каждого их этих методов, поскольку они все уже довольно детально описаны в других статьях и документации.

Инструменты​

Как пример CPU-bound задач мы возьмем разные хеш-функции. Инструментами станут “нативная” реализация конкретного модуля и версия, написанная на чистом JavaScript. Запускаться код буде на машине со следующими характеристиками: 8-ядерный процессор Intel® Core™ i7–7700HQ @ 2,80 ГГц

Будет ли что-то веселое?​

Последним возникает вопрос ― каким образом донести основную идею, процесс и результаты исследования…

И для этого была выбрана самая популярная, крутая и продвинутая игра… 1999-го года ― Heroes of Might and Magic III

А теперь, давайте окунемся в легенду​

Наш герой ― Node.js и как у любого героя, у него есть путь. Наш герой крепок и могуч. Уже победил множество врагов, потому решил, что настало время разобраться с самым страшным врагом ― CPU-bound задачами.

Команда Node.js​

Наш герой должен иметь команду, так кого он возьмет с собой?

Cluster ― это 7 черных драконов, 7 Child Processes ― 7 красных драконов та 1 красный дракон, которого называют JS, потому что он всегда извергает на врагов только одну струю огня.

7 Worker Threads ― 7 молодых зеленых дракона. Неопытных, но готовых к борьбе.

1 C++ Addon ― 1 архангел. Опытный воин, который не раскрывает всех секретов своей силы, но прекрасно показавший себя в предыдущих сражениях.

6e4ca02bb3d28f09e8c8a45ffdf1a3ce.png

Часть І​

Первая битва

И первое зло на нашем пути это 1,400,000 строк (скелетная пехота), и единственный путь, который позволит нам их побороть ― прогнать их через Murmurhash (не криптографическая хеш-функция общего назначения) как можно быстрее.

Модуль murmurhash3js будет использован, как чистая js имплементация и murmurhash-native как “нативная”.

4abceaad484a02b13ec7d261f6361232.png

Имплементация (JS 1 процесс) ― ничего особенного, просто запускаем хеш-функцию в цикле и фиксируем разницу во времени до и после:

const murmurHash3 = require('murmurhash3js');
...
const hash128x64JS = () => {
...
const beforeAll = microseconds.now();
for (let i = 0; i < 1400000; i++) {
const hash = murmurHash3.x64.hash128(file);
}
const afterAll = beforeAll + microseconds.since(beforeAll);
...
};
Имплементация (Child Processes) ― порождаем (spawn) новый процесс и ждем окончания всех подсчетов (событие “close”):

...
let beforeJS;
let afterJS;
...
const hash128x64JSChildProcess = (i) => new Promise((resolve, reject) => {
const hash128x64JSChildProcess = spawn('node',
['./runners/murmurhash/runChildProcess/hash128x64JSChildProcess.js', i]);
...
hash128x64JSChildProcess.stdout.on('close', () => {
...
if (...) afterJS = beforeJS + microseconds.since(beforeJS);
resolve();
});
});
beforeJS = microseconds.now();
await Promise.all([
hash128x64JSChildProcess(0),
hash128x64JSChildProcess(1),
hash128x64JSChildProcess(2),
hash128x64JSChildProcess(3),
hash128x64JSChildProcess(4),
hash128x64JSChildProcess(5),
hash128x64JSChildProcess(6)
]);
...
Имплементация (Cluster) ― разветвляем (fork) несколько (количество зависит от числа ядер процессора) рабочих процессов и ждем сообщение (messages) от них (про окончание работы) в основном процессе. В нашем случае як “сообщение” было использовано номер процесса:

...
const workers = [];

const masterProcess = () => new Promise((resolve, reject) => {
...
let start = 0;
for (let i = 0; i < 8; i++) {
const worker = cluster.fork();
workers.push(worker);

worker.on('message', (message) => {
...
if (...) resolve(microseconds.since(start));
});
}

start = microseconds.now();
workers[0].send({ processNumber: 0 });
workers[1].send({ processNumber: 1 });
workers[2].send({ processNumber: 2 });
workers[3].send({ processNumber: 3 });
workers[4].send({ processNumber: 4 });
workers[5].send({ processNumber: 5 });
workers[6].send({ processNumber: 6 });
});

const childProcess = () => {
process.on('message', (message) => {
hash128x64JSCluster(message.processNumber);
process.send({ processNumber: message.processNumber });
});
};
...
Имплементация (Worker Threads) ― почти та же, что и у “cluster” ― мы создаем несколько воркеров (workers) и ждем в основном потоке (main thread) сообщение (message) об окончании работы:

...
const threads = [];

const mainThread = () => new Promise((resolve, reject) => {
...
const start = microseconds.now();
threads.push(new Worker(__filename, { workerData: { processNumber: 0 } }));
threads.push(new Worker(__filename, { workerData: { processNumber: 1 } }));
threads.push(new Worker(__filename, { workerData: { processNumber: 2 } }));
threads.push(new Worker(__filename, { workerData: { processNumber: 3 } }));
threads.push(new Worker(__filename, { workerData: { processNumber: 4 } }));
threads.push(new Worker(__filename, { workerData: { processNumber: 5 } }));
threads.push(new Worker(__filename, { workerData: { processNumber: 6 } }));

for (let worker of threads) {
...
worker.on('message', (msg) => {
...
if (...) resolve(microseconds.since(start));
});
}
});
...
Имплементация (C++ Addon) ― просто используем C++ Addon (модуль) в основном потоке (так же просто, как в JS имплементации):

const { murmurHash128x64 } = require('murmurhash-native');
...
const hash128x64C = () => {
...
const beforeAll = microseconds.now();
for (let i = 0; i < 1400000; i++) {
const hash = murmurHash128x64(file);
}
const afterAll = beforeAll + microseconds.since(beforeAll);
...
};
…and “Action!”

ac41d4fd5b610a374737cafd16f8bb00.gif

Результаты первой битвы:

08c76703dcb32af441522f2fd63a5a37.png

Как мы видим, C++ Addon ― это самое быстрое решение в этом случае. Child Processes/Cluster/Worker Threads показали почти одинаковый результат.

Второй раунд

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

Имплементация абсолютно такая же, как и для Murmurhash. Отличаются только модули ― bcryptjs and bcrypt соответственно.

865fae176c1d0ea5a0dca0855c985357.png

Пример использования:

const bcrypt = require('bcrypt');
...
const hashBcryptC = () => {
...
const beforeAll = microseconds.now();
for (let i = 0; i < 140; i++) {
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync(file, salt);
}
const afterAll = beforeAll + microseconds.since(beforeAll);
...
};
626ba954d6b87d435523192fbc04397f.gif

Результаты второго раунда:

a86861eafb0d1a2153b6465ccfc18b96.png

В этом случае (sync) лучший вариант решения ― это разделить задание и выполнить параллельно, потому Child Processes/Cluster/Worker Threads справились лучше.

Финальная битва

Следующее зло ― это 140 строк (более мощные скелетные драконы) и мы точно сможем их победить, но на этот раз необходимо использовать асинхронный вариант Bcrypt. Имплементация аналогична предыдущей битве (даже модули те же).

ae1315eea42711c1948eae1fa8c9039e.png

Пример:

const bcrypt = require('bcrypt');
...
const hashPromise = (i) => async () => {
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(file, salt);
};

const hashBcryptC = async () => {
const tasks = [];
for (let i = 0; i < 140; i++) {
tasks.push(hashPromise(i));
}

const beforeAll = microseconds.now();

await Promise.all(tasks.map((task) => task()));

const afterAll = beforeAll + microseconds.since(beforeAll);

return afterAll - beforeAll;
};
8110dc5b1fd06368813a64ce35d763b4.gif

Результаты финальной битвы:

7d97701abe05742344351eedbb3a2af0.png

Мы не блокировали Event Loop и использовали UV thread pool, потому в этом случае C++ Addon вновь на коне.

…и результаты предыдущих битв:

4b0e6c40526b0446045ea2f821db0286.png

Необходимо отметить, что есть одно секретное оружие для нашего архангела. С его помощью он может сражаться даже еще эффективнее. Это количество потоков в UV thread pool (UV_TREADPOOL_SIZE=size), которые может быть увеличено (имеет значение 4 по умолчанию). В нашем случае bycrypt использует crypto.randomBytes(), потому это помогло уменьшить время исполнения bcrypt async почти в 2 раза (при установке значения 8).

Часть II​

Крепость Argon 2

Вторая часть нашей эпической истории начинается возле замка “Argon 2”. Его назвали в честь функции формирование ключа, которая была выбрана победителем Password Hashing Competition в июле 2015. Функция имеет 3 версии:

Node.js с командой должен взять эту крепость, используя argon2-browser (js) и hash-wasm (нативный) модули.

Пример использования:

const argon2 = require('argon2-browser');
...
const hashPromise = (i) => async () => {
const salt = new Uint8Array(16);
const hash = await argon2.hash({
pass: file,
salt,
time: 256,
mem: 512,
hashLen: 32,
parallelism: 1,
type: argon2.ArgonType.Argon2d,
});
};

const hashArgon2JS = async () => {
const tasks = [];
for (let i = 0; i < 14; i++) {
tasks.push(hashPromise(i));
}

const beforeAll = microseconds.now();
await Promise.all(tasks.map((task) => task()));
const afterAll = beforeAll + microseconds.since(beforeAll);
...
};
0ffbbaaa8cf0734b06e3a8b78630f3dd.gif

Результаты битвы:

a7a8ac1b19b843ea4194a45124d88350.png

C++ Addon снова на вершине решения чистых CPU-bound заданий.

Восстановление замка

Пока все битвы завершены. Мы должны отстроить город и заручится поддержкой местного населения. Для этого мы ознакомимся со всеми местными законами и традициями. К счастью, все находится в xlsx формате в 7 файлах, которые содержат по 5000 строк в каждом (js xlsx и нативный xlsx-util модули будут использованы для “магического чтения”).

Пример использования (чтение и парсинг файла):

const xlsx = require('xlsx');
...
const xlsxJS = async () => {
const array = [...Array(7).keys()];
const before = microseconds.now();
await Promise.all(array.map((i) => xlsx.readFile(`./helpers/examples/file_example_XLSX_5000 (${i}).xlsx`)));
const after = before + microseconds.since(before);
return after - before;
};
00c3ef137fb454f6c48b33dfa5b93a63.gif

Результаты чтения и парсинга:

8adea63af0e5a8e9cd7b422671b64a05.png

В этом случае мы имеем смешанное I/O (чтение файла) и CPU (парсинг) интенсивное задание. C++ Addon справился быстрее именно из-за второй составляющей этой работы.

Время перемен

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

  • Сгенерировать данные для конфигурации.
  • Управлять расширенной конфигурацией.
  • Не иметь побочных эффектов.
  • Организовать, упростить и унифицировать наш код.
Модули @rbicker/jsonnet (js) и @unboundedsystems/jsonnet (нативный).

Пример использования:

const Jsonnet = require('@rbicker/jsonnet');
...
const jsonnet = new Jsonnet();

const jsonnetJS = () => {
const beforeAll = microseconds.now();
for (let i = 0; i < 7; i++) {
const myTemplate = `
{
person1: {
name: "Alice",
happy: true,
},
person2: self.person1 { name: "Bob" },
}`;
const output = jsonnet.eval(myTemplate);
}

const afterAll = beforeAll + microseconds.since(beforeAll);
...
};
3942f5be609147e5cab053c61532d0d9.gif

Немного странный результат, который скорее всего зависит от внутренней реализации модулей.

В любом случае у нас есть финальные результаты:

d6c5b61b1cc31f63776e968253154675.png

И финальные результаты Части II:

bb0b9d8583f33455deea6a68399eb9e6.png

Выводы​

Из нашего исследования можно вынести следующее:

  1. Необходимо ответственно относиться к выбору модулей. Читать код библиотек и учитывать среду, в которой приложение будет запущено. Популярность модуля ― далеко не самый важный критерий выбора.
  2. Необходимо выбирать специфическое решение для специфической задачи. Child Processes, Cluster, Worker Threads ― каждый из этих инструментов имеет собственные характеристики и возможности использования.
  3. Не можно забывать о других языках программирования, которые могут помочь решить некоторые из ваших задач (C++ Addons, Node-API, Neon library).
  4. Необходимо планировать использование ресурсов (количество CPU или GPU ядер).
  5. Принимайте рациональные архитектурные решения (имплементируйте собственный thread pool, запускайте CPU-bound задания в отдельном микросервисе и тд).
  6. Найдите лучшую возможную комбинацию (C/C++/Rust/Go могут быть использованы не в основном потоке и event loop не будет заблокирован) и вы получите что-то вроде этого:
67213cbc20bd43ff4f174cd6e3d4f352.png

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

Надеюсь вам понравилась эта эпическая история и вы почувствовали себя частью легенды.

Для дополнительной информации и возможности проверить результаты, пожалуйста, посетите мой github.

 
Сверху