深入理解NodeJS 多進程和集群
進程和線程
“進程” 是計算機系統(tǒng)進行資源分配和調(diào)度的基本單位,我們可以理解為計算機每開啟一個任務(wù)就會創(chuàng)建至少一個進程來處理,有時會創(chuàng)建多個,如 Chrome 瀏覽器的選項卡,其目的是為了防止一個進程掛掉而應(yīng)用停止工作,而 “線程” 是程序執(zhí)行流的最小單元,NodeJS 默認是單進程、單線程的,我們將這個進程稱為主進程,也可以通過 child_process 模塊創(chuàng)建子進程實現(xiàn)多進程,我們稱這些子進程為 “工作進程”,并且歸主進程管理,進程之間默認是不能通信的,且所有子進程執(zhí)行任務(wù)都是異步的。
spawn 實現(xiàn)多進程
1、spawn 創(chuàng)建子進程
在 NodeJS 中執(zhí)行一個 JS 文件,如果想在這個文件中再同時(異步)執(zhí)行另一個 JS 文件,可以使用 child_process 模塊中的 spawn 來實現(xiàn),spawn 可以幫助我們創(chuàng)建一個子進程,用法如下。
// 文件:process.js const { spawn } = require("child_process"); const path = require("path"); // 創(chuàng)建子進程 let child = spawn("node", ["sub_process.js", "--port", "3000"], { cwd: path.join(__dirname, "test") // 指定子進程的當(dāng)前工作目錄 }); // 出現(xiàn)錯誤觸發(fā) child.on("error", err => console.log(err)); // 子進程退出觸發(fā) child.on("exit", () => console.log("exit")); // 子進程關(guān)閉觸發(fā) child.on("close", () => console.log("close")); // exit // close
spawn 方法可以幫助我們創(chuàng)建一個子進程,這個子進程就是方法的返回值,spawn 接收以下幾個參數(shù):
- command:要運行的命令;
- args:類型為數(shù)組,數(shù)組內(nèi)第一項為文件名,后面項依次為執(zhí)行文件的命令參數(shù)和值;
- options:選項,類型為對象,用于指定子進程的當(dāng)前工作目錄和主進程、子進程的通信規(guī)則等,具體可查看 官方文檔。
error 事件在子進程出錯時觸發(fā),exit 事件在子進程退出時觸發(fā),close 事件在子進程關(guān)閉后觸發(fā),在子進程任務(wù)結(jié)束后 exit 一定會觸發(fā),close 不一定觸發(fā)。
// 文件:~test/sub_process.js // 打印子進程執(zhí)行 sub_process.js 文件的參數(shù) console.log(process.argv);
通過上面代碼打印了子進程執(zhí)行時的參數(shù),但是我們發(fā)現(xiàn)主進程窗口并沒有打印,我們希望的是子進程的信息可以反饋給主進程,要實現(xiàn)通信需要在創(chuàng)建子進程時在第三個參數(shù) options 中配置 stdio 屬性定義。
2、spawn 定義輸入、輸出
// 文件:process.js const { spawn } = require("child_process"); const path = require("path"); // 創(chuàng)建子進程 let child = spawn("node", ["sub_process.js", "--port", "3000"], { cwd: path.join(__dirname, "test") // 指定子進程的當(dāng)前工作目錄 // 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 文件執(zhí)行的參數(shù) process.stdout.write(process.argv.toString());
通過上面配置 options 的 stdio 值為數(shù)組,上面的兩種寫法作用相同,都表示子進程和主進程共用了主進程的標準輸入、標準輸出、和錯誤輸出,實際上并沒有實現(xiàn)主進程與子進程的通信,其中 0 和 stdin 代表標準輸入,1 和 stdout 代表標準輸出,2 和 stderr 代表錯誤輸出。
上面這樣的方式只要子進程執(zhí)行 sub_process.js 就會在窗口輸出,如果我們希望是否輸出在主進程里面控制,即實現(xiàn)子進程與主進程的通信,看下面用法。
// 文件:process.js const { spawn } = require("child_process"); const path = require("path"); // 創(chuàng)建子進程 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 // 子進程執(zhí)行 sub_process.js process.stdout.write("hello world");
上面將 stdio 內(nèi)數(shù)組的值配置為 pipe(默認不寫就是 pipe),則通過流的方式實現(xiàn)主進程和子進程的通信,通過子進程的標準輸出(可寫流)寫入,在主進程通過子進程的標準輸出通過 data 事件讀取的流在輸出到窗口(這種寫法很少用),上面都只在主進程中開啟了一個子進程,下面舉一個開啟多個進程的例子。
例子的場景是主進程開啟兩個子進程,先運行子進程 1 傳遞一些參數(shù),子進程 1 將參數(shù)取出返還給主進程,主進程再把參數(shù)傳遞給子進程 2,通過子進程 2 將參數(shù)寫入到文件 param.txt 中,這個過程不代表真實應(yīng)用場景,主要目的是體會主進程和子進程的通信過程。
// 文件:process.js const { spawn } = require("child_process"); const path = require("path"); // 創(chuàng)建子進程 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 寫入的內(nèi)容,寫入子進程 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"); // 讀取主進程傳遞的參數(shù)并寫入文件 process.stdout.on("data", data => { fs.writeFile("param.txt", data, () => { process.exit(); }); });
有一點需要注意,在子進程 2 寫入文件的時候,由于主進程不知道子進程 2 什么時候?qū)懲?,所以主進程會卡住,需要子進程在寫入完成后調(diào)用 process.exit 方法退出子進程,子進程退出并關(guān)閉后,主進程會隨之關(guān)閉。
在我們給 options 配置 stdio 時,數(shù)組內(nèi)其實可以對標準輸入、標準輸出和錯誤輸出分開配置,默認數(shù)組內(nèi)為 pipe 時代表三者都為 pipe,分別配置看下面案例。
// 文件:process.js const { spawn } = require("spawn"); const path = require("path"); // 創(chuàng)建子進程 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 實現(xiàn)了默認打印而不通信,對標準輸入實現(xiàn)了通信,還有一種情況,如果希望子進程只是默默的執(zhí)行任務(wù),而在主進程命令窗口什么類型的輸出都禁止,可以在數(shù)組中對應(yīng)位置給定值 ignore,將上面案例修改如下。
// 文件:process.js const { spawn } = require("spawn"); const path = require("path"); // 創(chuàng)建子進程 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");
這次我們發(fā)現(xiàn)無論標準輸出和錯誤輸出都沒有生效,上面這些方式其實是不太方便的,因為輸出有 stdout 和 stderr,在寫法上沒辦法統(tǒng)一,可以通過下面的方式來統(tǒng)一。
3、標準進程通信
// 文件:process.js const { spawn } = require("spawn"); const path = require("path"); // 創(chuàng)建子進程 let child = spawn("node", ["sub_process.js"], { cwd: path.join(__dirname, "test"), stdio: [0, "pipe", "ignore", "ipc"] }); child.on("message", data => { console.log(data); // 回復(fù)消息給子進程 child.send("world"); // 殺死子進程 // process.kill(child.pid); }); // hello
// 文件:~test/sub_process.js // 給主進程發(fā)送消息 process.send("hello"); // 接收主進程回復(fù)的消息 process.on("message", data => { console.log(data); // 退出子進程 process.exit(); }); // world
這種方式被稱為標準進程通信,通過給 options 的 stdio 數(shù)組配置 ipc,只要數(shù)組中存在 ipc 即可,一般放在數(shù)組開頭或結(jié)尾,配置 ipc 后子進程通過調(diào)用自己的 send 方法發(fā)送消息給主進程,主進程中用子進程的 message 事件進行接收,也可以在主進程中接收消息的 message 事件的回調(diào)當(dāng)中,通過子進程的 send 回復(fù)消息,并在子進程中用 message 事件進行接收,這樣的編程方式比較統(tǒng)一,更貼近于開發(fā)者的意愿。
4、退出和殺死子進程
上面代碼中子進程在接收到主進程的消息時直接退出,也可以在子進程發(fā)送給消息給主進程時,主進程接收到消息直接殺死子進程,代碼如下。
// 文件:process.js const { spawn } = require("spawn"); const path = require("path"); // 創(chuàng)建子進程 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 // 給主進程發(fā)送消息 process.send("hello");
從上面代碼我們可以看出,殺死子進程的方法為 process.kill,由于一個主進程可能有多個子進程,所以指定要殺死的子進程需要傳入子進程的 pid 屬性作為 process.kill 的參數(shù)。
{% note warning %}
注意:退出子進程 process.exit 方法是在子進程中操作的,此時 process 代表子進程,殺死子進程 process.kill 是在主進程中操作的,此時 process 代表主進程。
{% endnote %}
5、獨立子進程
我們前面說過,child_process 模塊創(chuàng)建的子進程是被主進程統(tǒng)一管理的,如果主進程掛了,所有的子進程也會受到影響一起掛掉,但其實使用多進程一方面為了提高處理任務(wù)的效率,另一方面也是為了當(dāng)一個進程掛掉時還有其他進程可以繼續(xù)工作,不至于整個應(yīng)用掛掉,這樣的例子非常多,比如 Chrome 瀏覽器的選項卡,比如 VSCode 編輯器運行時都會同時開啟多個進程同時處理任務(wù),其實在 spawn 創(chuàng)建子進程時,也可以實現(xiàn)子進程的獨立,即子進程不再受主進程的控制和影響。
// 文件:process.js const { spawn } = require("spawn"); const path = require("path"); // 創(chuàng)建子進程 let child = spawn("node", ["sub_process.js"], { cwd: path.join(__dirname, "test"), stdio: "ignore", detached: true }); // 與主進程斷絕關(guān)系 child.unref();
// 文件:~test/sub_process.js const fs = require("fs"); setInterval(() => { fs.appendFileSync("test.txt", "hello"); });
要想創(chuàng)建的子進程獨立,需要在創(chuàng)建子進程時配置 detached 參數(shù)為 true,表示該子進程不受控制,還需調(diào)用子進程的 unref 方法與主進程斷絕關(guān)系,但是僅僅這樣子進程可能還是會受主進程的影響,要想子進程完全獨立需要保證子進程一定不能和主進程共用標準輸入、標準輸出和錯誤輸出,也就是 stdio 必須設(shè)置為 ignore,這也就代表著獨立的子進程是不能和主進程進行標準進程通信,即不能設(shè)置 ipc。
fork 實現(xiàn)多進程
1、fork 的使用
fork 也是 child_process 模塊的一個方法,與 spawn 類似,是在 spawn 的基礎(chǔ)上又做了一層封裝,我們看一個 fork 使用的例子。
// 文件:process.js const fork = require("child_process"); const path = require("path"); // 創(chuàng)建子進程 let child = fork("sub_process.js", ["--port", "3000"], { cwd: path.join(__dirname, "test"), silent: true }); child.send("hello world");
// 文件:~test/sub_process.js // 接收主進程發(fā)來的消息 process.on("message", data => console.log(data));
fork 的用法與 spawn 相比有所改變,第一個參數(shù)是子進程執(zhí)行文件的名稱,第二個參數(shù)為數(shù)組,存儲執(zhí)行時的參數(shù)和值,第三個參數(shù)為 options,其中使用 slilent 屬性替代了 spawn 的 stdio,當(dāng) silent 為 true 時,此時主進程與子進程的所有非標準通信的操作都不會生效,包括標準輸入、標準輸出和錯誤輸出,當(dāng)設(shè)為 false 時可正常輸出,返回值依然為一個子進程。
fork 創(chuàng)建的子進程可以直接通過 send 方法和監(jiān)聽 message 事件與主進程進行通信。
2、fork 的原理
其實 fork 的原理非常簡單,只是在子進程模塊 child_process 上掛了一個 fork 方法,而在該方法內(nèi)調(diào)用 spawn 并將 spawn 返回的子進程作為返回值返回,下面進行簡易實現(xiàn)。
// 文件: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 }); } // 創(chuàng)建子進程 let child = fork("sub_process.js", ["--port", "3000"], { cwd: path.join(__dirname, "test"), silent: false }); // 向子進程發(fā)送消息 child.send("hello world");
// 文件:~test/sub_process.js // 接收主進程發(fā)來的消息 process.on("message", data => console.log(data)); // hello world
spawn 中的有一些 fork 沒有傳的參數(shù)(如使用 node 執(zhí)行文件),都在內(nèi)部調(diào)用 spawn 時傳遞默認值或?qū)⒛J參數(shù)與 fork 傳入的參數(shù)進行整合,著重處理了 spawn 沒有的參數(shù) silent,其實就是處理成了 spawn 的 stdio 參數(shù)兩種極端的情況(默認使用 ipc 通信),封裝 fork 就是讓我們能更方便的創(chuàng)建子進程,可以更少的傳參。
execFile 和 exec 實現(xiàn)多進程
execFile 和 exec 是 child_process 模塊的兩個方法,execFile 是基于 spawn 封裝的,而 exec 是基于 execFile 封裝的,這兩個方法用法大同小異,execFile 可以直接創(chuàng)建子進程進行文件操作,而 exec 可以直接開啟子進程執(zhí)行命令,常見的應(yīng)用場景如 http-server 以及 weboack-dev-server 等命令行工具在啟動本地服務(wù)時自動打開瀏覽器。
// 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); });
exec 與 execFile 的區(qū)別在于傳參,execFile 第一個參數(shù)為文件的可執(zhí)行路徑或命令,第二個參數(shù)為命令的參數(shù)集合(數(shù)組),第三個參數(shù)為 options,最后一個參數(shù)為回調(diào)函數(shù),回調(diào)函數(shù)的形參為錯誤、標準輸出和錯誤輸出。
exec 在傳參上將 execFile 的前兩個參數(shù)進行了整合,也就是命令與命令參數(shù)拼接成字符串作為第一參數(shù),后面的參數(shù)都與 execFile 相同。
cluster 集群
開啟進程需要消耗內(nèi)存,所以開啟進程的數(shù)量要適合,合理運用多進程可以大大提高效率,如 Webpack 對資源進行打包,就開啟了多個進程同時進行,大大提高了打包速度,集群也是多進程重要的應(yīng)用之一,用多個進程同時監(jiān)聽同一個服務(wù),一般開啟進程的數(shù)量跟 CPU 核數(shù)相同為好,此時多個進程監(jiān)聽的服務(wù)會根據(jù)請求壓力分流處理,也可以通過設(shè)置每個子進程處理請求的數(shù)量來實現(xiàn) “負載均衡”。
1、使用 ipc 實現(xiàn)集群
ipc 標準進程通信使用 send 方法發(fā)送消息時第二個參數(shù)支持傳入一個服務(wù),必須是 http 服務(wù)或者 tcp 服務(wù),子進程通過 message 事件進行接收,回調(diào)的參數(shù)分別對應(yīng)發(fā)送的參數(shù),即第一個參數(shù)為消息,第二個參數(shù)為服務(wù),我們就可以在子進程創(chuàng)建服務(wù)并對主進程的服務(wù)進行監(jiān)聽和操作(listen 除了可以監(jiān)聽端口號也可以監(jiān)聽服務(wù)),便實現(xiàn)了集群,代碼如下。
// 文件:server.js const os = require("os"); // os 模塊用于獲取系統(tǒng)信息 const http = require("http"); const path = require("path"); const { fork } = rquire("child_process"); // 創(chuàng)建服務(wù) const server = createServer((res, req) => { res.end("hello"); }).listen(3000); // 根據(jù) CPU 個數(shù)創(chuàng)建子進程 os.cpus().forEach(() => { fork("child_server.js", { cwd: path.join(__dirname); }).send("server", server); });
// 文件:child_server.js const http = require("http"); // 接收來自主進程發(fā)來的服務(wù) process.on("message", (data, server) => { http.createServer((req, res) => { res.end(`child${process.pid}`); }).listen(server); // 子進程共用主進程的服務(wù) });
上面代碼中由主進程處理的請求會返回 hello,由子進程處理的請求會返回 child 加進程的 pid 組成的字符串。
2、使用 cluster 實現(xiàn)集群
cluster 模塊是 NodeJS 提供的用來實現(xiàn)集群的,他將 child_process 創(chuàng)建子進程的方法集成進去,實現(xiàn)方式要比使用 ipc 更簡潔。
// 文件:cluster.js const cluster = require("cluster"); const http = require("http"); const os = require("os"); // 判斷當(dāng)前執(zhí)行的進程是否為主進程,為主進程則創(chuàng)建子進程,否則用子進程監(jiān)聽服務(wù) if (cluster.isMaster) { // 創(chuàng)建子進程 os.cpus().forEach(() => cluster.fork()); } else { // 創(chuàng)建并監(jiān)聽服務(wù) http.createServer((req, res) => { res.end(`child${process.pid}`); }).listen(3000); }
上面代碼既會執(zhí)行 if 又會執(zhí)行 else,這看似很奇怪,但其實不是在同一次執(zhí)行的,主進程執(zhí)行時會通過 cluster.fork 創(chuàng)建子進程,當(dāng)子進程被創(chuàng)建會將該文件再次執(zhí)行,此時則會執(zhí)行 else 中對服務(wù)的監(jiān)聽,還有另一種用法將主進程和子進程執(zhí)行的代碼拆分開,邏輯更清晰,用法如下。
// 文件:cluster.js const cluster = require("cluster"); const path = require("path"); const os = require("os"); // 設(shè)置子進程讀取文件的路徑 cluster.setupMaster({ exec: path.join(__dirname, "cluster-server.js") }); // 創(chuàng)建子進程 os.cpus().forEach(() => cluster.fork());
// 文件:cluster-server.js const http = require("http"); // 創(chuàng)建并監(jiān)聽服務(wù) http.createServer((req, res) => { res.end(`child${process.pid}`); }).listen(3000);
通過 cluster.setupMaster 設(shè)置子進程執(zhí)行文件以后,就可以將主進程和子進程的邏輯拆分開,在實際的開發(fā)中這樣的方式也是最常用的,耦合度低,可讀性好,更符合開發(fā)的原則。
總結(jié)
本篇著重的介紹了 NodeJS 多進程的實現(xiàn)方式以及集群的使用,之所以在開頭長篇大論的介紹 spawn,是因為其他的所有跟多進程相關(guān)的方法包括 fork、exec 等,以及模塊 cluster 都是基于 spawn 的封裝,如果對 spawn 足夠了解,其他的也不在話下,希望大家通過這篇可以在 NodeJS 多進程相關(guān)的開發(fā)中起到一個 “路標” 的作用。
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
node.js 利用流實現(xiàn)讀寫同步,邊讀邊寫的方法
下面小編就為大家?guī)硪黄猲ode.js 利用流實現(xiàn)讀寫同步,邊讀邊寫的方法。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-09-09通過NodeJS輕松使用GRPC和協(xié)議緩沖區(qū)的方法
本文介紹了GRPC和協(xié)議緩沖區(qū)的基本概念,并展示了如何在NodeJS應(yīng)用程序中使用它們,GRPC是一個高性能RPC框架,協(xié)議緩沖區(qū)則用于定義服務(wù)和序列化消息,本文給大家介紹如何在NodeJS應(yīng)用程序中使用GRPC和協(xié)議緩沖區(qū),感興趣的朋友一起看看吧2024-10-10Node.js腳本提取OPML文件信息實現(xiàn)示例詳解
這篇文章主要為大家介紹了Node.js腳本提取OPML文件信息,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-09-09Node層模擬實現(xiàn)multipart表單的文件上傳示例
下面小編就為大家分享一篇Node層模擬實現(xiàn)multipart表單的文件上傳示例,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-01-01把Node.js程序加入服務(wù)實現(xiàn)隨機啟動
這篇文章主要介紹了把Node.js程序加入服務(wù)實現(xiàn)隨機啟動,本文使用qckwinsvc實現(xiàn)這個需求,講解了qckwinsvc的安裝和使用,需要的朋友可以參考下2015-06-06