Node启动运行时默认一个进程,只能在一个 CPU 中进行运算,无法应用服务器的多核 CPU,因此我们需要寻求一些解决方案。使用多进程分发策略,即主进程接收所有请求,然后通过一定的负载均衡策略可以将处理逻辑分发到不同的 Node.js 子进程中。
有 2 个不同的实现:
- 主进程监听一个端口,子进程不监听端口,通过主进程分发请求到子进程;
- 主进程和子进程分别监听不同端口,通过主进程分发请求到子进程。
在 Node.js 中的 cluster 模式使用的是第一个实现。
cluster 模式
先来实现一个简单的 app.js
const http = require('http');
/**
*
* 创建 http 服务,简单返回
*/
const server = http.createServer((req, res) => {
res.write(`hello world, start with cluster ${process.pid}`);
res.end();
});
/**
*
* 启动服务
*/
server.listen(3000, () => {
console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);
使用cluster包装启动
const cluster = require('cluster');
const instances = 2; // 启动进程数量
if (cluster.isMaster) { // 判断是否为主进程
for(let i = 0;i<instances;i++) { // 使用 cluster.fork 创建子进程
cluster.fork();
}
} else {
require('./app.js');
}
node cluster.js启动后访问localhost:3000,发现打印的进程 ID 是比较有规律的两个随机数,两者就是我们 fork 出来的两个子进程ID
那Node.js 的 cluster 是如何做到多个进程监听一个端口的;又是如何进行负载均衡请求分发的呢?
首先看这一段引入:
const cluster = require('cluster');
从cluster源码中可以看到导出逻辑:
const childOrPrimary = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'primary';
module.exports = require(`internal/cluster/${childOrPrimary}`);
导出时通过'NODE_UNIQUE_ID'是否存在于process.env对象中分别导出internal/cluster下的child.js或者primary.js,对应的就是子进程和主进程文件,第一次默认是会走到primary,cluster.isMaster为true,会走到
cluster.fork()
源码如下:
cluster.fork = function(env) {
cluster.setupPrimary(); // 创建主进程
const id = ++ids;
const workerProcess = createWorkerProcess(id, env);
const worker = new Worker({
id: id,
process: workerProcess
}); // 创建worker子进程
worker.on('message', function(message, handle) {
cluster.emit('message', this, message, handle);
}); // worker子进程结束处理来自主进程的message并处理
}
cluster.setupPrimary源码如下,可以看到 cluster.fork,一开始就会调用 setupPrimary 方法,创建主进程,由于该方法是通过 cluster.fork 调用,因此会调用多次,但是该模块有个全局变量 initialized 用来区分是否为首次,如果是首次则创建,否则则跳过:
// 下面这三个参数会在node内部功能实现的时候用到,之后我们看net源码的时候会用到这些参数
cluster.isWorker = false; // 是否是工作进程
cluster.isMaster = true; // 是否是主进程
cluster.isPrimary = true; // 是否是主进程
cluster.setupPrimary = function(options) {
// ...
if (initialized === true)
return process.nextTick(setupSettingsNT, settings);
initialized = true; // 区分是否为首次
schedulingPolicy = cluster.schedulingPolicy; // Freeze policy.
// ...
// 处理来自子进程的消息
process.nextTick(setupSettingsNT, settings);
// ...
};
createWorkerProcess实际上是调用child_process的fork方法创建子进程
const { fork } = require('child_process');
function createWorkerProcess(id, env) {
...
return fork(cluster.settings.exec, cluster.settings.args, {
cwd: cluster.settings.cwd,
env: workerEnv,
serialization: cluster.settings.serialization,
silent: cluster.settings.silent,
windowsHide: cluster.settings.windowsHide,
execArgv: execArgv,
stdio: cluster.settings.stdio,
gid: cluster.settings.gid,
uid: cluster.settings.uid
});
}
在创建后又会调用我们项目根目录下的 cluster.js 启动一个新实例,这时候由于 cluster.isMaster 是 false,因此会 require 到 internal/cluster/child 这个方法。
由于是 worker 进程,因此代码会 require ('./app.js') 模块,在该模块中会监听具体的端口
server.listen(3000, () => {
console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);
server.listen 会调用该模块中的 listenInCluster 方法:
function listenInCluster(server, address, port, addressType,
backlog, fd, exclusive, flags) {
exclusive = !!exclusive;
if (cluster === undefined) cluster = require('cluster');
if (cluster.isPrimary || exclusive) { // 判断是否为主进程
// Will create a new handle
// _listen2 sets up the listened handle, it is still named like this
// to avoid breaking code that wraps this method
server._listen2(address, port, addressType, backlog, fd, flags);
return;
}
const serverQuery = {
address: address,
port: port,
addressType: addressType,
fd: fd,
flags,
};
// Get the primary's server handle, and listen on it
cluster._getServer(server, serverQuery, listenOnPrimaryHandle);
function listenOnPrimaryHandle(err, handle) {
err = checkBindError(err, port, handle);
if (err) {
const ex = exceptionWithHostPort(err, 'bind', address, port);
return server.emit('error', ex);
}
// Reuse primary's server handle
server._handle = handle;
// _listen2 sets up the listened handle, it is still named like this
// to avoid breaking code that wraps this method
server._listen2(address, port, addressType, backlog, fd, flags);
}
}
上面代码中的第 7 行,判断为主进程,就是真实的监听端口启动服务,而如果非主进程则调用 cluster._getServer 方法,也就是 internal/cluster/child 中的 cluster._getServer 方法。
// `obj` is a net#Server or a dgram#Socket object.
cluster._getServer = function(obj, options, cb) {
// ...
const message = {
act: 'queryServer',
index,
data: null,
...options
};
message.address = address;
// Set custom data on handle (i.e. tls tickets key)
if (obj._getServerData)
message.data = obj._getServerData();
send(message, (reply, handle) => {
if (typeof obj._setServerData === 'function')
obj._setServerData(reply.data);
if (handle)
shared(reply, handle, indexesKey, cb); // Shared listen socket.
else
rr(reply, indexesKey, cb); // Round-robin.
});
obj.once('listening', () => {
cluster.worker.state = 'listening';
const address = obj.address();
message.act = 'listening';
message.port = (address && address.port) || options.port;
send(message);
});
};
重点在26行到32行,通过 send 方法,如果监听到 listening 发送一个消息给到主进程,主进程也有一个同样的 listening 事件,监听到该事件后将子进程通过 EventEmitter 绑定在主进程上,这样就完成了主子进程之间的关联绑定,并且只监听了一个端口。而主子进程之间的通信方式,就是我们常听到的 IPC 通信方式。
负载均衡
既然 Node.js cluster 模块使用的是主子进程方式,那么它是如何进行负载均衡处理的呢,这里就会涉及 Node.js cluster 模块中的两个模块。
round_robin_handle.js(非 Windows 平台应用模式),这是一个轮询处理模式,也就是轮询调度分发给空闲的子进程,处理完成后回到 worker 空闲池子中,这里要注意的就是如果绑定过就会复用该子进程,如果没有则会重新判断,这里可以通过上面的 app.js 代码来测试,用浏览器去访问,你会发现每次调用的子进程 ID 都会不变。
shared_handle.js( Windows 平台应用模式),通过将文件描述符、端口等信息传递给子进程,子进程通过信息创建相应的 SocketHandle / ServerHandle,然后进行相应的端口绑定和监听、处理请求。