Skip to main content

Що таке дочірні процеси в Node.js?

Дочірні процеси (child processes) дозволяють Node.js запускати окремі процеси ОС для виконання команд оболонки, зовнішніх програм або важких обчислень поза однопотоковим event loop.

Теорія

TL;DR

  • Аналогія: дочірні процеси - це окремі виконавці з власною пам'яттю, CPU-часом і event loop, а головний процес залишається вільним
  • Чотири методи: exec() для команд оболонки з буферизованим виводом, execFile() для прямого запуску бінарників, spawn() для потокової обробки великих даних, fork() для зв'язку двох Node.js-процесів через IPC
  • Головний поділ: exec() і execFile() збирають весь вивід перед викликом callback; spawn() і fork() передають дані потоком
  • Правило вибору: великі дані → spawn(), проста команда → exec(), два Node.js-процеси спілкуються → fork()
  • На відміну від worker threads, дочірні процеси мають ізольовану пам'ять і окремий V8

Швидкий приклад

js
const { spawn } = require('child_process'); // Потоковий вивід, без завантаження всього в пам'ять const child = spawn('ls', ['-la']); child.stdout.on('data', (data) => { console.log(`Вивід: ${data}`); }); child.on('close', (code) => { console.log(`Процес завершився з кодом ${code}`); }); // Event loop не заблокований console.log('Головний потік вільний');

spawn() повертає об'єкт дочірнього процесу, де stdout, stderr і stdin - це потоки. Дані надходять по мірі появи, а не одним блоком після завершення процесу.

Ключова різниця

Модуль child_process виводить Node.js за рамки однопотокової моделі через створення справжніх процесів ОС. На відміну від worker threads, які ділять одну V8-купу, кожен дочірній процес отримує власний V8, власну пам'ять і власний event loop. Зв'язок між процесами відбувається через IPC-канал у fork() або через піпи stdin/stdout. Об'єкти, передані через send(), серіалізуються і десеріалізуються. Посилання не передаються.

Коли що використовувати

  • exec(): команди оболонки з невеликим виводом (до ~1MB). git status, npm list, будь-який однорядковий shell-вираз. Приймає рядок, тому пайпи та редиректи працюють.
  • execFile(): запуск бінарника або скрипту напряму, без залучення оболонки. Безпечніше за exec(), бо не інтерпретує метасимволи shell. Підходить для скомпільованих бінарників, Python-скриптів або будь-чого, де до аргументів потрапляють дані від користувача.
  • spawn(): великий вивід, дані в реальному часі або тривалі процеси. Конвертація відео через ffmpeg, потокова обробка логів, збірка проекту. Дані передаються по мірі появи.
  • fork(): запуск Node.js-коду в окремому процесі з двостороннім обміном повідомленнями. Важкі обчислення, пули воркерів або будь-що, де потрібно тримати головний процес вільним під час серйозних обчислень.

Таблиця порівняння

МетодОболонка?БуферизаціяРозмір виводуIPCНайкраще для
exec()ТакПовнаМалий (<1MB)НіПрості команди оболонки
execFile()НіПовнаМалий (<1MB)НіПрямий запуск, безпечніше
spawn()НіПотоковийБез обмеженьНіВеликі або реалтаймові дані
fork()НіПотоковийБез обмеженьТакЗв'язок між Node.js-процесами

Як це працює всередині

Коли викликається spawn(), Node.js використовує системний виклик fork() на Unix/macOS або CreateProcess() на Windows для створення нового процесу. Батьківський процес отримує три файлових дескриптори, підключених до дочірнього: stdin, stdout і stderr. Для fork() Node.js додатково відкриває IPC-канал через Unix domain sockets або named pipes, що і дає змогу використовувати child.send() та process.on('message'). Кожен дочірній процес запускає власний V8, тому fork() коштує приблизно 30MB накладних витрат на процес, тоді як worker thread - близько 2MB.

Типові помилки

exec() для великого виводу:

js
// Неправильно: буферизує весь вивід, викидає ERR_CHILD_PROCESS_STDIO_MAXBUFFER exec('cat huge-file.txt', (error, stdout) => { console.log(stdout); // весь файл в пам'яті }); // Правильно: передаємо потоком spawn('cat', ['huge-file.txt']).stdout.pipe(process.stdout);

Ліміт maxBuffer за замовчуванням - 1MB. Можна збільшити через { maxBuffer: 10 * 1024 * 1024 }, але для справді великих даних краще одразу перейти на spawn().

Ігнорування подій error та exit:

js
// Неправильно: дочірній процес падає непоміченим, батьківський продовжує з хибними припущеннями const child = spawn('some-command'); child.stdout.on('data', (data) => console.log(data)); // Правильно: обробляємо обидві події child.on('error', (err) => console.error('Не вдалося запустити:', err)); child.on('exit', (code, signal) => { if (code !== 0) console.error(`Завершився з кодом ${code}`); });

Ін'єкція через exec():

js
// Неправильно: userId = "123; rm -rf /" стає командою оболонки const userId = req.query.id; exec(`grep ${userId} /etc/passwd`, callback); // Правильно: execFile не передає рядок через оболонку const { execFile } = require('child_process'); execFile('grep', [userId, '/etc/passwd'], callback);

execFile() передає аргументи масивом напряму до ОС, тому метасимволи оболонки ніколи не інтерпретуються.

Думати, що fork() ділить пам'ять з батьківським процесом:

js
// Хибне припущення: зміна даних у дочірньому процесі не оновлює батьківський const sharedData = { count: 0 }; child.send(sharedData); // дочірній змінює count - батько нічого не бачить // Правильно: повертаємо оновлений стан через повідомлення child.on('message', (updatedData) => { console.log('Батько отримав:', updatedData); });

Об'єкти серіалізуються через JSON.stringify при проходженні через IPC. Дочірній отримує копію, не посилання.

Залишати осиротілі процеси після завершення батьківського:

js
// Неправильно: дочірній процес продовжує роботу після падіння батьківського const child = spawn('long-running-process'); // Правильно: прибираємо за собою process.on('exit', () => child.kill()); // Якщо дочірній навмисно має пережити батьківський: const daemon = spawn('process', [], { detached: true }); daemon.unref(); // батько не чекатиме на нього

Особисто стикався з цим у CLI-інструменті, який читав метадані бінарних файлів через exec(). В розробці все було добре, але в продакшені при файлах понад 1MB прилітав крах. Перехід на spawn() з потоковою обробкою зайняв десять хвилин.

Де використовується в реальних проектах

  • Cluster-модуль Node.js: використовує fork() для запуску по одному воркеру на кожне CPU-ядро для балансування HTTP-трафіку
  • Jest і Mocha: запускають кожен тестовий набір у форкнутому процесі, щоб витоки пам'яті в одному наборі не впливали на інші
  • Webpack і Vite: породжують дочірні процеси для кроків компіляції, щоб watcher залишався чуйним
  • npm і yarn: внутрішньо використовують spawn() при запуску npm run build
  • Piscina та подібні бібліотеки пулів воркерів: використовують fork() для підтримки набору процесів під CPU-інтенсивні задачі

Питання для підготовки до співбесіди

Q: Яка різниця між spawn() і fork()?
A: spawn() запускає будь-який процес ОС (команду оболонки, бінарник, Python-скрипт) з потоковим I/O. fork() запускає конкретно Node.js-файл і додає IPC-канал для двостороннього обміну через send() і on('message'). Використати send() з процесом, запущеним через spawn(), не вийде.

Q: Чому в exec() є ліміт maxBuffer і як з ним працювати?
A: exec() збирає весь stdout і stderr в пам'яті перед викликом callback. Ліміт за замовчуванням - 1MB. Можна збільшити через { maxBuffer: N }, або перейти на spawn() для всього, що може дати більше кількох сотень кілобайт.

Q: Як додати таймаут для дочірнього процесу?
A: Передати { timeout: 5000 } у spawn() або exec() - процес буде знищений через 5 секунд. Або вручну: setTimeout(() => child.kill(), 5000). В обох випадках потрібно слухати подію exit, щоб переконатися, що процес справді завершився.

