在介绍 child_process 模块之前, 先来看一个例子.
- const http = require('http');
- const longComputation = () => {
- let sum = 0;
- for (let i = 0; i <1e10; i++) {
- sum += i;
- };
- return sum;
- };
- const server = http.createServer();
- server.on('request', (req, res) => {
- if (req.url === '/compute') {
- const sum = longComputation();
- return res.end(`Sum is ${sum}`);
- } else {
- res.end('Ok')
- }
- });
- server.listen(3000);
可以试一下使用上面的代码启动 Node.js 服务, 然后打开两个浏览器选项卡分别访问 / compute 和 /, 可以发现 node 服务接收到 / compute 请求时会进行大量的数值计算, 导致无法响应其他的请求(/).
在 Java 语言中可以通过多线程的方式来解决上述的问题, 但是 Node.js 在代码执行的时候是单线程的, 那么 Node.js 应该如何解决上面的问题呢? 其实 Node.js 可以创建一个子进程执行密集的 cpu 计算任务 (例如上面例子中的 longComputation) 来解决问题, 而 child_process 模块正是用来创建子进程的.
创建子进程的方式
child_process 提供了几种创建子进程的方式
异步方式: spawn,exec,execFile,fork
同步方式: spawnSync,execSync,execFileSync
首先介绍一下 spawn 方法
child_process.spawn(command[, args][, options])
command: 要执行的指令
args: 传递参数
options: 配置项
- const { spawn } = require('child_process');
- const child = spawn('pwd');
pwd 是 shell 的命令, 用于获取当前的目录, 上面的代码执行完控制台并没有任何的信息输出, 这是为什么呢?
控制台之所以不能看到输出信息的原因是由于子进程有自己的 stdio 流(stdin,stdout,stderr), 控制台的输出是与当前进程的 stdio 绑定的, 因此如果希望看到输出信息, 可以通过在子进程的 stdout 与当前进程的 stdout 之间建立管道实现
child.stdout.pipe(process.stdout);
也可以监听事件的方式(子进程的 stdio 流都是实现了 EventEmitter API 的, 所以可以添加事件监听)
- child.stdout.on('data', function(data) {
- process.stdout.write(data);
- });
在 Node.js 代码里使用的 console.log 其实底层依赖的就是 process.stdout
除了建立管道之外, 还可以通过子进程和当前进程共用 stdio 的方式来实现
- const { spawn } = require('child_process');
- const child = spawn('pwd', {
- stdio: 'inherit'
- });
stdio 选项用于配置父进程和子进程之间建立的管道, 由于 stdio 管道有三个 (stdin, stdout, stderr) 因此 stdio 的三个可能的值其实是数组的一种简写
pipe 相当于['pipe', 'pipe', 'pipe'](默认值)
ignore 相当于['ignore', 'ignore', 'ignore']
inherit 相当于[process.stdin, process.stdout, process.stderr]
由于 inherit 方式使得子进程直接使用父进程的 stdio, 因此可以看到输出
ignore 用于忽略子进程的输出(将 / dev/null 指定为子进程的文件描述符了), 因此当 ignore 时 child.stdout 是 null.
spawn 默认情况下并不会创建子 shell 来执行命令, 因此下面的代码会报错
- const { spawn } = require('child_process');
- const child = spawn('ls -l');
- child.stdout.pipe(process.stdout);
- // 报错
- events.js:167
- throw er; // Unhandled 'error' event
- ^
Error: spawn ls -l ENOENT
- at Process.ChildProcess._handle.onexit (internal/child_process.js:229:19)
- at onErrorNT (internal/child_process.js:406:16)
- at process._tickCallback (internal/process/next_tick.js:63:19)
- at Function.Module.runMain (internal/modules/cjs/loader.js:746:11)
- at startup (internal/bootstrap/node.js:238:19)
- at bootstrapNodeJSCore (internal/bootstrap/node.js:572:3)
- Emitted 'error' event at:
- at Process.ChildProcess._handle.onexit (internal/child_process.js:235:12)
- at onErrorNT (internal/child_process.js:406:16)
[... lines matching original stack trace ...]
at bootstrapNodeJSCore (internal/bootstrap/node.js:572:3)
如果需要传递参数的话, 应该采用数组的方式传入
- const { spawn } = require('child_process');
- const child = spawn('ls', ['-l']);
- child.stdout.pipe(process.stdout);
如果要执行 ls -l | wc -l 命令的话可以采用创建两个 spawn 命令的方式
- const { spawn } = require('child_process');
- const child = spawn('ls', ['-l']);
- const child2 = spawn('wc', ['-l']);
- child.stdout.pipe(child2.stdin);
- child2.stdout.pipe(process.stdout);
也可以使用 exec
- const { exec } = require('child_process');
- exec('ls -l | wc -l', function(err, stdout, stderr) {
- console.log(stdout);
- });
由于 exec 会创建子 shell, 所以可以直接执行 shell 管道命令. spawn 采用流的方式来输出命令的执行结果, 而 exec 也是将命令的执行结果缓存起来统一放在回调函数的参数里面, 因此 exec 只适用于命令执行结果数据小的情况.
其实 spawn 也可以通过配置 shell option 的方式来创建子 shell 进而支持管道命令, 如下所示
- const { spawn, execFile } = require('child_process');
- const child = spawn('ls -l | wc -l', {
- shell: true
- });
- child.stdout.pipe(process.stdout);
配置项除了 stdio,shell 之外还有 cwd,env,detached 等常用的选项
cwd 用于修改命令的执行目录
- const { spawn, execFile, fork } = require('child_process');
- const child = spawn('ls -l | wc -l', {
- shell: true,
- cwd: '/usr'
- });
- child.stdout.pipe(process.stdout);
env 用于指定子进程的环境变量(如果不指定的话, 默认获取当前进程的环境变量)
- const { spawn, execFile, fork } = require('child_process');
- const child = spawn('echo $NODE_ENV', {
- shell: true,
- cwd: '/usr'
- });
- child.stdout.pipe(process.stdout);
- NODE_ENV=randal node b.js
- // 输出结果
- randal
如果指定 env 的话就会覆盖掉默认的环境变量, 如下
- const { spawn, execFile, fork } = require('child_process');
- spawn('echo $NODE_TEST $NODE_ENV', {
- shell: true,
- stdio: 'inherit',
- cwd: '/usr',
- env: {
- NODE_TEST: 'randal-env'
- }
- });
- NODE_ENV=randal node b.js
- // 输出结果
- randal
detached 用于将子进程与父进程断开连接
例如假设存在一个长时间运行的子进程
- // timer.js
- while(true) {
- }
但是主进程并不需要长时间运行的话就可以用 detached 来断开二者之间的连接
- const { spawn, execFile, fork } = require('child_process');
- const child = spawn('node', ['timer.js'], {
- detached: true,
- stdio: 'ignore'
- });
- child.unref();
当调用子进程的 unref 方法时, 同时配置子进程的 stdio 为 ignore 时, 父进程就可以独立退出了
execFile 与 exec 不同, execFile 通常用于执行文件, 而且并不会创建子 shell 环境
fork 方法是 spawn 方法的一个特例, fork 用于执行 js 文件创建 Node.js 子进程. 而且 fork 方式创建的子进程与父进程之间建立了 IPC 通信管道, 因此子进程和父进程之间可以通过 send 的方式发送消息.
注意: fork 方式创建的子进程与父进程是完全独立的, 它拥有单独的内存, 单独的 V8 实例, 因此并不推荐创建很多的 Node.js 子进程
fork 方式的父子进程之间的通信参照下面的例子
- parent.js
- const { fork } = require('child_process');
- const forked = fork('child.js');
- forked.on('message', (msg) => {
- console.log('Message from child', msg);
- });
- forked.send({ hello: 'world' });
- child.js
- process.on('message', (msg) => {
- console.log('Message from parent:', msg);
- });
- let counter = 0;
- setInterval(() => {
- process.send({ counter: counter++ });
- }, 1000);
- node parent.js
- // 输出结果
- Message from parent: { hello: 'world' }
- Message from child { counter: 0 }
- Message from child { counter: 1 }
- Message from child { counter: 2 }
- Message from child { counter: 3 }
- Message from child { counter: 4 }
- Message from child { counter: 5 }
- Message from child { counter: 6 }
回到本文初的那个问题, 我们就可以将密集计算的逻辑放到单独的 js 文件中, 然后再通过 fork 的方式来计算, 等计算完成时再通知主进程计算结果, 这样避免主进程繁忙的情况了.
- compute.js
- const longComputation = () => {
- let sum = 0;
- for (let i = 0; i <1e10; i++) {
- sum += i;
- };
- return sum;
- };
- process.on('message', (msg) => {
- const sum = longComputation();
- process.send(sum);
- });
- index.js
- const http = require('http');
- const { fork } = require('child_process');
- const server = http.createServer();
- server.on('request', (req, res) => {
- if (req.url === '/compute') {
- const compute = fork('compute.js');
- compute.send('start');
- compute.on('message', sum => {
- res.end(`Sum is ${sum}`);
- });
- } else {
- res.end('Ok')
- }
- });
- server.listen(3000);
监听进程事件
通过前述几种方式创建的子进程都实现了 EventEmitter, 因此可以针对进程进行事件监听
常用的事件包括几种: close,exit,error,message
close 事件当子进程的 stdio 流关闭的时候才会触发, 并不是子进程 exit 的时候 close 事件就一定会触发, 因为多个子进程可以共用相同的 stdio.
close 与 exit 事件的回调函数有两个参数 code 和 signal,code 代码子进程最终的退出码, 如果子进程是由于接收到 signal 信号终止的话, signal 会记录子进程接受的 signal 值.
先看一个正常退出的例子
- const { spawn, exec, execFile, fork } = require('child_process');
- const child = exec('ls -l', {
- timeout: 300
- });
- child.on('exit', function(code, signal) {
- console.log(code);
- console.log(signal);
- });
- // 输出结果
- 0
- null
再看一个因为接收到 signal 而终止的例子, 应用之前的 timer 文件, 使用 exec 执行的时候并指定 timeout
- const { spawn, exec, execFile, fork } = require('child_process');
- const child = exec('node timer.js', {
- timeout: 300
- });
- child.on('exit', function(code, signal) {
- console.log(code);
- console.log(signal);
- });
- // 输出结果
- null
- SIGTERM
注意: 由于 timeout 超时的时候 error 事件并不会触发, 并且当 error 事件触发时 exit 事件并不一定会被触发
error 事件的触发条件有以下几种:
无法创建进程
无法结束进程
给进程发送消息失败
注意当代码执行出错的时候, error 事件并不会触发, exit 事件会触发, code 为非 0 的异常退出码
- const { spawn, exec, execFile, fork } = require('child_process');
- const child = exec('ls -l /usrs');
- child.on('error', function(code, signal) {
- console.log(code);
- console.log(signal);
- });
- child.on('exit', function(code, signal) {
- console.log('exit');
- console.log(code);
- console.log(signal);
- });
- // 输出结果
- exit
- 1
- null
message 事件适用于父子进程之间建立 IPC 通信管道的时候的信息传递, 传递的过程中会经历序列化与反序列化的步骤, 因此最终接收到的并不一定与发送的数据相一致.
- sub.js
- process.send({ foo: 'bar', baz: NaN });
- const cp = require('child_process');
- const n = cp.fork(`${__dirname}/sub.js`);
- n.on('message', (m) => {
- console.log('got message:', m); // got message: { foo: 'bar', baz: null }
- });
关于 message 有一种特殊情况要注意, 下面的 message 并不会被子进程接收到
- const { fork } = require('child_process');
- const forked = fork('child.js');
- forked.send({
- cmd: "NODE_foo",
- hello: 'world'
- });
当发送的消息里面包含 cmd 属性, 并且属性的值是以 NODE_开头的话, 这样的消息是提供给 Node.js 本身保留使用的, 因此并不会发出 message 事件, 而是会发出 internalMessage 事件, 开发者应该避免这种类型的消息, 并且应当避免监听 internalMessage 事件.
message 除了发送字符串, object 之外还支持发送 server 对象和 socket 对象, 正因为支持 socket 对象才可以做到多个 Node.js 进程监听相同的端口号.
未完待续......
参考资料
- https://medium.freecodecamp.org/node-js-child-processes-everything-you-need-to-know-e69498fe970a
- https://nodejs.org/dist/latest-v10.x/docs/api/child_process.html
来源: https://juejin.im/post/5b10a814f265da6e2a08a6f7