Що таке дочірні процеси в 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
Швидкий приклад
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() для великого виводу:
// Неправильно: буферизує весь вивід, викидає 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:
// Неправильно: дочірній процес падає непоміченим, батьківський продовжує з хибними припущеннями
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():
// Неправильно: 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() ділить пам'ять з батьківським процесом:
// Хибне припущення: зміна даних у дочірньому процесі не оновлює батьківський
const sharedData = { count: 0 };
child.send(sharedData);
// дочірній змінює count - батько нічого не бачить
// Правильно: повертаємо оновлений стан через повідомлення
child.on('message', (updatedData) => {
console.log('Батько отримав:', updatedData);
});Об'єкти серіалізуються через JSON.stringify при проходженні через IPC. Дочірній отримує копію, не посилання.
Залишати осиротілі процеси після завершення батьківського:
// Неправильно: дочірній процес продовжує роботу після падіння батьківського
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()
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()
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() з двостороннім обміном і обробкою помилок
// 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 });
}
}
});// 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() отримали б чужі відповіді. Цей патерн є основою будь-якого пулу воркерів.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.