你应该了解的Node child_process

4,150 阅读5分钟

多线程与单线程

C#,Java都是具有多线程的语言,多线程同步模式是将CPU分成几个线程,每个线程同步运行。

mutil.png

Node 采用单线程、异步非阻塞模式,也就是每一个计算独占CPU,遇到I/O请求,后面的计算不阻塞,异步执行,当I/O完成后,通过事件继续执行 计算2,这样非常适合单个进程

singgle.png

但是一个CPU一个进程不足以处理 庞大的I/O (例如从网络读取、访问数据库或文件系统) 。无论你的服务器多么强大,一个线程仅仅能够支持有限的处理能力。

实际上,Node运行模式虽然是单线程,但是同样可以利用多个进程,当然也能使用集群。

拓展Node应用程序最好的方式是利用多个进程,Node设计的初衷也是利用多个节点构建分布式应用程序。

child_process 子进程

利用Node提供的child_process模块,可以很容易的衍生出一个子进程,而且子进程之间可以通过事件消息系统进行互相通信。

在任何子进程中,我们能做的有很多。比如:

  • 可以通过执行系统命令去访问控制操作系统

  • 可以控制子进程的输入流,监听子进程的输出流。

  • 可以控制传给底层操作系统命令的参数

  • 可以命令的输出做任何我们想做的事情。比如,将一个命令的输出作为输入传递给另一个命令(就像我们在 Linux 中所做的那样),因为这么命令的输入和输出都可以使用Node流。

    Node的child_process模块提供四种异步函数和三种同步函数的方式创建子进程:

releation.png

我们将看一下四个异步函数如何使用以及他们之间的区别

spawn()

格式:child_process.spawn(command,[args],[options])

- command  <string>  要运行的命令
- args <string[]> 字符串参数列表
- options <object>
  - shell <boolean>|<string>  如果是 `true`,则在 shell 内运行 `command`
  - stdio <Array>|<string>  子进程的标准输入输出配置
  - cwd <string> 子进程的当前工作目录
  - env <object> 环境变量键值对,默认值:process.env  

spawn 函数在新的进程中启动一个命令,我们可以通过新的进程向命令传递任何参数。例如,衍生出一个新的进程执行pwd命令

const {spawn} = require('child_process')
const child=spawn('pwd')

我们从child_process中解构出spawn函数,然后将系统命令pwd传递给spawn函数,然后执行。

spawn函数返回一个ChildProcess 的实例,该实例继承了EventEmitter,那么我们可以在子进程实例上添加事件的回调函数。例如:我们注册一个监听子进程exit的回调函数。

const { spawn } = require('child_process')
const child = spawn('pwd')

//code <number>  :如果子进程自己退出,则为退出码
//signal <string> 终止子进程的信号
child.on("exit", (code, signal) => {
  console.log(
    "子进程退出:" + `code ${code} and signal ${signal}`
  )
})
//结果:子进程退出:code 0 and signal null

上面例子中,回调函数接收两个参数,codesignal。当子进程正常结束时,返回的code值为0,signal为null。

ChildProcess类继承EventEmitters 所以实例包括以下几种事件

exit

