NodeJS Cluster模組原始碼學習

hsabalaaaC發表於2019-01-19

一段常見的示例程式碼

const cluster = require('cluster');
const http = require('http');

if (cluster.isMaster) {
  // 根據cpu核心數出fork相同數量的子程式
} else {
  // 用http模組建立server監聽某一個埠
}
複製程式碼

引出如下問題:

  1. cluster模組如何區分子程式和主程式?

  2. 程式碼中沒有在主程式中建立伺服器,那麼如何主程式如何承擔代理伺服器的職責?

  3. 多個子程式共同偵聽同一個埠為什麼不會造成埠reuse error

1. cluster模組如何區分主程式/子程式

cluster.js - 原始碼

const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master';
module.exports = require(`internal/cluster/${childOrMaster}`);
複製程式碼

結論: 判斷環境變數中是否含有NODE_UNIQUE_ID, 有則為子程式,沒有則為主程式

1.1 isMaster & isWorker

這樣的話, 在對應的檔案中isMasterisWorker的值就明確啦

// child.js
module.exports = cluster;

cluster.isWorker = true;
cluster.isMaster = false;

// master.js
module.exports = cluster;

cluster.isWorker = false;
cluster.isMaster = true;
複製程式碼

那麼接下來的問題是: NODE_UNIQUE_ID從哪裡來?

1.2 NODE_UNIQUE_ID從哪裡來的?

internal/cluster/master.js檔案中搜尋NODE_UNIQUE_ID ----> 上層為createWorkerProcess函式 ----> 上層為cluster.fork函式

master.js原始碼中相關部分

const { fork } = require('child_process');

cluster.workers = {}

var ids = 0;

cluster.fork = function(env) {
  const id = ++ ids;
  const workerProcess = createWorkerProcess(id, env);
  const worker = new Worker({
    id: id,
    process: workerProcess
  });
  cluster.workers[worker.id] = worker;
  return worker
}

function createWorkerProcess(id, env) {
  const workerEnv = { ...process.env, ...env, NODE_UNIQUE_ID: `${id}` };
  
  return fork(args, {
    env: workerEnv
  })
}
複製程式碼

結論: 變數NODE_UNIQUE_ID是在主程式fork子程式時傳遞進去的引數,因此採用cluster.fork建立的子程式是一定包含NODE_UNIQUE_ID的,而直接使用child_process.fork的子程式是沒有NODE_UNIQUE_ID

並且, NODE_UNIQUE_ID將作為主程式中儲存活躍的工作程式物件的鍵值

2. 主程式中是否存在TCP伺服器, 如果有, 什麼時候建立的?

繼續描述一下這個問題的由來:

const cluster = require('cluster');
const http = require('http');

if (cluster.isMaster) {
  // 根據cpu核心數出fork相同數量的子程式
} else {
  // 用http模組建立server監聽某一個埠
}
複製程式碼

並沒有在cluster.isMaster條件語句中建立伺服器, 也沒有提供伺服器相關的路徑,介面。而主程式又需要承擔代理伺服器的 職責,那麼主程式中是否存在TCP伺服器?

我們來猜猜看可能的步驟

  • 子程式會執行http.createServer

  • http模組會呼叫net模組, 因為http.Server繼承net.Server

  • 同時偵聽埠, 建立net.Server例項, 建立的例項呼叫listen(port), 等待連結

這時如果主程式要建立伺服器就需要把建立伺服器相關資訊給主程式, 繼續猜測

  • 假設主程式已經拿到了伺服器相關的資訊, 主程式自己來建立

  • 後面的fork子程式就不用自己建立了,而是從主程式中get到相關資料

既然要在主程式需要得到完整的建立伺服器相關資訊, 那麼很可能在net模組listen相關方法中進行處理

2.1 在原始碼中找答案

github net 模組原始碼

Server.prototype.listen找找看,什麼時候把伺服器相關資訊傳遞給主程式了?

Server.prototype.listen = function(...args) {
  // 無視其他的判斷邏輯, 直達它的內心!
  if (成功) {
    listenInCluster()
    return this
  } else {
    // 無視
  }
}
複製程式碼

總的來說就是: Server.prototype.listen函式中,在成功進入條件語句後所有的情況都執行了listenInCluster函式後返回

接下來看listenInCluster函式

function listenInCluster(server, 建立伺服器需要的資料) {
  if (cluster === undefined) cluster = require('cluster')
  // 判斷是否是主程式
  if (cluster.isMaster) {
    server._listen2(建立伺服器需要的資料)
    return
  }
  // 建立伺服器需要的資料
  const serverQuery = {
      address: address,
      port: port,
      addressType: addressType,
      fd: fd,
      flags,
    };
  // 只剩下子程式
  cluster._getServer(server, 建立伺服器需要的資料, listenOnMasterHandle);
 
  function listenOnMasterHandle(err, handle) {
    server._handle = handle
    server._listen2(建立伺服器需要的資料)
  }
}
複製程式碼

按照前面的推斷: 子程式會給主程式傳送建立server需要的資料, 主程式去建立

所以接下來去看cluster模組的child._getServer函式

cluster._getServer = function(obj, options, cb) {
  // 組裝傳送的資料
  const message = {
    act: 'queryServer',
    ...options,
  }
  // 傳送資料
  send(message, (reply, handle) => {
  
  })
}
複製程式碼

