2.10 多进程服务
2.10.1 child_process模块
我们现在已经知道了Node是单线程运行的,这表示潜在的错误有可能导致线程崩溃,然后进程也会随着退出,无法做到企业追求的稳定性;另一方面,单进程也无法充分多核CPU,这是对硬件本身的浪费。Node社区本身也意识到了这一问题,于是从0.1版本就提供了child_process模块,用来提供多进程的支持。
child_process模块中包括了很多创建子进程的方法,包括fork、spawn、exec、execFile等等。它们的定义如下:
- child_process.exec(command[, options][, callback])
- child_process.spawn(command[, args][, options])
- child_process.fork(modulePath[, args][, options])
- child_process.execFile(file[, args][, options][,callback])
在这4个API中以spawn最为基础,因为其他三个API或多或少都是借助spawn实现的。
2.10.2 spawn
spawn方法的声明格式如下:
spawn方法会使用指定的command来生成一个新进程,执行完对应的command后子进程会自动退出。
该命令返回一个child_process对象,这代表开发者可以通过监听事件来获得命令执行的结果。
代码2.26 使用spwan来执行ls命令
其中spawn的第一个参数虽然是command,但实际接收的却是一个file,代码2.25可以在Linux或者Mac OSX上运行,这是由于ls命令也是以可执行文件形式存在的。
类似的,在Windows系统下我们可以试着使用dir命令来实现功能类似的代码:
然而在Windows下执行上面代码会出现形如Error: spawn dir ENOENT的错误。
原因就在于spawn实际接收的是一个文件名而非命令,正确的代码如下:
这个问题的原因与操作系统本身有关,在Linux中,一般都是文件,命令行的命令也不例外,例如ls命令是一个名为ls的可执行文件;而在Windows中并没有名为dir的可执行文件,需要通过cmd或者powershell之类的工具提供执行环境。
2.10.3 fork
在Linux环境下,创建一个新进程的本质是复制一个当前的进程,当用户调用fork后,操作系统会先为这个新进程分配空间,然后将父进程的数据原样复制一份过去,父进程和子进程只有少数值不同,例如进程标识符(PID)。
对于Node来说,父进程和子进程都有独立的内存空间和独立的V8实例,它们和父进程唯一的联系是用来进程间通信的IPC Channel。
此外,Node中fork和POSIX系统调用的不同之处在于Node中的fork并不会复制父进程。
Node中的fork是上面提到的spawn的一种特例,前面也提到了Node中的fork并不会复制当前进程。多数情况下,fork接收的第一个参数是一个文件名,使用fork("xx.js")相当于在命令行下调用node xx.js,并且父进程和子进程之间可以通过process.send方法来进行通信。示例代码如下:
代码2.27 master.js——调用fork来创建一个子进程
代码2.28 worker.js代码
fork内部会通过spawn调用process.executePath,即Node的可执行文件地址(例如/Users/likai/.nvm/versions/node/v6.9.4/bin/node)来生成一个Node实例,然后再用这个实例来执行fork方法的modulePath参数。
2.10.4 exec和execFile
如果我们开发一种系统,那么对于不同的模块可能会用到不同的技术来实现,例如Web服务器使用Node,然后再使用Java的消息队列提供发布订阅服务,这种情况下通常使用进程间通信的方式来实现。
但有时开发者不希望使用这么复杂的方式,或者要调用的干脆是一个黑盒系统,即无法通过修改源码来进行来实现进程间通信,这时候往往采用折中的方式,例如通过shell来调用目标服务,然后再拿到对应的输出。
笔者曾经做过一个项目,后台用一个Spark集群来进行数据的分析,然后将结果绘成图表展示给用户,当时一种备选方案就是采用B/S架构并使用Node来做Web服务器,当用户单击页面上的元素时,Node将其转换为Spark集群中的命令,这个过程就是使用Node调用Shell来完成的。
1.Shell简介
Shell其实很简单,在控制台输入cd ~/desktop,然后回车,这就是最简单的shell命令,把这行命令写在文本里就是一个shell脚本。
例如:
在Linux或者Mac OSX下可以使用命令:
来执行这个脚本,效果跟直接输入命令:
是一样的。
2.execFile方法
child_process提供了一个execFile方法,它的声明如下:
说明:
- file {String}要运行的程序的文件名
- args {Array}字符串参数列表
- options {Object}
- cwd {String}子进程的当前工作目录
- env {Object}环境变量键值对
- encoding {String}编码(默认为'utf8')
- timeout {Number}超时(默认为0)
- maxBuffer {Number}缓冲区大小(默认为200*1024)
- killSignal {String}结束信号(默认为'SIGTERM')
- callback {Function}进程结束时回调并带上输出
- error {Error}
- stdout {Buffer}
- stderr {Buffer}
- 返回:ChildProcess对象
可以看出,execfile和spawn在形式上的主要区别在于execfile提供了一个回调函数,通过这个回调函数可以获得子进程的标准输出/错误流。
使用shell进行跨进程调用长久以来被认为是不稳定的,这大概源于人们对控制台不友好的交互体验的恐惧(输入命令后,很可能长时间看不到一个输出,尽管后台可能在一直运算,但在用户看来和死机无异)。
在Linux下执行exec命令后,原有进程会被替换成新的进程,进而失去对新进程的控制,这代表着新进程的状态也没办法获取了,此外还有shell本身运行出现错误,或者因为各种原因出现长时间卡顿甚至失去响应等情况。
Node.js提供了比较好的解决方案,timeout解决了长时间卡顿的问题,stdout和stderr则提供了标准输出和错误输出,使得子进程的状态可以被获取。
2.10.5 各方法之间的比较
1.spawn和execfile
为了更好地说明,我们先写一段简单的C语言代码,并将其命名为example.c:
使用gcc编译该文件:
生成名为example的可执行文件,然后将这个可执行文件放到系统环境变量中(编辑~/.bash_profile),然后打开控制台,输入example,看到最后输出"Hello World"。
确保这个可执行文件在任意路径下都能访问。
我们分别用spawn和execfile来调用example文件。
首先是spawn。
代码2.29 使用spwan来调用
程序输出:
程序正确打印出了Hello World,此外还可以看到example最后的return 5会被作为子进程结束的code被返回。
然后是execfile。
代码2.30 使用execFile来调用
同样打印出Hello World,可见除了调用形式不同,二者相差不大。
2.execFile和spawn
在子进程的信息交互方面,spawn使用了流式处理的方式,当子进程产生数据时,主进程可以通过监听事件来获取消息;而exec是将所有返回的信息放在stdout里面一次性返回的,也就是该方法的maxBuffer参数,当子进程的输出超过这个大小时,会产生一个错误。
此外,spawn有一个名为shell的参数,下面是该参数在文档中的定义:
其类型为一个布尔值或者字符串,如果这个值被设置为true,就会启动一个shell来执行命令,这个shell在UNIX上是bin/sh,在Windows上则是cmd.exe。
3.exec和execfile
exec在内部也是通过调用execFile来实现的,我们可以从源码中验证这一点,在早期的Node源码中,exec命令会根据当前环境来初始化一个shell,例如cmd.exe或者/bin/sh,然后在shell中调用作为参数的命令。
代码2.31 Node V0.10.0源码/lib/child_process.js
通常execFile的效率要高于exec,这是因为execFile没有启动一个shell,而是直接调用spawn来实现的。
2.10.6 进程间通信
前面介绍的几个用于创建进程的方法,都是属于child_process的类方法,此外childProcess类继承了EventEmitter,在childProcess中引入事件给进程间通信带来很大的便利。
childProcess中定义了如下事件。
- Event: 'close':进程的输入输出流关闭时会触发该事件。
- Event: 'disconnect':通常childProcess.disconnect调用后会触发这一事件。
- Event: 'exit':进程退出时触发。
- Event: 'message':调用child_process.send会触发这一事件。
- Event: 'error':该事件的触发分为几种情况:
- 该进程无法创建子进程。
- 该进程无法通过kill方法关闭。
- 无法发送消息给子进程。
Event: 'error'事件无法保证一定会被触发,因为可能会遇到一些极端情况,例如服务器断电等。
上面也提到,childProcess模块定义了send方法,用于进程间通信,该方法的声明如下:
通过send方法发送的消息,可以通过监听message事件来获取。
代码2.32 父进程向子进程发送消息
代码2.33 子进程接收父进程消息
send方法的第一个参数类型通常为一个json对象或者原始类型,第二个参数是一个句柄,该句柄可以是一个net.Socket或者net.Server对象。下面是一个例子:
代码2.34 父进程发送一个Socket对象
代码2.35 子进程接收socket对象
2.10.7 Cluster
前面已经介绍了child process的使用,child_process的一个重要使用场景是创建多进程服务来保证服务稳定运行。
为了统一Node创建多进程服务的方式,Node在0.6之后的版本中增加了Cluster模块,Cluster可以看作是做了封装的child_Process模块。
Cluster模块的一个显著优点是可以共享同一个socket连接,这代表可以使用Cluster模块实现简单的负载均衡。
代码2.36 Cluster的简单例子
上面是使用Cluster模块的一个简单的例子,为了充分利用多核CPU,先调用OS模块的cpus()方法来获得CPU的核心数,假设主机装有两个CPU,每个CPU有4个核,那么总核数就是8。
在上面的代码中,Cluster模块调用fork方法来创建子进程,该方法和child_process中的fork是同一个方法。
Cluster模块采用的是经典的主从模型,由master进程来管理所有的子进程,可以使用cluster.isMaster属性判断当前进程是master还是worker,其中主进程不负责具体的任务处理,其主要工作是负责调度和管理,上面的代码中,所有的子进程都监听8000端口。
通常情况下,如果多个Node进程监听同一个端口时会出现Error: listen EADDRINUS的错误,而Cluster模块能够让多个子进程监听同一个端口的原因是master进程内部启动了一个TCP服务器,而真正监听端口的只有这个服务器,当来自前端的请求触发服务器的connection事件后,master会将对应的socket句柄发送给子进程。