Node單機集羣實現

該文章首發於個人博客,歡迎來踩 ~ 另外,本文的 代碼 demo 連接,能夠盡情 fork 提 PR😂。html

文章開頭,先給你們拋出一個問題。前端

用過 Node 的人都知道,Node 採用的是相似 Nginx 單進程、異步IO 的運行模型,這也是 Node 性能強勁的根源。咱們可能也常常聽人說 js 的執行是單進程、單線程的,那麼,若是換個說法,若說 Node 是單進程、單線程 的,是對的嗎?node

下面咱們來驗證一下。git

咱們來執行一個最簡單的 Node 程序。它只作一件事,就是不停接受標準輸入流並丟棄,這樣保證進程一直存在github

process.stdin.resume();
複製代碼

啓動後,咱們使用 ps -ef | grep node 命令找到該進程的 pid,並使用 top 命令查看該進程的線程數會打印出以下信息shell

top output

這裏就不在贅述 top 命令的用法了,感興趣的同窗能夠自行 google 😁。這裏框出來的部分就是進程中的線程數,能夠看到,並非 1,而是 7。由此咱們就有了上一個問題的結論。api

Node 是單進程,但不是單線程的數組

那咱們常說的 js 是單線程的又是怎麼回事呢?帶着問題,咱們來看一下 Node 的架構圖:網絡

Node

  • Node Standard library 就是咱們經常使用的 Node 核心模塊,如 fs、path、http 等等多線程

  • Node Bindings 是溝通JS 和 C++的橋樑,封裝V8和Libuv的細節,向上層提供基礎API服務

  • 最底層也是支撐 Node 的最核心的部分

    • V8 是Google開發的JavaScript引擎,提供JavaScript運行環境,能夠說它就是 Node.js 的發動機

    • Libuv 是專門爲Node.js開發的一個封裝庫,提供跨平臺的異步I/O能力

    • C-ares:提供了異步處理 DNS 相關的能力

    • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、數據壓縮等其餘的能力

要解釋爲何上圖會有 7 個線程,關鍵在於 libuv 這個庫。

libuv 是一個跨平臺的異步 IO 庫,實現了網絡請求、文件 IO、子進程、線程池等功能。

能夠發現,libuv 中是有線程池的,能夠推斷出那 7 個線程極可能就是 libuv 所建立的。具體緣由因爲篇幅有限,再加上這也不是本文的重點,就不贅述了。

感興趣的同窗能夠這樣啓動 Node,set UV_THREADPOOL_SIZE=100 && node your-node.js,並執行須要依賴 thread pool 的方法,如 fs.readFile,會發現線程數變多了。

綜上所述,咱們能夠獲得結論,Node 默認是單進程多線程的,而 js 執行是單線程的

索引

本文我將按照以下順序介紹如何利用 cluster 模塊建立一個單機集羣,以及 cluster 實現的基本原理。可以讓你們對 Node 的進程、進程間通訊機制有一個全面的瞭解

  1. Node 中的進程

  2. cluster 模塊使用

  3. cluster 模塊基本原理

因爲筆者仍是個渣渣,還有不少地方不理解,也可能存在描述不許確的地方,還請見諒。本文的 代碼 demo 連接,裏面還有一些問題待研究,都已用 TODO: 標註出來,若有大神瞭解,還請提 PR,在此提早感謝!!!

Node 中的進程

要實現一個單機集羣,首先就是要有建立子進程的能力。Node 默認是單進程運行的,但也能夠建立子進程從而利用多核 CPU 的能力。

Node 中建立子進程依賴的模塊是 child_process,方法主要有以下四個:

  • spawn(command[,args][,options]):核心方法,剩餘三個方法底層都依賴它

  • exec(command[,options][,callback]):衍生一個 shell 執行一個系統命令,與spawn不一樣的是它會有一個回調函數參數能夠獲知子進程的錯誤、標準輸出等

  • execFile(file[, args][, options][, callback]):衍生一個子進程執行一個可執行文件

  • fork(modulePath[,args][,options])forkspawn 的變體,專門用於衍生一個 node 進程,最大的特色是父子進程自帶通訊機制(IPC管道)

如上四個方法中,spwan 方法是核心,理解了它的用法,剩餘三個就很好學習了。

它存在幾個重要的 options,以下:

  • shell:默認 spawn 是不會在一個新的 shell 中執行的,若要開啓,可將該配置設置爲 true,或字符串指定 shell 的名稱。從而支持執行命令徹底是 shell 中的語法。詳見官方文檔

  • stdio:選項用於配置子進程與父進程之間創建的管道,詳見官方文檔

  • detached

    • 默認狀況下,父進程退出,子進程也會一併退出。當設置了該選項爲 true 時,子進程會獨立於父進程,即父進程退出子進程不會退出

    • 默認狀況下,父進程等待全部子進程退出後自動退出。若但願父進程能夠獨立於子進程退出,則能夠調用 childProcess.unref() 方法,斷開與子進程的關聯

以上 stdio、unref 兩個選項是實現單機集羣的關鍵選項,在下文也會用到。

進程間如何通訊?

要想實現多進程架構,進程間通訊能力是必不可少的。Node 中進程間通訊的方式有不少種,經常使用的以下:

  • IPC:Node 內置的進程間通訊方式,經過創建子進程時的 stdio 選項打開

    • 限制
      1. 須要拿到進程的 handle,好比 process 對象,所以徹底獨立的兩個進程沒法使用這種方式
  • stdio:此 stdio 非彼 stdio,只是一個代稱,表示經過進程的 stdin、stdout、stderr 來通訊

    • 限制
      1. 同上限制 1

      2. 只能傳遞 StringBuffer

  • socket:進程間通訊經常使用的一種手段。Node 中 net 模塊提供了經過 socket 通訊的功能

    • 優點:能夠方便地跨進程通訊,無需拿到進程的 handle

    • 限制:須要建立 socket 文件

本文將重點介紹 IPC 這種方式,這也是 Node 中最經常使用的方式,其餘通訊方式在 代碼 demo 中均可以找到。

  • 打開方式spawnstdio 選項傳入數組,並帶上 'ipc',如 ['ipc'],還能夠是 [0, 1, 2, 'ipc'],表示將子進程的 stdin、stdout、stderr 都繼承主進程的,並開啓 IPC 管道,詳見官方文檔

    // 代碼示例
    const cp = child_process.spawn('node', [你的文件路徑], {
        stdio: [0, 1, 2, 'ipc']
    });
    // 或
    const cp = child_process.fork(你的文件路徑);
    複製代碼

    fork 方法建立的子進程是默認就帶 IPC 管道的。

  • 使用方法

    • 主進程:在主進程中能夠拿到子進程的句柄,如上例就是 cp,經過 send 方法便可向其發送消息了。子進程經過 on('message') 事件監聽便可。

    • 子進程:子進程中經過 process 對象便可拿到主進程的句柄,使用方式與主進程同樣。

      /* 主進程 */
      const cp = spawn('node', [resolve(__dirname, './child.js')], {
          // 繼承父進程的 stdin、stdout、stderr,同時創建 IPC 通道
          stdio: [0, 1, 2, 'ipc']
      });
      
      // 將輸入發送給子進程
      process.stdin.on('data', (d) => {
          // 判斷 IPC 管道是否鏈接
          if (cp.connected) {
              cp.send(d.toString());
          }
      });
      
      cp.on('message', (data) => {
          log('父進程收到數據');
          log(data.toString());
      });
      
      cp.on('disconnect', () => {
          log('好的,再見兒子');
      });
      
      /* 子進程 */
      process.on('message', (data) => {
          process.send('子進程收到數據');
          // 若子進程沒有繼承父進程的 stdin、stdout、stderr,則該行沒有任何輸出
          process.stdout.write(data);
      });
      複製代碼

      本代碼示例在 process/ipc/ipc

使用 cluster 模塊建立集羣

終於到重點了。默認 Node 程序是跑在單個進程中,js 又是執行在單個線程中的,所以沒法利用多核 CPU 的並行能力。但 Node 也提供了 cluster 模塊用於方便地建立多個進程的單機集羣。

Node 單機集羣的核心思想是 「主從模式(Master-Worker)」,即 主進程負責分發工做給工做進程,工做進程負責完成交付的任務

以 Web Server 爲例,就是主進程負責監聽端口,並將每次到來的請求分發給工做進程去進行業務邏輯的處理。

先貼官方文檔

cluster 的經常使用 API 有以下幾個:

  • isMaster/isWorker:用於判斷當前進程是主進程仍是工做進程

  • setupMaster([settings]):cluster 內部經過 fork 建立子進程,該方法用於設置 fork 方法的默認配置,惟一沒法設置的是 fork 參數中的 env 屬性

  • fork(filepath?):建立工做進程

  • worker:當處在工做進程中,經過該字段獲取當前 Worker 實例的相關信息,包括 processid 等,更多字段參見文檔

  • cluster.schedulingPolicy:設置調度策略。這是一個全局設置,當第一個工做進程被衍生或者調用 cluster.setupMaster() 時,都將第一時間生效。cluster 中有以下兩種調度策略

    • cluster.SCHED_RR:即 round-robin,循環策略,即每一個工做進程按順序接收請求

    • cluster.SCHED_NONE:搶佔策略。即由系統自行決定該由哪一個工做進程來處理請求

下面來實現一個簡單的單機集羣。

/* 主進程 */
cluster.schedulingPolicy = cluster.SCHED_NONE;
cluster.setupMaster({
    exec: resolve(__dirname, './worker.js'),
});

for (let i = 0; i < os.cpus().length; i++) {
    cluster.fork();
}

/* 工做進程 */
http.createServer((req, res) => {
    console.log(worker.process.pid + ' 響應請求');
    res.end('hello');
}).listen(5000, () => {
    console.log('process %s started', worker.process.pid);
});
複製代碼

本示例代碼在 cluster/basic

這樣就實現了一個簡單的單機集羣,能夠經過 ab -n 10 -c 5 http://127.0.0.1:5000/ 命令去測試一下效果。

不出意外的話,server 的輸出應該以下圖:

server log

能夠看到分發給每一個工做進程的請求基本是平均的,你們能夠嘗試更換一下調度策略,再看看 👀~

可是目前咱們的集羣尚未任何錯誤處理能力,若其中一個工做進程出錯掛掉了怎麼辦?這樣工做進程就愈來愈少了。

要解決這個問題,只需在上例主進程代碼中加上簡單幾行便可。

cluster.on('exit', (worker, code, signal) => {
    console.log(`工做進程 ${worker.process.pid} 已退出`);
    const newWorker = cluster.fork();
    console.log(`已重啓工做進程,pid:${newWorker.process.pid}`);
});
複製代碼

本示例代碼在 cluster/refork

如上,經過 cluster.on('exit') 事件監聽子進程退出,自動重啓一個新的工做進程。這樣就能夠從容應對工做進程出錯的狀況。

如今咱們的集羣已經比較穩定了,但啓動還不太優雅。由於它只能在 shell 中啓動,至關於 shell 的一個子進程,當你退出 shell 後 shell 會將它所建立的子進程回收,咱們的服務就被幹掉了。

咱們須要一個讓服務後臺運行的方法。

還記得上面提到的 ChildProcess.unref 方法麼?這個方法是實現該功能的關鍵。

默認狀況下,父進程等待全部子進程退出後自動退出。若但願父進程能夠獨立於子進程(即子進程都退出後父進程依舊運行或者父進程無需等待子進程都退出便可退出),則能夠調用該方法,斷開與子進程的關聯,便可調用這個方法。

該方法有幾個注意事項:

  1. 若父子進程間存在通訊管道,則該選項無效,如 stdio: 'pipe'。必須將 stdio 設置爲 'ignore' 或將子進程標準輸入、輸出重定向到其餘地方(與父進程無關)才行

  2. 若啓用了它,則主進程默認會在執行完成後直接退出,但子進程不會退出,並被提高爲 init 進程的子進程(Mac 下是 launchd),即 ppid 爲 1

  3. 用 fork 實現不了 unref

下面來動手實現吧~

咱們只須要新建一個啓動腳本,它所作的就是接受命令啓動服務終止服務

實現原理就是經過上面描述的 unref 方法斷開與腳本進程的聯繫,讓它提高爲一個後臺進程,並把服務的進程 id 保存爲一個 pid 文件,用於在傳入 stop 子命令時 kill 調服務進程。

使用 detached 屬性也能夠達到相同效果,讓主進程退出後子進程依然存在,但相比 unref,使用 detached 還須要手動將主進程 kill 掉,不然默認主進程會等待全部子進程退出。

const pidFile = __dirname + '/pid';
// 若進程子命令是 stop,則 kill
if (process.argv[2] === 'stop') {
    const pid = fs.readFileSync(pidFile, 'utf8');

    if (!process.kill(pid, 0)) {
        console.log(`進程 ${pid} 不存在!`);
        return;
    }
    
    process.kill(Number(pid));
    fs.unlinkSync(pidFile);
}
else {
    const cp = spawn('node', [resolve(__dirname, './main.js')], {
        stdio: 'ignore'
    });
    // 記錄主進程 pid
    fs.writeFileSync(pidFile, cp.pid);
    // 刪除當前進程的引用計數,取消該進程與它子進程的關聯
    cp.unref();
}
複製代碼

本示例代碼在 cluster/background/index.js

這樣,咱們就能夠經過 node cluster/background/index.js 來啓動服務,並經過 cluster/background/index.js stop 終止服務啦~若想更方便地調用該命令,還能夠將該腳本改爲一個 shell 腳本,在文件頂部添加一個解析器註釋便可,如 #!/usr/bin/env node

至此,咱們已經完成了一個簡單、相對穩定的單機集羣,並能經過命令方便地啓動、關閉。

不過總的來講,咱們的集羣還遠遠不能用於生產環境,node 的 cluster 模塊實現的單機集羣仍是太粗糙,我的建議用 pm2 這樣功能全面、穩定,而且無需修改任何業務代碼的工具更好~

cluster 模塊基本原理

因爲筆者能力有限,目前尚未徹底看懂 cluster 模塊所有代碼,這裏只把明白的介紹一下,以後應該會再仔細研究一下,寫一篇 cluster 原理的文章😓。

  1. 如何實現 isMaster/isWorker?

    • 經過環境變量判斷當前進程是主進程仍是子進程,fork 子進程時 node 內部會給子進程添加一個特殊的環境變量
  2. 工做進程如何建立?

    • 工做進程由 child_process.fork 方法建立,所以它們能夠直接使用 IPC 和父進程通訊
  3. 請求如何處理?

    • 只由主進程監聽端口,將請求經過 IPC 管道分發給子進程,由子進程去處理

    • 子進程只啓動服務,不會真正監聽端口。由於內部 listen 方法被 fake 成一個直接返回 0 的空方法,所以不會去真正監聽端口

  4. 接問題 3,主進程的服務是在什麼時候建立的呢?

    • 主進程的 server 啓動實現是在子進程調用 listen 方法中開始的。子進程中如有調用 listen,則觸發主進程去建立 server 或獲取已建立的 server 句柄,建立時會把子進程啓動 server 的參數傳給主進程(好比端口、host 等)
  5. 接問題 3,主進程如何分發請求給工做進程?

    • 如上所屬,進程間可經過 IPC 管道通訊,即便用 process.send 方法向子進程發送消息。該方法還有個重要功能就是可以發送句柄,如 net.Servernet.Socket 等等,所以可以將主進程的 net.Server 實例直接發送給工做進程處理。

能看到這裏證實你是個熱愛技術的優秀程序猿,請不要猶豫,當即加入咱們!

字節跳動長期招前端,實習生 hc 不限,社招 hc 多多,快把你的簡歷發送到這個郵箱 => yuanye.markey@bytedance.com,並在郵件名稱中註明來自掘金。不要猶豫,就如今🚀!!!

相關文章
相關標籤/搜索