Эпиграф

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

Проблема

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

Комментарии (1)


  1. Ironcast
    28.11.2021 20:29

    Эх, ёлки, если б в своё время нам бы рассказывали про программирование -- не как набор функций и переменных с элементами математики а просто на примере функций Heroes2 или Doom2 без всяких переменных сейчас бы число профессиональных программистов было на порядок больше..