node Cluster 模組分析

hsy0發表於2018-12-29

原文連結

預備工作

分析的是從 node 原始碼的角度進行的,所以需要先配置原始碼的除錯環境。

需要準備的內容為:

  1. node 原始碼
  2. CLion

node 原始碼的獲取,通過以下命令列:

git clone https://github.com/nodejs/node.git
# 本文針對的版本
git checkout tags/v11.6.0
複製程式碼

獲取了 node 原始碼之後,需要在 CLion 中匯入專案,詳細可以參考此文 使用 cLion 除錯 node.js 原始碼

上文中提到的原始碼編譯指令為:

make -C out BUILDTYPE=Debug -j 4
複製程式碼

-j 4 的意義是同時執行的任務數,一般設定為 CPU 核數,可以通過下面指令獲得 CPU 核數:

[ $(uname) = 'Darwin' ] && sysctl -n hw.logicalcpu_max || lscpu -p | egrep -v '^#' | wc -l
複製程式碼

如果希望加快 node 原始碼的編譯速度的話,可以先嚐試獲取核數,然後調整 -j 的值。

node 中的 js 實現都在 lib 目錄下,需要注意的是,當 node 編譯完成之後,這些 js 檔案是都會被打包到編譯結果中的。當 node 在執行中要引入 lib 下的 js 檔案時,並不會從我們的原始碼目錄中讀取了,而是採用的編譯時打包進去的 js 內容。所以在修改了 lib 目錄下的 js 檔案後,需要重新對 node 進行編譯。

發現問題

為了測試 Cluster 的執行,需要準備一小段測試程式碼,儲存到 ./test-cluster.js:

如果沒有特別說明,接下來檔案路徑中的 . 都表示的是 node 原始碼目錄

const cluster = require('cluster');
const http = require('http');
// 下面的程式碼會依據 numCPUs 的值來建立對應數量的子程式,
// 由於目前硬體核數都會比較多,為了使除錯時的輸出內容儘可能的清晰,所以設定為 2
// const numCPUs = require('os').cpus().length;
const numCPUs = 2;

if (cluster.isMaster) {
    // 如果是 master 程式:
    // 1. 列印程式號
    // 2. 根據 numCPUs 的值來建立對應數量的子程式
    console.log("master pid: " + process.pid);

    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }
} else {
    // 如果是子程式:
    // 1. 列印程式號
    // 2. 建立 http server 例項
    // 3. 並呼叫上一步建立的例項的 listen 方法
    console.log("isMaster: " + cluster.isMaster + " pid: " + process.pid);

    const srv = http.createServer(function (req, res) {
        res.writeHead(200);
        res.end("hello world\n" + process.pid);
    });

    srv.listen(8000);
}
複製程式碼

接下來需要使用在 預備工作 中編譯好的 node 來執行上述程式碼:

./out/Debug/node test-cluster.js
複製程式碼

會得到如下的輸出:

master pid: 30094
isMaster: false pid: 30095
isMaster: false pid: 30096
複製程式碼

通過對比輸出內容,程式碼執行的過程類似:

  1. master 開始執行,即條件分支中的 cluster.isMaster 分支被執行,建立了 2 個子程式
  2. 在 2 個子程式中 else 分支被執行

這裡有幾個值得思考的問題:

  1. cluster 模組是如何區別 master 和 work 程式的;換言之,work 程式是如何被建立的
  2. 多個 work 程式中,都執行了 listen 方法,為什麼沒有報錯 ERR_SERVER_ALREADY_LISTEN
  3. 為什麼 master 程式在完成了建立程式的任務後沒有退出
  4. 請求是如何傳遞到 work 程式中並被處理的

對於問題3,準備下面兩個檔案

./test-fork-exit.js:

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

console.log("master pid: " + process.pid);

for (let i = 0; i < numCPUs; i++) {
    fork("./test-fork-sub.js")
}
複製程式碼

./test-fork-sub.js:

console.log("sub: " + process.pid);
複製程式碼

