Асинхронне програмування у Node.js

Child Processes — запуск зовнішніх процесів

Модуль child_process, exec, spawn, fork, делегування задач

Child Processes — запуск зовнішніх процесів

🎯 Мета лекції

  • Опанувати модуль child_process для запуску зовнішніх програм та утиліт з Node.js.
  • Зрозуміти відмінності між методами exec(), spawn(), execFile() та fork().
  • Навчитися обробляти вивід дочірніх процесів через потоки (streams) та буфери.
  • Дослідити міжпроцесну комунікацію (IPC) між батьківським та дочірнім Node.js процесами.
  • Розрізняти сценарії використання Child Processes та Worker Threads.
  • Оволодіти технікою безпечного запуску команд та валідації вводу для запобігання ін'єкцій.

🔑 Ключові терміни

  • Child Process (дочірній процес): окремий процес операційної системи, створений батьківським процесом Node.js.
  • IPC (Inter-Process Communication): механізм обміну даними між процесами через канали комунікації.
  • Shell: командна оболонка операційної системи (bash, zsh, cmd.exe, PowerShell), що інтерпретує команди.
  • Stream: потік даних, що дозволяє обробляти вивід процесу частинами (chunks), без завантаження всього у пам'ять.
  • Exit Code (код виходу): числове значення (0-255), що повертає процес при завершенні (0 = успіх, >0 = помилка).

Архітектурний контекст: обмеження однопроцесної моделі

У попередній лекції ми розглянули Worker Threads — механізм паралелізації JavaScript коду всередині одного процесу Node.js. Проте існують сценарії, де необхідно:

  • Запустити програми, написані іншими мовами (Python, Ruby, Go, системні утиліти).
  • Виконати системні команди (git, ffmpeg, ImageMagick, docker).
  • Ізолювати ненадійний код у окремому процесі для запобігання crash всього застосунку.
  • Масштабувати Node.js застосунок на кілька ядер процесора через кластеризацію.

Для цих задач Node.js надає модуль child_process, що дозволяє створювати та керувати дочірніми процесами операційної системи.

Фундаментальна відмінність: Worker Threads vs Child Processes

import { Worker } from 'node:worker_threads';

// Виконується JAVASCRIPT у межах одного процесу
const worker = new Worker('./compute.ts');

// ✅ Спільна пам'ять через SharedArrayBuffer
// ✅ Швидка комунікація (structured clone)
// ✅ Легковаговий (~2-10 МБ на Worker)
// ❌ Лише JavaScript/TypeScript код
// ❌ Crash Worker може вплинути на процес
Child Process є окремим процесом операційної системи з власним адресним простором пам'яті, ідентифікатором процесу (PID) та ресурсами. На відміну від Worker Thread, що є потоком у межах одного процесу, Child Process забезпечує повну ізоляцію та можливість запуску будь-якої програми, а не лише JavaScript коду.

Модуль child_process: огляд методів

Модуль child_process надає чотири основні методи створення дочірніх процесів:

МетодShellВивідКомунікаціяВикористання
exec()✅ ТакBuffer❌ НіКороткі shell-команди з невеликим виводом
execFile()❌ НіBuffer❌ НіЗапуск виконуваних файлів без shell
spawn()❌ НіStream❌ НіДовгі процеси з великим виводом
fork()❌ НіStream✅ IPCДочірні Node.js процеси з комунікацією

Також доступні синхронні версії (блокують Event Loop): execSync(), execFileSync(), spawnSync().

Візуалізація вибору методу

Loading diagram...
graph TD
    A[Потрібно запустити<br/>дочірній процес?]
    A -->|Так| B{Це Node.js<br/>скрипт?}
    
    B -->|Так| C{Потрібна<br/>IPC-комунікація?}
    C -->|Так| D[fork]
    C -->|Ні| E[spawn 'node']
    
    B -->|Ні| F{Використовувати<br/>shell?}
    
    F -->|Так| G{Розмір<br/>виводу?}
    G -->|Малий <br/>< 1 МБ| H[exec]
    G -->|Великий <br/>> 1 МБ| I[spawn sh -c]
    
    F -->|Ні| J{Розмір<br/>виводу?}
    J -->|Малий <br/>< 1 МБ| K[execFile]
    J -->|Великий <br/>> 1 МБ| L[spawn]
    
    style D fill:#DCFCE7,stroke:#16A34A
    style H fill:#DBEAFE,stroke:#2563EB
    style K fill:#DBEAFE,stroke:#2563EB
    style I fill:#FEF3C7,stroke:#CA8A04
    style L fill:#FEF3C7,stroke:#CA8A04
Правило вибору: Якщо потрібно запустити shell-команду (з пайпами, редиректами, підстановками) — використовуйте exec(). Якщо запускаєте конкретну програму з аргументами без shell — використовуйте spawn() або execFile(). Для Node.js процесів з IPC — використовуйте fork().

Метод exec(): запуск shell-команд

Метод exec() виконує команду через shell операційної системи (bash/zsh на Unix, cmd.exe/PowerShell на Windows) та буферизує весь вивід у пам'яті:

import { exec } from 'node:child_process';
import { promisify } from 'node:util';

const execPromise = promisify(exec);

async function runShellCommand() {
  try {
    const { stdout, stderr } = await execPromise('ls -lah /usr/local/bin');
    
    console.log('=== STDOUT ===');
    console.log(stdout);
    
    if (stderr) {
      console.error('=== STDERR ===');
      console.error(stderr);
    }
  } catch (error) {
    const execError = error as { code: number; stdout: string; stderr: string };
    console.error('Помилка виконання команди:');
    console.error('Exit Code:', execError.code);
    console.error('STDERR:', execError.stderr);
  }
}

runShellCommand();

Вивід:

node exec-example.ts
$ node exec-example.ts
=== STDOUT ===
total 328
drwxr-xr-x 45 root wheel 1.4K Jan 15 10:30 .
drwxr-xr-x 11 root wheel 352B Dec 1 09:15 ..
-rwxr-xr-x 1 root wheel 65K Jan 10 14:22 node
-rwxr-xr-x 1 root wheel 42K Jan 10 14:22 npm
-rwxr-xr-x 1 root wheel 38K Jan 10 14:22 pnpm
...

Синтаксис та параметри

exec(
  command: string,
  options?: {
    cwd?: string;              // Робоча директорія
    env?: object;              // Змінні оточення
    shell?: string;            // Shell для виконання (default: '/bin/sh' або 'cmd.exe')
    timeout?: number;          // Таймаут у мілісекундах
    maxBuffer?: number;        // Максимальний розмір буфера (default: 1 МБ)
    encoding?: string;         // Кодування виводу (default: 'utf8')
    killSignal?: string;       // Сигнал для завершення процесу (default: 'SIGTERM')
  },
  callback?: (error, stdout, stderr) => void
): ChildProcess

Використання shell-фіч: pipes та редиректи

Перевага exec() — можливість використання shell-синтаксису:

import { exec } from 'node:child_process';
import { promisify } from 'node:util';

const execPromise = promisify(exec);

async function complexShellCommand() {
  try {
    // Використовуємо pipe для з'єднання команд
    const { stdout } = await execPromise(
      'cat package.json | grep "version" | head -n 1'
    );
    
    console.log('Версія з package.json:', stdout.trim());
    // Виведе: "version": "1.0.0",
  } catch (error) {
    console.error('Помилка:', error);
  }
}

async function countLogFiles() {
  try {
    // Використовуємо wildcard для підрахунку файлів
    const { stdout } = await execPromise('ls -1 logs/*.log 2>/dev/null | wc -l');
    
    const count = parseInt(stdout.trim(), 10);
    console.log(`Знайдено ${count} лог-файлів`);
  } catch (error) {
    console.error('Помилка:', error);
  }
}

complexShellCommand();
countLogFiles();
Безпека: Метод exec() виконує команди через shell, що робить його вразливим до command injection атак, якщо команда містить дані від користувача. Ніколи не вставляйте неперевірений user input у команду без валідації та екранування:
// ❌ НЕБЕЗПЕЧНО: command injection
const userInput = req.query.filename; // "file.txt; rm -rf /"
exec(`cat ${userInput}`, callback); // Виконає rm -rf / !!!

// ✅ БЕЗПЕЧНО: використовуйте spawn() з масивом аргументів
spawn('cat', [userInput]);

Обмеження розміру виводу

За замовчуванням exec() буферизує лише 1 МБ виводу. Для великих виводів збільште maxBuffer або використовуйте spawn():

import { exec } from 'node:child_process';
import { promisify } from 'node:util';

const execPromise = promisify(exec);

async function largeOutput() {
  try {
    // ❌ Помилка: вивід перевищує 1 МБ
    await execPromise('find /usr -type f');
  } catch (error) {
    console.error(error); // Error: maxBuffer exceeded
  }
  
  try {
    // ✅ Збільшуємо буфер до 10 МБ
    const { stdout } = await execPromise('find /usr -type f', {
      maxBuffer: 10 * 1024 * 1024,
    });
    
    const lines = stdout.split('\n').length;
    console.log(`Знайдено ${lines} файлів`);
  } catch (error) {
    console.error('Помилка:', error);
  }
}

largeOutput();
Збільшення maxBuffer до дуже великих значень (>50 МБ) може призвести до високого споживання пам'яті. Для процесів з великим виводом (логи, результати обробки) використовуйте spawn() зі streaming обробкою.

Синхронні версії: execSync() та execFileSync()

Node.js надає синхронні версії методів, що блокують Event Loop до завершення процесу:

import { execSync } from 'node:child_process';

try {
  const output = execSync('git rev-parse --short HEAD', {
    encoding: 'utf-8',
    cwd: '/path/to/repo',
  });
  
  console.log('Поточний git commit:', output.trim());
} catch (error) {
  console.error('Помилка отримання git commit:', error);
}
Критично важливо: Синхронні методи (execSync, execFileSync, spawnSync) блокують Event Loop на весь час виконання процесу. Використовуйте їх лише у скриптах (CLI tools, build scripts), але ніколи у серверних застосунках (HTTP servers, API), де блокування Event Loop призведе до повної недоступності сервісу для інших користувачів.

Коли використовувати синхронні методи

✅ Прийнятно

Сценарії:

  • CLI-утиліти та скрипти автоматизації
  • Build scripts (webpack plugins, gulp tasks)
  • Тести (setup/teardown фікстур)
  • Одноразові скрипти обробки даних

Приклад: Отримання версії Node.js у build script

const nodeVersion = execSync('node --version', {
  encoding: 'utf-8'
}).trim();
console.log(`Build Node.js: ${nodeVersion}`);

❌ Неприйнятно

Сценарії:

  • HTTP-сервери та API
  • WebSocket-сервери
  • Довготривалі процеси (демони)
  • Будь-які застосунки з Event Loop

Приклад антипаттерну:

// ❌ АНТИПАТТЕРН: блокує всіх користувачів!
app.get('/git-log', (req, res) => {
  const log = execSync('git log --oneline');
  res.send(log); // Всі запити чекають!
});

Метод spawn(): streaming вивід та довгі процеси

На відміну від exec(), що буферизує весь вивід у пам'яті, метод spawn() надає потоки (streams) для читання stdout/stderr та запису у stdin. Це робить його ідеальним для:

  • Процесів з великим виводом (логи, результати обробки)
  • Довготривалих процесів (серверів, моніторингу)
  • Інтерактивних програм (REPL, CLI tools)
  • Обробки даних у режимі реального часу

Базовий приклад: запуск команди через spawn

import { spawn } from 'node:child_process';

// Запускаємо команду 'ls -lah /usr/local'
const child = spawn('ls', ['-lah', '/usr/local']);

