系列3|走進Node.js之多程式模型

iKcamp發表於2019-02-16

文:正龍(滬江網校Web前端工程師)

本文原創,轉載請註明作者及出處

之前的文章“走進Node.js之HTTP實現分析”中,大家已經瞭解 Node.js 是如何處理 HTTP 請求的,在整個處理過程,它僅僅用到單程式模型。那麼如何讓 Web 應用擴充套件到多程式模型,以便充分利用CPU資源呢?答案就是 Cluster。本篇文章將帶著大家一起分析Node.js的多程式模型。

首先,來一段經典的 Node.js 主從服務模型程式碼:

const cluster = require(`cluster`);
const numCPUs = require(`os`).cpus().length;

if (cluster.isMaster) {
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  require(`http`).createServer((req, res) => {
    res.end(`hello world`);
  }).listen(3333);
}

通常,主從模型包含一個主程式(master)和多個從程式(worker),主程式負責接收連線請求,以及把單個的請求任務分發給從程式處理;從程式的職責就是不斷響應客戶端請求,直至進入等待狀態。如圖 3-1 所示:

圍繞這段程式碼,本文希望講述清楚幾個關鍵問題:

  1. 從程式的建立過程;
  2. 在使用同一主機地址的前提下,如果指定埠已經被監聽,其它程式嘗試監聽同一埠時本應該會報錯(EADDRINUSE,即埠已被佔用);那麼,Node.js 如何能夠在主從程式上對同一埠執行 listen 方法?

程式 fork 是如何完成的?

在 Node.js 中,cluster.fork 與 POSIX 的 fork 略有不同:雖然從程式仍舊是 fork 建立,但是並不會直接使用主程式的程式映像,而是呼叫系統函式 execvp 讓從程式使用新的程式映像。另外,每個從程式對應一個 Worker 物件,它有如下狀態:none、online、listening、dead和disconnected。

ChildProcess 物件主要提供程式的建立(spawn)、銷燬(kill)以及程式控制程式碼引用計數管理(ref 與 unref)。在對Process物件(process_wrap.cc)進行封裝之外,它自身也處理了一些細節問題。例如,在方法 spawn 中,如果需要主從程式之間建立 IPC 管道,則通過環境變數 NODE_CHANNEL_FD 來告知從程式應該繫結的 IPC 相關的檔案描述符(fd),這個特殊的環境變數後面會被再次涉及到。

以上提到的三個物件引用關係如下:

cluster.fork 的主要執行流程:

  1. 呼叫 child_process.spawn;
  2. 建立 ChildProcess 物件,並初始化其 _handle 屬性為 Process 物件;Process 是 process_wrap.cc 中公佈給 JavaScript 的物件,它封裝了 libuv 的程式操縱功能。附上 Process 物件的 C++ 定義:

    interface Process {
      construtor(const FunctionCallbackInfo<Value>& args);
      void close(const FunctionCallbackInfo<Value>& args);
      void spawn(const FunctionCallbackInfo<Value>& args);
      void kill(const FunctionCallbackInfo<Value>& args);
      void ref(const FunctionCallbackInfo<Value>& args);
      void unref(const FunctionCallbackInfo<Value>& args);
      void hasRef(const FunctionCallbackInfo<Value>& args);
    }
  3. 呼叫 ChildProcess._handle 的方法 spawn,並會最終呼叫 libuv 庫中 uv_spawn

主程式在執行 cluster.fork 時,會指定兩個特殊的環境變數 NODE_CHANNEL_FD 和 NODE_UNIQUE_ID,所以從程式的初始化過程跟一般 Node.js 程式略有不同:

  1. bootstrap_node.js 是執行時包含的 JavaScript 入口檔案,其中呼叫 internalprocess.setupChannel;
  2. 如果環境變數包含 NODE_CHANNEL_FD,則呼叫 child_process._forkChild,然後移除該值;
  3. 呼叫 internalchild_process.setupChannel,在子程式的全域性 process 物件上監聽訊息 internalMessage,並且新增方法 send 和 _send。其中 send 只是對 _send 的封裝;通常,_send 只是把訊息 JSON 序列化之後寫入管道,並最終投遞到接收端。
  4. 如果環境變數包含 NODE_UNIQUE_ID,則當前程式是 worker 模式,載入 cluster 模組時會執行 workerInit;另外,它也會影響到 net.Server 的 listen 方法,worker 模式下 listen 方法會呼叫 cluster._getServer,該方法實質上向主程式發起訊息 {“act” : “queryServer”},而不是真正監聽埠。

IPC實現細節

上文提到了 Node.js 主從程式僅僅通過 IPC 維持聯絡,那這一節就來深入分析下 IPC 的實現細節。首先,讓我們看一段示例程式碼:

1-master.js

const {spawn} = require(`child_process`);
let child = spawn(process.execPath, [`${__dirname}/1-slave.js`], {
  stdio: [0, 1, 2, `ipc`]
});

child.on(`message`, function(data) {
  console.log(`received in master:`);
  console.log(data);
});

child.send({
  msg: `msg from master`
});

1-slave.js

process.on(`message`, function(data) {
  console.log(`received in slave:`);
  console.log(data);
});
process.send({
  `msg`: `message from slave`
});
node 1-master.js

執行結果如下:

細心的同學可能發現控制檯輸出並不是連續的,master和slave的日誌交錯列印,這是由於並行程式執行順序不可預知造成的。

socketpair

前文提到從程式實際上通過系統呼叫 execvp 啟動新的 Node.js 例項;也就是說預設情況下,Node.js 主從程式不會共享檔案描述符表,那它們到底是如何互發訊息的呢?

原來,可以利用 socketpair 建立一對全雙工匿名 socket,用於在程式間互發訊息;其函式簽名如下:

int socketpair(int domain, int type, int protocol, int sv[2]);

通常情況下,我們是無法通過 socket 來傳遞檔案描述符的;當主程式與客戶端建立了連線,需要把連線描述符告知從程式處理,怎麼辦?其實,通過指定 socketpair 的第一個引數為 AF_UNIX,表示建立匿名 UNIX 域套接字(UNIX domain socket),這樣就可以使用系統函式 sendmsgrecvmsg 來傳遞/接收檔案描述符了。

主程式在呼叫 cluster.fork 時,相關流程如下:

  1. 建立 Pipe(pipe_wrap.cc)物件,並且指定引數 ipc 為 true;
  2. 呼叫 uv_spawn,options 引數為 uv_process_options_s 結構體,把 Pipe 物件儲存在結構體的屬性 stdio 中;
  3. 呼叫 uv__process_init_stdio,通過 socketpair 建立全雙工 socket;
  4. 呼叫 uv__process_open_stream,設定 Pipe 物件的 iowatcher.fd 值為全雙工 socket 之一。

至此,主從程式就可以進行雙向通訊了。流程圖如下:

我們再回看一下環境變數 NODE_CHANNEL_FD,令人疑惑的是,它的值始終為3。程式級檔案描述符表中,0-2分別是標準輸入stdin、標準輸出stdout和標準錯誤輸出stderr,那麼可用的第一個檔案描述符就是3,socketpair 顯然會佔用從程式的第一個可用檔案描述符。這樣,當從程式往 fd=3 的流中寫入資料時,主程式就可以收到訊息;反之,亦類似。

從 IPC 讀取訊息主要是流操作,以後有機會詳解,下面列出主要流程:

  1. StreamBase::EditData 回撥 onread;
  2. StreamWrap::OnReadImpl 呼叫 StreamWrap::EditData;
  3. StreamWrap 的建構函式會呼叫 set_read_cb 設定 OnReadImpl;
  4. StreamWrap::set_read_cb 設定屬性 StreamWrap::read_cb_;
  5. StreamWrap::OnRead 中引用屬性 read_cb_;
  6. StreamWrap::ReadStart 呼叫 uv_read_start 時傳遞 Streamwrap::OnRead 作為第3個引數:
int uv_read_start(uv_stream_t* stream, uv_alloc_cb alloc_cb, uv_read_cb read_cb)

涉及到的類圖關係如下:

伺服器主從模型

以上大概分析了從程式的建立過程及其特殊性;如果要實現主從服務模型的話,還需要解決一個基本問題:從程式怎麼獲取到與客戶端間的連線描述符?我們打算從 process.send(只有在從程式的全域性 process 物件上才有 send 方法,主程式可以通過 worker.process 或 worker 訪問該方法)的函式簽名著手:

void send(message, sendHandle, callback)

其引數 message 和 callback 含義也許顯而易見,分別指待傳送的訊息物件和操作結束之後的回撥函式。那它的第二個引數 sendHandle 用途是什麼?

前文提到系統函式 socketpair 可以建立一對雙向 socket,能夠用來傳送 JSON 訊息,這一塊主要涉及到流操作;另外,當 sendHandle 有值時,它們還可以用於傳遞檔案描述符,其過程要相對複雜一些,但是最終會呼叫系統函式 sendmsg 以及 recvmsg。

傳遞與客戶端的連線描述符

在主從服務模型下,主程式負責跟客戶端建立連線,然後把連線描述符通過 sendmsg 傳遞給從程式。我們來看看這一過程:

從程式

  1. 呼叫 http.Server.listen 方法(繼承至 net.Server);
  2. 呼叫 cluster._getServer,向主程式發起訊息:

    {
      "cmd": "NODE_HANDLE",
      "msg": {
        "act": "queryServer"
      }
    }

主程式

  1. 接收處理這個訊息時,會新建一個 RoundRobinHandle 物件,為變數 handle。每個 handle 與一個連線端點對應,並且對應多個從程式例項;同時,它會開啟與連線端點相應的 TCP 服務 socket。

    class RoundRobinHandle {
      construtor(key, address, port, addressType, fd) {
        // 監聽同一端點的從程式集合
        this.all = [];
    
        // 可用的從程式集合
        this.free = [];
    
        // 當前等待處理的客戶端連線描述符集合
        this.handles = [];
    
        // 指定端點的TCP服務socket
        this.server = null;
      }
      add(worker, send) {
        // 把從程式例項加入this.all
      }
      remove(worker) {
        // 移除指定從程式
      }
      distribute(err, handle) {
        // 把連線描述符handle存入this.handles,並指派一個可用的從程式例項開始處理連線請求
      }
      handoff(worker) {
        // 從this.handles中取出一個待處理的連線描述符,並向從程式發起訊息
        // {
        //  "type": "NODE_HANDLE",
        //  "msg": {
        //    "act": "newconn",
        //  }
        // }
      }
    }
  2. 呼叫 handle.add 方法,把 worker 物件新增到 handle.all 集合中;
  3. 當 handle.server 開始監聽客戶端請求之後,重置其 onconnection 回撥函式為 RoundRobinHandle.distribute,這樣的話主程式就不用實際處理客戶端連線,只要分發連線給從程式處理即可。它會把連線描述符存入 handle.handles 集合,當有可用 worker 時,則向其傳送訊息 { “act”: “newconn” }。如果被指派的 worker 沒有回覆確認訊息 { “ack”: message.seq, accepted: true },則會嘗試把該連線分配給其他 worker。

流程圖如下:

從程式上呼叫listen

客戶端連線處理

從程式如何與主程式監聽同一埠?

原因主要有兩點:

I. 從程式中 Node.js 執行時的初始化略有不同

  1. 因為從程式存在環境變數 NODE_UNIQUE_ID,所以在 bootstrap_node.js 中,載入 cluster 模組時執行 workerInit 方法。這個地方與主程式執行的 masterInit 方法不同點在於:其一,從程式上沒有 cluster.fork 方法,所以不能在從程式繼續建立子孫程式;其二,Worker 物件上的方法 disconnect 和 destroy 實現也有所差異:我們以呼叫 worker.destroy 為例,在主程式上時,不能直接把從程式殺掉,而是通知從程式退出,然後再把它從集合裡刪除;當在從程式上時,從程式通知完主程式然後退出就可以了;其三,從程式上 cluster 模組新增了方法 _getServer,用於向主程式發起訊息 {“act”: “queryServer”},通知主程式建立 RoundRobinHandle 物件,並實際監聽指定埠地址;然後自身用一個模擬的 TCP 描述符繼續執行;
  2. 呼叫 cluster._setupWorker 方法,主要是初始化 cluster.worker 屬性,並監聽訊息 internalMessage,處理兩種訊息型別:newconn 和 disconnect;
  3. 向主程式發起訊息 { “act”: “online” };
  4. 因為從程式額環境變數中有 NODE_CHANNEL_FD,呼叫 internalprocess.setupChannel時,會連線到系統函式 socketpair 建立的雙向 socket ,並監聽 internalMessage ,處理訊息型別:NODE_HANDLE_ACK和NODE_HANDLE。

II. listen 方法在主從程式中執行的程式碼略有不同。

在 net.Server(net.js)的方法 listen 中,如果是主程式,則執行標準的埠繫結流程;如果是從程式,則會呼叫 cluster._getServer,參見上面對該方法的描述。

最後,附上基於libuv實現的一個 C 版 Master-Slave 服務模型,GitHub地址

啟動伺服器之後,訪問 http://localhost:3333 的執行結果如下:

相信通過本篇文章的介紹,大家已經對Node.js的Cluster有了一個全面的瞭解。下一次作者會跟大家一起深入分析Node.js程式管理在生產環境下的可用性問題,敬請期待。

相關文章

系列1|走進Node.js之啟動過程剖析

系列2|走進Node.js 之 HTTP實現分析

推薦: 翻譯專案Master的自述:

1. 乾貨|人人都是翻譯專案的Master

2. iKcamp出品微信小程式教學共5章16小節彙總(含視訊)

3. 開始免費連載啦~每週2更共11堂iKcamp課|基於Koa2搭建Node.js實戰專案教學(含視訊)| 課程大綱介紹

相關文章