你可能想知道的 Node 子进程模块

本文首发于个人博客,转载请注明出处:http://kohpoll.github.io/blog/2016/04/25/about-the-node-child-process/javascript

最近在使用 Node 的子进程模块实现一些功能,对相关知识进行了一个系统的学习总结,这篇文章将会简要介绍我总结的 Node 中和进程有关的内容。包括:进程和线程、Node 的单线程的真正含义、建立进程的三种方法、进程间通讯、进程以及信号量。有不当之处欢迎提出,一块儿交流。html

进程及线程

在真正开始介绍 Node 中的 child_process 模块以前,先来简要介绍一些操做系统的基础知识。java

咱们首先从操做系统的任务调度开始。node

现代的操做系统通常都是“多任务”的,能够同时运行多个任务。好比:咱们能够一边听歌一边敲代码一边下载小电影,还有一些任务在后台悄悄同时运行。可是当咱们只有一个 CPU 时,操做系统又是怎么作到“多任务”的?linux

操做系统会进行调度(任务切换)来实现多任务:也就是一个任务执行一段时间后被暂停,下一个任务再执行一段时间,而后不断循环执行下去,这样每一个任务都能获得交替执行。虽然是交替执行,可是CPU 执行效率很高,在各个任务间快速切换,给咱们的感受就是多个任务在“同时运行”,也就是咱们说的“多任务”。git

上面的调度并非真正的并行执行,真正的并行执行多个任务实际上只能在多核 CPU 上实现。可是,因为任务数量确定会远远多于 CPU 的核心数量,操做系统也会自动把不少任务轮流调度到每一个 CPU 上执行。github

一个任务实际上就是一个进程(Process),它是操做系统进行资源分配和调度的最小单位,是应用程序运行的载体,有本身独立的内存空间。shell

可是有些进程并不知足同时干一件事,好比:播放器播放小电影的时候,它能够同时播放视频、音频。编程

在一个进程内要同时干多件事就须要运行多个“子任务”,这些进程内的子任务就是线程(Thread),它是程序执行的最小单位,一个进程能够有一个或多个线程,各个线程间能够共享进程的内存空间。windows

因为每一个进程至少要干一件事,因此,一个进程至少有一个线程。固然,进程能够有多个线程,多个线程能够“同时执行”,多线程的执行方式和多进程是同样的,也是由操做系统在多个线程之间快速切换,让每一个线程都短暂地交替运行,看起来就像同时执行同样。可是,线程间的上下文切换要比进程的上下文切换开销小,也快得多。

咱们能够经过资源管理器(windows)或者活动监视器(mac)来查看咱们系统里的进程和线程,以下图是活动监视器的截图:

活动监视器中的进程及线程

固然也能够经过 ps、top 等命令来查看进程信息,能够参考:http://www.imooc.com/article/1071

让咱们总结下:

  • 线程是程序执行的最小单元,进程是任务调度的最小单元

  • 一个进程由一个或多个线程组成(至少一个),线程间能够共享进程的内存空间,进程间互相独立(有各自的内存空间)

  • 操做系统使用 CPU 时间分片来调度进程、线程的执行,从而实现多任务

  • 线程间的切换比进程间切换开销小

关于 Node 的单线程

咱们知道 Node 相似于浏览器里面的 JavaScript,是单线程的。那咱们如今须要理解 Node 的单线程究竟是什么意思?

这里说的单线程是指咱们所编写的代码运行在单线程上,实际上 Node 并非真的“单线程”。

当咱们执行 node app.js 时启动了一个进程,可是这个进程并非只有一个线程,而是同时建立了不少个线程(好比:异步 IO 须要的一些 IO 线程)。以下图所示(编号为 92347 的进程一共有 5 个线程):

Node 的进程和线程

可是,仍然只有一个线程会运行咱们编写的代码。这就是 Node 单线程的含义。

Node 实际上从语言层面就不支持建立线程,咱们只有能力建立进程。这让咱们的程序状态单一,不用在乎状态同步、死锁、上下文切换开销等等多线程编程中的头疼问题。固然,咱们能够经过进程间的通讯来共享一些“状态”,但并非线程间共享的那种状态。