// child.stdout — це Readable Stream
child.stdout.on('data', (data: Buffer) => {
  console.log('[STDOUT]:', data.toString());
});

// child.stderr — потік для помилок
child.stderr.on('data', (data: Buffer) => {
  console.error('[STDERR]:', data.toString());
});

// Подія завершення процесу
child.on('close', (code: number, signal: string | null) => {
  console.log(`Процес завершено з кодом ${code}`);
  
  if (signal) {
    console.log(`Процес убито сигналом: ${signal}`);
  }
});

// Подія помилки запуску
child.on('error', (error: Error) => {
  console.error('Помилка запуску процесу:', error.message);
});
Метод spawn()не використовує shell за замовчуванням. Це означає, що shell-специфічні фічі (pipes |, редиректи >, wildcards *, змінні $VAR) не працюють. Аргументи передаються як масив рядків, що робить метод безпечним від command injection.

Передача аргументів: масив vs shell-команда

import { spawn } from 'node:child_process';

// Аргументи передаються як масив
const userInput = req.query.filename; // "file.txt; rm -rf /"

// ✅ БЕЗПЕЧНО: userInput буде оброблений як ONE аргумент
const child = spawn('cat', [userInput]);

// Навіть якщо userInput = "file.txt; rm -rf /",
// команда інтерпретується як:
// cat "file.txt; rm -rf /" (шукає файл з таким ім'ям)

Обробка великих виводів: streaming vs buffering

Порівняємо обробку виводу команди find, що може повернути мільйони рядків:

import { exec } from 'node:child_process';
import { promisify } from 'node:util';

const execPromise = promisify(exec);

async function findFilesBuffered() {
  try {
    // ❌ Завантажує ВСЬ вивід у пам'ять
    const { stdout } = await execPromise('find /usr -type f', {
      maxBuffer: 50 * 1024 * 1024, // 50 МБ буфер
    });
    
    const files = stdout.split('\n');
    console.log(`Знайдено ${files.length} файлів`);
    
    // Споживання пам'яті: ~50 МБ
  } catch (error) {
    console.error('Помилка:', error);
  }
}

Передача даних у stdin: інтерактивна комунікація

spawn() дозволяє передавати дані у stdin дочірнього процесу через Writable Stream:

import { spawn } from 'node:child_process';

// Запускаємо Python-скрипт, що читає з stdin
const python = spawn('python3', ['-c', `
import sys
for line in sys.stdin:
    print(f"Python отримав: {line.strip()}")
    sys.stdout.flush()
`]);

// python.stdin — Writable Stream
python.stdin.write('Перше повідомлення\n');
python.stdin.write('Друге повідомлення\n');
python.stdin.write('Третє повідомлення\n');

// Закриваємо stdin (надсилаємо EOF)
python.stdin.end();

python.stdout.on('data', (data: Buffer) => {
  console.log('[Python STDOUT]:', data.toString());
});

python.on('close', (code) => {
  console.log(`Python завершився з кодом ${code}`);
});

Вивід:

node spawn-stdin.ts
$ node spawn-stdin.ts
[Python STDOUT]: Python отримав: Перше повідомлення
[Python STDOUT]: Python отримав: Друге повідомлення
[Python STDOUT]: Python отримав: Третє повідомлення
Python завершився з кодом 0

Використання shell-команд через spawn

Якщо потрібно використати shell-фічі (pipes, редиректи), передайте опцію shell: true:

import { spawn } from 'node:child_process';

// ✅ З shell: дозволяє використовувати pipes
const child = spawn('ls -lah | grep node | wc -l', {
  shell: true,
});

child.stdout.on('data', (data: Buffer) => {
  console.log('Кількість файлів з "node":', data.toString().trim());
});

// Альтернатива: явно вказати shell
const child2 = spawn('bash', [
  '-c',
  'cat package.json | jq .version'
]);
Опція shell: true робить spawn()вразливим до command injection, як і exec(). Використовуйте її лише з довіреними командами або коли shell-функціональність дійсно необхідна.

Метод execFile(): запуск виконуваних файлів без shell

Метод execFile() є проміжним рішенням між exec() та spawn():

  • Не використовує shell (безпечніше)
  • Буферизує вивід у пам'яті (як exec())
  • Швидший за exec() (відсутність overhead shell)
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execFilePromise = promisify(execFile);

async function runExecutable() {
  try {
    // Запускаємо виконуваний файл напряму
    const { stdout, stderr } = await execFilePromise('/usr/bin/git', [
      'log',
      '--oneline',
      '-n',
      '5',
    ]);
    
    console.log('Останні 5 git commits:');
    console.log(stdout);
  } catch (error) {
    console.error('Помилка:', error);
  }
}

runExecutable();

Коли використовувати execFile() замість exec()

✅ execFile()

Переваги:

  • Безпечніше (немає shell interpretation)
  • Швидше (відсутність overhead shell)
  • Підходить для виконуваних файлів

