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

—

от автора

Эпиграф

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

Проблема

Сегодня мы попытаемся раскрыть пути решения одной из самых больших проблем 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 архангел. Опытный воин, который не раскрывает всех секретов своей силы, но прекрасно показавший себя в предыдущих сражениях.

Часть І

Первая битва

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

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

Имплементация (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[i]);   }   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[i]);   }   const afterAll = beforeAll + microseconds.since(beforeAll);   ... };

…and “Action!”

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

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

Второй раунд

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

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

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

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[i], salt);   }   const afterAll = beforeAll + microseconds.since(beforeAll);   ... };

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

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

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

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

Пример:

const bcrypt = require('bcrypt'); ... const hashPromise = (i) => async () => {   const salt = await bcrypt.genSalt(10);   const hash = await bcrypt.hash(file[i], 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; };

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

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

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

Необходимо отметить, что есть одно секретное оружие для нашего архангела. С его помощью он может сражаться даже еще эффективнее. Это количество потоков в 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[i],     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);   ... };

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

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; };

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

В этом случае мы имеем смешанное 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);   ... };

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

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

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

Выводы

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

  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 не будет заблокирован) и вы получите что-то вроде этого:

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/589403/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *