Дочерние процессы в Node.js
- среда, 21 января 2026 г. в 00:00:04
В предыдущих статьях мы подробно рассмотрели воркеры в JavaScript. В этой уделим внимание похожему на них механизму, созданному для параллельных вычислений. В модуле child_process, определены функции для запуска других программ как дочерних процессов.
Если воркеры запускают отдельный поток внутри одного процесса в котором работает программа, то модуль child_process порождает новый процесс в ОС, что является гораздо более тяжёлой операцией. Архитектура многопоточки в JavaScript, такова что потоки максимально изолированы друг от друга и не имеют общей памяти. Тем не менее есть масса способов обойти это ограничение если очень нужно и в своих статьях я приводил подобные примеры. С процессами всё иначе. Они жёстко изолированы, один от другого и пробить этот барьер способов нет.
Если говорить простым языком то воркер, это как нанять ещё одного гребца на Вашу галеру. С ним можно быстро и легко общаться, ведь вы буквально находитесь на одном суде (процессе) и даже на прямую без посредников обмениваться сообщениями (SharedArrayBuffer). Но вот если он зарулит на скалы, то и потоните вы тоже вместе. Дочерний процесс же это как построить новую галеру с другим капитаном и экипажем. Дорого, долго, обменивать данными можно только через медленные записки (IPC), зато он полностью независим от Вас, а Вы от него.
Принимает в качестве аргумента команду для запуска и создаёт дочерний процесс, через полноценную командную оболочку. Как можно догадаться из названия, функция является синхронной и после запуска блокирует основной поток, на время работы дочернего процесса. Если в дочерней программе возникнет ошибка, то метод execSync() сгенерирует исключение Error: Command failed. Вывод метода идёт в stdout, основного потока.
Вторым аргументом, execSync() принимает объект с настройками. Список опций значителен, ниже подробно рассмотрим как они влияют на работу дочернего процесса.
По умолчанию execSync() возвращает буфер. Данная опция меняет вывод на строку, в указанной кодировке:
import { execSync } from 'child_process'
console.log(execSync('node --version', { encoding: 'utf8' })) // Используем кодировку UnicodeПоддерживается работа с множеством кодировок (latin1, ascii, hex и т.д.), но для большинства задач, выбор стоит между utf8 и стандартным буфером.
В дочернем процессе запускается команда с текстовым выводом;
Работа идёт с конфигами или логами;
Результат необходимо выводить пользователю сразу.
Работа идёт с бинарными данным;
Данные нужно сохранить в файл;
Данные получаются для передачи другому процессу;
Нужно экономить ресурсы, так как преобразование стандартного буфера в строку, требует дополнительных вычислительных ресурсов.
Current Working Directory - указывает рабочий каталог дочернего процесса. Если данный аргумент опущен, то он берётся из метода process.cwd().
import { execSync } from 'child_process'
const result = execSync('ls -la', {
encoding: 'utf8',
cwd: '/path/to/directory' // Команда выполнится в этой директории
})
console.log(result) // Выводим содержимое директорииДанную опцию удобно использовать, если для запуска дочернего процесса требуется команда cd.
Переменные среды, для дочернего процесса:
import { execSync } from 'child_process'
const result = execSync('echo "Node: $NODE_ENV, Port: $PORT, Debug: $DEBUG"', {
encoding: 'utf8',
env: {
NODE_ENV: 'production',
PORT: '3000',
DEBUG: 'app:*'
}
})
console.log(result)Если данный аргумент опущен, то он заполняется свойствами из process.env.
Позволяет передавать данные в стандартный ввод (stdin) дочернего процесса:
import { execSync } from 'child_process'
const result = execSync('cat', {
input: 'Привет из stdin!',
encoding: 'utf8'
})
console.log(result) // "Привет из stdin!"Если нужно запустить интерактивное приложение;
Для передачи данных между процессами.
Данная опция доступна только для синхронных версий функций.
Максимальное количество байтов вывода для stdout и stderr. Если дочерний процесс превысит указанный лимит, то он будет уничтожен и завершится ошибкой:
import { execSync } from 'child_process'
const result = execSync('echo "Привет Васятка!"', {
encoding: 'utf8',
maxBuffer: 1 // Всего 1 байт, получим ошибку с кодом ENOBUFS
})
console.log(result)Данный параметр позволяет предотвратить переполнение памяти, если вывод запускаемой программы будет слишком большим. Значение по умолчанию для execSync(), равно 1 мегабайту, поэтому если планируется работа с программой с интенсивным выводом, стоит увеличить значение.
Указывает какую командной оболочки использовать для дочернего процесса, принимая путь к исполняемому файлу оболочки. Например вот такой код запущенный на винде, позволит посмотреть содержимое директории при помощи команды ls (которой нет в Windows), через оболочку Git Bash:
import { execSync } from 'child_process'
const result = execSync('ls -la', {
encoding: 'utf8',
shell: 'C:\\Program Files\\Git\\bin\\bash.exe'
});
console.log(result)По умолчанию execSync() использует стандартную оболочку текущей ОС:
cmd.exe - в Windows;
/bin/sh в Linux.
Реализация кроссплатформенности, может потребовать выбор определённой оболочки в одной из ОС, чтобы не менять остальной код;
Нужны фичи конкретной оболочки.
Максимальное время работы дочернего процесса в миллисекундах. Если дочерний процесс не уложится в лимит, то будет уничтожен и завершится ошибкой:
import { execSync } from 'child_process'
const result = execSync('curl -I habr.com', {
encoding: 'utf8',
timeout: 100 // 100 миллисекунд
})
console.log(result) // Работа завершится ошибкой ETIMEDOUTДля сетевых запросов, чтобы программа не зависла, при долгом ответе сервера;
Для ненадёжных операций.
Идентификатор пользователя (user identifier), от имени которого будет запущен дочерний процесс:
import { execSync } from 'child_process'
const result = execSync('id -u', {
uid: 1000, // Устанавливает UID для дочернего процесса
encoding: 'utf8'
});
console.log(result) // 1000Позволяет запустить дочерний процесс от имени менее привилегированного пользователя, чем тот от имени которого был запущен главный процесс. Если попытаться запустить процесс из под более привилегированного пользователя, возникнет ошибка EPERM.
Является эквивалентом setuid в Linux;
Работает только в Lunux. Попытка запустить дочерний процесс с данной опцией в Windows приведёт к ошибке ENOTSUP
Данная опция позволяет установить идентификатор группы (Group ID), дочернему процессу:
import { execSync } from 'child_process'
const result = execSync('groups', {
gid: 10, // Устанавливает группу для дочернего процесса
encoding: 'utf8'
});
console.log(result) // wheel группа с gid = 10Как и в случае с uid родительский процесс должен быть запущен с достаточным уровнем прав, чтобы дочерние процессы могли запускаться, от указанной группы.
Указывает сигнал отправляемый дочернему процессу, при его принудительном завершении:
import { execSync } from 'child_process'
try {
const result = execSync('sleep 3', {
timeout: 1000, // Заведомо короткий лимит на выполннение
killSignal: 'SIGTERM' // Сигнал отправляемый при превышении таймаута
});
} catch (error) {
console.log('Процесс завершен с сигналом:', error.signal); // 'SIGTERM'
}В примере выше, чтобы имитировать ошибку, используется ещё одна опция: timeout, так как killSignal имеет смысл использовать только в связке с другими опциями, способными принудительно завершить дочерний процесс.
SIGTERM - корректное и мягкое завершение процесса. Является значением по умолчанию. Такой сигнал можно перехватить и обработать. Данный сигнал это вежливая просьба завершить процесс и некоторых случаях может быть проигнорирована. Остановка процесса таким сигналом, не является гарантированной;
SIGINT - сигнал возникающий при нажатии Ctrl+C в терминале. Как и SIGTERM его можно перехватить. SIGINT отличается от прочих сигналов, тем что он является пользовательским, а не инициированным самой ОС. Он может быть полезен для:
Работы с утилитами способными корректно обрабатывать SIGINT;
Интерактивных программ;
Долгие вычисления, которые может потребоваться прервать, но сохранив при этом весь прогресс.
SIGKILL (Signal Kill) - немедленное, принудительное завершения процесса. Это уже не вежливая просьба, а выдёргивание из розетки. Перехватить или проигнорировать данный сигнал невозможно. Он гарантированно завершает процесс. Применять столь жёсткие меры стоит только, если сигнал дочерний процесс проигнорировал более мягкий SIGTERM и не остановился. Или если ситуация аварийная, принудительная остановка требуется немедленно.
Используется для скрытия консольного окна дочернего процесса в Windows. Опция нужна чтобы консоль не появлялась при выполнении фоновых задач. В Linux и macOS данная опция игнорируется.
Запуск сборок, инициализаций, развёртываний, когда продолжение работы основного процесса, не имеет смысла, до завершения работы дочернего;
Быстрые вызовы, не способные на долго блокировать основной процесс.
Обработка пользовательского ввода через execSync(), это даже не дыра, это дырень в безопасности;
Длительные операции, способные на долго заблокировать родительский процесс;
Запуск перманентных процессов, типа серверов, брокеров сообщений и т.д., которые не подразумевают завершения своей работы, а находятся в постоянном ожидании.
В отличии от execSync(), для запуска подпрограммы не использует команду оболочку, а выполняет её на прямую:
import { execFileSync } from 'child_process'
const result = execFileSync('ls', ['-la']);
console.log(result.toString()) // Преобразуем полученный буффер в строкуПринимает 3 аргумента:
Исполняемый файл. Почему исполняемый файл, ведь мы передаём сюда, обычные команды как и в execSync()? Дело в том когда в Linux мы пишем команду, например ls, под капотом запускается исполняемый файл соответствующего приложения. То есть например две команды ниже, тождественны:
/bin/ls -la
ls -laПуть к нужному приложению ОС находит в переменной окружения PATH;
Массив флагов для команды;
Объект с опциями, которые полностью идентичны, опциям execSync(), поэтому нет смысла останавливаться на них отдельно.
Как прочие функции Node.js , execSync() и execFileSync() имеют асинхронные версии.
Асинхронный аналог execSync(). Может принимать 3 аргумента:
Команда для выполнения. Обязательный;
Объект с опциями. Опциональный;
Коллбэк-функция. Опциональный. Принимает 3 аргумента:
Ошибка возникшую при запуске дочернего процесса;
Вывод запускаемой команды;
Ошибки и предупреждения полученные из запущенной программы.
import { exec } from 'child_process'
exec('ls -la', (error, stdout, stderr) => {
if (error) {
console.error(`Ошибка выполнения: ${error.message}`)
}
if (stderr) {
console.error(`Ошибка команды: ${stderr}`)
}
console.log(`Вывод команды: ${stdout}`)
})Как видно в примере выше, мы пропустили объект с опциями и вторым аргументом следует коллбэк функция. Так делать можно если опции нам не нужны, но важно соблюдать порядок. Если поставить вторым аргументом коллбэк, а потом объект с опциями, то они будут проигнорированы. Опции отдельно рассматривать не станем, так как они идентичны опциям execSync().
Вместо буфера exec() возвращает объектChildProcess, дающий дополнительные возможности для взаимодействия с дочерним процессом и отслеживать его состояние в реальном времени.
При помощи свойств данного объекта можно узнать дополнительную информацию о дочернем процессе и его состоянии:
import { exec } from 'child_process'
const childProcess = exec('ls -la')
console.log(childProcess.pid) // ID процесса
console.log(childProcess.killed) // Был ли процесс убит
console.log(childProcess.exitCode) // Код выхода (если завершен)Так-же данный объект содержит свойства позволяющие повесить обработчики на события возникающие при работе дочернего процесса:
import { exec } from 'child_process'
const childProcess = exec('ls -la')
// Данные из stdout
childProcess.stdout.on('data', (data) => {
console.log(data.toString())
})
// Данные из stderr
childProcess.stderr.on('data', (data) => {
console.error(data.toString())
});
// Корректное завершение процесса
childProcess.on('close', (code) => {
console.log(`Процесс завершен с кодом: ${code}`)
})
// Процесс завершен с ошибкой
childProcess.on('exit', (code, signal) => {
console.log(`Выход с кодом: ${code}, сигнал: ${signal}`)
})
// Ошибка при запуске
childProcess.on('error', (error) => {
console.error(error)
})Завершает дочерний процесс, через отправку сигнала ОС:
import { exec } from 'child_process'
const child = exec('sleep 30', (error, stdout, stderr) => {
// callback отработает после того как мы принудительно завершим процесс
if (error) {
console.error(`Процесс завершился с ошибкой: ${error.message}`)
console.error(`Сигнал завершения: ${error.signal}`)
}
})
setTimeout(() => {
console.log('Завершаем дочерний процесс')
const killSignal = child.kill() // Сигнал отправляемый по умолчанию: SIGTERM
console.log(`Сигнал отправлен: ${killSignal}`)
}, 3000)В качестве аргумента принимает сигнал которым нужно завершить дочерний процесс. Подробно все возможные сигналы мы рассмотрели в блоке про опцию killSignal, функции execSync(), поэтому тут повторяться не будем.
По умолчанию родительский процесс не может завершиться, пока работает хотя бы один дочерний процесс. При создании дочернего процесса, он добавляется в цикл событий (event loop) родителя. Пока в цикле событий будет хоть один активный элемент, будь-то асинхронная функция, сетевое подключение или дочерний процесс, основной процесс не может завершиться и будет продолжать работу в режиме ожидания. Метод unref() удаляет дочерний процесс из цикла событий, тем самым позволяя завершить основной процесс раньше дочернего:
import { exec } from 'child_process'
const child = exec('sleep 10')
// Закрываем потоки дочернего процесса, чтобы он мог завершиться
child.stdin.end()
child.stdout.destroy()
child.stderr.destroy()
child.unref() // Без вызова unref() цикл продолжит ожидать окончания работы дочернего процесса, а с ним завершится, сразу после вывода в консоль ниже
console.log('Основная работа родителя выполнена. Цикл событий проверяет, есть ли активные задачи')Запуск второстепенных фоновых процессов, например логгера;
Когда дочерний процесс может доделать свою работу самостоятельно и ожидающий родитель, только зря расходует ресурсы;
Плавное завершение программы, когда мы завершаем родительский процесс, но даём спокойно отработать всем его дочкам.
После вызова unref() родительский процесс теряет всякую связь с дочерним и уже не может управлять им. Но что если он передумает? Как восстановить утерянную родственную связь? Именно для этого и существует метод ref(), отменяющий действие unref(). Модернизируем предыдущий пример, чтобы он демонстрировал работу ref():
import { exec } from 'child_process'
const child = exec('sleep 10')
// Закрываем потоки, чтобы исключить их влияние
child.stdin.end()
child.stdout.destroy()
child.stderr.destroy()
child.unref() // Отсоединяем дочерний процесс
setTimeout(() => {
console.log('Передумал! Доча я дождусь тебя!')
child.ref() // Восстанавливаем связь с дочерним процессом. Теперь родительский процесс будет ждать завершения дочернего
}, 2000)Использование ref() имеет смысл, только если ранее был вызван метод unref(). Оба метода дают продвинутый контроль над жененным циклом дочерних процессов и могут пригодиться для создания фоновых демонов, которые не должны зависеть от работы основного приложения.
Осуществляет потоковый доступ к выводу дочернего процесса, при выполнении, давая возможность, записывать данные в дочерний процесс, через ввод в его стандартном потоке. spawn() позволяет динамически общаться с дочерним процессом и гибко управлять им.
По умолчанию не использует командную оболочку. Как и в execFile(), аргументы командной строки передаются во втором аргументе в виде массива. Результат работы возвращается в виде объекта ChildProcess:
import { spawn } from 'child_process'
const child = spawn('find', ['.', '-name', '*.js']) // Найдём все JavaScript файлы в директории
child.stdout.on('data', (data) => { // Данные из вывода приходят потоком
console.log(data)
})
child.stderr.on('data', (error) => { // Ошибки тоже приходят потоком
console.error(error)
})
child.on('close', (code) => {
console.log(`Дочерний процесс завершился с кодом: ${code}`)
})Получаемые из дочернего процесса данные можно сразу начать обрабатывать, не дожидаясь, окончательного завершения работы запущенной команды.
spawn() может не только получать данные из дочернего процесса, но отравлять их в него, через поток stdin, являющийся Writeable, в отличи от Readable потоков stdout и stderr:
import { spawn } from 'child_process'
const child = spawn('wc', ['-m']) // Команда посчитывающая количество символов
child.stdin.write('Это данные для подсчета длины строки') // Передаём на вход дочернего процесса строку
child.stdout.on('data', (data) => {
console.log(data.toString())
})
child.stdin.end() // Закрываем поток, иначе дочерний процесс не завершитьсяПодходят для запуска длительных и асинхронных операций, работающих в реальном времени (например логирование, архивирование, чтение больших файлов);
Не используют оболочку, всегда запуская команды на прямую;
Работа идёт на основе потоков, данные обрабатываются постепенно, по мере поступления.
Функция позволяющая запускать модули JS, как дочерние процессы. Данная функция хорошо оптимизирована именно под запуск кода JavaScript и при запуске автоматически настраивает IPC-канал (Inter-Process Communication) - дуплексную систему обмена сообщениями, схожую с сокетами и созданную специально для взаимодействия между процессами Node.js.
Как и в случае с методами, часть опций принимаемых fork() уже рассматривались в контексте других функций работающих с дочерними процессами, далее сосредоточимся только на уникальных.
Управляет наследованием потоков ввода/вывода родительского процесса. Значение по умолчанию false, но если установить true, то вывод дочернего процесса отделится от родительского. То есть если явно указать значение по умолчанию в главном потоке:
import { fork } from 'child_process'
const child = fork('child.js', [], {
silent: false // Эквивалент stdio: [stdin, stdout, stderr, ipc]
})То выводы дочернего процесса:
console.log('Этот вывод виден в консоли родителя')Пойдёт прямиком в вывод родителя и будет виден в консоли. А если установить противоположное значение, то для того чтобы увидеть вывод дочернего процесса, нужно будет заморочиться, перехватив его, через событие:
import { fork } from 'child_process'
const child = fork('./child.js', [], {silent: true})
child.stdout.on('data', (data) => {
console.log('Дочерний stdout:', data.toString())
})Данная опция может быть полезной в продакшене, чтобы в вывод главного процесса не шёл лишний шум от дочек.
Standard Input/Output - стандартный ввод/вывод - массив определяющие работу потоков ввода/вывода. Настройку невозможно изменить, после запуска дочернего процесса. Например, можно вместо опции silent: true, отделить вывод дочернего процесса через stdio:
import { fork } from 'child_process'
const child = fork('child.js', [], {
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
})
child.stdout.on('data', (data) => {
console.log('Дочерний stdout:', data.toString())
})Каждый аргумент массива переданного в отвечает за свой канал ввода/вывода:
stdin - входной поток;
stdout - поток вывода;
stderr - поток ошибок;
IPC - межпроцессорное взаимодействие.
Каждый из каналов может получить одно из следующих значений:
pipe - создаёт канал между процессами;
ignore - полное игнорирование данного потока;
inherit - значение наследуется от родительского процесса.
Файловый дескриптор:
import { fork } from 'child_process'
import fs from 'fs'
const fd = fs.openSync('simple.txt', 'w')
const child = fork('child.js', [], {
stdio: ['pipe', fd, 'pipe', 'ipc'] // stdout пишет в файл simple.txt
})null - если передать такое значение, то поток будет использовать значение по умолчанию.
['ignore', 'ignore', 'ignore', 'ipc'] - для фоновых процессов, которые не должны сильно засорят родительский процесс;
['inherit', 'inherit', 'inherit', 'ipc'] - если требуется запустить в дочернем процессе интерактивное приложение;
['pipe', 'pipe', process.stderr, 'ipc'] - если требуется логирование ошибок в дочернем процессе.
Путь к исполняемому файлу Node.js используемому, для запуска дочернего процесса. Например, так можно запустить дочку на другой версии Node.js, для тестирования или использования несовместимых с основной версией библиотек:
import { fork } from 'child_process'
const child = fork('child.js', [], {execPath: '/path/to/node16/node'})В дочернем процессе проверим версию Node.js:
console.log(process.version) // v16.16.0Executable arguments - массив аргументов передаваемых дочернему процессу при запуске. Данные аргументы являются аналогами флагов, передаваемых процессу при запуске через консоль:
import { fork } from 'child_process'
const child = fork('child.js', [], {
execArgv: ['--max-old-space-size=50'] // Ограничиваем размер выделяемой на дочерний процесс памяти
})В дочернем процессе переполним и без того небольшой объём выделенной памяти:
let bigArray = []
for (let i = 0; i < 10000000; i++) {
bigArray.push(new Array(1000)) // Создаём большие объекты, чтобы переполнить память
}Выполнение кода выше, очень быстро завершится ошибкой: FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory, что говорит о переполнении памяти выделенной на дочерний процесс. А что такое max-old-space-size и почему он олд, подробно уже разбиралось в статье воркеры в Node.js, тут оно работает точно так-же.
Для отладки дочерних процессов;
Тонкая настройка выделяемой на дочерний процесс памяти;
Профилирование производительности.
Определяет формат сериализации данных, при обмене данными между процессами через IPC-канал. Может работать в двух режимах, о которых ниже.
Работает по умолчанию. Основан на стандартных методах: JSON.stringify() и JSON.parse(). Может преобразовывать только json-совместимые типа (Date(), Set(), Map(), Buffer())
Использует более продвинутый алгоритм v8 serialization, похожий на алгоритм структурного клонирования, подробно разобранный в статье про воркеры. Данный алгоритм, позволяющий передавать сложные типы данных, сохраняя их тип:
import { fork } from 'child_process'
const child = fork('child.js', [], {serialization: 'advanced'})
child.send(new Date())В дочернем методе попробуем вызвать метод у переданного объекта:
process.on('message', (data) => {
console.log(data.getFullYear()) // 2026
})И всё сработает! Если бы не режим advanced, то мы бы получили ошибку TypeError: data.getFullYear is not a function, так как объект Date(), потерялся бы при передаче и в дочерний процесс пришла бы строка.
Для передачи типизированных массивов;
Требуется сохранить тип сложного объекта;
Передаваемый объект содержит циклические ссылки, которые могут сломать json-серилизацию;
Для передачи больших объёмов данных, за счёт того что алгоритм v8 serialization, хорошо оптимизирован.
Позволяет управлять жизненным циклом дочернего процесса, через специальный объект AbortSignal, что даёт больше контроля над дочерним процессами:
import { fork } from 'child_process'
const controller = new AbortController()
const child = fork('child.js', [], { signal: controller.signal })
child.on('error', (err) => { // При использовании signal обязательно возникнет ошибка которую нужно будет перехватить
if (err.name === 'AbortError') {
console.log('Процесс отменён по сигналу')
}
})
setTimeout(() => { // Через 3 секунды отправляем сигнал на завершение дочернего процесса
controller.abort()
}, 3000)
child.on('exit', (code, signal) => {
console.log(`Дочерний процесс завершён с сигналом: ${signal}`)
})Дочерний процесс пусть просто имитирует длительную работу:
console.log('Дочерний процесс запущен')
setTimeout(() => {
console.log('Дочерний процесс всё сделал!') // Не увидим, так как отменим процесс раньше 5 секунд
}, 5000)Перехват ошибки AbortError в примерах выше, это не баг, а фича именно так реагирует дочерний процесс на вызов метода abort() и если его убрать возникнет ошибка: AbortError: The operation was aborted.
Данная опция позволяет завершить целый пулл дочерних процессов, всего одним сигналом. Модифицируем предыдущий пример чтобы это продемонстрировать:
import { fork } from 'child_process'
const controller = new AbortController()
for (let i = 0; i < 3; i++) {
const child = fork('child.js', [], {signal: controller.signal}) // Все дочерние процессы созданные в цикле получат один и тот-же сигнал
child.on('error', (error) => {
if (error.name === 'AbortError') {
console.log(`Процесс ${child.pid} отменён по сигналу`)
}
})
child.on('exit', (code, signal) => {
console.log(`Процесс ${child.pid} завершён с сигналом: ${signal}`)
})
}
setTimeout(() => {
controller.abort() // Завершаем все 3 процесса, через 1 сигнал
}, 3000)И в дочерний процесс добавим вывод PID, чтобы отличать один от другого:
console.log(`Дочерний процесс запущен c ${process.pid}`)
setTimeout(() => {
console.log('Дочерний процесс всё сделал!')
}, 5000)Убийство множества дочерних процессов, одним сигналом гораздо быстрее и проще хранения ссылок на них, для того чтобы в дальнейшем прервать их методом kill().
В начале статьи мы подробно рассмотрели опции, которые можно использовать при создании дочернего процесса. Далее рассмотрели объект ChildProcess, возвращаемый асинхронными методами. Всё это работает одинаково в разных функциях, создающих дочерние процессы, поэтому рассмотрев что-то единожды далее мы подробно на этом не останавливались. Но объект ChildProcess, возвращаемый fork() имеет свои уникальные методы.
Отправляет сообщение дочернему процессу. Обмен сообщениями идёт через IPC:
import { fork } from 'child_process'
const child = fork('child.js')
child.send('Привет от папы!')
child.on('message', (message) => {
console.log('Родительский процесс получил:', message)
})А вот так выглядит дочерний скрипт child.js:
process.on('message', (msg) => {
console.log('Дочерний процесс получил:', msg)
process.send('Привет от дочки!')
})Из примера понятно что fork(), как и spawn() создаёт динамические дочерние процессы, с которыми родитель может общаться, влияя на их работу.
Данный метод доступен и при работе с функцией spawn(). Переделаем предыдущий главный процесс под работу через эту функцию:
import { spawn } from 'child_process'
const child = spawn(
'node',
['child.js'],
{stdio: ['pipe', 'inherit', 'inherit', 'ipc']}
)
child.send('Привет от папы!')
child.on('message', (message) => {
console.log('Родительский процесс получил:', message)
})В примере выше, для налаживания обмена сообщениями, через IPC в spawn(), нужно повозиться с настройкой опции stdio, управляющей потоками данных. Все приведённые выше настройки функция fork(), делает сама "под капотом". По сути fork() является тем-же spawn(), но с автоматической настройкой, межпроцессорного взаимодействия. Пример со spawn(), полезен для понимания как оно устроено, а в боевых условиях, разумнее использовать fork().
Любые подающиеся сериализации объекты: массивы, строки, числа и т.д;
Более сложные сущности которые нужно передать: функции, сокеты, файловые дескрипторы. Передадим в дочерний процесс сокет и наладим обмен сообщениями через него:
import { fork } from 'child_process'
import net from 'net'
const child = fork('child.js')
const server = net.createServer()
server.on('connection', (socket) => {
child.send('socket', socket) // Передаём сокерт дочернему процессу
})
server.listen(0, '127.0.0.1', () => { // Запускаем сервер. 0 - означает что сервер будет слушать любой свободный порт
const port = server.address().port
console.log(`Сервер слушает порт ${port}`)
const clientSocket = net.createConnection({ port: port }, () => { // Создаём сокет для родительского процесса
setTimeout(() => { // Таймаут нужен чтобы дочерний процесс успел подняться
clientSocket.write('Привет от папы через клиентский сокет!') // Пишем в новый сокет, а не в переданный дочернему процессу
clientSocket.on('data', (data) => { // Ответ дочернего процесса тоже читаем через новый
console.log('Через клиентский сокет получил:', data.toString())
})
}, 500)
})
})Примем сокет в дочернем процессе:
process.on('message', (msg, socket) => {
socket.on('data', (data) => {
console.log('Дочерний процесс получил через сокерт:', data.toString())
socket.write('Привет от дочки через переданный сокет!');
})
})Объект опцией. Единственная опция которая доступна в этом объекте это keepOpen. Если установить в ней значение false, то при передаче сокета дочернему процессу, как в примере выше, он останется открытым и родитель сможет писать в него:
import { fork } from 'child_process'
import net from 'net'
const child = fork('child.js')
const server = net.createServer()
server.on('connection', (socket) => {
child.send('socket', socket, { keepOpen: true })
socket.write('Привет от папы через переданный сокет!') // Теперь родитель может ПИСАТЬ в сокет после передачи!
})
server.listen(0, '127.0.0.1', () => {
const port = server.address().port
console.log(`Сервер слушает порт ${port}`)
const clientSocket = net.createConnection({ port: port }, () => {
clientSocket.on('data', (data) => { // Клиент будет слушать все входящие сообщения
console.log('Клиент получил:', data.toString())
})
// Отправляем сообщение от клиента
setTimeout(() => {
clientSocket.write('Сообщение от клиента!')
}, 500)
})
})Если бы не опция keepOpen, то попытка записи в уже отправленный сокет, завершилась ошибкой: Error [ERR_SOCKET_CLOSED]: Socket is closed. Значение по умолчанию у данной опции: false.
4. Коллбэк функция, вызываемая при отправке сообщения дочернему процессу или если его отправить не удалось. Может пригодится, если для продолжения работы главного процесса, нужны гарантии что сообщение дочке было отправлено. Дополним пример:
import { fork } from 'child_process'
import net from 'net'
const child = fork('child.js')
const server = net.createServer()
server.on('connection', (socket) => {
child.send('socket', socket, { keepOpen: true }, (error) => {
if (error) {
console.error(error)
} else {
console.log('Сообщение успешно отправлено дочернему процессу!')
socket.write('Привет от папы через переданный сокет!')
}
})
})
server.listen(0, '127.0.0.1', () => { // Запускаем сервер. 0 - означает что сервер будет слушать любой свободный порт
const port = server.address().port
console.log(`Сервер слушает порт ${port}`)
const clientSocket = net.createConnection({ port: port }, () => { // Создаём сокет для родительского процесса
setTimeout(() => { // Таймаут нужен чтобы дочерний процесс успел подняться
clientSocket.write('Привет от папы через клиентский сокет!') // Пишем в новый сокет, а не в переданный дочернему процессу
clientSocket.on('data', (data) => { // Ответ дочернего процесса тоже читаем через новый
console.log('Через клиентский сокет получил:', data.toString())
})
}, 500)
})
})Разрывает IPC-канал между дочерним и родительским процессом. Дополним наш самый первый пример с функцией fork():
import { fork } from 'child_process'
const child = fork('child.js')
child.send('Привет от папы!')
child.on('message', (message) => {
console.log('Родительский процесс получил:', message)
})
child.on('disconnect', () => { // Данное событие генерируется после вызова disconnect()
console.log('Соединение с дочерним процессом разорвано')
})
setTimeout(() => {
console.log('Разрываем IPC соединение')
child.disconnect()
}, 2000)Данный метод позволяет корректно завершить соединение если оно больше не нужно и высвободить расходуемые на него ресурсы. При этом сам дочерний процесс не завышается, а продолжает работать дальше без соединения с главным, если у него остались незавершённые задачи.
Сложные многопроцессорные вычисления;
Создание микросервисов на Node.js;
Фоновые вычисления;
Изоляция модулей, чтобы ошибка в одном из них, никак не влияла на другие.
Дочерние процессы в Node.js это мощный, но тяжёлый инструмент, для сложных задач, которые должны выполняться независимо, используя многопроцессорность по максимуму. Для простых вещей подойдут, легковесные воркеры.
Дочерние процессы как и прочие API в Node.js имеют обширную и даже несколько избыточную архитектуру, открывающую массу путей для решения задач. Они позволяют запускать в отдельных процессах, как собственные скрипты на JavaScript, так и инициализировать сторонние приложения, используя результаты их работы. Но всё это требует умелого управления ресурсами и налаживания общения между процессами. Весь инструментарий для реализации, даже самых непростых схем, тут имеется. От разработчика требуется исключительно наличие прямых рук, приделанных к правильным частям тела.