Node.js--cluster原理分析
cluster,你真的弄明白了吗?
在上一篇文章中,我们已经了解到了cluster模块的基本使用,cluster使用起来非常简单,
const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster) { for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`工作进程 ${worker.process.pid} 已退出`); }); } else { http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n'); }).listen(8000); }
上面的代码就简单的将cluster模块加入到了node.js项目中。但是仔细分析一下这段代码你可能会产生这些疑问:主进程仅仅fork出了子进程,并没有创建httpserver,说好的主进程接收请求分发给子进程呢?每一个子进程都创建了一个httpserver,并侦听同一个端口?是这样的吗?局面好像很尴尬。如果仅仅知道上面的这段代码,似乎无法解决我们的疑惑。那就到源代码中去瞧瞧,推荐一篇好文。如果你也感兴趣,打开源码过一遍,整个世界就明朗了。
cluster.js
'use strict'; const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master'; module.exports = require(`internal/cluster/${childOrMaster}`);
上面的三行代码就是cluster.js的全部内容,可以看出,子进程和主进程的区分是通过‘NODE_UNIQUE_ID’来判断的。我们分析cluster.fork方法可以发现,在createworkprocess中都会对NODE_UNIQUE_ID进行赋值,而master进程中是没有NODE_UNIQUE_ID的。所以再demo程序中可以分别在主进程和子进程中执行不同的内容。因此主进程执行完后,就仅仅fork出了子进程。
主进程httpserver
主进程执行完毕后,子进程开始执行响应的代码,子进程首先创建httpserver,然后监听端口号,而正是这个listen方法,暗藏着问题的关键。http模块http.server继承了net模块的net.server,那我们就来看看net.js中的Server.prototype.listen干了哪些事。
在listen中主要执行了listenInCluster方法,其输入信息包含ip,端口号,地址类型,backlog和fd等,listenInCluster函数主要内容如上图中所示,当当前进程是主进程时,直接创建服务监听;如果是子进程,则执行_getserver函数,该函数位于lib/internal/cluster/child.js中,它会传入创建httpserver所需要的端口等相关信息,并向主进程发送‘serverQuery’指令,主进程接收到‘serverQuery’指令后,会new出一个RoundRobinHandle的实例,在这个过程中,主进程服务被创建,端口被监听,子进程被加入到调度度列中。这些完成后,子进程执行回调函数,继续后续操作。
子进程服务创建
在上面的图中还有一个_listen2()方法,该函数对应执行的函数为setupListenHandle(),
function setupListenHandle(address, port, addressType, backlog, fd) { //... if (!address && typeof fd !== 'number') { rval = createServerHandle('::', port, 6, fd); //... if (rval === null) rval = createServerHandle(address, port, addressType, fd); //... this[async_id_symbol] = getNewAsyncId(this._handle); this._handle.onconnection = onconnection; this._handle.owner = this; //... }
通过createServerHandle函数创建句柄(句柄可理解为用户空间的socket),同时给属性onconnection赋值,最后侦听端口,设定backlog。那么,socket处理请求过程“socket(),bind()”步骤就是在createServerHandle完成。
子进程是否也对端口进行了监听?
我们在将实现回到child.js中的_getServer()函数,当子进程向主进程发送完消息后,执行回调函数。由于cluster默认的策略是round-robbin,所以会执行rr()函数:
function rr(message, indexesKey, cb) {
if (message.errno)
return cb(message.errno, null);
var key = message.key;
function listen(backlog) {
// TODO(bnoordhuis) Send a message to the master that tells it to
// update the backlog size. The actual backlog should probably be
// the largest requested size by any worker.
return 0;
}
//...
const handle = { close, listen, ref: noop, unref: noop };
handles[key] = handle;
cb(0, handle);
}
从上面的代码中可以看出,在listen()中直接返回,没有做任何操作。因此子进程服务没有创建对底层服务端socket的进行监听,所以自然不会出现子进程端口复用的情况。最后,调用cb函数,将fake后的handle传递给上层net.Server,设置net.Server对底层的socket的引用。此后,子进程利用fake后的handle做端口侦听(其实压根啥都没有做),执行成功后返回。
client通过tcp连接向主进程发送请求,那主进程又是如何将请求传递给子进程处理呢?
子进程TCP服务器没有创建底层socket,它主要依赖IPC通道与主进程通信,既然主进程负责接受客户端请求,那么理所应当由主进程分发客户端请求给某个子进程,由子进程处理请求。具体分配给哪个子进程处理,是由round-robbine分发策略来决定的。由于子进程在server中设置了对底层的socket的引用,所以子进程接收到任务后,触发connection事件开始执行业务逻辑。
对于该部分还需要持续关注,因为涉及底层libuv,需要结合C++代码一起理解。比如:IPC通信方式有多种,node.js是如何决定使用哪种方式来通信?
总结
对于cluster的分析,得出以下结论:
1. cluster在创建子进程时,会在环境变量中增加标识,以此来区分主进程和子进程
2. listen函数在实现时对主进程和子进程进行了区分,在不同的进程中会执行不同操作
3. nodeJS封装了进程间通信的方法,支持在进程间发送句柄的功能,句柄可以是一个socket对象,一个管道等等
4. 一个端口只能被一个进程监听,但是该端口可以建立多个连接(accpet是产生的套接字),不同进程间可以共享这些套接字
5. 子进程的listen函数并没有监听端口,它在listen时将端口和地址等信息发送给主进程,由主进程进行监听。
主进程在收到accept事件时,产生连接socket,并把它发送给子进程。子进程直接通过该socket跟client端进行通信