child_process в Node.js: exec, execFile, spawn и fork

✓ Все примеры выполнены на Node.js v22.23.1 (LTS), июль 2026

Содержание

Node однопоточен для вашего JavaScript, но никто не мешает запустить рядом другой процесс: конвертер видео, git, скрипт на Python. За это отвечает встроенный модуль node:child_process.

Проблема в том, что у него четыре похожих функции, и выбор между ними — не вкусовщина, а вопрос безопасности.

Чем отличаются exec, execFile, spawn и fork

Функция Оболочка Вывод Когда брать
execFile нет буфер в памяти по умолчанию для коротких команд
spawn нет поток долгие задачи, большой вывод
exec да буфер в памяти только если реально нужны пайпы и &&
fork нет поток + канал IPC запуск другого Node-скрипта с обменом сообщениями

Ключевой столбец — второй. Оболочка (cmd.exe или /bin/sh) означает, что вашу командную строку сначала разберёт кто-то другой.

Как запустить программу: execFile

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

const execFileAsync = promisify(execFile);

const { stdout } = await execFileAsync(process.execPath, ['-e', 'console.log("привет из дочернего")']);
console.log('stdout:', stdout.trim());
stdout: привет из дочернего

Программа и её аргументы разделены: путь отдельно, массив аргументов отдельно. Никто ничего не парсит — ОС получает их как есть.

Почему exec опасен

exec принимает одну строку и отдаёт её оболочке. Вот что из этого выходит на практике.

Беда первая — строка ломается на пробелах. Реальный случай из проверки этих примеров: путь к Node на Windows содержит пробел, и exec без кавычек развалился:

await execAsync(`${process.execPath} -e "console.log(1)"`);
без кавычек путь с пробелом ломается -> "C:\Program" не является внутренней или внешней командой

Оболочка увидела C:\Program и Files\nodejs\node.exe как разные слова. С кавычками — работает:

await execAsync(`"${process.execPath}" -e "console.log('привет из оболочки')"`);
с кавычками работает : привет из оболочки

Беда вторая — и главная. Всё, что попало в строку, оболочка тоже считает командой. Если туда подставляется что-то от пользователя, он может дописать && и свою команду: exec('convert ' + userFile) при userFile = "a.png && rm -rf /" выполнит обе. Это классическая инъекция команд.

execFile этой проблемы лишён по устройству. Передадим ему заведомо «злую» строку аргументом:

const evilInput = 'x" && echo ВЫПОЛНЕНА_ЧУЖАЯ_КОМАНДА && echo "';
const safe = await execFileAsync(process.execPath, ['-e', 'console.log(process.argv[1])', evilInput]);
то же через execFile : x" && echo ВЫПОЛНЕНА_ЧУЖАЯ_КОМАНДА && echo "   <- просто строка, не команда

Строка пришла в программу обычным значением аргумента. Оболочки нет — интерпретировать некому.

Правило: если в команду попадает хоть что-то, пришедшее извне, exec использовать нельзя.

Куда уходит ваша команда: с оболочкой и без

exec — через оболочку

Строку разбирает cmd.exe или /bin/sh

  • Вы отдаёте одну строку
  • Оболочка делит её по пробелам
  • Путь C:\Program Files\… разваливается
  • &&, |, $() из данных пользователя выполнятся
  • Законно: только если нужны пайпы и вся строка ваша

execFile / spawn — напрямую

Оболочки нет, интерпретировать некому

  • Программа отдельно, аргументы массивом
  • Пробелы в пути не проблема
  • && rm -rf / придёт просто строкой
  • Инъекция невозможна по устройству
  • Выбор по умолчанию

Как поймать код возврата и stderr

Промисифицированные exec/execFile считают ненулевой код выхода ошибкой и бросают исключение. Всё нужное лежит в объекте ошибки:

try {
  await execFileAsync(process.execPath, ['-e', 'console.error("в stderr"); process.exit(3)']);
} catch (err) {
  console.log('поймали ошибку | code:', err.code, '| stderr:', err.stderr.trim());
}
поймали ошибку | code: 3 | stderr: в stderr

Важно: вывод в stderr сам по себе ошибкой не является. Многие программы пишут туда предупреждения и прогресс, завершаясь с кодом 0. Судите по коду выхода, а не по наличию текста в stderr.

Почему падает на большом выводе: maxBuffer

exec и execFile копят весь вывод в памяти. Лимит по умолчанию — 1 МБ, при превышении процесс убивается:

await execFileAsync(process.execPath,
  ['-e', 'console.log("x".repeat(2 * 1024 * 1024))'],
  { maxBuffer: 1024 });
превысили maxBuffer -> ERR_CHILD_PROCESS_STDIO_MAXBUFFER

Поднять лимит можно, но это лечение симптома. Если вывод потенциально большой — нужен spawn.

Как получать вывод потоком: spawn

spawn не копит ничего: stdout и stderr — обычные потоки, читаемые по мере поступления. Для долгих задач это ещё и означает, что прогресс виден сразу, а не в конце.

import { spawn } from 'node:child_process';
import { once } from 'node:events';

const child = spawn(process.execPath, ['-e', 'for (let i = 1; i <= 3; i++) console.log("строка " + i)']);

let collected = '';
child.stdout.on('data', (chunk) => { collected += chunk; });

const [code] = await once(child, 'close');
console.log('код выхода:', code);
console.log(collected.trim());
код выхода: 0
строка 1
строка 2
строка 3

Раз это потоки, их можно соединять напрямую — например, отдать вывод дочернего процесса прямо в HTTP-ответ через pipeline, не собирая в памяти. Подробнее про такие конвейеры — в статье про потоки и pipeline, а про сам ответ сервера — в разборе HTTP-сервера.

Событие close мы ждём через events.once — так событийный API дочернего процесса встраивается в обычный async/await.

Когда нужен fork

fork — частный случай spawn для запуска другого Node-файла. Его особенность — канал обмена сообщениями:

// родитель
const child = fork('./worker.js');
child.send({ task: 'посчитать' });
child.on('message', (msg) => console.log('от дочернего:', msg));

// worker.js
process.on('message', (msg) => {
  process.send({ result: 42 });
});

Важная оговорка: fork поднимает отдельный процесс Node со своей памятью и своим V8 — это десятки мегабайт. Мы замеряли базовый расход: пустой процесс занимает около 43 МБ RSS. Если задача просто в тяжёлых вычислениях, дешевле worker_threads: потоки внутри одного процесса делят память и стартуют быстрее.

Что было на этой странице в 2017 году

Первая версия этого разбора вышла 18 августа 2017 года и начиналась с честного перечисления возможностей модуля:

«С помощью модуля child_process мы можем запускать команды shell… Модуль позволяет запускать параллельные дочерние процессы… Модуль позволяет обмениваться сообщениями между собой.»

Все три пункта верны и сегодня — API child_process за восемь лет почти не изменился. Изменился контекст вокруг него.

Что стоит добавить к той версии:

  • Про безопасность. Тогда «запускать команды shell» подавалось как возможность, без оговорок. Сегодня понятно, что это же и главный риск модуля: exec поднимает оболочку, и всё, что пришло от пользователя, она интерпретирует. Раздел про инъекцию выше — то, чего в 2017-м на этой странице не было.
  • Про промисы. Импорт выглядел так: const exec = require("child_process").exec;, а все примеры были на колбэках. util.promisify к тому моменту уже существовал — он появился в Node 8, за три месяца до публикации, — но в статье не упоминается ни разу. Привычкой он тогда ещё не стал.
  • Про fork. Тогда обмен сообщениями между процессами был основным способом задействовать несколько ядер. Сегодня для вычислений внутри одного приложения есть worker_threads (стабильны с Node 12) — они дешевле, потому что не поднимают отдельный процесс V8.

Ирония в том, что самый частый повод лезть в child_process в 2017-м — «запустить что-то параллельно» — сегодня решается другим модулем. А сам child_process остался ровно для того, чем и был: запускать чужие программы.

Что выбрать

  • Короткая команда, вывод маленькийexecFile.
  • Долгая задача или большой выводspawn.
  • Нужны именно пайпы, &&, подстановки оболочки, и все части команды вашиexec.
  • Второй Node-скрипт с обменом даннымиfork; если нужны только вычисления — worker_threads.

Честная оговорка к этому списку: exec в реальном коде почти всегда оказывается не «осознанным выбором ради пайпов», а привычкой — он первый в документации и принимает знакомую строку. Если вы тянетесь к нему по привычке, замена на execFile не будет стоить вам ничего.


Смежные темы: потоки — как читать вывод spawn без буфера; события — почему у дочернего процесса есть on('close'); объект process — как ваш скрипт выглядит с той стороны. Полный список — в справочнике по Node.js.

Частые вопросы

Дочерний процесс — это способ обойти однопоточность Node.js?
Отчасти. Он действительно снимает нагрузку с основного цикла, но платите отдельным процессом и сериализацией данных при обмене. Для вычислений внутри своего кода worker_threads дешевле: потоки делят память и стартуют быстрее.
Как передать данные в дочерний процесс?
Аргументами (execFile/spawn), через stdin, через переменные окружения ({ env: {...} }) или сообщениями child.send(), если процесс запущен через fork.
Что делать с зависшим дочерним процессом?
Убить через child.kill(). Надёжнее сразу задавать таймаут опцией { timeout: 5000 } — тогда Node прибьёт процесс сам.
child_process работает одинаково на Windows и Linux?
Нет. Оболочка разная (cmd.exe против /bin/sh), синтаксис команд разный, часть сигналов на Windows недоступна. Это ещё одна причина не полагаться на exec.
Почему exec падает на пути с пробелом?
Потому что exec отдаёт всю строку оболочке, а та делит её по пробелам. Путь C:\Program Files\nodejs\node.exe превращается в два слова. Либо кавычьте путь, либо используйте execFile, который оболочку не поднимает.