Node.js: Worker Threads проти C++ Addons

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Жоден дракон не може встояти перед загадками та втраченим часом на їх вирішення.
Дж.Р.Р. Толкін

Мене звати Олексій Новохацький, я — Software Engineer. Node.js використовую, починаючи з версії 0.10.36. Зараз працюю над архітектурою високонавантажених систем, проводжу технічні співбесіди, втілюю в життя власні проєкти.

Для виступів та публікацій намагаюсь обирати нетривіальні теми, в яких хочеться розібратись самому.

Тому в цій статті спробую розкрити шляхи вирішення однієї з найбільших проблем JavaScript ― CPU-bound завдання. Зробимо це у двох частинах. У першій частині будуть використані винятково CPU-bound завдання. У другій ― модулі, які використовуються для front-end, 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 майже вдвічі (при встановленні значення 8).

Частина II

Фортеця Argon 2

Друга частина нашої епічної історії починається біля замку «Argon 2». Його назвали на честь функції формування ключа, яку обралипереможцем Password Hashing Competition у липні 2015 року. Функція має три версії:

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 не буде заблоковано) і ви отримаєте щось на кшталт цього:

Дякую за прочитання.

Сподіваюсь, вам сподобалась ця епічна історія, і ви відчули себе частиною легенди.

Підписуйтесь на Medium та My-Talks, щоб не пропустити нових статей та виступів.

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

👍НравитсяПонравилось20
В избранноеВ избранном6
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Цікава стаття і круте оформлення!

Спасибо, интересная статья, думал что воркер треды будут быстрее чайлд процессов)!
Ну и оформление\стиль просто 12 из 10, крутяк!)

Главная причина почему многие контрибьютеры были многие годы против worker threads — это потому что они достаточно бессмыслены. Каждый поток является по сути отдельным инстансом ноды — своя V8 isolate, свой libuv event loop и так далее. Многие поклонники многопоточности — или пришли из Windows или Java. JVM была сразу разработана с поддержкой многопоточности поэтому там есть выигрыш при создании потока (не надо инициализировать VM). V8 не поддерживает многопоточности (в JavaScript это не надо) поэтому выгрыш минимальный.

Цікаво, чому у Node.js такі буденні завдання як JSON.parse() та JSON.stringify() не передаються у C++ addons. Підозрюю, що C++ addons — це важка артилерія, яку ще треба прогрівати перед стартом, чи щось таке...

Можете пояснити детальніше?

JSON — це власне клас v8 (C++), тому додатково застосовувати аддони в цьому випадку не має сенсу

Я собі уявив, що C++ addons не блокують event loop. Чи це не так? JSON.parse() та JSON.stringify() блокують event loop, тому сенс може бути в тому, щоб передавати цю обробку у зовнішній сервіс, як це відбувається з базою даних.

Залежить від реалізації аддона та способу його використання (може бути запущений, як у основному потоці, так і в окремому процесі/потоці). Заміна CPU-bound завдання на I/O-bound («перетворення» обрахунків на запит до зовнішнього сервіса) може мати профіт в деяких умовах (наприклад при великих об’ємах цих обрахунків та відносно невеликому кінцевому результаті), але звучить скоріше як виключення. Для таких речей в більшості випадків уже є готові рішення (а у цих рішені готові API). Щодо JSON.stringify/parse важко уявляю собі задачу, яку довелось би виносити в окремий сервіс для працюючого застосунку, хіба що використати ті ж worker threads або child processes.
PS. До речі підходи до вирішення CPU-bound завдань у тому числі з винесенням в окремий сервіс будуть розглянуті в моїй наступній статті.
Тому підписуйтесь тут та на Medium.

важко уявляю собі задачу, яку довелось би виносити в окремий сервіс для працюючого застосунку, хіба що використати ті ж worker threads або child processes.

Парсинг тіла запиту у декілька мегабайт, чи більше.

Мені здається в такому випадку проблемою є сам факт парсингу тіла запиту (json) в декілька мегабайт. Маю на увазі саме в основному потоці, саме працюючого backend застосунку. Якщо такий монстр прилітає з frontend — щось явно йде не так і потрібно переглядати контракти та домовлятись. Найчастіше з моєї практики такі запити — це якісь сторонні сервіси (статистика, списки геолокацій і тд), які можна обробляти не у окремих потоках і процесах, а взагалі в окремих сервісах і на окремих машинах (ресурсах). Після цього отримані дані вже у обробленому вигляді можуть бути доступні скажімо так «основному застосунку». Якщо все ж з якоїсь причини необхідно парсити huge json саме під час запиту, то знову ж реалізація як на мене залежить від багатьох умов: наявні ресурси — кількість ядер, який має бути результат — можливо необхідно і розпарсити і одразу провести якісь розрахунки/шифрування/ще щось CPU-bound. Можна запускати такі завдання як не в основному потоці/процесі, так і в окремому (мікро)сервісі, враховуючи мережеву затримку.

Если речь идет об JSON.parse (стандартный парсинг из юзерленда) — то там главный тормоз — создание JavaScript объектов. В общем случае эти объекты нельзя создавать не из «главного треда».

(Сразу уточню что при использовании worker threads — каждый worker имеет свой один главный поток).

JSON.parse() та JSON.stringify() блокують event loop

Так JSON.parse ж не асинхронное API — как они могут не блокировать ивент луп? Даже если и добавят новое API вроде await JSON.parseAsync() с колбеком или промисом, то оно не шибко надо- если он и зафризит луп на 100мс на очень жирном джосне, то другие ноды больше на себя запросов подхватят, а количество потоков все ровно ограничено, юзались бы они во внутреннем пуле, или инстансами ноды. Но если бы оно было, то не помешало- за плечами его не носить.

Щось я прочитав так. За результатами benchmark-ків найкращій JavaScript — це С++. Хоча нічого дивного, JS від початку так і створювався, скріпт визиває функції браузера які в свою чергу написані на С++. Взагалі чудова стаття.

Дякую за відгук.
Така вже тяжка доля у С/С++ в Node.js. — «працювать, працювать, працювати, В праці сконать!»

Да, оформление — абсолютный топ из всего наверное, что я видел вообще. Автору низкий поклон!

Не очень момент понял — для Argon2 вы используете модуль hash-wasm, но называете его нативным. Когда как это не нативный код совсем (и они сами этого не скрывают).

Более того, стоило бы добавить сравнение еще и WASM реализаций — проще, чем плюсы, и быстрее, чем JS.

Дякую за відгук.
Можливо був недоствтньо точним у формулюванні.
Мав на увазі, что цей модуль «Compiled from heavily optimized algorithms written in C».
Порівняння з WASM (та реалізацією на Golang) буде в наступній статті, яку буде опубліковано до кінця року (вірогідніше за тиждень-два).
Тому підписуйтесь тут та на Medium.

инфографика — топ)

Подписаться на комментарии