首页 / Node.js 教程 / child_process 子进程

Node.js 教程

child_process 子进程

本教程共 76 篇 · 第 33 篇 · 更新于 2026-07-25 · 约 10 分钟阅读

Node.jschild_process子进程spawnIPC

33. child_process 子进程

本节目标:- execspawnfork 的区别和各自的用法 - 怎么把命令的输出拿到手、怎么捕获错误 - 父子进程之间怎么通信 - 用子进程跑外部命令时最容易出事的安全坑

Node.js 是单线程跑事件循环的,但这不代表它只能干一件事。有些活儿它就是不适合在主线程里做:调一个系统命令、跑一段 CPU 密集的脚本、或者执行另一个 Node.js 程序。这时候就得靠 child_process 模块去开一个「子进程」来干。

这一章带你把 execspawnfork 三个常用方法摸一遍,搞清楚它们各自适合什么场景,以及怎么跟 shell 打交道才不踩坑。

为什么要用子进程

Node.js 自己不擅长 CPU 密集计算。你要是在主线程里跑个 fib(45),整个事件循环就被卡死,所有请求都动不了。更常见的需求其实是:调用系统里的 lsffmpegpython 这类现成工具,或者把一部分逻辑丢给另一个 Node 程序去算。

child_process 就是干这个的。它在操作系统层面 fork 出一个新进程,让那个进程去执行你的命令,再通过流或者回调把结果交回来。

Note

如果你的目标只是「吃满多核跑 Web 服务」,优先考虑 cluster(第 34 章)或 worker_threads(第 35 章)。child_process 更适合「执行外部命令 / 隔离运行一段重逻辑」这类场景。

exec:最简单的一脚踢

exec 适合跑那种「命令短、输出也不大」的场景。它会启动一个 shell,把命令执行完,把标准输出(stdout)和标准错误(stderr)缓存起来,等进程结束后一次性通过回调还给你。

import { exec } from 'node:child_process';

exec('ls -lh', (error, stdout, stderr) => {
  if (error) {
    console.error(`执行出错: ${error.message}`);
    return;
  }
  if (stderr) {
    console.error(`stderr: ${stderr}`);
    return;
  }
  console.log(`stdout:\n${stdout}`);
});

回调的三个参数很直观:error 是执行失败的对象,stdout 是命令的正常输出,stderr 是错误输出。Windows 上默认用的是 cmd.exe,所以命令写法要迁就它。

exec 还能传 options,几个常用的:

exec('node --version', {
  cwd: '/tmp',          // 子进程的工作目录
  timeout: 5000,        // 超时就杀掉,毫秒
  maxBuffer: 1024 * 1024, // stdout/stderr 最大缓冲,超出会杀进程
  env: { ...process.env, FOO: 'bar' },
}, (error, stdout) => {
  // ...
});
Warning

exec 会开 shell,而且它把整段命令当字符串执行。如果你把用户输入直接拼进命令字符串,就等于给攻击者留了一扇后门——这就是命令注入(command injection)。永远不要写 exec('grep ' + userInput) 这种代码。要跑外部命令,优先用下面讲的 spawnexecFile

还有个隐蔽的坑是 maxBuffer:它默认只有 1MB,子进程输出一旦超过这个上限,exec 会直接把进程杀掉,回调里拿到的是个 ENOBUFS 错误,而不是你想要的完整输出。所以凡是你预估输出可能不小的场景,从一开始就用 spawn 走流,别等线上爆了才想起这回事。

spawn:流式、大数据量的首选

spawnexec 最大的不同在于:它不缓存输出,而是给你三个流(stdin、stdout、stderr),数据一边产生一边推过来。命令输出可能很大(比如导出一个几百 MB 的文件),用 spawn 不会把内存撑爆。

import { spawn } from 'node:child_process';

const child = spawn('node', ['support.js', '42']);

child.stdout.on('data', chunk => {
  console.log(`拿到数据: ${chunk}`);
});

child.stderr.on('data', chunk => {
  console.error(`出错: ${chunk}`);
});

child.on('close', code => {
  console.log(`子进程退出,退出码 ${code}`);
});

注意 spawn 的参数是「命令 + 参数数组」的形式:spawn('node', ['support.js', '42'])。因为参数被拆成了数组,Node 不会通过 shell 去解释它们,所以天然比 exec 安全——用户输入塞不进额外的 shell 语法。

support.js 里可以这样拿到参数:

// support.js
console.log(`处理任务 ${process.argv[2]}`);

控制子进程的 stdin

spawn 还能反过来往子进程里写数据,相当于你手动操作它的标准输入:

const child = spawn('grep', ['hello']);

child.stdin.write('hello world\n');
child.stdin.write('goodbye world\n');
child.stdin.end(); // 关闭输入,告诉它没数据了

child.stdout.on('data', chunk => {
  console.log(`匹配到: ${chunk}`); // 只会输出 hello world
});

detached:让子进程独立跑

默认情况下,父进程退了子进程也会跟着没。设置 detached: true 并配合 stdio: 'ignore',可以让子进程脱离父进程独立运行,比如常驻一个后台任务:

const child = spawn('node', ['worker.js'], {
  detached: true,
  stdio: 'ignore',
});

child.unref(); // 父进程退出时不再等它
console.log(`已启动独立子进程,pid=${child.pid}`);
Tip

execspawn 都有同步版本 execSyncspawnSync,会阻塞事件循环直到命令跑完。只建议在脚本、CLI 工具这种「本来就是一次性跑完就退出」的场景用,别在 Web 服务里用它们。

execFile:不开 shell 的轻量版

exec 因为要开 shell,天生带着命令注入的风险。execFile 和它几乎一样,但不启动 shell,直接执行可执行文件。只要你不传 shell 特有的语法(管道、&& 之类),用 execFileexec 安全一截。

