人人都会AI编程

10.2 子进程:child_process 模块

更新时间:2026-07-10

在 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

execFileexec 类似,但它直接执行指定的可执行文件,并不启动 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 内建命令(例如 direcho 等)和管道符、重定向等特性。如果需要这些功能,还得回到 exec 或显式启动一个 Shell。

10.2.4 spawn:流式处理,适合大量数据与长期进程

spawn 是底层方法,返回一个包含 stdoutstderr 流和 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

forkspawn 的一个特化版本,专门用于创建执行 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_threadsfork 的隔离性更强(独立进程,独立内存),但资源占用也更大。实际项目中应根据任务类型进行选择。

10.2.6 父子进程通信

通过 stdio 管道

对于 spawnexecFile 创建的进程,最基本的通信方式就是父进程读取子进程的 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: trueprocess.kill(-child.pid))。

10.2.8 实用场景与最佳实践

  • 执行系统命令:使用 execexecFile,例如运行 ffmpeg 提取音频、调用 ipconfig 获取网络信息。注意对用户输入的参数进行严格校验,避免命令注入。
  • 处理大文件或流数据:使用 spawn 配合 pipe 进行实时压缩、格式转换等。
  • CPU 密集任务:使用 fork(多进程)或 worker_threads(多线程)将计算卸载。fork 更适合需要完全隔离或调用不同 Node 版本的情况。
  • 进程守护与重启:主进程可以监听子进程的 exit 事件,当子进程异常退出时自动重启,配合 PM2 等生产环境进程管理工具可实现更健壮的方案。
  • 避免 shell 注入:凡是包含用户输入的参数,尽量使用 execFilespawn 的数组参数形式,避免拼接字符串直接交给 shell。

10.2.9 与其他模块的关系

  • cluster 模块底层也是基于 child_process.fork 实现多进程负载均衡的,区别在于 cluster 会自动管理 worker 并共享同一个服务端口。
  • worker_threads 提供更轻量的线程级并行,适合共享内存场景,而 fork 是独立的进程,内存隔离更彻底。
  • process 模块本身也有 child_process 不具备的进程级信息(如 process.pidprocess.ppid),但两者结合可以构建出质量很高的多进程架构。

掌握了 child_process 模块,就等于掌握了 Node.js 与操作系统其他程序协作的钥匙。无论是脚本自动化、微服务中的任务调度,还是作为多语言系统的粘合剂,它都是一个朴实却强大的工具。在后续的小节中,我们将进一步探讨如何使用 cluster 模块来优雅地利用多核 CPU,以及 worker_threads 如何在线程层面实现并行计算。