那麼接下來主程式就應該對queryServer作出想要的處理

具體可以看cluster/master.js

const RoundRobinHandle = require('internal/cluster/round_robin_handle');

const handles = new Map()

function onmessage(message, handle) {
  if (message.act === 'queryServer') {
    queryServer(worker, message)
  }
}
queryServer(worker, message) {
  const key = `${message.address}:${message.port}:${message.addressType}:` +
              `${message.fd}:${message.index}`;
  const constructor = RoundRobinHandle
  
  let handle = new constructor(建立伺服器相關資訊)
  
  handles.set(key, handle);
}
複製程式碼

終於要到終點了:

internal/cluster/round_robin_handle.js

function RoundRobinHandle(建立伺服器相關資訊) {
  this.server = net.createServer()
  this.server.listen(.....)
}
複製程式碼

2.2 主程式在cluster模式下如何建立伺服器的結論

主程式fork子程式, 子程式中有顯式建立伺服器的操作,但實際上在cluster模式下, 子程式是把建立伺服器所需要的資料傳送給主程式, 主程式來隱式建立TCP伺服器

流程圖

NodeJS Cluster模組原始碼學習

3. 為什麼多個子程式可以監聽同一個埠?

這個問題可以轉換為: 子程式中有沒有也建立一個伺服器,同時偵聽某個埠呢?

其實,上面的原始碼分析中可以得出結論:子程式中確實建立了net.Server物件,可是它沒有像主程式那樣在libuv層構建socket控制程式碼,子程式的net.Server物件使用的是一個假控制程式碼來'欺騙'使用者埠已偵聽

3.1 首先要明確預設的排程策略: round-robin

這部分可以參考文章Node.js V0.12 新特性之 Cluster 輪轉法負載均衡

主要就是說:Node.js v0.12引入了round-robin方式, 用輪轉法來分配請求, 每個子程式的獲取的時間的機會都是均等的(windows除外)

原始碼在internal/cluster/master.js

var schedulingPolicy = {
  'none': SCHED_NONE,
  'rr': SCHED_RR
}[process.env.NODE_CLUSTER_SCHED_POLICY];

if (schedulingPolicy === undefined) {
  // FIXME Round-robin doesn't perform well on Windows right now due to the
  // way IOCP is wired up.
  schedulingPolicy = (process.platform === 'win32') ? SCHED_NONE : SCHED_RR;
}

cluster.schedulingPolicy = schedulingPolicy;
複製程式碼

3.2 證明子程式拿到的是假控制程式碼

上面說明了:預設的排程策略是round-robin, 那麼子程式將建立伺服器的資料傳送給主程式, 當主程式傳送建立伺服器成功的訊息後,子程式會執行回撥函式

原始碼在internal/cluster/child.js _getServer中

cluster._getServer = function(obj, options, cb) {
  const indexesKey = [address,
                      options.port,
                      options.addressType,
                      options.fd ].join(':');
  send(message, (reply, handle) => {
    if (typeof obj._setServerData === 'function')
      obj._setServerData(reply.data);
      
    // 這裡可以反推出主程式返回的handle為null
    if (handle)
      shared(reply, handle, indexesKey, cb);  // Shared listen socket.
    else
      rr(reply, indexesKey, cb);              // Round-robin.
  });
}
複製程式碼

rr函式, 注意這裡的回撥函式其實就是net模組中的listenOnMasterHandle方法

function rr(message, indexesKey, cb) {
  const key = message.key
  const handle = { close, listen, ref: noop, unref: noop };
  handles.set(key, handle)
  // 將假控制程式碼傳遞給上層的net.Server
  cb(0, handle)
}
複製程式碼

所以結論是這樣:子程式壓根沒有建立底層的服務端socket做偵聽,所以在子程式建立的HTTP伺服器偵聽的埠根本不會出現埠複用的情況

3.3 子程式沒有建立底層socket, 如何接收請求和傳送響應呢?

顯而易見:主程式的伺服器中會建立RoundRobinHandle決定分發請求給哪一個子程式,篩選出子程式後傳送newconn訊息給對應的子程式

4. 請求分發策略 RoundRobin

原始碼見internal/cluster/round_robin_handle

module.exports = RoundRobinHandle

function RoundRobinHandle(建立伺服器需要的引數) {
  // 儲存空閒的子程式
  this.free = []
  // 存放待處理的使用者請求
  this.handles = []
}
// 負責篩選出處理請求的子程式
RoundRobinHandle.prototype.distribute = function(err, handle) {
  this.handles.push(handle)
  const worker = this.free.shift()
  
  if (worker) {
    this.handoff(worker)
  }
}
// 獲取請求,並通過IPC傳送控制程式碼handle和newconn訊息,等待子程式返回
RoundRobinHandle.prototype.handoff = function(worker) {
  const handle = this.handles.shift()
  
  if (handle === undefined) {
    this.free.push(worker)
    return
  }
  
  const message = { act: 'newconn', key: this.key }
  
  sendHelper(worker.process, message, handle, reply => {
    if (reply.accepted)
      handle.close();
    // 某個子程式辦事不力,給下一個子程式再試試  
    else
      this.distribute(0, handle)  
    this.handoff(worker)  
  })
}
複製程式碼

參考

Nodejs cluster模組深入探究

相關文章