執行 ./out/Debug/node test-fork-exit.js 後,會發現 master 程式在建立了兩個程式後退出了。

接下來將對上述幾個問題進行分析。

問題1. work 程式的建立

在執行了 ./out/Debug/node test-cluster.js 命令之後,master 程式即被啟動,在該程式中,執行 test-cluster.js 中的程式碼。

首先執行的就是 const cluster = require('cluster');。開啟 cluster 模組的原始碼 ./lib/child_process.js:

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

可以看到,程式碼會根據 childOrMaster 的值來決定引入的是 internal/cluster/child 還是 internal/cluster/master 模組。而 childOrMaster 的值取決於環境變數中是否設定了 NODE_UNIQUE_ID,如果設定了,那麼載入對應的 child 模組,否則載入對應的 master 模組。

顯然,預設環境變數中是沒有對 NODE_UNIQUE_ID 標識進行設定的,於是引入的就是 ./internal/cluster/master.js

注意下面的程式碼片段:

cluster.isMaster = true;
複製程式碼

於是,在接下來的條件判斷 cluster.isMastertrue,進而會執行子程式的建立,也就是呼叫 master 模組中的 fork 方法。

注意 fork 方法中的片段:

cluster.setupMaster();
const id = ++ids;
const workerProcess = createWorkerProcess(id, env);
複製程式碼

只需要注意自增的 id,接下來看下 createWorkerProcess 的程式碼片段:

workerEnv.NODE_UNIQUE_ID = '' + id;

return fork(cluster.settings.exec, cluster.settings.args, {
  cwd: cluster.settings.cwd,
  env: workerEnv,
  silent: cluster.settings.silent,
  windowsHide: cluster.settings.windowsHide,
  execArgv: execArgv,
  stdio: cluster.settings.stdio,
  gid: cluster.settings.gid,
  uid: cluster.settings.uid
});
複製程式碼

於是發現其實是呼叫的 child_process 模組中的 fork 方法,並設定了環境變數 NODE_UNIQUE_ID,上文提到 cluster 模組被引入的時候,會根據環境變數是否存在 NODE_UNIQUE_ID 標識而決定引入 child 還是 master

另外,child_process.fork 方法第一個引數為 modulePath,也就是需要在子程式中執行的 js 檔案路徑,對應上述程式碼中 cluster.settings.exec 的值,對該變數的設定程式碼在 setupMaster 方法中:

var settings = {
  args: process.argv.slice(2),
  exec: process.argv[1],
  execArgv: process.execArgv,
  silent: false
};
複製程式碼

process.argv[1] 為當前程式的入口檔案,對於這個例子中的主程式而言,即為: ./test-cluster.js(實際值為對應的絕對路徑)

於是 cluster 模組作用下的 master 程式中的 fork 方法執行的內容可以簡單部分歸納為:

  1. 設定環境變數 NODE_UNIQUE_ID
  2. 執行 child_process.fork,引數 modulePath 為主程式入口檔案

接下來就是子程式中執行的過程。

子程式進來執行的還是與主程式相同的檔案,之所以執行了 cluster.isMasterfalse 的分支,是因為 ./internal/cluster/child.js 的程式碼片段:

cluster.isMaster = false;
複製程式碼

問題2. listen 方法

子程式中都執行了 listen 方法,但是卻沒有報錯,於是嘗試分析 listen 的執行細節。

http 模組中的 Server 是繼承於 net.Server,見 ./lib/_http_server.js 中:

function Server(options, requestListener) {
 // ...
 net.Server.call(this, { allowHalfOpen: true });
 // ...
}
複製程式碼

而 listen 方法存在於 net.Server 上。檢視 net.Server.listen 中主要的動作都是對引數的 normalization,然後呼叫 net.Server::listenInCluster 方法:

if (cluster.isMaster || exclusive) {
  // Will create a new handle
  // _listen2 sets up the listened handle, it is still named like this
  // to avoid breaking code that wraps this method
  server._listen2(address, port, addressType, backlog, fd, flags);
  return;
}

cluster._getServer(server, serverQuery, listenOnMasterHandle);
複製程式碼

這裡需要注意的是,listen 方法都是在子程式中執行的,所以 cluster.isMasterfalse,而 exclusive 是未設定的,故也為 false。於是,子程式中的 listen 實際執行的是 cluster._getServer 方法,並且這裡的 cluster 模組實際是引入的 ./lib/internal/cluster/child.js,於是檢視該檔案中的 _getServer 方法片段:

const message = util._extend({
  act: 'queryServer',
  index,
  data: null
}, options);

send(message, (reply, handle) => {
  if (typeof obj._setServerData === 'function')
    obj._setServerData(reply.data);

  if (handle)
    shared(reply, handle, indexesKey, cb);  // Shared listen socket.
  else
    rr(reply, indexesKey, cb);              // Round-robin.
});
複製程式碼

send 方法最終會呼叫 ./lib/internal/cluster/utils.js 中的 sendHelper 方法,而該方法會向父程式傳送 { cmd: 'NODE_CLUSTER' } 訊息,根據文件的 描述NODE_ 起頭的 cmd 為 內部訊息(internalMessage),需要通過 .on('internalMessage', lister) 來監聽它。

由於這個訊息是從子程式發往父程式的、即主程式的,於是在 ./lib/internal/cluster/master.js 中找到了相關的監聽程式碼片段:

worker.process.on('internalMessage', internal(worker, onmessage));
複製程式碼

接著通過 onmessage 方法定位到 queryServer 方法中的程式碼片段:

const key = `${message.address}:${message.port}:${message.addressType}:` +
            `${message.fd}:${message.index}`;
var handle = handles.get(key);

if (handle === undefined) {
  let address = message.address;

  // Find shortest path for unix sockets because of the ~100 byte limit
  if (message.port < 0 && typeof address === 'string' &&
      process.platform !== 'win32') {

    address = path.relative(process.cwd(), address);

    if (message.address.length < address.length)
      address = message.address;
  }

  var constructor = RoundRobinHandle;
  // UDP is exempt from round-robin connection balancing for what should
  // be obvious reasons: it's connectionless. There is nothing to send to
  // the workers except raw datagrams and that's pointless.
  if (schedulingPolicy !== SCHED_RR ||
      message.addressType === 'udp4' ||
      message.addressType === 'udp6') {
    constructor = SharedHandle;
  }

  handle = new constructor(key,
                           address,
                           message.port,
                           message.addressType,
                           message.fd,
                           message.flags);
  handles.set(key, handle);
}
複製程式碼

當這段程式碼首次被執行時,會建立一個 handle,並將其和 key 關聯起來。對於 TCP 鏈路,在沒有特別指定 schedulingPolicy 的情況下,handle 均為 RoundRobinHandle 的例項。而檢視 ./lib/internal/cluster/round_robin_handle.js 檔案中的 RoundRobinHandle 建構函式細節,則發現具體的 listen 繫結動作:

if (fd >= 0)
  this.server.listen({ fd });
else if (port >= 0) {
  this.server.listen({
    port,
    host: address,
    // Currently, net module only supports `ipv6Only` option in `flags`.
    ipv6Only: Boolean(flags & constants.UV_TCP_IPV6ONLY),
  });
} else
  this.server.listen(address);  // UNIX socket path.
複製程式碼

由於兩個子程式都在先後順序不確定的情況下向 master 傳送 queryServer 內部訊息,所以上面的程式碼會被執行兩次。如果兩次的 key 不一樣,就會導致 handle === undefined 的條件判斷為 true,進而 listen 兩次,最終發生 ERR_SERVER_ALREADY_LISTEN 錯誤。但是在上面的執行過程中,並沒有報錯,說明了兩次的 key 是相同的。

來看下 key 的內容:

const key = `${message.address}:${message.port}:${message.addressType}:` +
            `${message.fd}:${message.index}`;
複製程式碼

很顯然,對於分別來自兩個子程式的訊息而言,除了 message.index 之外,其餘項的內容都是相同的,那麼 message.index 的生成過程在 ./lib/internal/cluster/child.js 中的程式碼片段:

const indexesKey = [address,
                    options.port,
                    options.addressType,
                    options.fd ].join(':');

let index = indexes.get(indexesKey);

if (index === undefined)
  index = 0;
else
  index++;
複製程式碼

可見,兩個子程式在分別執行這段程式碼的時候,index 首次都將為 0,從而印證了上面的 key 是相同的假設。

之所以子程式中都會執行 listen 方法,而不報錯的原因小結如下:

  1. 子程式中並沒有執行實際的 listen 動作,取而代之的是通過傳送訊息,請求父程式來執行 listen
  2. 父程式中的 listen 由於相同的 key 使得多次動作被合併,最終只 listen 了一次

問題3. 不退出

答案現階段只能先從文件中尋找答案,詳細見 options.stdio,以下為節選:

It is worth noting that when an IPC channel is established between the parent and child processes, and the child is a Node.js process, the child is launched with the IPC channel unreferenced (using unref()) until the child registers an event handler for the 'disconnect' event or the 'message' event. This allows the child to exit normally without the process being held open by the open IPC channel.

簡單的說,如果子程式中沒有對事件 disconnectmessage 進行監聽的話,那麼主程式在等待子程式執行完畢之後,會正常的退出。後半句的「主程式會等待...」見 options.detached,以下為節選:

By default, the parent will wait for the detached child to exit.

為了印證,可以先將 ./test-fork-sub.js 程式碼改為:

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

execSync("sleep 3");
複製程式碼

執行 ./out/Debug/node test-fork-exit.js 會發現,在大約等待了幾秒之後,也就是子程式執行完畢之後,主程式進行了退出。

再次將 ./test-fork-sub.js 程式碼改為:

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

execSync("sleep 3");

console.log(`child: ${process.pid} resumed`);
process.on("message", () => {});
複製程式碼

可以發現,由於子程式監聽了 message 事件,使得主程式和子程式之間的 IPC channel 阻止了主程式的退出。

./lib/internal/cluster/child.js 中的 _setupWorker 方法中的片段:

process.once('disconnect', () => {
  // ...
});
複製程式碼

也印證了這一點。

問題4. 處理請求

回到 ./lib/internal/cluster/round_robin_handle.js 檔案,注意建構函式 RoundRobinHandle 中的程式碼片段,注意該程式碼是在主程式中呼叫的:

// ...
this.server = net.createServer(assert.fail);
// ...
this.server.once('listening', () => {
  this.handle = this.server._handle;
  this.handle.onconnection = (err, handle) => this.distribute(err, handle);
  this.server._handle = null;
  this.server = null;
});
複製程式碼

net.createServer 的引數如果是函式的話,那麼該函式的作用實際是用來處理 connection 的回撥函式。之所以會達到回撥的效果,是因為在 ./lib/net.js 中的 Server 建構函式的片段:

if (typeof options === 'function') {
  connectionListener = options;
  options = {};
  this.on('connection', connectionListener);
} else if (options == null || typeof options === 'object') {
  // ...
} else {
  // ...
}
複製程式碼

那麼上面傳遞的是 assert.fail,也就是說,如果該方法成功地被回撥了的話,那麼程式應該報錯。 既然沒有報錯,那麼說明在新的 connection 進來之後,沒有觸發 connection 事件。要搞清楚這點,就要看看 net.Server 上的 connection 事件是如何被觸發的。

net.Server 上的 connection 事件是在該檔案內的 onconnection 方法中被觸發的:

self.emit('connection', socket);
複製程式碼

onconnection 顧名思義也是一個 event listener,它是在相同檔案內的 setupListenHandle 中被引用的:

this._handle.onconnection = onconnection;
複製程式碼

setupListenHandle 函式是在呼叫 net.Server 上的 listen 方法被逐步呼叫到的:

  1. RoundRobinHandle 建構函式內部的 listen
  2. net.Server 上的 listen
  3. net.Server 上的 listenInCluster
  4. net.Server 上的 _listen2
  5. net.Server 內的 setupListenHandle

最終在 setupListenHandle 的程式碼片段中發現:

this._handle.onconnection = onconnection;
複製程式碼

回到上面列出的 RoundRobinHandle 中的程式碼:

this.server.once('listening', () => {
  // 這裡的 this.server._handle 是對 listen fd 的 wrapper
  this.handle = this.server._handle;
  // 回撥中的 handle 是對 connection fd 的 wrapper
  this.handle.onconnection = (err, handle) => this.distribute(err, handle);
  this.server._handle = null;
  this.server = null;
});
複製程式碼

可以看出,在真正的 listen 動作執行成功之後,listening 事件被觸發,進入到上面的程式碼中,然後上面的程式碼複寫了 handle 物件上的 onconnection 屬性的值,在此之前,該屬性的值即為 assert.fail。handle 物件此時還是一個 TCP_Wrapper 物件 (對 CPP 層面物件的包裹的一個物件)。

由於 master 環境下 RoundRobinHandle 構造 net.Server 物件的目的僅僅是希望獲得其內部的 listen fd handle 物件,因為 master 只需要將接下來的 connection fd handle 派發給 works 即可,所以上面的回撥中在獲得了該物件之後,取消了對 net.Server 物件的引用。

跟進上述程式碼中的 distribute 方法,發現其會呼叫 handoff 方法,向 work 傳送 connection fd handle。也就是說,當 master 程式接收到新連線之後,會將其派發給 work。接下來需要在 ./lib/internal/cluster/child.js 檔案中找到事件監聽函式:

function onmessage(message, handle) {
  if (message.act === 'newconn')
    onconnection(message, handle);
  else if (message.act === 'disconnect')
    _disconnect.call(worker, true);
}
複製程式碼

進一步發現 onconnection 方法中:

const server = handles.get(key);
// ...
server.onconnection(0, handle);
複製程式碼

接來下先搞清楚 server 是什麼時候被新增進 handles 中的,然後再看看 server.onconnection 做了什麼。

回顧下子程式中所做的事情,可以結合上面的章節。另外,為了方便結合程式碼進行理解,故同時給出具體的程式碼位置:

  1. work 程式呼叫 httpServer 上的 listen 方法,該方法繼承自父類 net.Server code
  2. work 程式呼叫 net.Server 上的 listen 方法 code
  3. work 程式呼叫 net.Server::listenInCluster code
  4. work 程式呼叫 cluster child 模組上的 _getServer,並期望被回撥 code
  5. work 向 master 程式傳送 queryServer 訊息,並期望被回撥 code
  6. master 構造 RoundRobinHandle 例項,並將發來 queryServer 訊息的 work 註冊到其中,並期望被回撥。在回撥中,會向 work 傳送訊息,觸發第 5 步中 work 期望的回撥 code
  7. 第 6 步中的回撥引數 handle 都將是 false 值 code
  8. 從而當回撥到第 5 步時,work 程式將執行 rr 方法,該方法會偽造一個 handle 物件,加入到 handles 中,並以該物件回撥第 4 步 code
  9. work 程式中開始執行第 4 步的回撥函式 listenOnMasterHandle,該函式中設定了 server._handle = handle,注意這裡的 handle 即為上一步產生的 handle;並呼叫了 listen2 code
  10. listen2 即為 setupListenHandle,而 setupListenHandle 內部設定了 handle 物件的 onconnection 屬性 code

接下來 work 程式中處理請求的邏輯就都與不使用 clsuter 模組時的請求處理邏輯一致了,因為是使用的同樣的處理函式,只不過是在 work 程式中執行。

請求的處理邏輯可以小結為:

  1. master 程式進行實際的 listen 動作,並等待客戶端連線
  2. 客戶端連線由 master 程式,通過訊息派發給 work 程式
  3. work 程式中複用一般情況下的請求處理程式碼、對請求進行處理

最後看下 RoundRobinHandle 中的派發機制:

function RoundRobinHandle(key, address, port, addressType, fd, flags) {
  this.key = key;
  this.all = new Map();
  this.free = [];
  this.handles = [];

  // ...

  this.server.once('listening', () => {
    // ...
    
    // 1. 當接收到新的客戶端連線後,呼叫 this.distribute 方法
    this.handle.onconnection = (err, handle) => this.distribute(err, handle);
    
    // ...
  });
}

// master 程式接收到 work 程式的 `queryServer` 訊息後,會呼叫該方法。
// 1. 先將 work 記錄到 this.all 這個 map 中
// 2. 呼叫 this.handoff 方法,該方法導致兩個結果:
//   2.1 如果此時有 pending handle 的話,那麼即刻使用 work 處理
//   2.2 否則,則將 work 加入到 this.free 這個 map 中
RoundRobinHandle.prototype.add = function(worker, send) {
  //...
  
  this.all.set(worker.id, worker);
  
  const done = () => {
    if (this.handle.getsockname) {
      const out = {};
      this.handle.getsockname(out);
      // TODO(bnoordhuis) Check err.
      send(null, { sockname: out }, null);
    } else {
      send(null, null, null);  // UNIX socket.
    }

    this.handoff(worker);  // In case there are connections pending.
  };

  // ...
};

// 該方法的作用就是講 connection handle 進行派發
// 1. 先將 handle 加入到 pending 佇列中
// 2. 嘗試使用 this.free 的第一個 work 處理 pending 佇列。如果存在 free work 的話,
//    該 work 還將會被移出 this.free 
RoundRobinHandle.prototype.distribute = function(err, handle) {
  this.handles.push(handle);
  const worker = this.free.shift();

  if (worker)
    this.handoff(worker);
};

// 該方法包含了具體的派發動作
// 1. 從 pending handle 佇列取出第一個專案,如果佇列為空,則將 work 加入到
//    this.free map 中,否則進行派發動作
// 2. 派發是通過將 handle 經由訊息傳送給 work 程式的,即 sendHelper 部分
RoundRobinHandle.prototype.handoff = function(worker) {
  if (this.all.has(worker.id) === false) {
    return;  // Worker is closing (or has closed) the server.
  }

  const handle = this.handles.shift();

  if (handle === undefined) {
    this.free.push(worker);  // Add to ready queue again.
    return;
  }

  const message = { act: 'newconn', key: this.key };

  sendHelper(worker.process, message, handle, (reply) => {
    // 該回撥由 ./lib/internal/cluster/child.js#L180 觸發
    if (reply.accepted)
      // work 程式表示它可以處理該 handle,handle 傳送到 work 程式中時應該是
      // 使用的副本的形式。所以主程式則可以關閉屬於其上下文的 handle。handle 內部的
      // fd 被加入到 work 程式的 event loop 中
      handle.close();
    else
      // 如註釋所描述的,重新呼叫一次 this.distribute,嘗試其他的 work
      this.distribute(0, handle);  // Worker is shutting down. Send to another.

    // 再次呼叫 this.handoff
    // 如果還有 pending handle,則處理之,否則將 work 重新加入到 this.free 中
    this.handoff(worker);
  });
};
複製程式碼

排程的機制可以簡單理解為:

  1. 連線 conn 到來之後,如果有空閒的 work,則告知其處理 conn
  2. 否則將 conn 加入 pending 佇列
  3. 由於 work 的啟用是由 connection 事件觸發的,所以在 work 處理完 conn 之後,需要主動的再次消化 pending 佇列中的內容,該過程連續進行,直到當發現佇列為空時,將自身重新標記為 free,等待下一次的 connection 事件對其進行啟用

另外需要注意的是,cluster 模組中對 works 程式沒有重啟的機制,work 程式如果遇到沒有主動處理的異常就會退出,master 程式不會自動的補齊 works 數量,當所有的 works 都退出後,即不存在任一個 IPC channel 了,master 程式也將退出。

掘金自動抓取的內容不自動更新,所以之後的文章都會手動拷貝到專欄中。

相關文章