Використовуйте для:

  • Запуску бінарних утиліт (git, curl, ffmpeg)
  • Виконання скриптів з shebang (#!/usr/bin/env node)
  • Коли не потрібні shell-фічі

✅ exec()

Переваги:

  • Підтримка shell-синтаксису (pipes, редиректи)
  • Зручно для простих команд

Використовуйте для:

  • Складних команд з pipes (cat | grep | sort)
  • Використання shell-функцій (wildcards, змінних)
  • Швидкого прототипування

Обробка помилок та exit codes

Дочірній процес може завершитися з кодом виходу (exit code) від 0 до 255:

  • 0: успішне завершення
  • 1-127: помилки програми
  • 128+N: процес убито сигналом N (наприклад, 137 = SIGKILL)
import { spawn } from 'node:child_process';

function runCommandWithErrorHandling(command: string, args: string[]) {
  const child = spawn(command, args);
  
  let stdout = '';
  let stderr = '';
  
  child.stdout.on('data', (data) => {
    stdout += data.toString();
  });
  
  child.stderr.on('data', (data) => {
    stderr += data.toString();
  });
  
  child.on('close', (code, signal) => {
    if (code === 0) {
      console.log('✓ Команда виконана успішно');
      console.log(stdout);
    } else if (signal) {
      console.error(`✗ Процес убито сигналом: ${signal}`);
    } else {
      console.error(`✗ Команда завершилась з помилкою (exit code ${code})`);
      console.error('STDERR:', stderr);
      
      // Інтерпретація типових exit codes
      switch (code) {
        case 1:
          console.error('Загальна помилка виконання');
          break;
        case 126:
          console.error('Команда знайдена, але не може бути виконана');
          break;
        case 127:
          console.error('Команда не знайдена');
          break;
        case 130:
          console.error('Процес перервано (Ctrl+C)');
          break;
        default:
          console.error('Невідома помилка');
      }
    }
  });
  
  child.on('error', (error) => {
    console.error('✗ Помилка запуску процесу:', error.message);
    
    if (error.message.includes('ENOENT')) {
      console.error('Програма не знайдена. Перевірте PATH.');
    }
  });
}

// Приклад: команда, що не існує
runCommandWithErrorHandling('nonexistent-command', ['arg1']);

// Приклад: команда з помилкою виконання
runCommandWithErrorHandling('ls', ['/nonexistent-directory']);

Вивід:

node error-handling.ts
$ node error-handling.ts
✗ Помилка запуску процесу: spawn nonexistent-command ENOENT
Програма не знайдена. Перевірте PATH.
✗ Команда завершилась з помилкою (exit code 1)
STDERR: ls: /nonexistent-directory: No such file or directory
Загальна помилка виконання
Завжди обробляйте обидві події: 'close' (нормальне завершення) та 'error' (помилка запуску). Подія 'error' генерується, коли процес не вдалося запустити (команда не знайдена, недостатньо прав), тоді як 'close' генерується при будь-якому завершенні процесу, включаючи помилки виконання.

Таймаути та примусове завершення процесів

Для запобігання зависання процесів встановлюйте таймаути:

import { spawn } from 'node:child_process';

function runWithTimeout(
  command: string,
  args: string[],
  timeoutMs: number
): Promise<string> {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args);
    let stdout = '';
    let timedOut = false;
    
    // Встановлюємо таймаут
    const timeout = setTimeout(() => {
      timedOut = true;
      child.kill('SIGTERM'); // Спроба graceful shutdown
      
      // Якщо через 5 секунд процес не завершився — kill -9
      setTimeout(() => {
        if (!child.killed) {
          child.kill('SIGKILL');
        }
      }, 5000);
    }, timeoutMs);
    
    child.stdout.on('data', (data) => {
      stdout += data.toString();
    });
    
    child.on('close', (code) => {
      clearTimeout(timeout);
      
      if (timedOut) {
        reject(new Error(`Процес перевищив таймаут ${timeoutMs}мс`));
      } else if (code === 0) {
        resolve(stdout);
      } else {
        reject(new Error(`Exit code ${code}`));
      }
    });
    
    child.on('error', (error) => {
      clearTimeout(timeout);
      reject(error);
    });
  });
}

// Використання
runWithTimeout('sleep', ['10'], 2000) // Таймаут 2 секунди
  .then((output) => console.log('Вивід:', output))
  .catch((error) => console.error('Помилка:', error.message));
  
// Вивід: Помилка: Процес перевищив таймаут 2000мс
Сигнали завершення процесів:
  • SIGTERM (15): graceful shutdown — дозволяє процесу завершити роботу коректно
  • SIGKILL (9): примусове завершення — процес убивається негайно, без можливості очищення ресурсів
  • SIGINT (2): переривання (Ctrl+C) — зазвичай обробляється процесом для graceful shutdown
Завжди спочатку намагайтеся використати SIGTERM, і лише якщо процес не реагує протягом кількох секунд — використовуйте SIGKILL.

Метод fork(): створення дочірніх Node.js процесів з IPC

Метод fork() є спеціалізованою версією spawn() для створення дочірніх Node.js процесів з вбудованим каналом міжпроцесної комунікації (IPC — Inter-Process Communication). Це дозволяє батьківському та дочірнім процесам обмінюватися повідомленнями через process.send() та process.on('message').

Базовий приклад: батьківський та дочірній процеси

worker.ts — дочірній процес:

// worker.ts
process.on('message', (message: any) => {
  console.log('[Worker] Отримано повідомлення:', message);
  
  if (message.task === 'compute') {
    const result = fibonacci(message.n);
    
    // Відправляємо результат назад у батьківський процес
    process.send?.({
      task: 'compute',
      result,
      workerId: process.pid,
    });
  }
});

