上一小节我们对比了 spawn、exec、execFile、fork 四种创建子进程的方式,它们的共同点之一就是:父进程与子进程之间存在数据交互通道。掌握好进程间的通信方式,才能真正将子进程用作“数据处理单元”或“任务执行器”,而不是一个凭空运行的独立脚本。本节我们就聚焦两类最核心的通信手段:标准流管道 与 IPC 消息通信。
1. 标准流:每个进程都有的三条管道
无论 Unix 哲学还是 Node.js 的实现,每个进程启动时都会默认打开三个标准流:
- stdin:标准输入流,可读,用于向进程发送数据。
- stdout:标准输出流,可写,进程正常日志/结果通常输出到这里。
- stderr:标准错误流,可写,专门输出错误信息,独立于 stdout。
在 Node.js 的 child_process 模块中,创建子进程时通过 stdio 配置选项,可以控制父子进程之间如何处理这些流。最常见的配置是 'pipe'(默认,spawn 和 exec 都是),它会为每个流创建管道,父进程可以通过 child.stdin、child.stdout、child.stderr 直接读写。
实战:将数据传入子进程并获取输出
假设我们有一个 uppercase.js 脚本,把从标准输入读到的文本转为大写后从标准输出吐出:
// uppercase.js
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
process.stdout.write(chunk.toUpperCase());
});
process.stdin.on('end', () => {
process.exit(0);
});
父进程可以用 spawn 执行它,然后:
const { spawn } = require('child_process');
const child = spawn('node', ['uppercase.js'], {
stdio: ['pipe', 'pipe', 'pipe'] // 显式 pipe,其实也是默认值
});
// 向子进程写入数据
child.stdin.write('hello\n');
child.stdin.write('world\n');
child.stdin.end(); // 结束输入,通知子进程 EOF
// 收集子进程的标准输出
let result = '';
child.stdout.on('data', (chunk) => {
result += chunk.toString();
});
child.stdout.on('end', () => {
console.log('转换结果:\n', result);
// 输出: HELLO
// WORLD
});
要点:
- 父进程必须调用
child.stdin.end()来关闭输入流,否则子进程可能一直在等待数据而不会退出。 - 错误输出应该单独收集
child.stderr,不要把正常输出和错误输出混在一起,避免日志混乱。
管道级联(pipe chain):让几个子进程协同工作
Node.js 流最妙的地方在于,我们可以将多个进程用 pipe() 串联起来,就像 Linux 命令行中的 | 一样。例如,一个进程读取文件,压缩内容,再传给另一个进程写入目标文件:
const { spawn } = require('child_process');
const fs = require('fs');
// 读文件 -> gzip 压缩 -> 写入压缩文件
const reader = spawn('cat', ['source.log']);
const gzip = spawn('gzip');
const writer = fs.createWriteStream('source.log.gz');
reader.stdout.pipe(gzip.stdin);
gzip.stdout.pipe(writer);
writer.on('finish', () => {
console.log('压缩完成');
});
真实开发场景:
- 调用系统命令处理多媒体(ffmpeg 转码、imagemagick 压缩)。
- 数据库备份:
mysqldump|gzip|上传到对象存储。 - 避免在 Node.js 中处理大文件占用内存,直接让系统工具通过管道交互。
容易发生的坑:
- 如果管道中某个环节出错并退出,没有监听
error事件会导致进程静默失败;必须给每个 child 进程绑定error和exit事件。 - 使用
pipe()时,目标流的背压会自动传递到源流(可读流暂停),但需要注意:如果自己在data事件手动调用write,则必须处理背压(write返回false时暂停读取),否则可能耗尽内存。
2. IPC 消息通信:fork 专用的双向通道
当使用 fork 创建子进程时,Node.js 会自动在父子进程间建立一条 IPC(Inter-Process Communication)通道。与标准流不同,IPC 传递的是序列化后的 JavaScript 值(基于 serialization,类似 JSON 但支持更多类型如 Buffer、Error),而不是二进制流。这让它非常适合结构化消息的传递,比如任务参数、处理结果、心跳信号等。
在 fork 模式下,子进程是独立的 Node.js 脚本,它们通过 process.send() 发送消息,父进程通过 child.on('message') 接收;反过来也一样,父进程也可以 child.send() 给子进程,子进程用 process.on('message') 接收。
基本模式:父子双向通信
子进程脚本 worker.js:
// worker.js
process.on('message', (msg) => {
console.log(`子进程收到任务: ${msg.task}`);
// 模拟耗时处理
const result = { status: 'done', taskId: msg.task };
// 发送结果回父进程
process.send(result);
// 如果不需要继续接收新任务,主动退出
process.exit(0);
});
父进程:
const { fork } = require('child_process');
const child = fork('./worker.js');
// 监听子进程消息
child.on('message', (msg) => {
console.log(`父进程收到结果:`, msg);
});
// 发送一个任务给子进程
child.send({ task: 'compile' });
传递的数据必须是可序列化的:对象、数组、字符串、数字、布尔、Buffer、null 等都可以直接传递,但函数、Symbol、原型链等会丢失或报错。如果需要传递复杂对象,可以用 JSON.stringify/parse 或者利用 Advanced serialization(Node.js v13.2 起支持传递某些原生对象)。
实际应用:任务分发与进程池
fork + IPC 的典型场景是实现一个简单的进程池,将 CPU 密集任务分配给多个 Worker:
// 父进程 pool.js
const { fork } = require('child_process');
const cpus = require('os').cpus();
const workers = [];
const tasks = [{ id: 1 }, { id: 2 }, { id: 3 }]; // 任务队列
// 创建一个 Worker 处理任务
function createWorker() {
const worker = fork('./heavy-task.js');
worker.on('message', (result) => {
console.log(`任务 ${result.id} 完成,结果: ${result.value}`);
// 分配新任务或清理
if (tasks.length > 0) {
worker.send(tasks.shift());
} else {
worker.kill();
}
});
return worker;
}
// 初始化工作池(不超过 CPU 核数)
for (let i = 0; i < Math.min(cpus.length, tasks.length); i++) {
workers.push(createWorker());
}
// 给每个 Worker 发送第一个任务
workers.forEach((worker, index) => {
worker.send(tasks.shift());
});
子进程 heavy-task.js:
process.on('message', (task) => {
// 模拟 CPU 密集计算
let result = 0;
for (let i = 0; i < 1e8; i++) {
result += i;
}
process.send({ id: task.id, value: result });
// 不退出,继续等待新任务(如果父进程还发)
});
相比 worker_threads,fork 的优势在于进程间完全隔离,一个 Worker 崩溃不会影响主进程,代价是通信开销稍大,但更适合执行不可信的或可能 crash 的计算任务。
IPC 通道不是无限的,要注意关闭和泄漏
每个 fork 的通道都会占用资源和文件描述符。当子进程退出时,IPC 通道会自动断开,但如果父进程一直保留 child 对象引用而未处理 exit 事件,通道所占用的资源可能不会立即释放。合理模式是:一旦不再需要通信,调用 child.disconnect() 显式关闭 IPC 通道(注意:disconnect 并不会杀死子进程,子进程仍可继续运行,只是不能再收发消息)。
3. 标准流和 IPC 的选用时机
- 标准流管道 适合处理文本/二进制流,尤其是跟系统命令交互、批处理大文件、使用现有 Unix 工具链。操作对象是流,需要注意背压与编码。
- IPC 消息 适合在 Node.js 进程间传递结构化的任务和结果,编程模型更简单,可以直接发送对象,处理并发任务、进程池、Worker 模式。
两种机制也可以混合使用:比如通过 fork 创建子进程时,配置其 stdio 为 'inherit',让子进程的日志直接复用到父进程的控制台;同时使用 IPC 发送控制消息。配置 stdio 数组可以实现对标准流和 IPC 的精细控制。
下面是一个混合的例子:
const child = fork('./worker.js', [], {
stdio: ['pipe', 'inherit', 'inherit', 'ipc'] // stdin 管道,stdout/stderr 复用父进程,保留 IPC
});
child.stdin.write('start\n'); // 通过标准输入触发一些逻辑
child.on('message', (msg) => {}); // 同时接收 IPC 消息
4. 本节小结与避坑指南
掌握父子进程通信是 Node.js 实现多任务协作的基础,下面是几个常见的坑点:
- 忘记关闭 stdin:如果通过
stdin传入数据,记得end(),否则子进程可能永不结束。 - stdout/stderr 缓冲区满:当子进程产生大量输出而父进程没有及时消费,可能导致子进程阻塞甚至死锁。应该确保及时监听
data事件或使用pipe()自动处理背压。 - fork 的消息队列堆积:如果主进程向子进程发送了大量消息,但子进程处理缓慢,可能会耗尽内存。需要实现背压(比如父进程检查
send返回值,或在drain事件后再继续发送)。 - 错误隔离:任何一个子进程可能意外退出,一定要监听
exit和error事件,避免静默丢失任务。
充分理解标准流管道和 IPC,能让你编写出更健壮、高效的多进程应用,无论是编排系统命令,还是构建自定义的任务分发框架。在下一节中,我们将讨论 cluster 模块,看 Node.js 如何用 IPC 和流来实现多进程负载均衡和进程守护。