Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке дочірні процеси в Node.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Дочірні процеси** (child processes) в Node.js - це окремі процеси ОС, які запускаються через модуль `child_process`, щоб звільнити event loop від блокуючих операцій: команд оболонки, важких обчислень, зовнішніх програм. ```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(`Exit: ${code}`)); ``` **Ключове:** `exec()` для простих команд, `spawn()` для потокової обробки великих даних, `fork()` для IPC між Node.js-процесами.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Дочірні процеси** (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()` отримали б чужі відповіді. Цей патерн є основою будь-якого пулу воркерів.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.