Node.js中的单线程无阻塞性能非常适合单个进程。但是最终,一个CPU中的一个进程不足以处理应用程序不断增加的工作量。

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

Node.js在单个线程中运行的事实并不意味着我们不能利用多个进程,当然也不能利用多个计算机。

使用多个进程是扩展Node应用程序的最佳方法。 Node.js设计用于构建具有多个节点的分布式应用程序。这就是为什么它被命名节点。 可伸缩性已植入平台中,您在应用程序的生命周期后期就不会开始考虑它。

子进程模块

我们可以使用Node的child_process模块轻松旋转一个子进程,并且这些子进程可以通过消息传递系统轻松地相互通信。

child_process模块使我们能够通过在子进程中运行任何系统命令来访问操作系统功能。

我们可以控制该子进程的输入流,并监听其输出流。我们还可以控制要传递给底层OS命令的参数,并且可以对该命令的输出执行任何所需的操作。例如,我们可以将一个命令的输出作为输入传递给另一个命令(就像我们在Linux中一样),因为可以使用Node.js流向我们展示这些命令的所有输入和输出。

请注意,我将在本文中使用的示例全部基于Linux。在Windows上,您需要切换与Windows替代品一起使用的命令。

有四种不同的方法来创建节点的子流程:spawn()fork()exec(),和execFile()

我们将看到这四个函数之间的区别以及何时使用它们。

产生的子进程

spawn函数在新进程中启动命令,我们可以使用它传递该命令的任何参数。例如,下面的代码产生一个将执行pwd命令的新进程。

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

const child = spawn('pwd');

我们只需将spawn功能从child_process模块中解构出来,然后以OS命令作为第一个参数执行即可。

执行该spawn函数(child上面的对象)的结果是一个ChildProcess实例,该实例实现了EventEmitter API。这意味着我们可以直接在此子对象上注册事件的处理程序。例如,当子进程退出时,我们可以通过为exit事件注册一个处理程序来做一些事情:

child.on(' exit ',function(code,signal){

console.log('子进程以'+

`code $ {code}和signal $ {signal}` 退出);

});

上面的处理程序为我们code提供了子进程的出口以及signal用于终止子进程的(如果有的话)。signal当子进程正常退出时,此变量为null。

其他事件,我们可以用注册的处理程序ChildProcess实例是disconnecterrorclose,和message

  • disconnect当父进程手动调用该child.disconnect函数时,将发出该事件。
  • error如果无法生成或杀死进程,则发出该事件。
  • closestdio子进程的流关闭时,将发出该事件。
  • message事件是最重要的事件。当子进程使用该process.send()函数发送消息时发出。父/子进程可以这样相互通信。我们将在下面看到一个示例。

每个孩子的过程也得到了三个标准stdio流,我们可以访问使用child.stdinchild.stdoutchild.stderr

当这些流关闭时,使用它们的子进程将发出close事件。此close事件与exit事件不同,因为多个子进程可能共享相同的stdio流,因此一个子进程退出并不意味着流被关闭。

由于所有流都是事件发射器,因此我们可以在stdio附加到每个子进程的流上侦听不同的事件。与普通进程不同,在子进程中,stdoutstderr流是可读流,而stdin流是可写流。从本质上讲,这与在主要过程中发现的那些类型相反。我们可以用于这些流的事件是标准事件。最重要的是,在可读流上,我们可以监听data事件,该事件将包含命令的输出或执行命令时遇到的任何错误:

child.stdout.on('data',(data)=> {

console.log(`child stdout:\ n $ {data}`);

});

child.stderr.on('data',(data)=> {

console.error(`child stderr:\ n $ {data}`);

});

上面的两个处理程序会将这两种情况都记录到主进程stdout和中stderr。当我们执行spawn上面的函数时,pwd命令的输出将被打印,并且子进程将使用code退出0,这意味着未发生任何错误。

我们可以spawn使用函数的第二个参数spawn将参数传递给函数执行的命令,该参数是要传递给命令的所有参数的数组。例如,要find在带有-type f参数的当前目录上执行命令(仅列出文件),我们可以执行以下操作:

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

如果在命令执行过程中发生错误,例如,如果在上面给出查找无效的目标,则child.stderr data事件处理程序将被触发,exit事件处理程序将报告退出代码1,表示发生了错误。错误值实际上取决于主机操作系统和错误类型。

