Node.JS 无疑是走向大前端, 全栈工程师技术栈最快的捷径 (但是一定要会一门其他后台语言, 推荐 Golang), 虽然 Node.JS 做很多事情都做不好, 但是在某些方面还是有它的优势.
众所周知, Node.JS 中的 JavaScript 代码执行在单线程中, 非常脆弱, 一旦出现了未捕获的异常, 那么整个应用就会崩溃.
这在许多场景下, 尤其是 web 应用中, 是无法忍受的. 通常的解决方案, 便是使用 Node.JS 中自带的 cluster 模块, 以 master-worker 模式启动多个应用实例. 然而大家在享受 cluster 模块带来的福祉的同时, 不少人也开始好奇
1. 为什么我的应用代码中明明有 App.listen(port);, 但 cluter 模块在多次 fork 这份代码时, 却没有报端口已被占用?
2.Master 是如何将接收的请求传递至 worker 中进行处理然后响应的?
带着这些疑问我们开始往下看
TIPS:
本文编写于 2019 年 12 月 8 日, 是最新版本的 Node.JS 源码
Cluster 源码解析:
入口 :
- const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master';
- module.exports = require(`internal/cluster/${childOrMaster}`);
分析
会根据一个当前的 Node_UNIQUE_ID(后面会讲) 是否在环境变量中判断是子进程还是主进程, 然后引用不同的 JS 代码
NODE_UNIQUE_ID 是一个唯一标示, Node.JS 的 Cluster 多进程模式, 采用默认的调度算法是 round-robin, 其实就是轮询. 官方解释是实践效率非常高, 稳定
之前的问题一: 为什么我的应用代码中明明有 App.listen(port);, 但 cluter 模块在多次 fork 这份代码时, 却没有报端口已被占用?
我在 Node.JS 的官网找到了答案:
原来所有的 net.Socket 都被设置了 SO_REUSEADDR
这个 SO_REUSEADDR 到底是什么呢?
为什么需要 SO_REUSEADDR 参数?
服务端主动断开连接以后, 需要等 2 个 MSL 以后才最终释放这个连接, 重启以后要绑定同一个端口, 默认情况下, 操作系统的实现都会阻止新的监听套接字绑定到这个端口上.
我们都知道 TCP 连接由四元组唯一确定. 形式如下
{local-ip-address:local-port , foreign-ip-address:foreign-port}
一个典型的例子如下图
TCP 要求这样的四元组必须是唯一的, 但大多数操作系统的实现要求更加严格, 只要还有连接在使用这个本地端口, 则本地端口不能被重用 (bind 调用失败)
启用 SO_REUSEADDR 套接字选项可以解除这个限制, 默认情况下这个值都为 0, 表示关闭. 在 Java 中, reuseAddress 不同的 JVM 有不同的实现, 在我本机上, 这个值默认为 1 允许端口重用. 但是为了保险起见, 写 TCP,HTTP 服务一定要主动设置这个参数为 1.
目前常见的网络编程模型就是多进程或多线程, 根据 accpet 的位置, 分为如下场景
2 种场景
(1) 单进程或线程创建 socket, 并进行 listen 和 accept, 接收到连接后创建进程和线程处理连接
(2) 单进程或线程创建 socket, 并进行 listen, 预先创建好多个工作进程或线程 accept() 在同一个服务器套接字
这两种模型解充分发挥了多核 CPU 的优势, 虽然可以做到线程和 CPU 核绑定, 但都会存在:
1. 单一 listener 工作进程或线程在高速的连接接入处理时会成为瓶颈
2. 多个线程之间竞争获取服务套接字
3. 缓存行跳跃
4. 很难做到 CPU 之间的负载均衡
5. 随着核数的扩展, 性能并没有随着提升
6.SO_REUSEPORT 解决了什么问题
7.SO_REUSEPORT 支持多个进程或者线程绑定到同一端口, 提高服务器程序的性能
解决的问题:
1. 允许多个套接字 bind()/listen() 同一个 TCP/UDP 端口
2. 每一个线程拥有自己的服务器套接字
3. 在服务器套接字上没有了锁的竞争
4. 内核层面实现负载均衡
5. 安全层面, 监听同一个端口的套接字只能位于同一个用户下面
其核心的实现主要有三点:
1. 扩展 socket option, 增加 SO_REUSEPORT 选项, 用来设置 reuseport
2. 修改 bind 系统调用实现, 以便支持可以绑定到相同的 IP 和端口
3. 修改处理新建连接的实现, 查找 listener 的时候, 能够支持在监听相同 IP 4. 和端口的多个 sock 之间均衡选择.
5. 有了 SO_RESUEPORT 后, 每个进程可以自己创建 socket,bind,listen,accept 相同的地址和端口, 各自是独立平等的
让多进程监听同一个端口, 各个进程中 accept socket fd 不一样, 有新连接建立时, 内核只会唤醒一个进程来 accept, 并且保证唤醒的均衡性.
总结: 原来端口被复用是因为设置了 SO_REUSEADDR, 当然不止这一点, 下面会继续描述
回到源码第一行
NODE_UNIQUE_ID 是什么?
下面给出介绍:
- function createWorkerProcess(id, env) {
- // ...
- workerEnv.NODE_UNIQUE_ID = '' + id;
- // ...
- return fork(cluster.settings.exec, cluster.settings.args, {
- env: workerEnv,
- silent: cluster.settings.silent,
- execArgv: execArgv,
- gid: cluster.settings.gid,
- uid: cluster.settings.uid
- });
- }
原来, 创建子进程的时候, 给了每个进程一个唯一的自增标示 ID
随后 Node.JS 在初始化时, 会根据该环境变量, 来判断该进程是否为 cluster 模块 fork 出的工作进程, 若是, 则执行 workerInit() 函数来初始化环境, 否则执行 masterInit() 函数
就是这行入口的代码~
module.exports = require(`internal/cluster/${childOrMaster}`);
接下来我们需要看一下 net 模块的 listen 函数源码:
- // lib.NET.JS
- // ...
- function listen(self, address, port, addressType, backlog, fd, exclusive) {
- exclusive = !!exclusive;
- if (!cluster) cluster = require('cluster');
- if (cluster.isMaster || exclusive) {
- self._listen2(address, port, addressType, backlog, fd);
- return;
- }
- cluster._getServer(self, {
- address: address,
- port: port,
- addressType: addressType,
- fd: fd,
- flags: 0
- }, cb);
- function cb(err, handle) {
- // ...
- self._handle = handle;
- self._listen2(address, port, addressType, backlog, fd);
- }
- }
仔细一看, 原来 listen 函数会根据是不是主进程做不同的操作!
上面有提到 SO_REUSEADDR 选项, 在主进程调用的_listen2 中就有设置.
子进程初始化的每个 workerinit 函数中, 也有 cluster._getServer 这个方法,
你可能已经猜到, 问题一的答案, 就在这个 cluster._getServer 函数的代码中. 它主要干了两件事:
向 master 进程注册该 worker, 若 master 进程是第一次接收到监听此端口 / 描述符下的 worker, 则起一个内部 TCP 服务器, 来承担监听该端口 / 描述符的职责, 随后在 master 中记录下该 worker.
Hack 掉 worker 进程中的 net.Server 实例的 listen 方法里监听端口 / 描述符的部分, 使其不再承担该职责.
对于第一件事, 由于 master 在接收, 传递请求给 worker 时, 会符合一定的负载均衡规则 (在非 Windows 平台下默认为轮询), 这些逻辑被封装在 RoundRobinHandle 类中. 故, 初始化内部 TCP 服务器等操作也在此处:
- // lib/cluster.JS
- // ...
- function RoundRobinHandle(key, address, port, addressType, backlog, fd) {
- // ...
- this.handles = [];
- this.handle = null;
- this.server = net.createServer(assert.fail);
- if (fd>= 0)
- this.server.listen({ fd: fd });
- else if (port>= 0)
- this.server.listen(port, address);
- else
- this.server.listen(address); // UNIX socket path.
- /// ...
- }
在子进程中:
- function listen(backlog) {
- return 0;
- }
- function close() {
- // ...
- }
- function ref() {}
- function unref() {}
- var handle = {
- close: close,
- listen: listen,
- ref: ref,
- unref: unref,
- }
由于 net.Server 实例的 listen 方法, 最终会调用自身_handle 属性下 listen 方法来完成监听动作, 故在代码中修改之: 此时的 listen 方法已经被 hack , 每次调用只能发挥 return 0 , 并不会监听端口
- // lib.NET.JS
- // ...
- function listen(self, address, port, addressType, backlog, fd, exclusive) {
- // ...
- if (cluster.isMaster || exclusive) {
- self._listen2(address, port, addressType, backlog, fd);
- return; // 仅在 worker 环境下改变
- }
- cluster._getServer(self, {
- address: address,
- port: port,
- addressType: addressType,
- fd: fd,
- flags: 0
- }, cb);
- function cb(err, handle) {
- // ...
- self._handle = handle;
- // ...
- }
- }
这里可以看到, 传入的回调函数中的 handle, 已经把 listen 方法重新定义, 返回 0, 那么等子进程调用 listen 方法时候, 也是返回 0, 并不会去监听端口, 至此, 焕然大悟, 原来是这样, 真正监听端口的始终只有主进程!
上面通过将近 3000 字讲解, 把端口复用这个问题讲清楚了, 下面把负载均衡这块也讲清楚. 然后再讲 PM2 的原理实现, 其实不过是对 cluster 模式进行了封装, 多了很多功能而已~
首先画了一个流程图
核心实现源码:
- function RoundRobinHandle(key, address, port, addressType, backlog, fd) {
- // ...
- this.server = net.createServer(assert.fail);
- // ...
- var self = this;
- this.server.once('listening', function() {
- // ...
- self.handle.onconnection = self.distribute.bind(self);
- });
- }
- RoundRobinHandle.prototype.distribute = function(err, handle) {
- this.handles.push(handle);
- var worker = this.free.shift();
- if (worker) this.handoff(worker);
- };
- RoundRobinHandle.prototype.handoff = function(worker) {
- // ...
- var message = { act: 'newconn', key: this.key };
- var self = this;
- sendHelper(worker.process, message, handle, function(reply) {
- // ...
- });
解析
定义好 handle 对象中的 onconnection 方法
触发事件时, 取出一个子进程通知, 传入句柄
子进程接受到消息和句柄后, 做相应的业务处理:
- // lib/cluster.JS
- // ...
- // 该方法会在 Node.JS 初始化时由 src/node.JS 调用
- cluster._setupWorker = function() {
- // ...
- process.on('internalMessage', internal(worker, onmessage));
- // ...
- function onmessage(message, handle) {
- if (message.act === 'newconn')
- onconnection(message, handle);
- // ...
- }
- };
- function onconnection(message, handle) {
- // ...
- var accepted = server !== undefined;
- // ...
- if (accepted) server.onconnection(0, handle);
- }
总结下来, 负载均衡大概流程:
1. 所有请求先同一经过内部 TCP 服务器, 真正监听端口的只有主进程.
2. 在内部 TCP 服务器的请求处理逻辑中, 有负载均衡地挑选出一个 worker 进程, 将其发送一个 newconn 内部消息, 随消息发送客户端句柄.
3.Worker 进程接收到此内部消息, 根据客户端句柄创建 net.Socket 实例, 执行具体业务逻辑, 返回.
至此, Cluster 多进程模式, 负载均衡讲解完毕, 下面讲 PM2 的实现原理, 它是基于 Cluster 模式的封装
PM2 的使用:
NPM i pm2 -g
pm2 start App.JS
pm2 ls
这样就可以启动你的 Node.JS 服务, 并且根据你的电脑 CPU 个数去启动相应的进程数, 监听到错误事件, 自带重启子进程, 即使更新了代码, 需要热更新, 也会逐个替换, 号称永动机.
它的功能:
1. 内建负载均衡 (使用 Node cluster 集群模块)
2. 后台运行
3.0 秒停机重载, 我理解大概意思是维护升级的时候不需要停机.
4. 具有 Ubuntu 和 CentOS 的启动脚本
5. 停止不稳定的进程 (避免无限循环)
6. 控制台检测
7. 提供 HTTP API
8. 远程控制和实时的接口 API ( Node.JS 模块, 允许和 PM2 进程管理器交互 )
先来一张 PM2 的架构图:
pm2 包括 Satan 进程, God Deamon 守护进程, 进程间的远程调用 rpc,cluster 等几个概念
如果不知道点西方文化, 还真搞不清他的文件名为啥是 Satan 和 God:
撒旦 (Satan), 主要指《圣经》中的堕天使 (也称堕天使撒旦), 被看作与上帝的力量相对的邪恶, 黑暗之源, 是 God 的对立面.
1.Satan.JS 提供了程序的退出, 杀死等方法, 因此它是魔鬼; God.JS 负责维护进程的正常运行, 当有异常退出时能保证重启, 所以它是上帝. 作者这么命名, 我只能说一句: oh my god.
God 进程启动后一直运行, 它相当于 cluster 中的 Master 进程, 守护者 worker 进程的正常运行.
2.rpc(Remote Procedure Call Protocol) 是指远程过程调用, 也就是说两台服务器 A,B, 一个应用部署在 A 服务器上, 想要调用 B 服务器上应用提供的函数 / 方法, 由于不在一个内存空间, 不能直接调用, 需要通过网络来表达调用的语义和传达调用的数据. 同一机器不同进程间的方法调用也属于 rpc 的作用范畴.
3. 代码中采用了 axon-rpc 和 axon 两个库, 基本原理是提供服务的 server 绑定到一个域名和端口下, 调用服务的 client 连接端口实现 rpc 连接. 后续新版本采用了 pm2-axon-rpc 和 pm2-axon 两个库, 绑定的方法也由端口变成. sock 文件, 因为采用 port 可能会和现有进程的端口产生冲突.
执行流程
程序的执行流程图如下:
每次命令行的输入都会执行一次 satan 程序. 如果 God 进程不在运行, 首先需要启动 God 进程. 然后根据指令, satan 通过 rpc 调用 God 中对应的方法执行相应的逻辑.
以 pm2 start App.JS -i 4 为例, God 在初次执行时会配置 cluster, 同时监听 cluster 中的事件:
- // 配置 cluster
- cluster.setupMaster({
- exec : path.resolve(path.dirname(module.filename), 'ProcessContainer.js')
- });
- // 监听 cluster 事件
- (function initEngine() {
- cluster.on('online', function(clu) {
- // worker 进程在执行
- God.clusters_db[clu.pm_id].status = 'online';
- });
- // 命令行中 kill pid 会触发 exit 事件, process.kill 不会触发 exit
- cluster.on('exit', function(clu, code, signal) {
- // 重启进程 如果重启次数过于频繁直接标注为 stopped
- God.clusters_db[clu.pm_id].status = 'starting';
- // 逻辑
- ...
- });
- })();
在 God 启动后, 会建立 Satan 和 God 的 rpc 链接, 然后调用 prepare 方法. prepare 方法会调用 cluster.fork, 完成集群的启动
- God.prepare = function(opts, cb) {
- ...
- return execute(opts, cb);
- };
- function execute(env, cb) {
- ...
- var clu = cluster.fork(env);
- ...
- God.clusters_db[id] = clu;
- clu.once('online', function() {
- God.clusters_db[id].status = 'online';
- if (cb) return cb(null, clu);
- return true;
- });
- return clu;
- }
PM2 的功能目前已经特别多了, 源码阅读非常耗时, 但是可以猜测到一些功能的实现:
例如
如何检测子进程是否处于正常活跃状态?
采用心跳检测
每隔数秒向子进程发送心跳包, 子进程如果不回复, 那么调用 kill 杀死这个进程
然后再重新 cluster.fork() 一个新的进程
子进程发出异常报错, 如何保证一直有一定数量子进程?
子进程可以监听到错误事件, 这时候可以发送消息给主进程, 请求杀死自己
并且主进程此时重新调用 cluster.fork 一个新的子进程
目前不少 Node.JS 的服务, 依赖 Nginx+pm2+docker 来实现自动化 + 监控部署,
pm2 本身也是有监听系统的, 分免费版和收费版~
具体可以看官网, 以及搜索一些操作手册等进行监控操作, 配置起来比较简单,
这里就不做概述了.
https://pm2.keymetrics.io/
如果感觉写得不错, 麻烦帮忙点个赞然后分享给你身边多人, 原创不易, 需要支持~!
来源: https://segmentfault.com/a/1190000021230376