function fibonacci(n: number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(`[Worker ${process.pid}] Готовий до роботи`);

// Повідомляємо батьківський процес про готовність
process.send?.({ status: 'ready', pid: process.pid });

main.ts — батьківський процес:

// main.ts
import { fork } from 'node:child_process';
import { resolve } from 'node:path';

const worker = fork(resolve(__dirname, 'worker.ts'));

worker.on('message', (message: any) => {
  console.log('[Main] Отримано від Worker:', message);
  
  if (message.status === 'ready') {
    console.log(`[Main] Worker ${message.pid} готовий`);
    
    // Відправляємо завдання у Worker
    worker.send({
      task: 'compute',
      n: 42,
    });
  } else if (message.task === 'compute') {
    console.log(`[Main] Результат обчислення: ${message.result}`);
    
    // Завершуємо Worker після отримання результату
    worker.disconnect();
  }
});

worker.on('exit', (code, signal) => {
  console.log(`[Main] Worker завершено з кодом ${code}`);
});

console.log('[Main] Запуск Worker процесу...');

Вивід:

node main.ts
$ node main.ts
[Main] Запуск Worker процесу...
[Worker 12345] Готовий до роботи
[Main] Отримано від Worker: { status: 'ready', pid: 12345 }
[Main] Worker 12345 готовий
[Worker] Отримано повідомлення: { task: 'compute', n: 42 }
[Main] Отримано від Worker: { task: 'compute', result: 267914296, workerId: 12345 }
[Main] Результат обчислення: 267914296
[Main] Worker завершено з кодом 0

Відмінності fork() від spawn()

Характеристикаfork()spawn()
Тип процесуЛише Node.js скриптиБудь-яка програма
IPC-канал✅ Вбудований (process.send())❌ Немає (лише stdin/stdout)
ВикористанняПаралелізація Node.js кодуЗапуск зовнішніх програм
КомунікаціяStructured messages (objects)Текстові потоки (strings)
OverheadВищий (повний Node.js процес)Нижчий (залежить від програми)
Метод fork() автоматично встановлює змінну оточення NODE_CHANNEL_FD, що дозволяє дочірньому Node.js процесу використовувати process.send() та process.on('message') для комунікації з батьківським процесом. Якщо спробувати викликати process.send() у процесі, що не був створений через fork(), виникне помилка.

Паралельна обробка даних через fork(): Worker Pool

Розглянемо практичний приклад створення пулу дочірніх процесів для паралельної обробки великого масиву даних:

processor-worker.ts — дочірній процес-обробник:

import { readFile } from 'node:fs/promises';

interface Task {
  taskId: string;
  filePath: string;
}

process.on('message', async (task: Task) => {
  try {
    console.log(`[Worker ${process.pid}] Обробка файлу: ${task.filePath}`);
    
    // Читаємо файл
    const content = await readFile(task.filePath, 'utf-8');
    
    // Обробляємо дані (підрахунок слів)
    const words = content.split(/\s+/).filter((w) => w.length > 0);
    const wordCount = words.length;
    const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
    
    // Відправляємо результат
    process.send?.({
      taskId: task.taskId,
      filePath: task.filePath,
      wordCount,
      uniqueWordCount: uniqueWords.size,
      pid: process.pid,
    });
  } catch (error) {
    process.send?.({
      taskId: task.taskId,
      error: (error as Error).message,
      pid: process.pid,
    });
  }
});

process.send?.({ status: 'ready', pid: process.pid });

main.ts — координатор з Worker Pool:

import { fork, ChildProcess } from 'node:child_process';
import { readdir } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { cpus } from 'node:os';

interface Task {
  taskId: string;
  filePath: string;
}

class ProcessPool {
  private workers: ChildProcess[] = [];
  private freeWorkers: ChildProcess[] = [];
  private taskQueue: Task[] = [];
  private results = new Map<string, any>();
  
  constructor(
    private workerScript: string,
    private poolSize: number = cpus().length
  ) {}
  
  async initialize(): Promise<void> {
    console.log(`Створення пулу з ${this.poolSize} процесів...`);
    
    const workerPromises = Array.from({ length: this.poolSize }, (_, i) => {
      return new Promise<void>((resolve) => {
        const worker = fork(resolve(__dirname, this.workerScript));
        
        worker.on('message', (message: any) => {
          if (message.status === 'ready') {
            console.log(`Worker ${message.pid} готовий`);
            this.workers.push(worker);
            this.freeWorkers.push(worker);
            resolve();
          } else {
            this.handleWorkerResult(worker, message);
          }
        });
        
        worker.on('exit', (code) => {
          console.log(`Worker завершено з кодом ${code}`);
          this.removeWorker(worker);
        });
      });
    });
    
    await Promise.all(workerPromises);
    console.log('Пул процесів готовий\n');
  }
  
  private handleWorkerResult(worker: ChildProcess, result: any): void {
    this.results.set(result.taskId, result);
    
    if (result.error) {
      console.error(`✗ Помилка обробки ${result.taskId}: ${result.error}`);
    } else {
      console.log(`✓ Оброблено ${result.filePath} (${result.wordCount} слів)`);
    }
    
    // Повертаємо Worker у пул вільних
    this.freeWorkers.push(worker);
    
    // Обробляємо наступну задачу з черги
    this.processNextTask();
  }
  
  private processNextTask(): void {
    if (this.taskQueue.length === 0 || this.freeWorkers.length === 0) {
      return;
    }
    
    const task = this.taskQueue.shift()!;
    const worker = this.freeWorkers.shift()!;
    
    worker.send(task);
  }
  
  private removeWorker(worker: ChildProcess): void {
    const idx = this.workers.indexOf(worker);
    if (idx !== -1) this.workers.splice(idx, 1);
    
    const freeIdx = this.freeWorkers.indexOf(worker);
    if (freeIdx !== -1) this.freeWorkers.splice(freeIdx, 1);
  }
  
  public async submitTask(task: Task): Promise<void> {
    this.taskQueue.push(task);
    this.processNextTask();
  }
  
  public async waitForCompletion(): Promise<Map<string, any>> {
    return new Promise((resolve) => {
      const checkInterval = setInterval(() => {
        const allTasksProcessed = 
          this.taskQueue.length === 0 &&
          this.freeWorkers.length === this.workers.length;
        
        if (allTasksProcessed) {
          clearInterval(checkInterval);
          resolve(this.results);
        }
      }, 100);
    });
  }
  
  public async terminate(): Promise<void> {
    console.log('\nЗавершення всіх Worker процесів...');
    
    for (const worker of this.workers) {
      worker.disconnect();
      worker.kill();
    }
    
    this.workers = [];
    this.freeWorkers = [];
  }
}

// Використання
async function main() {
  const pool = new ProcessPool('processor-worker.ts', 4);
  await pool.initialize();
  
  // Отримуємо список файлів для обробки
  const files = await readdir('./data');
  const textFiles = files.filter((f) => f.endsWith('.txt'));
  
  console.log(`Відправка ${textFiles.length} файлів на обробку...\n`);
  const startTime = Date.now();
  
  // Відправляємо задачі у пул
  for (const file of textFiles) {
    await pool.submitTask({
      taskId: file,
      filePath: join('./data', file),
    });
  }
  
  // Чекаємо завершення всіх задач
  const results = await pool.waitForCompletion();
  
  const duration = Date.now() - startTime;
  
  // Статистика
  const successful = Array.from(results.values()).filter((r) => !r.error);
  const totalWords = successful.reduce((sum, r) => sum + r.wordCount, 0);
  
  console.log('\n=== Результати ===');
  console.log(`Оброблено файлів: ${successful.length}`);
  console.log(`Загальна кількість слів: ${totalWords}`);
  console.log(`Час виконання: ${(duration / 1000).toFixed(2)}с`);
  
  await pool.terminate();
}

main().catch(console.error);

Вивід:

node main.ts
$ node main.ts
Створення пулу з 4 процесів...
Worker 12346 готовий
Worker 12347 готовий
Worker 12348 готовий
Worker 12349 готовий
Пул процесів готовий
Відправка 12 файлів на обробку...
[Worker 12346] Обробка файлу: ./data/article-01.txt
[Worker 12347] Обробка файлу: ./data/article-02.txt
[Worker 12348] Обробка файлу: ./data/article-03.txt
[Worker 12349] Обробка файлу: ./data/article-04.txt
✓ Оброблено ./data/article-01.txt (2847 слів)
✓ Оброблено ./data/article-02.txt (1923 слів)
...
=== Результати ===
Оброблено файлів: 12
Загальна кількість слів: 28492
Час виконання: 3.47с
Завершення всіх Worker процесів...
Використовуйте fork() для створення Worker Pool, коли:
  • Кожна задача займає >500мс (щоб накладні витрати на IPC були виправданими)
  • Потрібна повна ізоляція — crash одного Worker не впливає на інші
  • Обробляються незалежні задачі (файли, запити, обчислення)
Для коротших задач (<500мс) або коли потрібна спільна пам'ять — використовуйте Worker Threads.

Практичний приклад: інтеграція з Python

Розглянемо реальний сценарій інтеграції Node.js з Python для машинного навчання:

ml_model.py — Python-скрипт з ML-моделлю:

import sys
import json
import numpy as np
from sklearn.linear_model import LinearRegression

def train_and_predict(data_points):
    """Навчання лінійної регресії та передбачення"""
    X = np.array([[x] for x, _ in data_points])
    y = np.array([y for _, y in data_points])
    
    model = LinearRegression()
    model.fit(X, y)
    
    # Передбачення для наступних 5 точок
    future_X = np.array([[max(X)[0] + i] for i in range(1, 6)])
    predictions = model.predict(future_X)
    
    return {
        'slope': float(model.coef_[0]),
        'intercept': float(model.intercept_),
        'predictions': predictions.tolist()
    }

if __name__ == '__main__':
    # Читаємо JSON з stdin
    input_data = json.loads(sys.stdin.read())
    
    result = train_and_predict(input_data['dataPoints'])
    
    # Виводимо JSON у stdout
    print(json.dumps(result))
    sys.stdout.flush()

node-ml-bridge.ts — Node.js інтерфейс:

import { spawn } from 'node:child_process';

interface DataPoint {
  x: number;
  y: number;
}

interface MLResult {
  slope: number;
  intercept: number;
  predictions: number[];
}

function runPythonML(dataPoints: DataPoint[]): Promise<MLResult> {
  return new Promise((resolve, reject) => {
    const python = spawn('python3', ['ml_model.py']);
    
    let stdout = '';
    let stderr = '';
    
    python.stdout.on('data', (data) => {
      stdout += data.toString();
    });
    
    python.stderr.on('data', (data) => {
      stderr += data.toString();
    });
    
    python.on('close', (code) => {
      if (code === 0) {
        try {
          const result = JSON.parse(stdout);
          resolve(result);
        } catch (error) {
          reject(new Error(`Помилка парсингу JSON: ${error}`));
        }
      } else {
        reject(new Error(`Python завершився з кодом ${code}\nSTDERR: ${stderr}`));
      }
    });
    
    python.on('error', (error) => {
      reject(new Error(`Помилка запуску Python: ${error.message}`));
    });
    
    // Відправляємо дані у Python через stdin
    python.stdin.write(JSON.stringify({ dataPoints }));
    python.stdin.end();
  });
}

// Використання
async function main() {
  const trainingData: DataPoint[] = [
    { x: 1, y: 2.1 },
    { x: 2, y: 3.9 },
    { x: 3, y: 6.2 },
    { x: 4, y: 7.8 },
    { x: 5, y: 10.1 },
    { x: 6, y: 11.9 },
  ];
  
  console.log('Навчання ML-моделі через Python...');
  
  try {
    const result = await runPythonML(trainingData);
    
    console.log('\n=== Результати моделі ===');
    console.log(`Нахил (slope): ${result.slope.toFixed(4)}`);
    console.log(`Зміщення (intercept): ${result.intercept.toFixed(4)}`);
    console.log('\nПередбачення для наступних 5 точок:');
    result.predictions.forEach((pred, i) => {
      console.log(`  x=${7 + i}: y ≈ ${pred.toFixed(2)}`);
    });
  } catch (error) {
    console.error('Помилка:', (error as Error).message);
  }
}

main();

Вивід:

node node-ml-bridge.ts
$ node node-ml-bridge.ts
Навчання ML-моделі через Python...
=== Результати моделі ===
Нахил (slope): 1.9857
Зміщення (intercept): 0.1429
Передбачення для наступних 5 точок:
x=7: y ≈ 14.04
x=8: y ≈ 16.03
x=9: y ≈ 18.01
x=10: y ≈ 20.00
x=11: y ≈ 21.99

Цей підхід дозволяє використовувати потужні Python-бібліотеки (NumPy, scikit-learn, TensorFlow) з Node.js застосунків без необхідності переписувати ML-логіку на JavaScript.

Візуалізація: архітектура Child Processes

Розглянемо діаграму взаємодії батьківського процесу Node.js з дочірніми процесами різних типів:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Parent Node.js Process" as Parent #DBEAFE {
  component "HTTP Server" as Server
  component "Child Process Manager" as Manager
}

