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, который оболочку не поднимает.