Node.js 子进程:你需要知道的一切

Node.js 子进程:你需要知道的一切

https://medium.freecodecamp.org/node-js-child-processes-everything-you-need-to-know-e69498fe970amedium.freecodecamp.org

Node.js 中在单进程中单线程,非阻塞的性能非常好。但是,一个 CPU 一个进程不足以处理程序中日益增加的工作负载。

无论你的服务器多么强大,一个线程只能支持有限的负载。

事实上,Node.js 运行在一个单线程里面并不意味着我们不能利用多进程的优势,当然还有多机器。

使用多进程是扩展一个 Node 应用最好的方式。Node.js 被设计用来通过很多节点创建分布式应用。这也是为什么被命名为 Node。伸缩性是融入平台里面的,而不是在一个应用的生命周期后期才开始考虑的事情。

注意,在读这篇文章之前,你需要理解 Node.js events(事件) 和 streams(流)。如果不理解,我建议你先读下这两篇文章:

前端开发-晓:Node.js 事件循环工作流程 & 生命周期(翻译)zhuanlan.zhihu.com图标前端开发-晓:Node.js 流(stream):你需要知道的一切zhuanlan.zhihu.com图标

子进程模块

我们可以使用 Node.js 的 child_process 模块很容易地衍生一个子进程,并且那些父子进程使用一个消息系统相互之间可以很容易地交流。

child_process 模块使我们在一个运行良好的子进程内运行一些能够进入操作系统的命令。

我们可以控制子进程的输入流,并且监听它的输出流。我们也可以控制传递给潜在的操作系统命令的参数,我们可以通过那个命令的输出做我们想做的事情。比如:可以将一个命令的输出作为另一个的输入(就像我们在 Linux 做的那样),因为那些命令的输入和输出都是使用流的形式呈现给我们。

注意:这篇文章的所有例子都是基于 Linux 的。在 Windows 上面,你需要切换我使用的命令为 windows 上面可替代的命令。

在 Node 中,有 4 种方式创建子进程:spawn(),fork(),exec(),execFile()。

就让我们来看一看这四个函数的差异,并且什么时候使用它们。

衍生一个子进程

spawn 函数在一个新的进程里启动一个命令,可以给这个命令传递任何的参数。例如,这里有一段代码衍生一个新进程并且执行 pwd 命令。

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

我们简单地从 child_process 模块中析构出 spawn 函数,并且将操作系统的命令作为第一个参数,然后执行它。

执行 spawn 函数(上面 child 对象)的结果是一个实现了 EventEmitter API 的 childProcess 实例。这意味着我们可以在这个 child 对象上面直接为某些事件注册处理器。比如,我们可以为 exit 事件注册一个处理器在 child process 退出的时候做一些事情:

child.on('exit', function(code, signal){
    console.log('child process exited with' + `code ${code} and signal ${signal}`);
});

上面的处理器接受到了子进程的退出 code 和 singal,如果有的话,是被用来终止子进程的。如果子进程正常退出 singal 变量是 null。

另外一些我们可以为 ChildProcess 实例注册的事件有 discount,error,close,message。

  • 当父进程手动调用 child.discount 的时候,discount 事件会被触发。
  • 如果进程不能被衍生(spawn)或者被 killed,error 事件被触发。
  • 当一个子进程的 stdio 关闭的时候,close 事件被触发。
  • message 事件是最重要的一个。当子进程使用 process.send() 函数发送信息的时候,message 事件会被触发。这就是父子进程相会交流的方式。我们将会在下面看到一个例子。

每一个子进程都会得到三个标准的输入输出流,我们可以通过 child.stdin,child.stdout 和 child.stderr 进入。

当那些流关闭之后,使用他们的子进程将会触发 close 事件。这个 close 事件和 exit 事件不同,因为多个子进程可能共享相同的 stdio 流,因此一个子进程退出不代表流关闭了。

既然所有的流都是事件发射器,我们可以在那些被绑定到每一个子进程的 stdio 流监听不同的事件。不像在一个正常的进程中,在子进程中,stdout/stderr 流是可读的流,而 stdin 是可写的流。基本上是主进程中相反类型的。可以在这些流上使用的事件是标准的。更重要的是,在可读流上,我们可以监听 data 事件,可以得到命令的输出和在执行命令时遇到的错误:

child.stdout.on('data', (data) => {
    console.log(`child stdout: ${data}`)
});
child.stderr.on('data', (data) => {
    console.error(`stderror ${data}`);
});