子进程stdin是可写流。我们可以使用它向命令发送一些输入。就像任何可写流一样,使用它的最简单方法是使用pipe函数。我们只是将可读流传输到可写流。由于主流程stdin是可读流,因此我们可以将其通过管道stdin传输到子流程流。例如:

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命令,该命令在Linux中对行,单词和字符进行计数。然后,我们将主进程stdin(这是可读流)通过管道传送到子进程stdin(这是可写流)。这种结合的结果是我们得到了一个标准的输入模式,在该模式下,我们可以键入一些内容,当我们点击时Ctrl+D,所键入的内容将用作wc命令的输入。

我们也可以通过管道将多个进程的标准输入/输出相互传递,就像我们可以使用Linux命令一样。例如,我们可以stdoutfind命令的管道传输到命令的标准输入,wc计算当前目录中的所有文件:

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(`文件数$ {data}`);

});

-lwc命令中添加了参数,以使其仅计算行数。执行后,以上代码将输出当前目录下所有目录中所有文件的计数。

Shell语法和exec函数

默认情况下,该spawn函数不会创建外壳程序来执行我们传递给它的命令。这使它比exec创建外壳的函数稍微更有效。该exec功能还有另一个主要区别。它缓冲命令生成的输出,并将整个输出值传递给回调函数(而不是使用流,而是这样spawn做)。

这是find | wc 使用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 (`文件数$ {stdout}`);

});

由于该exec函数使用外壳执行命令,因此我们可以在此处利用外壳管道功能直接使用外壳语法