该事件在子进程结束后触发。 如果进程退出,则 code 为进程最终退出码,否则为 null。 如果进程因收到信号而终止,则 signal 是信号的字符串名称,否则为 null。 两者之一将始终是非 `null。

'exit' 事件被触发时,子进程标准输入输出流可能仍处于打开状态

disconnect

调用父进程中的 subprocess.disconnect() 方法或子进程中的 process.disconnect() 方法后会触发 'disconnect' 事件。 断开连接后就不能再发送或接收消息,且 subprocess.connected 属性为 false

error

在无法衍生该进程,或进程无法终止,或向子进程发送消息失败,该事件就会被触发。发生错误后,'exit' 事件可能会也可能不会触发。

在监听 'exit''error' 事件时,防止多次意外调用回调函数

close

该事件在进程结束_并且_子进程的标准输入输出流已关闭后触发。 这与 'exit' 事件不同,因为多个进程可能共享相同的标准输入输出流。 'close' 事件将始终在 'exit''error'(如果子进程未能衍生)已经触发后触发。

  • code 如果子进程自己退出,则为退出码。
  • signal终止子进程的信号。
const { spawn } = require('child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.on('close', (code,signal) => {
  console.log(`child process close all stdio with code ${code}`);
});

ls.on('exit', (code,signal) => {
  console.log(`child process exited with code ${code}`);
});

message

该事件是很重要的一个。当子进程使用 process.send() 发送消息时触发 'message' 事件。这就是父/子进程可以相互通信的原因。我们将在下面看到一个例子

每个子进程具有标准流stdio。所以我们可以使用child.stdin,child.stdout,child.stderr

当标准流stdio关闭时,会触发close事件。close事件和exit事件不同,因为多个子进程共享同一个标准流stdio,所以当一个子进程退出,并不代表标准流会关闭,也就是 exit事件被触发的时候,close 事件不一定被触发。

由于所有流(Stream)都继承了EventEmitter,所以,可以在stdio附加到每个子进程的流上监听不同的事件。与正常进程不同的是,在子进程中,stdout/stderr是可读流,而stdin流是可写流。这和主进程截然相关。最重要的是,在可读流上,我们可以监听data事件,该事件将在命令输出或执行命令时遇到的任何错误的时候,都会触发data 事件

const { spawn } = require('child_process')
const child = spawn('pwd')

child.on("exit", (code, signal) => {
  console.log(
    "子进程退出:" + `code ${code} and signal ${signal}`
  )
})

child.stdout.on("data", data => {
  console.log(`child stdout:\n${data}`);
});

child.stderr.on("data", data => {
  console.error(`child stderr:\n${data}`);
});

//  运行结果:
//
//  dchild stdout:
//  /Users/liujianwei/Documents/personal_code/node-demo
//
//  子进程退出:code 0 and signal null

上面的例子展示了标准输出和标准错误都可以通过监听data事件,处理执行结果。可以看到运行结果,子进程退出时code值为0,代表没有异常错误发生。

可以通过spawn第二个参数,给命令传递参数,第二个参数是数组类型。例如:利用find命令查找当前目录下面的所有文件,需要参数 -type f

const child = spawn("find", [".", "-type", "f"]);

执行命令的过程中如果发生错误,child.stderrdata事件会被触发,同时exit事件将抛出code值,值为1,代表着发生了异常错误。错误值实际上取决于主机操作系统和错误类型。

子进程stdin是一个可写的流。我们可以通过他给命令行传入一些东西。就像任何可写流一样,消费可写流最简单的方式是通过pipe函数,可以简单的将可读流传入到可写流。主进程的stdin是一个可读流,子进程的stdin是一个可写流,可以通过pipe将他们连接。例如:

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

const child = spawn("wc");

process.stdin.pipe(child.stdin);

child.stdout.on("data", data => {
  console.log(`child stdout:\n${data}`);
});

上面的例子中,子进程运行wc命令,等待输入。然后通过pipe函数,主进程将sdtin传入到子进程的stdin。通过这种组合,得到了一个标准输入模式,我们可以在其中输入一些东西,通过 Ctrl+D结束输入,我们输入的内容将用作wc命令的输入。

wc.gif

我们也可以像Linux一样,通过 pipe函数把多个子进程串联起来。例如:

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

const find = spawn("find", [".", "-type", "f"]);
const wc = spawn("wc", ["-l"]);

find.stdout.pipe(wc.stdin);

wc.stdout.on("data", data => {
  console.log(`Number of files ${data}`);
});

Shell语法和exec()

exec方法将会生成一个子shell,然后在该 shell 中执行命令,并缓冲产生的数据,当子流程完成后,并将子进程的输出以回调函数参数的形式一次性返回。exec方法会从子进程中返回一个完整的buffer。默认情况下,这个buffer的大小应该是200k。如果子进程返回的数据大小超过了200k,程序将会崩溃,同时显示错误信息“Error:maxBuffer exceeded”。你可以通过在exec的可选项中设置一个更大的buffer体积来解决这个问题,但是你不应该这样做,因为exec本来就不是用来返回很多数据的方法

我们把上面的例子用exec实现

const { exec } = require("child_process");

exec("find . -type f | wc -l", (err, stdout, stderr) => {
  if (err) {
    console.error(`exec error: ${err}`);
    return;
  }

  console.log(`Number of files ${stdout}`);
});

因为exec方法是通过shell去执行的命令,所以我们可以直接写shell语法,充分shell管道的特性

默认情况下,spwan不会创建一个shell去执行命令,但是可以通过第三个参数设置去支持shell去执行命令。上面我们提到,spawn 会返回一个带有stdout和stderr流的对象。可以通过pipe很方便的消费数据。

const child = spawn("find . -type f | wc -l", {
  stdio: "inherit",
  shell: true
});

设置了stdio:"inherit",当我们执行代码时,子进程会继承主进程的stdin,stdoutstderr。这会导致主进程的process.stdout会触发子进程的data事件,并立即输出结果。

设置了shell:true,我们可以像exec那样使用shell执行命令。

当你想要子进程返回大量数据给Node时,比如说图像处理,读取二进制数据等等,你最好使用spawn方法。

当你想用shell语法,并且数据非常小,使用exec是最好的选择

这里我们在说一下spwan另外两个重要的参数cwdenv。cwd可以设置当前工作目录:

const child = spawn("find . -type f | wc -l", {
  stdio: "inherit",
  shell: true,
  cwd: "/Users/liujianwei/Downloads"
});

env设置环境变量,默认是process.env。如果设置了该参数,那么将获取不到process.env上的变量。

const { spawn } = require('child_process')
const child = spawn("echo $ANSWER ;\n echo $HOME; ", {
  stdio: "inherit",
  shell: true,
  env: { ANSWER: 42 }
})

上面的的例子中,ANSWER结果是42,无法获取到ANSWER 结果是42,无法获取到HOME的值

execFile()

child_process.execFile() 函数与 child_process.exec() 类似,不同之处在于它默认不衍生 shell。 而是,指定的可执行文件 file 直接作为新进程衍生,使其比 child_process.exec() 略有效率。

支持与 child_process.exec() 相同的选项。 由于未衍生 shell,因此不支持 I/O 重定向和文件通配等行为。

const { execFile } = require('child_process');
const child = execFile('node', ['--version'], (error, stdout, stderr) => {
  if (error) {
    throw error;
  }
  console.log(stdout);
});

fork()

child_process.fork 是 spawn()的特殊形式,用于在子进程中运行的模块,如 fork(‘./son.js’) 相当于 spawn(‘node’, [‘./son.js’]) 。与spawn方法不同的是,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);

看一下执行效果:

fork.gif

在父文件里面,我们用fork执行了child.js,同时,我们监听了message事件,当子进程通过process.send发送消息的时候,父文件中的message事件将会被触发。

同样,我们也可以在父进程通过process.send给子进程发消息。

我们举个实际项目中可能会用到的例子

const http = require("http");

const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e9; 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);

我们有一个http服务,该服务有两个端点,当我们请求/compute端点的时候,执行longComputation函数进行大量CUP计算,会阻塞I/O。如果此时我们在访问 另一个端点,是得不到OK响应的。

解决方法是将 longComputation函数放在一个子进程中执行,通过fork去执行,这样不会不阻塞I/O。代码如下:

创建一个compute.js文件,放入计算

const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  }
  return sum;
};

process.on("message", msg => {
  const sum = longComputation();
  process.send(sum);
});

改造:

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);

上面的代码受 fork 的进程数量限制,但是当我们执行它并通过 http 请求耗时计算端点时,主服务器根本没有被阻塞并且可以接受进一步的请求

Node的cluster模块就是基于这种思想。

最后

文本是根据Node.js Child Processes: Everything you need to know这篇文章,并结合自己的理解做了简单的翻译,如果有错误欢迎指正。

参考资料

Node.js Child Processes: Everything you need to know

node真的是单线程吗?

Node.js 子进程(exec、spawn、fork)