NodeJS 多進程和集羣

在這裏插入圖片描述

原文出自:https://www.pandashen.com

進程和線程

“進程” 是計算機系統進行資源分配和調度的基本單位,我們可以理解爲計算機每開啓一個任務就會創建至少一個進程來處理,有時會創建多個,如 Chrome 瀏覽器的選項卡,其目的是爲了防止一個進程掛掉而應用停止工作,而 “線程” 是程序執行流的最小單元,NodeJS 默認是單進程、單線程的,我們將這個進程稱爲主進程,也可以通過 child_process 模塊創建子進程實現多進程,我們稱這些子進程爲 “工作進程”,並且歸主進程管理,進程之間默認是不能通信的,且所有子進程執行任務都是異步的。

spawn 實現多進程

1、spawn 創建子進程

在 NodeJS 中執行一個 JS 文件,如果想在這個文件中再同時(異步)執行另一個 JS 文件,可以使用 child_process 模塊中的 spawn 來實現,spawn 可以幫助我們創建一個子進程,用法如下。

// 文件:process.js
const { spawn } = require("child_process");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js", "--port", "3000"], {
    cwd: path.join(__dirname, "test") // 指定子進程的當前工作目錄
});

// 出現錯誤觸發
child.on("error", err => console.log(err));

// 子進程退出觸發
child.on("exit", () => console.log("exit"));

// 子進程關閉觸發
child.on("close", () => console.log("close"));

// exit
// close

spawn 方法可以幫助我們創建一個子進程,這個子進程就是方法的返回值,spawn 接收以下幾個參數:

  • command:要運行的命令;
  • args:類型爲數組,數組內第一項爲文件名,後面項依次爲執行文件的命令參數和值;
  • options:選項,類型爲對象,用於指定子進程的當前工作目錄和主進程、子進程的通信規則等,具體可查看 官方文檔

error 事件在子進程出錯時觸發,exit 事件在子進程退出時觸發,close 事件在子進程關閉後觸發,在子進程任務結束後 exit 一定會觸發,close 不一定觸發。

// 文件:~test/sub_process.js
// 打印子進程執行 sub_process.js 文件的參數
console.log(process.argv);

通過上面代碼打印了子進程執行時的參數,但是我們發現主進程窗口並沒有打印,我們希望的是子進程的信息可以反饋給主進程,要實現通信需要在創建子進程時在第三個參數 options 中配置 stdio 屬性定義。

2、spawn 定義輸入、輸出

// 文件:process.js
const { spawn } = require("child_process");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js", "--port", "3000"], {
    cwd: path.join(__dirname, "test") // 指定子進程的當前工作目錄
    // stdin: [process.stdin, process.stdout, process.stderr]
    stdio: [0, 1, 2] // 配置標準輸入、標準輸出、錯誤輸出
});

// C:\Program Files\nodejs\node.exe,g:\process\test\sub_process.js,--port,3000
// 文件:~test/sub_process.js
// 使用主進程的標準輸出,輸出 sub_process.js 文件執行的參數
process.stdout.write(process.argv.toString());

通過上面配置 optionsstdio 值爲數組,上面的兩種寫法作用相同,都表示子進程和主進程共用了主進程的標準輸入、標準輸出、和錯誤輸出,實際上並沒有實現主進程與子進程的通信,其中 0stdin 代表標準輸入,1stdout 代表標準輸出,2stderr 代表錯誤輸出。

上面這樣的方式只要子進程執行 sub_process.js 就會在窗口輸出,如果我們希望是否輸出在主進程裏面控制,即實現子進程與主進程的通信,看下面用法。

// 文件:process.js
const { spawn } = require("child_process");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js"], {
    cwd: path.join(__dirname, "test"),
    stdio: ["pipe"]
});

child.stdout.on("data", data => console.log(data.toString()));

// hello world
// 文件:~test/sub_process.js
// 子進程執行 sub_process.js
process.stdout.write("hello world");

上面將 stdio 內數組的值配置爲 pipe(默認不寫就是 pipe),則通過流的方式實現主進程和子進程的通信,通過子進程的標準輸出(可寫流)寫入,在主進程通過子進程的標準輸出通過 data 事件讀取的流在輸出到窗口(這種寫法很少用),上面都只在主進程中開啓了一個子進程,下面舉一個開啓多個進程的例子。

例子的場景是主進程開啓兩個子進程,先運行子進程 1 傳遞一些參數,子進程 1 將參數取出返還給主進程,主進程再把參數傳遞給子進程 2,通過子進程 2 將參數寫入到文件 param.txt 中,這個過程不代表真實應用場景,主要目的是體會主進程和子進程的通信過程。

// 文件:process.js
const { spawn } = require("child_process");
const path = require("path");

// 創建子進程
let child1 = spawn("node", ["sub_process_1.js", "--port", "3000"], {
    cwd: path.join(__dirname, "test"),
});

let child2 = spawn("node", ["sub_process_2.js"], {
    cwd: path.join(__dirname, "test"),
});


// 讀取子進程 1 寫入的內容,寫入子進程 2
child1.stdout.on("data", data => child2.stdout.write(data.toString));
// 文件:~test/sub_process_1.js
// 獲取 --port 和 3000
process.argv.slice(2).forEach(item => process.stdout.write(item));
// 文件:~test/sub_process_2.js
const fs = require("fs");

// 讀取主進程傳遞的參數並寫入文件
process.stdout.on("data", data => {
    fs.writeFile("param.txt", data, () => {
        process.exit();
    });
});

有一點需要注意,在子進程 2 寫入文件的時候,由於主進程不知道子進程 2 什麼時候寫完,所以主進程會卡住,需要子進程在寫入完成後調用 process.exit 方法退出子進程,子進程退出並關閉後,主進程會隨之關閉。

在我們給 options 配置 stdio 時,數組內其實可以對標準輸入、標準輸出和錯誤輸出分開配置,默認數組內爲 pipe 時代表三者都爲 pipe,分別配置看下面案例。

// 文件:process.js
const { spawn } = require("spawn");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js"], {
    cwd: path.join(__dirname, "test"),
    stdio: [0, "pipe", 2]
});

// world
// 文件:~test/sub_process.js
console.log("hello");
console.error("world");

上面代碼中對 stderr 實現了默認打印而不通信,對標準輸入實現了通信,還有一種情況,如果希望子進程只是默默的執行任務,而在主進程命令窗口什麼類型的輸出都禁止,可以在數組中對應位置給定值 ignore,將上面案例修改如下。

// 文件:process.js
const { spawn } = require("spawn");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js"], {
    cwd: path.join(__dirname, "test"),
    stdio: [0, "pipe", "ignore"]
});
// 文件:~test/sub_process.js
console.log("hello");
console.error("world");

這次我們發現無論標準輸出和錯誤輸出都沒有生效,上面這些方式其實是不太方便的,因爲輸出有 stdoutstderr,在寫法上沒辦法統一,可以通過下面的方式來統一。

3、標準進程通信

// 文件:process.js
const { spawn } = require("spawn");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js"], {
    cwd: path.join(__dirname, "test"),
    stdio: [0, "pipe", "ignore", "ipc"]
});

child.on("message", data => {
    console.log(data);

    // 回覆消息給子進程
    child.send("world");

    // 殺死子進程
    // process.kill(child.pid);
});

// hello
// 文件:~test/sub_process.js
// 給主進程發送消息
process.send("hello");

// 接收主進程回覆的消息
process.on("message", data => {
    console.log(data);

    // 退出子進程
    process.exit();
});

// world

這種方式被稱爲標準進程通信,通過給 optionsstdio 數組配置 ipc,只要數組中存在 ipc 即可,一般放在數組開頭或結尾,配置 ipc 後子進程通過調用自己的 send 方法發送消息給主進程,主進程中用子進程的 message 事件進行接收,也可以在主進程中接收消息的 message 事件的回調當中,通過子進程的 send 回覆消息,並在子進程中用 message 事件進行接收,這樣的編程方式比較統一,更貼近於開發者的意願。

4、退出和殺死子進程

上面代碼中子進程在接收到主進程的消息時直接退出,也可以在子進程發送給消息給主進程時,主進程接收到消息直接殺死子進程,代碼如下。

// 文件:process.js
const { spawn } = require("spawn");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js"], {
    cwd: path.join(__dirname, "test"),
    stdio: [0, "pipe", "ignore", "ipc"]
});

child.on("message", data => {
    console.log(data);

    // 殺死子進程
    process.kill(child.pid);
});

// hello world
// 文件:~test/sub_process.js
// 給主進程發送消息
process.send("hello");

從上面代碼我們可以看出,殺死子進程的方法爲 process.kill,由於一個主進程可能有多個子進程,所以指定要殺死的子進程需要傳入子進程的 pid 屬性作爲 process.kill 的參數。

{% note warning %}
注意:退出子進程 process.exit 方法是在子進程中操作的,此時 process 代表子進程,殺死子進程 process.kill 是在主進程中操作的,此時 process 代表主進程。
{% endnote %}

5、獨立子進程

我們前面說過,child_process 模塊創建的子進程是被主進程統一管理的,如果主進程掛了,所有的子進程也會受到影響一起掛掉,但其實使用多進程一方面爲了提高處理任務的效率,另一方面也是爲了當一個進程掛掉時還有其他進程可以繼續工作,不至於整個應用掛掉,這樣的例子非常多,比如 Chrome 瀏覽器的選項卡,比如 VSCode 編輯器運行時都會同時開啓多個進程同時處理任務,其實在 spawn 創建子進程時,也可以實現子進程的獨立,即子進程不再受主進程的控制和影響。

// 文件:process.js
const { spawn } = require("spawn");
const path = require("path");

// 創建子進程
let child = spawn("node", ["sub_process.js"], {
    cwd: path.join(__dirname, "test"),
    stdio: "ignore",
    detached: true
});

// 與主進程斷絕關係
child.unref();
// 文件:~test/sub_process.js
const fs = require("fs");

setInterval(() => {
    fs.appendFileSync("test.txt", "hello");
});

要想創建的子進程獨立,需要在創建子進程時配置 detached 參數爲 true,表示該子進程不受控制,還需調用子進程的 unref 方法與主進程斷絕關係,但是僅僅這樣子進程可能還是會受主進程的影響,要想子進程完全獨立需要保證子進程一定不能和主進程共用標準輸入、標準輸出和錯誤輸出,也就是 stdio 必須設置爲 ignore,這也就代表着獨立的子進程是不能和主進程進行標準進程通信,即不能設置 ipc

fork 實現多進程

1、fork 的使用

fork 也是 child_process 模塊的一個方法,與 spawn 類似,是在 spawn 的基礎上又做了一層封裝,我們看一個 fork 使用的例子。

// 文件:process.js
const fork = require("child_process");
const path = require("path");

// 創建子進程
let child = fork("sub_process.js", ["--port", "3000"], {
    cwd: path.join(__dirname, "test"),
    silent: true
});

child.send("hello world");
// 文件:~test/sub_process.js
// 接收主進程發來的消息
process.on("message", data => console.log(data));

fork 的用法與 spawn 相比有所改變,第一個參數是子進程執行文件的名稱,第二個參數爲數組,存儲執行時的參數和值,第三個參數爲 options,其中使用 slilent 屬性替代了 spawnstdio,當 silenttrue 時,此時主進程與子進程的所有非標準通信的操作都不會生效,包括標準輸入、標準輸出和錯誤輸出,當設爲 false 時可正常輸出,返回值依然爲一個子進程。

fork 創建的子進程可以直接通過 send 方法和監聽 message 事件與主進程進行通信。

2、fork 的原理

其實 fork 的原理非常簡單,只是在子進程模塊 child_process 上掛了一個 fork 方法,而在該方法內調用 spawn 並將 spawn 返回的子進程作爲返回值返回,下面進行簡易實現。

// 文件:fork.js
const childProcess = require("child_process");
const path = require("path");

// 封裝原理
childProcess.fork = function (modulePath, args, options) {
    let stdio = options.silent ? ["ignore", "ignore", "ignore", "ipc"] : [0, 1, 2, "ipc"];
    return childProcess.spawn("node", [modulePath, ...args], {
        ...options,
        stdio
    });
}

// 創建子進程
let child = fork("sub_process.js", ["--port", "3000"], {
    cwd: path.join(__dirname, "test"),
    silent: false
});

// 向子進程發送消息
child.send("hello world");
// 文件:~test/sub_process.js
// 接收主進程發來的消息
process.on("message", data => console.log(data));

// hello world

spawn 中的有一些 fork 沒有傳的參數(如使用 node 執行文件),都在內部調用 spawn 時傳遞默認值或將默認參數與 fork 傳入的參數進行整合,着重處理了 spawn 沒有的參數 silent,其實就是處理成了 spawnstdio 參數兩種極端的情況(默認使用 ipc 通信),封裝 fork 就是讓我們能更方便的創建子進程,可以更少的傳參。

execFile 和 exec 實現多進程

execFileexecchild_process 模塊的兩個方法,execFile 是基於 spawn 封裝的,而 exec 是基於 execFile 封裝的,這兩個方法用法大同小異,execFile 可以直接創建子進程進行文件操作,而 exec 可以直接開啓子進程執行命令,常見的應用場景如 http-server 以及 weboack-dev-server 等命令行工具在啓動本地服務時自動打開瀏覽器。

// execFile 和 exec
const { execFile, exec } = require("child_process");

let execFileChild = execFile("node", ["--version"], (err, stdout, stderr) => {
    if (error) throw error;
    console.log(stdout);
    console.log(stderr);
});

let execChild = exec("node --version", (err, stdout, stderr) => {
    if (err) throw err;
    console.log(stdout);
    console.log(stderr);
});

execexecFile 的區別在於傳參,execFile 第一個參數爲文件的可執行路徑或命令,第二個參數爲命令的參數集合(數組),第三個參數爲 options,最後一個參數爲回調函數,回調函數的形參爲錯誤、標準輸出和錯誤輸出。

exec 在傳參上將 execFile 的前兩個參數進行了整合,也就是命令與命令參數拼接成字符串作爲第一參數,後面的參數都與 execFile 相同。

cluster 集羣

開啓進程需要消耗內存,所以開啓進程的數量要適合,合理運用多進程可以大大提高效率,如 Webpack 對資源進行打包,就開啓了多個進程同時進行,大大提高了打包速度,集羣也是多進程重要的應用之一,用多個進程同時監聽同一個服務,一般開啓進程的數量跟 CPU 核數相同爲好,此時多個進程監聽的服務會根據請求壓力分流處理,也可以通過設置每個子進程處理請求的數量來實現 “負載均衡”。

1、使用 ipc 實現集羣

ipc 標準進程通信使用 send 方法發送消息時第二個參數支持傳入一個服務,必須是 http 服務或者 tcp 服務,子進程通過 message 事件進行接收,回調的參數分別對應發送的參數,即第一個參數爲消息,第二個參數爲服務,我們就可以在子進程創建服務並對主進程的服務進行監聽和操作(listen 除了可以監聽端口號也可以監聽服務),便實現了集羣,代碼如下。

// 文件:server.js
const os = require("os"); // os 模塊用於獲取系統信息
const http = require("http");
const path = require("path");
const { fork } = rquire("child_process");

// 創建服務
const server = createServer((res, req) => {
    res.end("hello");
}).listen(3000);

// 根據 CPU 個數創建子進程
os.cpus().forEach(() => {
    fork("child_server.js", {
        cwd: path.join(__dirname);
    }).send("server", server);
});
// 文件:child_server.js
const http = require("http");

// 接收來自主進程發來的服務
process.on("message", (data, server) => {
    http.createServer((req, res) => {
        res.end(`child${process.pid}`);
    }).listen(server); // 子進程共用主進程的服務
});

上面代碼中由主進程處理的請求會返回 hello,由子進程處理的請求會返回 child 加進程的 pid 組成的字符串。

2、使用 cluster 實現集羣

cluster 模塊是 NodeJS 提供的用來實現集羣的,他將 child_process 創建子進程的方法集成進去,實現方式要比使用 ipc 更簡潔。

// 文件:cluster.js
const cluster = require("cluster");
const http = require("http");
const os = require("os");

// 判斷當前執行的進程是否爲主進程,爲主進程則創建子進程,否則用子進程監聽服務
if (cluster.isMaster) {
    // 創建子進程
    os.cpus().forEach(() => cluster.fork());
} else {
    // 創建並監聽服務
    http.createServer((req, res) => {
        res.end(`child${process.pid}`);
    }).listen(3000);
}

上面代碼既會執行 if 又會執行 else,這看似很奇怪,但其實不是在同一次執行的,主進程執行時會通過 cluster.fork 創建子進程,當子進程被創建會將該文件再次執行,此時則會執行 else 中對服務的監聽,還有另一種用法將主進程和子進程執行的代碼拆分開,邏輯更清晰,用法如下。

// 文件:cluster.js
const cluster = require("cluster");
const path = require("path");
const os = require("os");

// 設置子進程讀取文件的路徑
cluster.setupMaster({
    exec: path.join(__dirname, "cluster-server.js")
});

// 創建子進程
os.cpus().forEach(() => cluster.fork());
// 文件:cluster-server.js
const http = require("http");

// 創建並監聽服務
http.createServer((req, res) => {
    res.end(`child${process.pid}`);
}).listen(3000);

通過 cluster.setupMaster 設置子進程執行文件以後,就可以將主進程和子進程的邏輯拆分開,在實際的開發中這樣的方式也是最常用的,耦合度低,可讀性好,更符合開發的原則。

總結

本篇着重的介紹了 NodeJS 多進程的實現方式以及集羣的使用,之所以在開頭長篇大論的介紹 spawn,是因爲其他的所有跟多進程相關的方法包括 forkexec 等,以及模塊 cluster 都是基於 spawn 的封裝,如果對 spawn 足夠了解,其他的也不在話下,希望大家通過這篇可以在 NodeJS 多進程相關的開發中起到一個 “路標” 的作用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章