请注意,如果您执行外部提供的任何类型的动态输入,则使用Shell语法会带来安全风险。用户可以简单地使用shell语法字符(例如;)进行命令注入攻击。和$(例如,command + ’; rm -rf ~’

exec函数缓冲输出,并将其exec作为stdout那里的参数传递给回调函数(的第二个参数)。此stdout参数是我们要打印的命令输出。

exec如果需要使用shell语法,并且命令期望的数据量很小,则该函数是不错的选择。(请记住,exec将在返回之前将整个数据缓冲在内存中。)

spawn当命令期望的数据量很大时,该函数是更好的选择,因为该数据将与标准IO对象一起流传输。

如果需要,我们可以使产生的子进程继承其父进程的标准IO对象,而且更重要的是,我们还可以使spawn函数使用shell语法。这是find | wc使用spawn函数实现的相同命令:

const child = spawn('find。-type f | wc -l',{

stdio:'inherit',

shell:true

});

因为的stdio: 'inherit'上面的选项,当我们执行的代码,子进程继承的主要过程stdinstdoutstderr。这导致子流程数据事件处理程序在主流上被触发process.stdout,从而使脚本立即输出结果。

由于shell: true上面的选项,我们能够在传递的命令中使用shell语法,就像我们对所做的那样exec。但是使用此代码,我们仍然可以获得该spawn函数提供给我们的数据流的优势。这确实是两全其美。

child_process除了shell和以外,我们还可以在函数的最后一个参数中使用其他一些不错的选择stdio。例如,我们可以使用该cwd选项来更改脚本的工作目录。例如,这是spawn使用外壳程序将工作目录设置为我的“下载”文件夹的全部文件示例。cwd这里的选项将使脚本计算我拥有的所有文件~/Downloads

const child = spawn('find。-type f | wc -l',{

stdio:'inherit',

shell:true,

cwd:'/ Users / samer / Downloads'

});

我们可以使用的另一个选项是env指定对新子进程可见的环境变量的选项。此选项的默认值是process.env使任何命令都可以访问当前流程环境。如果要覆盖该行为,我们可以简单地传递一个空对象作为env选项或将新值视为唯一的环境变量:

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

上面的echo命令无法访问父进程的环境变量。例如,$HOME它不能使用access ,但是它可以访问,$ANSWER因为它是通过该env选项作为自定义环境变量传递的。

最后一个要解释的重要子流程选项是该detached选项,它使子流程独立于其父流程运行。

假设我们有一个timer.js使事件循环繁忙的文件:

setTimeout(()=> {   
  //保持事件循环繁忙
},20000);

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

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

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

child.unref();

分离的子进程的确切行为取决于操作系统。在Windows上,分离的子进程将具有其自己的控制台窗口,而在Linux上,分离的子进程将成为新进程组和会话的领导者。

如果在unref分离的进程上调用该函数,则父进程可以独立于子进程退出。如果子进程正在执行一个长时间运行的进程,这可能会很有用,但要使其在后台运行,子进程的stdio配置也必须独立于父进程。

上面的示例将timer.js通过分离并忽略其父stdio文件描述符在后台运行节点脚本(),以便父级可以在子级继续在后台运行的同时终止。

execFile函数

如果您需要在不使用Shell的情况下执行文件,那么该execFile功能就是您所需要的。它的行为与该exec函数完全相同,但是不使用外壳程序,因此使其效率更高。在Windows上,某些文件无法单独执行,例如.bat.cmd文件。这些文件无法execFile使用execspawn将shell设置为true来执行。

*同步功能

功能spawnexec以及execFilechild_process模块还具有同步阻塞版本将等到子进程退出。

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

当试图简化脚本编写任务或任何启动处理任务时,这些同步版本可能很有用,但应避免使用它们。

fork()函数

fork功能是spawn用于生成节点进程的功能的变体。spawn和之间的最大区别fork是,在使用时forksend将为子进程建立一个通信通道,因此我们可以将派生进程上的函数与全局process对象本身一起使用,以在父进程和派生进程之间交换消息。我们通过EventEmitter模块接口来实现。这是一个例子:

父文件parent.js

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

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

forked.on('message',(msg)=> { 
  console.log('来自孩子的消息',msg); 
}); 

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

子文件child.js

process.on('message',(msg)=> { 
  console.log('来自父母的消息:',msg); 
}); 

让计数器= 0; 

setInterval(()=> { 
  process.send({counter:counter ++}); 
},1000);

在上面的父文件中,我们进行分叉child.js(它将使用node命令执行该文件),然后侦听该message事件。message每当孩子使用时process.send,都会发出该事件,我们每秒都会这样做。

为了将消息从父对象传递给子对象,我们可以send在分支对象本身上执行该函数,然后,在子脚本中,我们可以侦听message全局process对象上的事件。

当执行parent.js上面的文件时,它将首先发送{ hello: 'world' }要由分支子进程打印的对象,然后,分支子进程将每秒发送一个递增的计数器值,以供父进程打印。

让我们为该fork函数做一个更实际的例子。

假设我们有一个处理两个端点的http服务器。这些端点之一(/compute如下所示)在计算上很昂贵,将需要几秒钟来完成。我们可以使用long for循环来模拟:

const http = require('http');const longComputation =()=> { 
  让sum = 0; 
  for(令i = 0; i <1e9; i ++){ 
    sum + = i; 
  }; 
  返回总和 
};const server = http.createServer();server.on('request',(req,res)=> { 
  if(req.url ==='/ compute'){ 
    const sum = longComputation();
     返回res.end(`Sum是$ {sum}` ); 
  } else { 
    res.end('Ok')
  } 
}); 

server.listen(3000);

这个程序有个大问题;当/compute请求端点时,服务器将无法处理任何其他请求,因为事件循环忙于long for循环操作。

根据长操作的性质,有几种方法可以解决此问题,但是对所有操作都有效的一种解决方案是,仅将计算操作移至另一个过程中fork

我们首先将整个longComputation函数移到其自己的文件中,并在通过主进程的消息指示时使其调用该函数:

在一个新compute.js文件中:

const longComputation =()=> { 
  让sum = 0; 
  for(令i = 0; i <1e9; i ++){ 
    sum + = i; 
  }; 
  返回总和 
}; 

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

现在,我们无需在主进程事件循环中进行冗长的操作,而是可以fork使用compute.js文件并使用messages接口在服务器和派生进程之间传递消息。

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发送给发出请求的用户。

当然,上面的代码受我们可以分叉的进程数量的限制,但是当我们执行它并通过http请求较长的计算端点时,主服务器根本不会被阻塞,并且可以接受其他请求。

只有对过去既往不咎,才能甩掉沉重的包袱;只有能够看清自己,才能做到轻装上阵。只要不放弃,就没有什么能让自己退缩;只要够坚强,就没有什么能把自己打垮。加油!

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