經過Node.js的Cluster模塊源碼,深刻PM2原理

Node.js無疑是走向大前端、全棧工程師技術棧最快的捷徑(可是必定要會一門其餘後臺語言,推薦Golang),雖然Node.js作不少事情都作很差,可是在某些方面仍是有它的優點。

衆所周知,Node.js中的JavaScript代碼執行在單線程中,很是脆弱,一旦出現了未捕獲的異常,那麼整個應用就會崩潰。前端

這在許多場景下,尤爲是web應用中,是沒法忍受的。一般的解決方案,即是使用Node.js中自帶的cluster模塊,以master-worker模式啓動多個應用實例。然而你們在享受cluster模塊帶來的福祉的同時,很多人也開始好奇

1.爲何個人應用代碼中明明有app.listen(port);,但cluter模塊在屢次fork這份代碼時,卻沒有報端口已被佔用?node

2.Master是如何將接收的請求傳遞至worker中進行處理而後響應的?web

帶着這些疑問咱們開始往下看算法

TIPS:docker

本文編寫於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代碼npm

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,並進行listenaccept,接收到鏈接後建立進程和線程處理鏈接

(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 ( Nodejs 模塊,容許和PM2進程管理器交互 )


先來一張PM2的架構圖:

pm2包括 Satan進程、God Deamon守護進程、進程間的遠程調用rpc、cluster等幾個概念

若是不知道點西方文化,還真搞不清他的文件名爲啥是 SatanGod

撒旦(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/

若是感受寫得不錯,麻煩幫忙點個贊而後分享給你身邊多人,原創不易,須要支持~!

歡迎關注微信公衆號:前端巔峯

儘可能都是原創內容,回覆加羣就能夠加入小姐姐衆多的前端交流羣~

相關文章
相關標籤/搜索