在 Node.js 中,虽然主线程通过事件循环能够应对大部分 I/O 密集场景,但有些任务并不适合直接在主线程执行:调用外部命令行工具、运行其他语言编写的脚本、将 CPU 密集计算隔离到独立进程等。这时就需要 子进程。child_process 模块提供了创建和管理子进程的能力,是 Node.js 与操作系统交互的重要桥梁。
10.2.1 四种创建方式概览
child_process 提供了四种异步创建子进程的方法,它们在参数传递方式、缓冲策略和适用场景上各有区别:
| 方法 | 默认缓冲输出 | 参数形式 | 是否派生 Node 进程 | 典型场景 |
|------|------------|---------|------------------|---------|
| exec | 有缓冲(内存中累积,有大小限制) | 完整命令字符串 | 不派 | 执行简单命令,输出量小的场景 |
| execFile | 有缓冲 | 可执行文件 + 参数数组 | 不派 | 执行外部程序,不通过 shell,避免命令注入 |
| spawn | 无缓冲(流式) | 可执行文件 + 参数数组 | 不派 | 处理大量数据输出、长时间运行的进程 |
| fork | 无缓冲(流式,但自带 IPC 通道) | 模块路径 + 参数数组 | 专门用于 Node 进程 | 创建 Node 子进程,利用 IPC 通信 |
其中 spawn 是最基础的方法,其他三个都是在 spawn 之上封装而来。理解了 spawn,其余的就比较好掌握。
10.2.2 exec:简单命令,一次性输出
exec 接受一个完整的 Shell 命令字符串,在子进程中通过 Shell 解析执行。它会缓冲进程的整个 stdout 和 stderr 输出,当进程退出时一次性传递给回调函数。
优点:写法直观,便于拼接动态参数。
缺点:输出内容完全缓存在内存中,如果命令产生大量数据(例如 cat 大文件),容易造成内存溢出;此外命令字符串可能带来命令注入风险。
const { exec } = require('child_process');
// 执行 ls 命令,列出当前目录
exec('ls -la', { cwd: '/usr' }, (error, stdout, stderr) => {
if (error) {
console.error(`执行出错: ${error}`);
return;
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
});
exec 还提供了一个 options.maxBuffer 选项,默认值为 1024 * 1024(1MB)。如果输出超过这个值,子进程会被终止。对于预期有大输出的场景,应当使用 spawn。
10.2.3 execFile:直接执行程序,绕过 Shell
execFile 与 exec 类似,但它直接执行指定的可执行文件,并不启动 Shell 来解析命令。参数以数组形式传递,因此天然避免了命令注入。
const { execFile } = require('child_process');
execFile('node', ['--version'], (error, stdout, stderr) => {
if (error) {
console.error('执行失败:', error);
return;
}
console.log('Node.js 版本:', stdout);
});
由于不经过 Shell 解析,execFile 的效率略高于 exec,也更安全。不过它不能使用 Shell 内建命令(例如 dir、echo 等)和管道符、重定向等特性。如果需要这些功能,还得回到 exec 或显式启动一个 Shell。
10.2.4 spawn:流式处理,适合大量数据与长期进程
spawn 是底层方法,返回一个包含 stdout、stderr 流和 stdin 可写流的 ChildProcess 对象。子进程的输出不会在内存中累积,而是以流的形式实时返回,因此适合处理大量数据或长时间运行的进程。
const { spawn } = require('child_process');
const child = spawn('find', ['.', '-name', '*.js']);
child.stdout.on('data', (chunk) => {
console.log(`输出: ${chunk}`);
});
child.stderr.on('data', (chunk) => {
console.error(`错误: ${chunk}`);
});
child.on('close', (code) => {
console.log(`进程退出,退出码: ${code}`);
});
spawn 还允许通过 stdio 选项配置父子进程间的管道行为。例如,将子进程的 stdout 直接 pipe 到另一个进程的 stdin,实现 Unix 风格的管道:
const { spawn } = require('child_process');
// 等价于 shell: cat file.txt | grep "error"
const cat = spawn('cat', ['file.txt']);
const grep = spawn('grep', ['error']);
cat.stdout.pipe(grep.stdin);
grep.stdout.on('data', (data) => {
console.log(`匹配结果: ${data}`);
});
这种流式管道不仅内存效率高,而且符合 Node.js 中 Stream 的哲学,可以与转换流、文件流组合使用。
10.2.5 fork:专为 Node 进程设计的 spawn
fork 是 spawn 的一个特化版本,专门用于创建执行 Node.js 脚本的子进程。它与 spawn 的最大区别在于:
- 自动建立 IPC 通道,允许父子进程间通过
process.send()和'message'事件进行双向通信。 - 子进程同样是 Node.js 进程,能够使用 require 加载模块,拥有独立的事件循环。
典型应用:将 CPU 密集型计算隔离到子进程,避免阻塞主事件循环。
主进程文件(parent.js):
const { fork } = require('child_process');
const child = fork('./child.js');
child.on('message', (message) => {
console.log('收到结果:', message);
});
child.send({ task: 'heavy_computation', data: [1, 2, 3] });
子进程文件(child.js):
process.on('message', (msg) => {
if (msg.task === 'heavy_computation') {
// 模拟密集计算
let result = 0;
for (let i = 0; i < 1e9; i++) {
result += i;
}
process.send({ result });
}
});
这种模式在需要利用多核 CPU 或隔离风险任务时非常有用。相比于 worker_threads,fork 的隔离性更强(独立进程,独立内存),但资源占用也更大。实际项目中应根据任务类型进行选择。
10.2.6 父子进程通信
通过 stdio 管道
对于 spawn 和 execFile 创建的进程,最基本的通信方式就是父进程读取子进程的 stdout/stderr,或者向子进程的 stdin 写入数据。这种方式基于流,适合文本或二进制数据流。
const child = spawn('sort');
child.stdin.write('banana\n');
child.stdin.write('apple\n');
child.stdin.end();
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.on('close', () => {
console.log('排序结果:', output); // apple\nbanana\n
});
通过 IPC(进程间通信)
fork 在创建子进程时会在父子之间建立一个 IPC 通道,底层通常基于操作系统的域套接字(Unix domain socket)或命名管道。通过这个通道,父子进程可以传递 JSON 兼容的数据,以及部分内置对象(如 Buffer 的引用副本)。
IPC 通信遵循消息序列化,不会出现粘包问题,一条 send 对应一个 message 事件,适合频繁交互的场景。但需要注意,大量的 IPC 通信会增加系统开销,不应滥用。
10.2.7 子进程的生命周期管理
事件监听
ChildProcess 对象提供了几个重要事件:
'spawn':子进程成功创建。'exit':进程退出,参数为退出码和信号。'close':进程退出且 stdio 流关闭,是更可靠的退出检测。'error':创建进程失败时触发。'message':收到 IPC 消息(仅 fork)。
推荐使用 close 事件来确认进程已经完全结束,特别是在使用流管道时。
杀死子进程
- 调用
child.kill([signal])向子进程发送信号,默认是 SIGTERM。可通过'exit'事件获取信号。 - 如果子进程已经退出,
kill调用不会报错。 - 对于子进程的子进程(即孙子进程),默认不会被 kill。如果需要清理整个进程树,可以配合操作系统命令(如 Linux 上的
process.kill(-pid)或在 spawn 时设置detached: true并process.kill(-child.pid))。
10.2.8 实用场景与最佳实践
- 执行系统命令:使用
exec或execFile,例如运行ffmpeg提取音频、调用ipconfig获取网络信息。注意对用户输入的参数进行严格校验,避免命令注入。 - 处理大文件或流数据:使用
spawn配合pipe进行实时压缩、格式转换等。 - CPU 密集任务:使用
fork(多进程)或worker_threads(多线程)将计算卸载。fork更适合需要完全隔离或调用不同 Node 版本的情况。 - 进程守护与重启:主进程可以监听子进程的
exit事件,当子进程异常退出时自动重启,配合 PM2 等生产环境进程管理工具可实现更健壮的方案。 - 避免 shell 注入:凡是包含用户输入的参数,尽量使用
execFile或spawn的数组参数形式,避免拼接字符串直接交给 shell。
10.2.9 与其他模块的关系
cluster模块底层也是基于child_process.fork实现多进程负载均衡的,区别在于 cluster 会自动管理 worker 并共享同一个服务端口。worker_threads提供更轻量的线程级并行,适合共享内存场景,而fork是独立的进程,内存隔离更彻底。process模块本身也有child_process不具备的进程级信息(如process.pid、process.ppid),但两者结合可以构建出质量很高的多进程架构。
掌握了 child_process 模块,就等于掌握了 Node.js 与操作系统其他程序协作的钥匙。无论是脚本自动化、微服务中的任务调度,还是作为多语言系统的粘合剂,它都是一个朴实却强大的工具。在后续的小节中,我们将进一步探讨如何使用 cluster 模块来优雅地利用多核 CPU,以及 worker_threads 如何在线程层面实现并行计算。