单线程也会带来一些问题:

  1. 没法利用多核 CPU(只能得到一个 CPU 的时间分片)

  2. 错误会引发整个应用退出(整个应用就一个进程,挂了就挂了)

  3. 大量计算长时间占用 CPU,致使阻塞线程内其余操做(异步 IO 发不出调用,已完成的异步 IO 回调不能及时执行)

这些问题实际上都有对应的解决方案。咱们会使用 Master-Worker 的管理方式来建立和管理多个工做进程(工做进程数量通常会等于系统 CPU 的核心数量),保证应用可以充分利用多核 CPU,同时在发生错误时能够优雅退出和自动重启(好比 recluster 模块)。咱们会新建立一个独立进程来进行耗时的计算,而后将计算结果传回给主线程。它们本质上都在使用 Node 提供的子进程功能。

进程建立简明指南

在 Node 中,大致上有三种建立进程的方法:

  • exec / execFile

  • spawn

  • fork

exec / execFile

exec(command, options, callback)execFile(file, args, options, callback) 比较相似,会使用一个 Buffer 来存储进程执行后的标准输出结果,们能够一次性在 callback 里面获取到。不太适合输出数据量大的场景。

须要注意的是,exec 会首先建立一个新的 shell 进程出来,而后执行 commandexecFile 则是直接将可执行的 file 建立为新进程执行。因此,execfile 会比 exec 高效一些。

exec 比较适合用来执行 shell 命令,而后获取输出(好比:exec('ps aux | grep "node"')),可是 execFile 却没办法这么用,由于它实际上只接受一个可执行的命令,而后执行(无法使用 shell 里面的管道之类的东西)。

// child.js
console.log('child argv: ', process.argv);
// parent.js
const child_process = require('child_process');
const p = child_process.exec(
  'node child.js a b', // 执行的命令
  {},
  (err, stdout, stderr) => {
    if (err) {
      // err.code 是进程退出时的 exit code,非 0 都被认为错误
      // err.signal 是结束进程时发送给它的信号值
      console.log('err:', err, err.code, err.signal);
    }
    console.log('stdout:', stdout);
    console.log('stderr:', stderr);
  }
);
console.log('child pid:', p.pid);
// parent.js
const p = child_process.execFile(
  'node', // 可执行文件
  ['child.js', 'a', 'b'], // 传递给命令的参数
  {},
  (err, stdout, stderr) => {
    if (err) {
      // err.code 是进程退出时的 exit code,非 0 都被认为错误
      // err.signal 是结束进程时发送给它的信号值
      console.log('err:', err, err.code, err.signal);
    }
    console.log('stdout:', stdout);
    console.log('stderr:', stderr);
  }
);
console.log('child pid:', p.pid);

两个方法还能够传递一些配置项,以下所示:

{
    // 能够指定命令在哪一个目录执行
    'cwd': null,
    // 传递环境变量,node 脚本能够经过 process.env 获取到         
    'env': {},
    // 指定 stdout 输出的编码,默认用 utf8 编码为字符串(若是指定为 buffer,那 callback 的 stdout 参数将会是 Buffer)       
    'encoding': 'utf8',
    // 指定执行命令的 shell,默认是 /bin/sh(unix) 或者 cmd.exe(windows)
    'shell': '',
    // kill 进程时发送的信号量
    'killSignal': 'SIGTERM',
    // 子进程超时未执行完,向其发送 killSignal 指定的值来 kill 掉进程
    'timeout': 0,
    // stdout、stderr 容许的最大输出大小(以 byte 为单位),若是超过了,子进程将被 kill 掉(发送 killSignal 值)
    'maxBuffer': 200 * 1024,
    // 指定用户 id
    'uid': 0,
    // 指定组 id
    'gid': 0
}

spawn

spawn(command, args, options) 适合用在进程的输入、输出数据量比较大的状况(由于它支持以 stream 的使用方式),能够用于任何命令。