上面的两个处理器将会在主进程的 stdout 和 stderr 的两种情况下打印日志。当执行 spawn 函数时,pwd 的命令结果将会被打印,子进程以 code 0 退出,表明没有错误发生。

我们可以用 spawn 函数的第二个参数向执行的命令传递参数,是一个数组的参数。例如,在当前文件夹下执行带有参数 -type f 的 find 命令(将文件列出来),可以这样做:

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

如果在命令执行过程中发生错误了,例如,我们在上面给了一个非法的路径,child.stderr data 事件处理器将会被触发,exit 事件处理器会被报告一个退出码 1,表明有一个错误发生了。错误时退出码的值根据实际的宿主系统及错误类型。

一个子进程 stdin 是一个可写的流。我们可以用它发送一个命令的输入。像任何一个可写流那样,最简单消费它的方式时使用 pipe 函数。我们简单地将一个可读流 pipe 到另一个可写流里。因为主进程的 stdin 是可读流,我们可以将它 pipe 到子进程的 stdin 流里。例如:

const {spawn} = require('child_process');
const child = spawn('wc');
process.stdin.pipe(child.stdin);
child.stdout.on('data', (data) => {
    console.log(`child stdout: ${data}`);
});

在上面的例子中,子进程执行了 wc 命令,是一个计算行数,单词数,和字母数的Linux 命令。我们可以将主进程的 stdin(是可读流)pipe 到子进程的 stdin(是一个可读流)。结合的结果是我们得到了一个标准的输入模式,我们可以输入一些东西,当 CTRL+ D 时,我们输入的将会被用来作为 wc 命令的参数。

我们也可以在多进程标准输入输出之间相互 pipe,就像我们用 Linux 命令做的那样。例如:我们可以 pipe find 命令的 stdout 到 wc(在当前目录中统计所有的文件) 命令的 stdin 里。

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

我再执行 wc 命令时添加了一个 -l 参数,只统计行数。当执行的时候,上面的代码将会打印出在当前路径下所有文件夹里面的文件的函数统计信息。

shell 语法和 exec 函数

默认地,spawn 函数并没有创建一个 shell 去执行我们传入地命令。这使得它比 exec 函数执行稍微高效一点儿,exec 创建了个 shell。exec 函数有另一个主要地区别。将命令的输出放到缓冲区,并且将整个输出值传递给一个回调(而不是像 spawn 那样使用流)。

用 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 语法代替 pipe 特性。

注意,如果你执行外部提供的任何类型的动态输入,使用 shell 语法是有安全风险的。用户使用 像 ;的shell 语法字符和 $ 来进行命令注入攻击(例如:command + `; rm -rf ~`)。

exec 函数将输出放入缓存区,并且将它作为 stdout 传递给回调函数(exec 函数的第二个参数)。stdout 是我们想要打印的命令的输出。

如果你想要使用 shell 语法并且运行命令输出的所期望的数据比较小,建议使用 exec 函数(记住,exec 在返回结果数据之前,会在内存中缓存整个数据)。

如果期望的数据很大,那么建议使用 spawn 函数,因为数据可以被标准的 IO 对象流化(streamed)。

如果我们想的话,我们可以在父进程里面衍生(spawn)一个继承标准 IO 对象的子进程,但更重要的是我们也可以使用 spawn 函数使用 shell 语法。下面是 find | wc 命令的 spawn 的实现版本:

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

因为上面的 stdio: 'inherit' 的配置选项,当我们执行代码的时候,子进程将会继承主进程的 stdin,stdout,stderr。这回导致子进程数据事件处理器在主进程的 process.stdout 流中被触发,让脚本有正确的输出方式。

因为上面的 shell: true 的配置选项,我们可以在传递的命令中使用 shell 语法,就想我们在 exec 函数中做的那样。但是用这段代码,我们仍能利用 spawn 函数输出的数据的流的特性。真是一举两得的好事情。

spawn 函数的最后一个参数除了 shell 和 stdio 配置选项外,还有其他一些配置选项。例如: 我们可以通过 cwd 选项更改脚本的工作目录。统计 ~/Downloads 文件夹下所有的文件的统计信息:

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

另外一个可以用的配置选项是 env,可以指定子进程的环境变量。默认的是 process.env,给了子进程一个命令可以进入父 process 环境。如果我们想覆盖这个默认行为,可以给 env 选项一个空对象,或者给一个对象作为子进程唯一的环境变量。

const child = spawn('echo $ANSWER', {
    stdio: 'inherit',
    shell: true,
    end: {ANSWER: 42}
});

上面的命令不能获取父进程的环境变量。比如:不能获取 $HOME,但是可以获取 $ANSWER ,因为是通过 env 选项配置的,作为子进程的局部环境变量。

