1.为什么需要子进程
Node.js的主事件循环是单线程的。异步I/O不会阻塞它,但CPU密集型任务会。图像处理、视频转码、大数据排序、复杂的加密计算,这些任务一旦在主线程执行,后续所有请求都要排队等待。
子进程解决的是这个问题。它把任务交给操作系统创建的独立进程,主进程的事件循环继续处理其他请求。子进程完成任务后,把结果通过标准输出、标准错误或IPC通道返回。
除了绕过CPU瓶颈,子进程还用于几种场景:调用系统命令(git、ffmpeg、ImageMagick)、执行其他语言编写的脚本、隔离不稳定的第三方程序、利用多核CPU做并行计算。Node.js的cluster模块内部就是用child_process.fork实现的。
,子进程不是线程。它有自己的内存空间、PID、V8实例(如果是Node.js进程)。进程间不能直接共享内存,通信需要通过流或IPC消息。这个隔离性带来了稳定性,也带来了序列化和传输开销。
2.四种创建方式的定位
child_process模块提供四个主要方法,选择哪一个取决于任务的性质。
exec启动一个shell,在shell里执行命令字符串,输出缓冲到内存后一次性返回。适合输出量小、需要shell特性的命令,比如管道、重定向、通配符。
execFile直接执行可执行文件,不经过shell。参数以数组形式传递,性能更好,也没有shell注入风险。适合调用已知路径的二进制程序。
spawn创建进程并以流的方式处理输出。不缓冲,数据到达即触发事件。适合长时间运行、输出量大、需要实时处理的场景。
fork是spawn的特殊形式,专门用于启动新的Node.js进程,并且自动建立IPC通道。父进程和子进程可以通过send和message事件互相传递对象。
选择逻辑可以简化成几个问题:需要shell语法吗?需要实时输出吗?需要和子进程交换结构化数据吗?答案决定用哪个方法。
3.exec:执行shell命令
exec的签名是exec(command[,options],callback)。command是完整命令字符串,callback接收error、stdout、stderr。
const { exec } = require('node:child_process');
exec('dir', (error, stdout, stderr) => {
if (error) {
console.error(`执行失败: ${error.message}`);
return;
}
if (stderr) {
console.error(`stderr: ${stderr}`);
return;
}
console.log(stdout);
});
在Linux或macOS上把dir换成ls。这段代码会列出当前目录内容,等命令结束后把结果一次性打印出来。
exec的一个关键限制是maxBuffer,默认1MB。超过这个大小,子进程会被终止,callback收到错误。处理大输出时要么调大maxBuffer,要么改用spawn。
exec('find / -name "*.log"', { maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
// ...
});
shell注入是exec需要警惕的问题。如果命令字符串里拼接了用户输入,攻击者可以通过分号、反引号、$()注入额外命令。
// 危险写法
exec(`git log ${userInput}`, callback);
如果userInput是;rm-rf/,后果严重。处理不可信输入时,应该用execFile或spawn,把参数作为数组元素传递,避免经过shell解析。
4.execFile:直接执行可执行文件
execFile的签名是execFile(file[,args][,options],callback)。它不启动shell,直接调用file,args是参数数组。
假设有一个sample.js:
console.log('Hello from sample.js');
用execFile调用:
const { execFile } = require('node:child_process');
execFile('node', ['sample.js'], (error, stdout, stderr) => {
if (error) {
console.error(error);
return;
}
console.log(stdout);
});
输出:
Hello from sample.js
这里node是文件,['sample.js']是参数。整个命令没有经过shell,所以即使参数里包含特殊字符也不会被解释为命令分隔符。
execFile的适用场景包括:调用git、ffmpeg、imagemagick这类工具,参数结构明确;执行其他语言脚本,如python、ruby;需要精确控制参数边界的场景。它的输出仍然是缓冲的,所以只适合输出量可控的任务。
5.spawn:流式处理与实时输出
spawn的签名是spawn(command[,args][,options]),返回一个ChildProcess实例。输出通过stdout、stderr流暴露。
const { spawn } = require('node:child_process');
const child = spawn('node', ['-v']);
child.stdout.on('data', (data) => {
console.log(`版本: ${data}`);
});
child.stderr.on('data', (data) => {
console.error(`错误: ${data}`);
});
child.on('close', (code) => {
console.log(`子进程退出,退出码 ${code}`);
});
输出:
版本: v22.0.0
子进程退出,退出码 0
data事件在数据到达时触发,不需要等进程结束。这对于实时日志、进度输出、大文件处理很重要。
一个场景:用ffmpeg转码视频,需要读取转码进度。用spawn启动ffmpeg,监听stderr上的进度信息,实时更新任务状态。如果用exec,只能等转码完成才知道结果,无法给用户反馈。
spawn的options中,stdio控制标准流的处理方式。默认是'pipe',即创建管道让父进程读取。如果子进程的输出不需要处理,可以设为'inherit',直接继承父进程的标准流,避免管道开销。
const child = spawn('ffmpeg', ['-i', 'input.mp4', 'output.mp4'], {
stdio: 'inherit'
});
spawn不会自动等待子进程结束。需要通过close或exit事件确认。close在标准流关闭后触发,exit在进程退出时触发。两者有细微差异:close保证所有输出已经读完,通常用close更稳妥。
6.fork:Node.js进程间通信
fork的签名是fork(modulePath[,args][,options])。它启动一个新的Node.js进程执行modulePath,并在父子之间建立IPC通道。
worker.js:
process.send('来自子进程的消息');
父进程:
const { fork } = require('node:child_process');
const child = fork('./worker.js');
child.on('message', (message) => {
console.log(message);
});
输出:
来自子进程的消息
fork的价值在于IPC。通过child.send()和child.on('message'),父子进程可以传递对象、数组、字符串。消息会被序列化,默认使用JSON序列化,Node.js较新版本支持通过serialization:'advanced'使用结构化克隆,支持更多类型。
一个实用模式是让子进程执行计算任务,把参数传进去,把结果传回来。
主进程:
const { fork } = require('node:child_process');
const child = fork('./compute.js');
child.send({ numbers: [1, 2, 3, 4, 5] });
child.on('message', (result) => {
console.log('计算结果:', result);
child.kill();
});
compute.js:
process.on('message', (data) => {
const sum = data.numbers.reduce((a, b) => a + b, 0);
process.send(sum);
});
这种结构适合把CPU密集型计算从主线程移出。主进程保持响应,子进程专注计算。计算完成后子进程可以退出,也可以复用处理下一个任务。
fork的另一个特性是它默认继承父进程的execArgv,但可以通过options覆盖。silent:true选项会阻止子进程的输出直接打印到父进程终端,改为通过管道读取。
7.错误处理与退出码
子进程可能因为多种原因失败:命令不存在、参数错误、权限不足、执行过程中崩溃。错误处理分几个层面。
spawn的错误事件:
const { spawn } = require('node:child_process');
const child = spawn('invalid-command');
child.on('error', (err) => {
console.log('发生错误:', err.message);
});
输出:
发生错误: spawn invalid-command ENOENT
ENOENT表示文件不存在。这类错误发生在进程创建阶段,不会触发close事件。需要在error里处理。
进程退出码:
child.on('close', (code, signal) => {
if (code !== 0) {
console.error(`子进程异常退出,code=${code}, signal=${signal}`);
}
});
code为0表示正常退出,非0表示异常。signal在被信号终止时非空,比如被SIGKILL杀掉。有些程序用特定退出码表示业务错误,需要根据约定判断。
exec和execFile把错误统一放在callback的第一个参数里,包含message、code、signal、killed等字段。处理时要区分是命令本身失败还是进程创建失败。
一个容易忽略的点:子进程的错误不会自动传播到父进程。如果不监听error和close,父进程可能不知道子进程已经失败。在任务队列、批处理、定时任务这类场景,必须显式处理。
8.安全注意事项
shell注入是使用exec时的主要风险。原则很简单:如果命令字符串中包含任何外部输入,就不要用exec。
路径和参数要用数组传递:
const { execFile } = require('node:child_process');
// 安全
execFile('git', ['log', '--oneline', '-n', '10'], callback);
权限最小化。子进程以父进程的权限运行。如果只是读取文件,不要用root启动整个应用。在容器环境里,用非root用户运行Node.js进程。
环境变量传递。默认情况下,子进程继承父进程的环境变量,包括密钥和凭证。如果子进程不需要这些,应该在options里通过env显式指定。
spawn('some-tool', [], {
env: { PATH: process.env.PATH, HOME: process.env.HOME }
});
资源限制。子进程可能消耗大量内存或CPU。可以用ulimit、cgroup、或者Node.js的--max-old-space-size限制。长时间运行的子进程要设置超时,避免僵尸进程。
9.项目实例:批量文件处理
假设需要把一批图片转换成缩略图,用ImageMagick的cort命令。任务数量多,单个转换可能失败,需要记录每个任务的结果。
const { execFile } = require('node:child_process');
const path = require('node:path');
const images = ['a.jpg', 'b.jpg', 'c.jpg'];
function cortOne(input) {
return new Promise((resolve) => {
const output = path.join('thumbs', path.basename(input));
execFile('cort', [input, '-resize', '200x200', output], (error) => {
if (error) {
resolve({ input, ok: false, message: error.message });
return;
}
resolve({ input, ok: true, output });
});
});
}
async function run() {
const results = [];
for (const image of images) {
results.push(await cortOne(image));
}
const failed = results.filter((r) => !r.ok);
console.log(`完成 ${results.length} 个任务,失败 ${failed.length} 个`);
}
run();
这里用execFile而不是exec,因为cort的参数结构明确,不需要shell。用Promise包装回调,配合async/await让流程更清晰。串行处理避免同时启动过多子进程,如果需要并发,可以控制并发数而不是一次性全部启动。
10.现在框架中的惯用写法
Express或Fastify项目里,子进程通常封装成服务模块,暴露Promise接口。调用方不直接接触child_process的回调风格。
NestJS中可以通过Provider注入ProcessService,统一管理超时、日志、重试。
WorkerThreads是另一个选择。worker_threads模块提供线程级别的并行,共享内存,启动开销比子进程小。但它不适合调用外部程序,也不提供进程隔离。选择标准是:纯JavaScript计算用worker_threads,调用系统命令或需要进程隔离用child_process。
任务队列(BullMQ、Bee-Queue)内部也常用fork或spawn来执行耗时任务,把任务处理从Web进程分离出去。
11.本节课程知识要点
-
child_process是内置模块,用于创建和管理子进程。
-
exec经过shell,输出缓冲,适合小输出和需要shell特性的命令。
-
execFile不经过shell,参数以数组传递,避免注入风险。
-
spawn流式处理输出,适合长时间运行和大输出任务。
-
fork专门启动Node.js进程,自带IPC通道。
-
子进程错误不会自动传播,必须监听error和close。
-
exec的maxBuffer默认1MB,大输出需要调整或改用spawn。
-
不要把外部输入拼接到exec的命令字符串里。
-
子进程默认继承环境变量,敏感信息要显式控制。
12.练习与思考
-
用exec和execFile分别执行
gitlog--oneline-n5,比较两者的写法和安全性差异。 -
用spawn启动一个持续输出日志的命令,实时打印每一行,并在进程退出时输出退出码。
-
用fork实现一个计算斐波那契数列的子进程,主进程传入n,子进程返回结果。
-
模拟一个不存在的命令,分别用spawn和exec触发错误,观察错误对象的字段差异。
-
说明exec的maxBuffer超限时会发生什么,并写出改用spawn处理大输出的方案。
子进程模块把操作系统的进程能力接进Node.js。exec、execFile、spawn、fork四个方法各有定位,选择的关键是看清任务的性质:是否需要shell、输出量多大、是否需要结构化通信。安全性上,避免把不可信输入交给exec,参数尽量用数组传递。工程上,错误处理、超时控制、并发限制这三件事做不做,决定了子进程方案在生产环境是否稳定。