Q: Чи може дочірній процес пережити батьківський?
A: Так. Запусти з { detached: true } і виклич child.unref(). Дочірній стає лідером власної групи процесів і продовжує роботу після завершення батьківського. Саме так з Node.js-скрипта створюють фонових демонів.

Q: Яка різниця у продуктивності між fork() і worker threads?
A: fork() створює окремий процес ОС з власним V8 - близько 30MB накладних витрат на процес. Worker threads ділять один процес і V8-купу - близько 2MB на потік. Для справжнього паралелізму на кількох ядрах обидва підходять. Для обміну пам'яттю через SharedArrayBuffer - тільки worker threads.

Q (Senior): Як реалізувати пул воркерів через fork() і які крайові випадки потрібно врахувати?
A: Створюємо масив форкнутих процесів, підтримуємо чергу задач і призначаємо роботу за round-robin або по готовності. Складність - у крайових випадках: дочірній процес падає під час задачі (перезапустити і поставити задачу назад у чергу), таймаут задачі (знищити процес і повторити), витік пам'яті у довгоживучих дітях (перезапуск після N задач), порядок IPC-повідомлень (додати correlation ID до кожного запиту). Бібліотеки на кшталт Piscina вирішують усе це. Писати своє варто для розуміння, але не для продакшену без ретельного тестування.

Приклади

Базовий: команда оболонки через exec()

js
const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); async function getInstalledPackages() { try { const { stdout } = await execAsync('npm list --depth=0'); return stdout; } catch (err) { console.error('npm list завершився з помилкою:', err.message); return null; } }

promisify(exec) обгортає callback API в Promise. Весь вивід надходить одразу в stdout, бо exec() буферизує його. Для npm list це прийнятно - вивід невеликий.

Середній: потокова обробка великого файлу через spawn()

js
const { spawn } = require('child_process'); const fs = require('fs'); // Фільтруємо рядки з помилками без завантаження файлу в пам'ять const grep = spawn('grep', ['ERROR', '/var/log/app.log']); const output = fs.createWriteStream('errors.txt'); grep.stdout.pipe(output); grep.on('error', (err) => console.error('grep не вдалося запустити:', err)); grep.on('close', (code) => { if (code === 0) { console.log('Готово, errors.txt записано'); } else { console.error(`grep завершився з кодом ${code}`); } }); // Цей рядок виконується одразу, event loop не заблокований console.log('Фільтрація запущена...');

pipe() підключає stdout дочірнього процесу напряму до writable stream без проміжного буфера в пам'яті. Event loop залишається вільним, поки через нього проходять гігабайти логів.

Просунутий: fork() з двостороннім обміном і обробкою помилок

js
// worker.js - виконується у власному процесі process.on('message', (msg) => { if (msg.cmd === 'sum') { try { const result = msg.data.reduce((a, b) => a + b, 0); process.send({ id: msg.id, result }); } catch (err) { process.send({ id: msg.id, error: err.message }); } } });
js
// parent.js const { fork } = require('child_process'); const path = require('path'); const child = fork(path.join(__dirname, 'worker.js')); let messageId = 0; const pending = new Map(); function calculate(data) { return new Promise((resolve, reject) => { const id = ++messageId; pending.set(id, { resolve, reject }); child.send({ id, cmd: 'sum', data }); }); } child.on('message', (msg) => { const handler = pending.get(msg.id); if (!handler) return; pending.delete(msg.id); msg.error ? handler.reject(new Error(msg.error)) : handler.resolve(msg.result); }); child.on('error', (err) => console.error('Воркер не запустився:', err)); process.on('exit', () => child.kill()); calculate([1, 2, 3, 4, 5]).then((result) => { console.log('Сума:', result); // Сума: 15 child.kill(); });

Поле id в кожному повідомленні - це ключ для зіставлення запиту і відповіді. Без нього два паралельних виклики calculate() отримали б чужі відповіді. Цей патерн є основою будь-якого пулу воркерів.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?