最后一个重要的子进程配置选项是 detached,它可以使子进程独立于它的父进程运行。

假设我们有一个文件 timer.js,可以是时间循环忙碌:

setTimeout(() => {  
  // keep the event loop busy
}, 20000);

我们可以使用 detached 选项在后台执行它:

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

子进程 detached 后的行为取决于操作系统。在 windows 上面,解绑后(detached)的子进程有它们自己的window 控制台。然而,在 Linux 上面,解绑后(detached)的子进程将会领导一个新的进程组和 session。

如果在解绑的进程上面调用 unref 函数,父进程可以独立于子进程退出。如果子进程在运行一个长时间的任务,这将非常有用,但是在后台保持运行,否则子进程的配置也不得不独立于父进程之外。

上面的示例通过设置 detached 和 stdio 选项在后台执行了一个 node 脚本,以至于当子进程在后台执行的时候,父进程可以退出。

execFile 函数

如果你学要执行一个文件不需要使用 shell,exec 函数就是你所需要的。它表现的和 exec 函数一样,但是不用 shell,这让它更高效一点儿。在 windows 上面,一些文件如 .bat 和 .cmd 凭它们自己不能被执行,这些文件不能被 execFile 执行,执行它们 需要 exec 或者将 shell 设置为 true 的 spawn 函数。

*Sync 函数

从 child_process 模块导出的函数 spawn,exec,execFile 都有同步阻塞的版本,等待直到子进程退出。

const { 
  spawnSync, 
  execSync, 
  execFileSync,
} = require('child_process');

那些同步版本在试图简化脚本任务或者任何启动进程任务的时候有用,但是应该避免。

fork() 函数

fork 函数是spawn 函数的另一种衍生(fork) node 进程的形式。spawn 和 fork 之间最大的不同是当使用 fork 函数时,到子进程的通信通道被建立了,因此我们可以在子进程里通过全局的 process 使用 send 函数,在父子进程之间交换信息。通过 EventEmitter 模块接口实现的。下面是例子:

parent.js

const {fork} = require('child_process');
const forked = fork('child.js');
forked.on('message', (msg) => {
    console.log('messsgae from child', msg);
});

forked.send({hello: 'world'});

child.js

process.on('message', (msg) => {
    console.log('message from parent:', msg);
});
let conter = 0;
setInterval(() => {
    process.send({counter: counter++});
}, 1000);

在上面的 parent.js 里面,我们衍生(fork)了 child.js 文件(将会被 node 命令执行),然后监听了 message 事件。当子进程使用 process.send 的时候,message 事件就会被触发。

为了将信息从父进程传递到子进程,我们在被衍生(fork)的对象上面执行了 send 函数,然后在 child.js 里面,我们可在全局对象 process 上面监听 message 事件。

当执行 parent.js 的时候,它会首先发送一个对象 {hello: 'world'}。在 child.js 中,每秒中向父进程发送一个增加的数。


让我们做更多的关于 fork 函数的实例。

假设我们有一个 http 服务器要处理两端。其中一端(/compute)计算比较多,将会花费几秒钟才能完成。我们可以用一个长循环来模拟:

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

这个程序有一个很大的问题,当请求 /compute 时,服务器将不能处理其他的请求,因为事件循环在忙于长循环操作。

根据长循环操作的本质有好几种解决办法,但其中一个适合所有操作的解决方案是用 frok 将计算操作放到另一个进程中。

将整个 longComputation 函数放到一个文件中,然后受主进程指示触发这个函数。

在一个新的 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);
});

现在,不再是在主进程事件循环中做长循环操作,我们可以衍生(fork) compute.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);

当收到一个 /compute 的请求时,我们发送了一个简单的信息给子进程开始执行长循环。主进程的事件循环不会被阻塞。

一旦子进程完成了长循环操作,它会通过 process.send 将结果发送给主进程。

在父进程里面,我们在子进程上面监听了 message 事件。当我们得到这个值的时候,我们有了一个 sum 值,然后将它通过 http 发送给用户。

上面的例子中的代码,受可以 fork 的进程数的限制,但当我们收到需要长时间计算的请求时,可以执行它,而主线程一点儿也不会被阻塞,可以处理其他的请求。

Node 的 cluster 模块,基于子进程的理念并且在多个子进程之间负载,可以在任意的操作系统上面创建。

前端开发-晓:Node.js 集群(cluster):扩展你的 Node.js 应用zhuanlan.zhihu.com图标

谢谢阅读

编辑于 2018-05-21

文章被以下专栏收录