// child.js
console.log('child argv: ', process.argv);
process.stdin.pipe(process.stdout);
// parent.js
const p = child_process.spawn(
  'node', // 须要执行的命令
  ['child.js', 'a', 'b'], // 传递的参数
  {}
);
console.log('child pid:', p.pid);
p.on('exit', code => {
  console.log('exit:', code);
});

// 父进程的输入直接 pipe 给子进程(子进程能够经过 process.stdin 拿到)
process.stdin.pipe(p.stdin);

// 子进程的输出 pipe 给父进程的输出
p.stdout.pipe(process.stdout);
/* 或者经过监听 data 事件来获取结果
var all = '';
p.stdout.on('data', data => {
    all += data; 
});
p.stdout.on('close', code => {
    console.log('close:', code);
    console.log('data:', all);
});
*/

// 子进程的错误输出 pipe 给父进程的错误输出
p.stderr.pipe(process.stderr);

咱们能够执行 cat bigdata.txt | node parent.js 来进行测试,bigdata.txt 文件的内容将被打印到终端。

spawn 方法的配置(options)以下:

{
    // 能够指定命令在哪一个目录执行
    'cwd': null,
    // 传递环境变量,node 脚本能够经过 process.env 获取到         
    'env': {},
    // 配置子进程的 IO
    'stdio': 'pipe',
    // 为子进程独立运行作好准备
    'detached': false,
    // 指定用户 id
    'uid': 0,
    // 指定组 id
    'gid': 0
}

咱们这里主要介绍下 detachedstdio 这两个配置。

stdio

stdio 用来配置子进程和父进程之间的 IO 通道,能够传递一个数组或者字符串。好比,['pipe', 'pipe', 'pipe'],分别配置:标准输入、标准输出、标准错误。若是传递字符串,则三者将被配置成同样的值。咱们简要介绍其中三个能够取的值:

  • pipe(默认):父子进程间创建 pipe 通道,能够经过 stream 的方式来操做 IO

  • inherit:子进程直接使用父进程的 IO

  • ignore:不创建 pipe 通道,不能 pipe、不能监听 data 事件、IO 全被忽略

好比上面的代码若是改写成下面这样,效果彻底同样(子进程直接使用了父进程的 IO):

const p = child_process.spawn(
  'node', ['child.js', 'a', 'b'],
  {
    // 'stdio': ['inherit', 'inherit', 'inherit']
    'stdio': 'inherit'
  }
);
console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

detached

detached 配置主要用来建立常驻的“后台”进程,好比下面的代码:

// child.js
setInterval(() => {
  console.log('child');
}, 1000);
// parent.js
const p = child_process.spawn(
  'node', ['child.js', 'a', 'b'],
  {
    'stdio': 'ignore', // 父子进程间不创建通道
    'detached': true   // 让子进程能在父进程退出后继续运行
  }
);
// 默认状况,父进程会等子进程,这个方法可让子进程彻底独立运行
p.unref();

console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

这样就实现了常驻的后台进程,父进程退出了、shell 关掉了,子进程都会一直运行,直到手动将它 kill 掉。

虽然在子进程里面,咱们每隔 1s 就输出了一个信息,可是其实根本就看不到。若是咱们想要记录子进程的输出的话,能够给它指定一个单独的 IO(不能和父进程创建 IO 通道,不然无法独立运行):

const out = fs.openSync('./out.log', 'a');
const err = fs.openSync('./err.log', 'a');

// parent.js
const p = child_process.spawn(
  'node', ['child.js', 'a', 'b'],
  {
    'stdio': ['ignore', out, err], // 父子进程间不创建通道
    'detached': true   // 让子进程能在父进程退出后继续运行
  }
);
// 默认状况,父进程会等子进程,这个方法可让子进程彻底独立运行
p.unref();

console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

fork

fork(modulePath, args, options) 其实是 spawn 的一个“特例”,会建立一个新的 V8 实例,新建立的进程只能用来运行 Node 脚本,不能运行其余命令。而且会在父子进程间创建 IPC 通道,从而实现进程间通讯。

// child.js
console.log('child argv: ', process.argv);
process.stdin.pipe(process.stdout);
// parent.js
const p = child_process.fork(
  'child.js', // 须要执行的脚本路径
  ['a', 'b'], // 传递的参数
  {}
);
console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

