一、概述
由于node.js 主线程是单线程的,因此当我们的 node 程序运行时,通常只启动了一个进程,只能在一个 CPU 中进行运算,无法运用服务器中的多核 CPU。这显然是对于多核CPU服务器性能的浪费,因此我们需要寻求一些解决方案,来充分利用服务器的多核CPU,从而提升我们程序的运行效率。而对于这种情况,我们通常的做法就是 多进程分发策略:即主进程接收所有的请求,通过一定的负载均衡策略分发到不同的子进程中。而这一方案,其实也有两种不同的实现方式:
- 主进程监听一个端口,子进程不监听端口,主进程分发请求到子进程中。这样做的好处在于,通常只需要占用一个端口,通信相对简单,转发策略也更为灵活。缺点则是实现相对会比较复杂,对主进程的稳定性也有更高的要求。
- 主进程和子进程分别监听不同的端口,通过主进程分发请求到子进程。即创建一个主进程,以及若干个子进程。由主进程监听客户端连接请求,并根据特定的策略,转发给子进程。这样做的优势在于:实现相对简单,各实例相对独立,这对服务稳定性有好处。而缺点则是:增加端口的占用,且进程之间通信会比较麻烦。
而node 的 cluster 模块就是第一个方案的实现。cluster 模块是对child_process 模块的进一步封装,专用于解决单进程NodeJS Web服务器无法充分利用多核CPU的问题。
cluster 具体使用起来很简单,node官方文档也将的非常仔细,在此也就不多做赘述了:
其原理官方文档也有所提及:其工作进程是由 child_process.fork() 方法创建,因此它们可以使用 IPC 和 父进程通信,从而使其各进程交替处理连接服务。在 node 的主从模型中,master 主管监听端口,以及将对应任务分发给 worker 子进程,起着一个中枢的作用。按照我们通常的理解,如果根据使用各 worker 进程的负载情况来挑选woker来执行对应的任务,效率应该会比直接循环发放要来的高,但 node 文档中提到这种声明方式会受到操作系统的调度机制影响,使其分发变得不稳定,因此 node 也就将 循环法 作为了默认的分发策略。
需要注意的是,node 官方文档中使用 worker 来表示主进程 fork 出的子进程,这其实会让不少前端开发者会将其与浏览器环境中的 worker 多线程相混淆,但他们其实不是一个东西。
二、线程和进程
上面提到,cluster 是解决单进程的问题。但大家估计也听说过JavaScript是单线程的语言(实际上,我在面试一些候选人的时候,问到 node 的多进程实现,他们也会反问我JavaScript是单线程的,关多进程什么事情)。因此为了方便后续的讨论,我们再次还需要再说明一下,线程和进程的区别。
首先,进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程是资源分配的基本单位,而线程是独立调度的基本单位,一个进程中可以有多个线程,它们可以共享进程资源。它们的主要区别如下:
- 拥有资源:进程是资源分配的基本单位,而线程则不拥有资源,只能去访问其隶属于的进程的资源
- 调度:线程中是独立调度的基本单位
- 系统开销:线程的切换,只需保存和设置少许的寄存器内容,开销很小。而进程的切换,则涉及当前执行CPU环境的保存和新调度进程CPU环境的设置
- 通信方面:线程间可以通过直接读取同一进程中的数据进行通信,但是进程通信需要借助 IPC。
三、Cluster原理
对于 cluster 模块,我们主要需要关注两部分的内容:
- cluster 是如何做到多个进程监听同一端口的
- node 是如何实现负载均衡请求分发的
3.1、多进程监听同一端口
在 cluster 模式中,存在 master 和 worker 的概念。我在上面也提到了,这个 worker 并不是我们在浏览器中的worker。在这里,master 指的是主进程,而 worker 指的是子进程。它们的创建也非常简单:
在上述代码中,第一次 require 的 cluster 对象就模式是一个 master。对应的源码也非常简单,本质上是通过进程环境变量设置来进程判断,这是node的主进程在进行子进程管理时的标识,当调用cluster.fork()
时,会生成一个子进程时会以一个自增ID的形式生成这个环境变量。如果没有设置就是 master 进程,反之即为 worker:
https://github.com/nodejs/node/blob/master/lib/cluster.js
因此我们第一次调用 cluster 模块就是 master 进程,后续的则都为 worker。另外主进程和子进程 require 文件也不同:
- 主进程:internal/cluster/primary
- 子进程:internal/cluster/child
主进程模块
先让我们来瞅瞅 master 进程的创建过程,由于代码其实还挺多,因此就不全部粘贴出来了,可自行去浏览:
可以看到,当我们执行 cluster.fork
时,一开始会调用 setupPrimary
方法,创建主进程。因为这个方法是通过 cluster.fork
调用,因此会调用多次,但该模块有个全局变量 initialized
用于区分是否为首次创建,如果是首次则进行创建,如果不是则跳过。代码如下:
而 cluster.fork
方法,其实也很简单,具体代码如下:
首先是进程进程的初始化,也就是创建 master。然后就是进行id的递增,再创建 worker 子进程。而在 createWorkerProcess
方法中,实际是使用 child_process
来创建子进程的。
需要注意的是,在初始化代码时,我们调用了两次 cluster.fork
方法,因此会创建两个子进程,在创建后又会调用我们项目根目录下的 cluster.js
启动一个新实例,但此时 cluster.isMaster
为 false,因此会 reuqire
到子进程的方法。
且由于是 worker 进程,因此代码会require('./app.js')
模块,在该模块中会监听具体的端口:
这里的 server.listen
会调用 net
模块中的 listenInCluster
方法,该方法中有一个关键信息:
上面代码中,首先判断是否为主进程,如果是就是真实的监听端口启动服务,而如果非主进程则调用 internal/cluster/child
中的cluster._getServer
方法。
然后就会通过一个send
方法,如果监听到 listening
就发送一个 messgae
给主线程,而主线程同样的也有一个 listening
时间,监听到该事件后将子进程通过 EventEmitter
绑定到主进程上,这样就完成了主子进程的关联绑定,并且只监听了一个端口。而主子进程之间的通信方式,就是我们常说的 IPC 通信。
总结起来如下图所示:
(图转自:github.com/chyingp/nod…)
3.2、负载均衡原理
cluster 模块进行负载均衡处理,主要涉及两个模块:
- round robin handle.js 此模块是针对于非 Windows 平台应用的模式,主要的做法是轮询处理,也就是轮询调度分发给空闲的子进程,处理完成后回到 worker 空闲池中。需要注意的是如果已经完成绑定了子进程,就会复用该子进程,如果没有就会重新进行判断。
- shared handle.js 此模块针对 Windows 平台应用的模式,通过将文件描述符、端口等信息传递给子进程,子进程通过复写掉
cluster._getServer
方法,从而在server.listen
中保证只有主进程监听端口,主子进程通过 IPC 进程通信,其次主进程根据平台或者协议不同,应用不同模块来进行分发请求给子进程处理。