import { execFile } from 'node:child_process';

execFile('node', ['support.js', '42'], (error, stdout) => {
  if (error) {
    console.error(`出错: ${error.message}`);
    return;
  }
  console.log(stdout);
});

注意第一个参数是可执行文件本身('node'),后面的数组才是参数。它和 spawn 在安全上是同等级的——都不经过 shell。区别在于返回方式:execFile 走回调、spawn 走流。输出不大就用 execFile,输出可能很大就上 spawn

一个真实场景:把脏活丢给子进程

光讲 API 有点抽象,说个常见活儿:主服务要处理用户上传的一批图片,用 sharp 在 Node 里转格式会卡主线程,你决定调命令行工具 convert 去做。这种「主进程接活、子进程干重活」的模式长这样:

import { spawn } from 'node:child_process';
import { writeFile, unlink } from 'node:fs/promises';

export async function resizeImage(inputPath, outputPath, width) {
  return new Promise((resolve, reject) => {
    const child = spawn('convert', [
      inputPath,
      '-resize', `${width}x`,
      outputPath,
    ]);

    let errBuf = '';
    child.stderr.on('data', c => (errBuf += c));
    child.on('error', reject);
    child.on('close', code => {
      if (code === 0) resolve(outputPath);
      else reject(new Error(`convert 失败,退出码 ${code}: ${errBuf}`));
    });
  });
}

这里有几个我踩过的细节:spawn 的参数是数组,绝对不要把整条命令拼成一个字符串丢进去;convert 不存在时会触发 error 事件而不是 close 非 0,所以两者都要监听;临时文件处理完记得 unlink 删掉,不然磁盘会被堆满。

fork:专门跑 Node.js 模块的亲儿子

forkspawn 的一个特例,专门用来启动另一个 Node.js 文件。它最爽的地方在于:父进程和子进程之间自动建立了一条 IPC(进程间通信)通道,双方可以直接用 send()on('message') 传消息,不用你手动去读写流。

// parent.js
import { fork } from 'node:child_process';

const child = fork('./child.js');

child.send({ type: 'task', payload: { n: 40 } });

child.on('message', msg => {
  console.log(`子进程算完了: ${msg.result}`);
  child.kill();
});
// child.js
process.on('message', msg => {
  if (msg.type === 'task') {
    const fib = n => (n <= 1 ? n : fib(n - 1) + fib(n - 2));
    const result = fib(msg.payload.n);
    process.send({ result });
  }
});

这条通信管道的存在,让 fork 特别适合做「进程池」——主进程接请求,把重活分发给一组常驻子进程去算,自己不被阻塞。第 34 章的 cluster 底层其实也是靠 fork 来拉起多个 worker 的。

Note

fork 创建的子进程是完整的 Node.js 实例,启动成本比轻量的 worker 高一些。如果只是想并行跑 JS 计算又不想付进程启动的代价,可以回头看看第 35 章的 worker_threads

跟 shell 交互

有些任务绕不开 shell,比如管道连起来的一串命令、&& 串联步骤、或者 Windows 上特有的批处理。这时候用 exec 最省事,因为它默认就开 shell。

// 用 shell 的管道把两个命令串起来
exec('cat access.log | grep 404 | wc -l', (err, stdout) => {
  if (!err) console.log(`404 请求数: ${stdout.trim()}`);
});

如果你想用 spawn 但又要 shell 语义,可以手动指定 shell: true

spawn('cat access.log | grep 404', { shell: true });
Warning

shell: truespawn 的安全优势又还回去了——参数又变回会被 shell 解释。只要命令里掺了任何外部输入,就又回到命令注入的风险里。能用数组参数 spawn(cmd, [args]) 就不用 shell: true

错误处理与退出码

子进程「崩了」和「正常退出但返回非 0」是两种不同的情况,要分开听:

  • exit / close 事件:进程结束就触发,code 是退出码,signal 是被什么信号杀掉的(比如 SIGTERM)。
  • error 事件:是父进程这边出了问题,比如命令根本不存在、没法 spawn。注意它和「子进程返回非 0」不是一回事。
const child = spawn('some-command', ['--flag']);

child.on('error', err => {
  // 这里通常是命令找不到、权限不够这类问题
  console.error(`启动失败: ${err.message}`);
});

child.on('close', (code, signal) => {
  if (code !== 0) {
    console.error(`进程以退出码 ${code} 结束(信号 ${signal})`);
  }
});
Tip

closeexit 的区别:exit 在进程退出时立刻触发,但此时 stdio 流可能还没关干净;close 会等到所有 stdio 流都关闭后才触发。一般监听 close 更稳妥,能确保数据都收全了。

在 v24 权限模型下跑子进程

Node.js 从 v20 起提供了权限模型(—permission),到 v24.2.0 已经稳定。一旦你用 --permission 启动进程,默认禁止创建子进程——这正好能挡住被污染的依赖偷偷 child_process.exec() 干坏事。

如果你的程序确实需要 fork/spawn,要显式放行:

node --permission --allow-child-process app.js

但要注意,权限模型不会自动继承到子进程身上。也就是说父进程开了 --permission,子进程默认还是没有文件、网络等权限的,需要单独给它配 --allow-fs-read 之类的参数。

记住一个核心原则:子进程是个强力的工具,但凡是把用户输入送进 shell 的地方,都是攻击者盯着的下手点。 选法的要点是——简单命令用 exec,大数据量或要喂 stdin 用 spawn 的数组参数,跑 Node 模块还要双向通信用 fork;只要命令里掺了用户输入,一律走 spawn 数组参数或 execFile,绝不拼接字符串。下一章我们进入 Web 安全的第一道门:Cookie 和 Session。