上面代码的效果和使用 spawn 并配置 stdio: inherit 的效果是一致的。咱们看下该方法的配置(options)就知道缘由了:

{
    // 能够指定命令在哪一个目录执行
    'cwd': null,
    // 传递环境变量,node 脚本能够经过 process.env 获取到         
    'env': {},
    // 建立子进程使用的 node 的执行路径(默认是:process.execPath)
    'execPath': '',
    // 建立子进程时,传递给执行程序的参数(默认是:process.execArgv)
    'execArgv': [],
    // 设置为 true 时,父子间将创建 IO 的 pipe 通道(pipie);设置为 false 时(默认),子进程直接使用父进程的 IO(inherit)
    'silent': false,
    // 指定用户 id
    'uid': 0,
    // 指定组 id
    'gid': 0
}

总结

  • exec / execFile:使用 Buffer 来存储进程的输出,能够在回调里面获取输出结果,不太适合数据量大的状况;能够执行任何命令;不建立 V8 实例

  • spawn:支持 stream 方式操做输入输出,适合数据量大的状况;能够执行任何命令;不建立 V8 实例;能够建立常驻的后台进程

  • fork:spawn 的一个特例;只能执行 Node 脚本;会建立一个 V8 实例;会创建父子进程的 IPC 通道,可以进行通讯

进程间通讯

咱们上面介绍的三种建立子进程的方法都会返回一个 ChildProcess 类的实例,它其实继承于 EventEmitter

咱们上面已经看到了一些用法:

  • 获取进程的 pid

  • 监听 exit 等事件(其余事件有:errorclose 等)

  • 访问 stdinstdoutstderr 属性(这些属性又是 Stream 的实例,能够像操做 stream 同样进行操做)

这部分咱们简要介绍下进程间通讯的方法,主要就是经过收发消息来实现。

实际上默认状况下,只有 fork 出的子进程才能和父进程收发消息,由于 fork 会创建父子进程的 IPC 通道,其余方法并不会创建这种通道。

// child.js
console.log('child argv: ', process.argv);
process.on('message', m => {
  console.log('message in child:', m);
});
setTimeout(() => {
  process.send('send from child');
}, 2000);
// parent.js
const p = child_process.fork(
  'child.js', ['a', 'b'],
  {}
);
console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});
p.on('message', m => {
  console.log('message from child: ', m);
});
p.send('send from parent');

经过监听 message 事件和调用 send 方法,咱们就能够在父子进程间进行通讯了。至于通讯协议,咱们能够本身设计或者直接使用 JSON,毕竟传递的都是一推字符串,很易用。

进程及信号量

除了咱们会和进程通讯外,实际上操做系统也会给进程发送一种叫作信号量的“消息”来告知进程某些事件发生了。通常会使用 kill [sid] [pid] 命令来发送信号量,一些常见的信号量以下:

kill [sid] [pid] process.on(evt) 说明
kill -1 / kill -HUP process.on('SIGHUP') 通常表示进程须要从新加载配置
kill -2 / kill -SIGINT / ctrl+c process.on('SIGINT') 退出进程
kill -15 / kill -TERM process.on('SIGTERM') 中止进程(kill 的默认信号)
kill -9 / kill -KILL 监听不到 kernel 直接停掉进程,而且不通知进程

实际上 process 还能够监听 exit 事件,监听 exit 事件和监听信号量事件是不同的。exit 事件只有在执行 process.exit() 或者进程结束时才会触发。

因此,一个“优雅”的进程通常会绑定 exitSIGINTSIGTERM 事件,在 exit 事件中处理进程的清理工做,而后在 SIGTERMSIGINT 事件中调用 process.exit() 来让进程真正退出。(若是你想耍流氓,能够绑定 SIGTERMSIGINT 事件,而后啥也不作,这样除非使用 kill -9,你的进程将永远不会退出......)

除了经过 kill 命令发送信号量,咱们也可使用子进程的 .kill(sig) 方法来发送信号,好比:p.kill('SIGINT');或者 processprocess.kill(pid, 'SIGINT')

参考资料

相关文章
相关标签/搜索