NodeJS 多進程和集羣

在這裏插入圖片描述


閱讀原文


進程和線程

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


spawn 實現多進程

一、spawn 建立子進程

在 NodeJS 中執行一個 JS 文件,若是想在這個文件中再同時(異步)執行另外一個 JS 文件,可使用 child_process 模塊中的 spawn 來實現,spawn 能夠幫助咱們建立一個子進程,用法以下。node

// 文件: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 接收如下幾個參數:web

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

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

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

經過上面代碼打印了子進程執行時的參數,可是咱們發現主進程窗口並無打印,咱們但願的是子進程的信息能夠反饋給主進程,要實現通訊須要在建立子進程時在第三個參數 options 中配置 stdio 屬性定義。api

二、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 事件讀取的流在輸出到窗口(這種寫法不多用),上面都只在主進程中開啓了一個子進程,下面舉一個開啓多個進程的例子。app

例子的場景是主進程開啓兩個子進程,先運行子進程 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,在寫法上沒辦法統一,能夠經過下面的方式來統一。

三、標準進程通訊

// 文件: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 事件進行接收,這樣的編程方式比較統一,更貼近於開發者的意願。

四、退出和殺死子進程

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

// 文件: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 %}

五、獨立子進程

咱們前面說過,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 實現多進程

一、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 事件與主進程進行通訊。

二、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 核數相同爲好,此時多個進程監聽的服務會根據請求壓力分流處理,也能夠經過設置每一個子進程處理請求的數量來實現 「負載均衡」。

一、使用 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 組成的字符串。

二、使用 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 多進程相關的開發中起到一個 「路標」 的做用。

相關文章
相關標籤/搜索