package "Child Processes" #DCFCE7 {
  component "Node.js Worker 1\n(fork)" as Fork1 #D1FAE5
  component "Node.js Worker 2\n(fork)" as Fork2 #D1FAE5
  component "Python Script\n(spawn)" as Python #FEF3C7
  component "ffmpeg\n(spawn)" as FFmpeg #FEF3C7
}

cloud "Client" as Client #E2E8F0
database "File System" as FS #E2E8F0

Client --> Server : HTTP Request
Server --> Manager : Delegate Task

Manager --> Fork1 : IPC (process.send)
Fork1 --> Manager : IPC (process.send)

Manager --> Fork2 : IPC (process.send)
Fork2 --> Manager : IPC (process.send)

Manager --> Python : stdin (JSON)
Python --> Manager : stdout (JSON)

Manager --> FFmpeg : stdin (video stream)
FFmpeg --> FS : output.mp4
FFmpeg --> Manager : stderr (progress)

note right of Fork1
  Використовує IPC для
  двостороннього обміну
  структурованими даними
end note

note right of Python
  Комунікація через
  stdin/stdout з
  JSON серіалізацією
end note

note right of FFmpeg
  Streaming обробка відео.
  Progress через stderr.
end note

@enduml

Порівняння підходів: Worker Threads vs Child Processes vs Cluster

