Node.js: Worker Threads проти C++ Addons
Жоден дракон не може встояти перед загадками та втраченим часом на їх вирішення.
Дж.Р.Р. Толкін
Мене звати Олексій Новохацький, я — 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. Запускатись код буде на машині з наступними характеристиками:
Чи буде щось веселе
Останнім виникло питання — яким саме чином донести основну ідею, процес та результати досліджень...
І для цього обрав найпопулярнішу, найкрутішу та найпросунутішу гру
І тепер ― давайте поринемо в легенду
Наш герой ― 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 року. Функція має три версії:
- Argon2d підходить для захисту цифрової валюти та інформаційних систем, що не піддаються атакам по стороннім каналам.
- Argon2i забезпечує високий захист від trade-off атак, але працює повільніше версії d через кілька проходів по пам’яті.
- Argon2id — це гібридна версія.
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:
Висновки
Із нашого дослідження можна винести наступне:
- Відповідально ставтесь до вибору модулів. Читайте код бібліотек та враховуйте середовище, в якому застосунок буде запущено. Популярність модуля ― далеко не найважливіший критерій вибору.
- Обирайте специфічне вирішення для специфічної задачі. Child Processes, Cluster, Worker Threads ― кожен з цих інструментів має власні характеристики та можливості використання.
- Не забувайте про інші мови програмування, які можуть допомогти нам вирішити деякі з наших завдань (C++ Addons, Node-API, Neon library).
- Плануйте використання своїх ресурсів (кількість CPU або GPU ядер).
- Приймайте раціональні архітектурні рішення (імплементуйте власний thread pool, запускайте CPU-bound завдання в окремому мікросервісі, тощо).
- Знайдіть найкращу можливу комбінацію (C/C++/Rust/Go можуть бути використані не в основному потоці та event loop не буде заблоковано) і ви отримаєте щось на кшталт цього:
Дякую за прочитання.
Сподіваюсь, вам сподобалась ця епічна історія, і ви відчули себе частиною легенди.
Підписуйтесь на Medium та My-Talks, щоб не пропустити нових статей та виступів.
Для додаткової інформації і можливості перевірити результати, будь ласка, відвідайте мій github.
24 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів