Node.js中Child Process的实战应用与常见陷阱解析

司徒沐语 前端 阅读 1,830
赞 19 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

在 Node.js 项目里调用子进程(Child Process)这事儿,我干过太多次了——从打包脚本、自动化测试,到调用 Python 脚本处理图像,甚至跑 shell 命令部署服务。一开始图快,直接 exec('some-command') 一扔,结果日志乱码、进程卡死、内存泄漏轮番上阵,折腾得我半夜三点还在看 top

Node.js中Child Process的实战应用与常见陷阱解析

后来我总结了一套自己的写法,核心就两点:永远用 spawn,别用 exec;永远监听所有事件,别只管 stdout。

下面这段是我现在项目里通用的封装:

const { spawn } = require('child_process');

function runCommand(command, args = [], options = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, {
      stdio: 'pipe',
      ...options,
    });

    let stdout = '';
    let stderr = '';

    child.stdout.on('data', (data) => {
      stdout += data.toString();
    });

    child.stderr.on('data', (data) => {
      stderr += data.toString();
    });

    child.on('error', (err) => {
      reject(new Error(Child process failed to start: ${err.message}));
    });

    child.on('close', (code) => {
      if (code === 0) {
        resolve({ stdout, stderr });
      } else {
        reject(new Error(Process exited with code ${code}: ${stderr}));
      }
    });
  });
}

为什么这么写?首先,spawn 是流式处理,不会像 exec 那样把整个输出缓存到内存里。我曾经用 exec 跑一个输出 50MB 日志的命令,直接 OOM 挂掉。spawn 就稳多了,边读边处理,内存压力小。

其次,必须同时监听 stdoutstderr。很多命令出错时会把错误信息写到 stderr,如果你只监听 stdout,那错误就静默了,你以为成功了,其实早就崩了。

最后,close 事件比 exit 更可靠,因为它确保所有 I/O 都完成了。我之前用 exit,偶尔会漏掉最后一行日志,改用 close 后再没出现过。

这几种错误写法,别再踩坑了

我见过也写过不少反面教材,列几个高频雷区:

  • 直接用 exec 处理大输出exec 默认 buffer 限制是 1MB,超过就报错 maxBuffer exceeded。你可能觉得“我这个命令输出不大”,但现实是,日志量不可控,尤其在生产环境。别赌,直接用 spawn
  • 忽略 stderr:比如这样:
    exec('my-script.sh', (error, stdout) => {
          if (error) console.error(error);
          else console.log(stdout);
        });

    看似没问题,但如果 my-script.sh 写了 echo "error" >&2,你就收不到。更糟的是,有些工具即使退出码为 0 也会往 stderr 打 warning,你完全不知道。

  • 不处理 error 事件:如果子进程根本没启动成功(比如命令不存在),spawn 会触发 error 事件,但不会触发 close。如果你只监听 close,那 Promise 就永远 pending,内存泄漏+逻辑卡死。
  • shell: true 图方便:虽然 spawn('ls -l', { shell: true }) 看着爽,但这是安全隐患。万一参数里有用户输入,比如 '; rm -rf /',你就完蛋了。永远拆成 [command, arg1, arg2] 形式,让系统直接调用,绕过 shell 解析。

我去年在一个 CI 脚本里用了 shell: true + 用户传入的分支名,结果某次分支名带了个空格和分号,直接把临时目录清空了。从那以后,我对 shell: true 敬而远之。

实际项目中的坑

除了代码层面,实战中还有几个细节容易翻车:

1. 编码问题:Windows 上默认是 GBK,Linux 是 UTF-8。如果你在跨平台项目里跑子进程,输出中文可能会乱码。我的做法是统一设置 env

const child = spawn('my-command', [], {
  env: {
    ...process.env,
    LC_ALL: 'en_US.UTF-8',
  },
});

不过 Windows 不认 LC_ALL,所以更稳妥的方式是在读取 data 后手动转码,或者干脆避免输出非 ASCII 字符。

2. 进程残留:如果父进程 crash 了,子进程可能变成孤儿进程继续跑。我在长时间运行的服务里加了清理逻辑:

const children = [];

process.on('exit', () => {
  children.forEach(child => {
    if (!child.killed) child.kill();
  });
});

// 创建子进程时 push 到 children
const child = spawn(...);
children.push(child);

虽然不能 100% 保证(比如 kill -9 父进程),但至少能覆盖大部分优雅退出场景。

3. 超时控制:子进程可能卡死(比如等用户输入)。我一般在外层加个超时:

const timeout = 60000; // 60s
const controller = new AbortController();

const timer = setTimeout(() => {
  controller.abort();
  child.kill();
}, timeout);

try {
  await runCommand(...);
} catch (err) {
  if (controller.signal.aborted) {
    throw new Error('Command timed out');
  }
  throw err;
} finally {
  clearTimeout(timer);
}

注意:child.kill() 只是发信号,不一定立刻终止,所以配合 AbortController 标记状态更安全。

4. 权限和路径:在容器或 CI 环境里,经常遇到 “command not found”。别假设 PATH 一致,最好用绝对路径,或者通过 which 先查一下。比如:

const { stdout } = await runCommand('which', ['ffmpeg']);
const ffmpegPath = stdout.trim();
await runCommand(ffmpegPath, [...]);

结尾唠叨两句

以上是我踩坑后总结的 Child Process 使用姿势。说实话,这套方案也不是完美的——比如跨平台编码问题还是有点麻烦,超时控制在极端情况下也可能失效。但至少在我们几十个 Node 项目里跑了一年多,没再出过子进程相关的线上事故。

核心思想就一句:别偷懒,把每个事件都兜住,把每个边界都考虑到。 子进程不是黑盒,它是个随时可能爆炸的定时炸弹,你得给它装上保险丝。

以上是我个人对 Child Process 的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多(比如和 Worker Threads 结合),后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论