Node.js надає три механізми паралелізму, кожен з яких має свою нішу:

import { Worker } from 'node:worker_threads';

// Паралелізація JavaScript у межах процесу
const worker = new Worker('./compute.ts', {
  workerData: { n: 1000000 }
});

// ✅ Ідеально для:
// - CPU-інтенсивних JS обчислень
// - Спільної пам'яті (SharedArrayBuffer)
// - Низьких накладних витрат

// ❌ Обмеження:
// - Лише JavaScript/TypeScript
// - Спільний heap (crash може вплинути)

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

КритерійWorker ThreadsChild ProcessesCluster Module
Час створення~10-30мс~50-200мс~50-200мс
Пам'ять на Worker~2-10 МБ~30-50 МБ~30-50 МБ
ІзоляціяСлабкаСильнаСильна
Спільна пам'ять✅ SharedArrayBuffer❌ Ні❌ Ні
Типи програмЛише JSБудь-якіЛише Node.js
IPC швидкістьДуже швидкаСередняСередня
ВикористанняCPU-bound JSЗовнішні програмиHTTP/WebSocket servers

Безпека: валідація та екранування команд

При роботі з Child Processes критично важливо запобігати command injection атакам:

Небезпечні паттерни

import { exec } from 'node:child_process';

// ❌ КРИТИЧНА ВРАЗЛИВІСТЬ: user input у exec()
app.get('/api/file', (req, res) => {
  const filename = req.query.filename; // "file.txt; rm -rf /"
  
  exec(`cat ${filename}`, (error, stdout) => {
    // Shell виконає ДВІ команди:
    // 1. cat file.txt
    // 2. rm -rf / (видалить всю файлову систему!)
    res.send(stdout);
  });
});

Безпечні підходи

Крок 1: Використовуйте spawn() замість exec()

import { spawn } from 'node:child_process';

app.get('/api/file', (req, res) => {
  const filename = req.query.filename;
  
  // ✅ БЕЗПЕЧНО: filename передається як аргумент, не інтерпретується shell
  const cat = spawn('cat', [filename]);
  
  cat.stdout.pipe(res);
  cat.stderr.on('data', (data) => {
    console.error('Error:', data.toString());
  });
});

Крок 2: Валідуйте user input

import { spawn } from 'node:child_process';
import { resolve, normalize } from 'node:path';

app.get('/api/file', (req, res) => {
  const filename = req.query.filename as string;
  
  // ✅ Валідація: лише буквено-цифрові символи та дефіс
  if (!/^[\w\-\.]+$/.test(filename)) {
    return res.status(400).json({ error: 'Invalid filename' });
  }
  
  // ✅ Обмеження директорії: лише /data/files/
  const allowedDir = resolve(__dirname, 'data/files');
  const requestedPath = normalize(resolve(allowedDir, filename));
  
  if (!requestedPath.startsWith(allowedDir)) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  const cat = spawn('cat', [requestedPath]);
  cat.stdout.pipe(res);
});

Крок 3: Використовуйте whitelist дозволених команд

const ALLOWED_COMMANDS = new Map([
  ['imagemagick', '/usr/bin/convert'],
  ['ffmpeg', '/usr/bin/ffmpeg'],
  ['git', '/usr/bin/git'],
]);

function runAllowedCommand(
  command: string,
  args: string[]
): Promise<string> {
  const execPath = ALLOWED_COMMANDS.get(command);
  
  if (!execPath) {
    throw new Error(`Command "${command}" not allowed`);
  }
  
  return new Promise((resolve, reject) => {
    const child = spawn(execPath, args);
    let stdout = '';
    
    child.stdout.on('data', (data) => {
      stdout += data.toString();
    });
    
    child.on('close', (code) => {
      if (code === 0) resolve(stdout);
      else reject(new Error(`Exit code ${code}`));
    });
  });
}

// Використання
runAllowedCommand('imagemagick', ['-resize', '50%', 'input.jpg', 'output.jpg']);
Критичні правила безпеки:
  1. Ніколи не використовуйте exec() з user input
  2. Завжди валідуйте user input (whitelist, regex, path traversal)
  3. Використовуйте spawn() з масивом аргументів
  4. Обмежуйте дозволені команди через whitelist
  5. Встановлюйте таймаути для запобігання DoS атакам
  6. Логуйте всі виклики команд для аудиту безпеки

