Node.js--cluster原理分析
2019-08-06

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"); }).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端进行通信

 

, 1, 0, 9);