Підсумок та рекомендації

Child Processes є потужним механізмом інтеграції Node.js з зовнішніми програмами та масштабування застосунків, але вимагають обережного використання.

Коли використовувати Child Processes

✅ Рекомендовано

Сценарії використання:

  • Запуск системних утиліт (git, docker, curl, tar)
  • Конвертація медіа через ffmpeg, ImageMagick
  • Інтеграція з Python/Ruby/Go скриптами
  • Виконання bash/shell скриптів
  • Ізоляція ненадійного коду (plugins, user scripts)
  • Паралельна обробка незалежних задач
  • Масштабування HTTP-серверів (через Cluster)

Критерії:

  • Потрібно запустити не-JavaScript програму
  • Необхідна повна ізоляція (crash не повинен впливати)
  • Задача займає >500мс (накладні витрати виправдані)

❌ НЕ рекомендовано

Антипаттерни:

  • Короткі операції (<100мс) — overhead перевищує виграш
  • JavaScript обчислення — використовуйте Worker Threads
  • I/O операції — використовуйте async/await
  • Потрібна спільна пам'ять — використовуйте Worker Threads
  • User input у команди без валідації — command injection!

Альтернативи:

  • Worker Threads для JavaScript коду
  • async/await для I/O операцій
  • Нативні модулі (N-API) для С/С++ інтеграції
  • Cluster Module для HTTP-серверів

Чек-лист використання Child Processes

Вибір методу
checklist
  • exec() — для простих shell-команд з малим виводом (<1 МБ)
  • spawn() — для процесів з великим виводом або streaming
  • fork() — для дочірніх Node.js процесів з IPC
  • execFile() — для виконуваних файлів без shell
Безпека
checklist
  • Валідація всього user input (whitelist, regex)
  • Використання spawn() замість exec() з user data
  • Обмеження дозволених команд через whitelist
  • Перевірка path traversal атак
  • Встановлення таймаутів для всіх процесів
Обробка помилок
checklist
  • Обробка події 'error' (помилка запуску)
  • Обробка події 'close' (завершення процесу)
  • Логування stderr для діагностики
  • Перевірка exit code (0 = успіх)
  • Graceful shutdown через SIGTERM перед SIGKILL
Продуктивність
checklist
  • Використання Worker Pool для повторних задач
  • Streaming обробка для великих виводів
  • Обмеження кількості одночасних процесів
  • Моніторинг споживання пам'яті
  • Вимірювання накладних витрат vs виграшу

Корисні бібліотеки та інструменти

execa
npm package
Покращена версія child_process з Promise API, кращою обробкою помилок та streaming.
import { execa } from 'execa';

const { stdout } = await execa('git', ['log', '--oneline', '-n', '5']);
console.log(stdout);
cross-spawn
npm package
Кросплатформенний spawn, що правильно обробляє відмінності Windows/Unix.
npm install cross-spawn
shelljs
npm package
Unix shell команди у Node.js (ls, cd, cp, rm) з кросплатформенною підтримкою.
import shell from 'shelljs';

shell.exec('git commit -am "Update"');
pm2
process manager
Production process manager для Node.js з load balancing, моніторингом та автоматичним restart.
npm install -g pm2
pm2 start app.js -i max  # Запуск на всіх CPU ядрах

Практичні приклади використання

Конвертація відео через ffmpeg

import { spawn } from 'node:child_process';
import { createWriteStream } from 'node:fs';

function convertVideo(
  inputPath: string,
  outputPath: string
): Promise<void> {
  return new Promise((resolve, reject) => {
    const ffmpeg = spawn('ffmpeg', [
      '-i', inputPath,
      '-c:v', 'libx264',
      '-crf', '23',
      '-c:a', 'aac',
      '-b:a', '128k',
      '-progress', 'pipe:2', // Progress у stderr
      outputPath,
    ]);
    
    ffmpeg.stderr.on('data', (data) => {
      const progress = data.toString();
      
      // Парсимо прогрес
      const timeMatch = progress.match(/time=(\d+):(\d+):(\d+\.\d+)/);
      if (timeMatch) {
        const [, hours, minutes, seconds] = timeMatch;
        const totalSeconds = 
          parseInt(hours) * 3600 +
          parseInt(minutes) * 60 +
          parseFloat(seconds);
        
        console.log(`Прогрес: ${totalSeconds.toFixed(1)}с`);
      }
    });
    
    ffmpeg.on('close', (code) => {
      if (code === 0) resolve();
      else reject(new Error(`ffmpeg exit code ${code}`));
    });
  });
}

// Використання
convertVideo('./input.mp4', './output.mp4')
  .then(() => console.log('Конвертація завершена'))
  .catch(console.error);

Виконання Git-команд

import { spawn } from 'node:child_process';

async function gitCommitAndPush(message: string): Promise<void> {
  // git add .
  await runGitCommand(['add', '.']);
  
  // git commit -m "message"
  await runGitCommand(['commit', '-m', message]);
  
  // git push origin main
  await runGitCommand(['push', 'origin', 'main']);
}

function runGitCommand(args: string[]): Promise<string> {
  return new Promise((resolve, reject) => {
    const git = spawn('git', args);
    let stdout = '';
    let stderr = '';
    
    git.stdout.on('data', (data) => {
      stdout += data.toString();
    });
    
    git.stderr.on('data', (data) => {
      stderr += data.toString();
    });
    
    git.on('close', (code) => {
      if (code === 0) {
        console.log(`✓ git ${args.join(' ')}`);
        resolve(stdout);
      } else {
        reject(new Error(`Git error: ${stderr}`));
      }
    });
  });
}

gitCommitAndPush('Add new feature')
  .then(() => console.log('Changes pushed successfully'))
  .catch(console.error);

Література та джерела:

Copyright © 2026