首頁 > 軟體

深入瞭解 Node的多程序服務實現

2022-06-06 18:00:07

我們現在已經知道了Node是單執行緒執行的,這表示潛在的錯誤有可能導致執行緒崩潰,然後程序也會隨著退出,無法做到企業追求的穩定性;另一方面,單程序也無法充分多核CPU,這是對硬體本身的浪費。Node社群本身也意識到了這一問題,於是從0.1版本就提供了child_process模組,用來提供多程序的支援。

1. child_process 模組

child_process模組中包括了很多建立子程序的方法,包括forkspawnexecexecFile等等。它們的定義如下:

  • child_process.exec(command[, options][, callback])
  • child_process.spawn(command[, args][, options])
  • child_process.fork(modulePath[, args][, options])
  • child_process.execFile(file[, args][, options][, callback])

在這4個API中以spawn最為基礎,因為其他三個API或多或少都是藉助spawn實現的。

2. spawn

spawn方法的宣告格式如下:

child_process.spawn(command[, args][, options])

spawn方法會使用指定的command來生成一個新程序,執行完對應的command後子程序會自動退出。

該命令返回一個child_process物件,這代表開發者可以通過監聽事件來獲得命令執行的結果。

下面我們使用spwan來執行ls命令:

const spawn = require('child_process').spawn;
const ls = spawn('ls', ['-1h', '/usr']);

ls.stdout.on('data', (data) => {
    console.log('stdout: ', daata.toString());
});

ls.stderr.on('data', (data) => {
    console.log('stderr: ', daata.toString());
});

ls.on('close', (code) => {
    console.log('child process exited with code', code);
});

其中spawn的第一個引數雖然是command,但實際接收的卻是一個file,可以在Linux或者Mac OSX上執行,這是由於ls命令也是以可執行檔案形式存在的。

類似的,在Windows系統下我們可以試著使用dir命令來實現功能類似的程式碼:

const spawn = require('child_process').spawn;
const ls = spawn('dir');

ls.stdout.on('data', (data) => {
    console.log('stdout: ', daata.toString());
});

然而在Windows下執行上面程式碼會出現形如Error:spawn dir ENOENT的錯誤。

原因就在於spawn實際接收的是一個檔名而非命令,正確的程式碼如下:

const spawn = require('child_process').spawn;
const ls = spawn('powershell', ['dir']);

ls.stdout.on('data', (data) => {
    console.log('stdout: ', daata.toString());
});

這個問題的原因與作業系統本身有關,在Linux中,一般都是檔案,命令列的命令也不例外,例如ls命令是一個名為ls的可執行檔案;而在Windows中並沒有名為dir的可執行檔案,需要通過cmd或者powershell之類的工具提供執行環境。

3. fork

在Linux環境下,建立一個新程序的本質是複製一個當前的程序,當用戶呼叫 fork 後,作業系統會先為這個新程序分配空間,然後將父程序的資料原樣複製一份過去,父程序和子程序只有少數值不同,例如程序識別符號(PD)。

對於 Node 來說,父程序和子程序都有獨立的記憶體空間和獨立的 V8 範例,它們和父程序唯一的聯絡是用來程序間通訊的 IPC Channel。

此外,Node中fork和 POSIX 系統呼叫的不同之處在於Node中的fork並不會複製父程序。

Node中的fork是上面提到的spawn的一種特例,前面也提到了Node中的fork並不會複製當前程序。多數情況下,fork接收的第一個引數是一個檔名,使用fork("xx.js")相當於在命令列下呼叫node xx.js,並且父程序和子程序之間可以通過process.send方法來進行通訊。

下面我們來看一個簡單的栗子:

// master.js 呼叫 fork 來建立一個子程序
const child_process = require('child_process');
const worker = child_process.fork('worker.js', ['args1']);
worker.on('exit', () => {
  console.log('child process exit');
});
worker.send({ msg: 'hello child' });
worker.on('message', msg => {
  console.log('from child: ', msg);
});


// worker.js
const begin = process.argv[2];
console.log('I am worker ' + begin);
process.on('message', msg => {
  console.log('from parent ', msg);
  process.exit();
});
process.send({ msg: 'hello parent' });

fork內部會通過spawn呼叫process.executePath,即Node的可執行檔案地址來生成一個Node範例,然後再用這個範例來執行fork方法的modulePath引數。

輸出結果為:

I am worker args1
from parent  { msg: 'hello child' }
from child:  { msg: 'hello parent' }
child process exit

4. exec 和 execFile

如果我們開發一種系統,那麼對於不同的模組可能會用到不同的技術來實現,例如 Web伺服器使用 Node ,然後再使用 Java 的訊息佇列提供釋出訂閱服務,這種情況下通常使用程序間通訊的方式來實現。

但有時開發者不希望使用這麼複雜的方式,或者要呼叫的乾脆是一個黑盒系統,即無法通過修改原始碼來進行來實現程序間通訊,這時候往往採用折中的方式,例如通過 shell 來呼叫目標服務,然後再拿到對應的輸出。

child_process提供了一個execFile方法,它的宣告如下:

child_process.execFile(file, args, options, callback) 

說明:

  • file {String}要執行的程式的檔名
  • args {Array}字串參數列
  • options {Object}
    • cwd {String}子程序的當前工作目錄
    • env {Object}環境變數鍵值對
    • encoding {String}編碼(預設為 'utf8'
    • timeout {Number}超時(預設為 0)
    • maxBuffer {Number}緩衝區大小(預設為 200*1024)
    • killSignal {String}結束訊號(預設為'SIGTERM'
  • callback {Function}程序結束時回撥並帶上輸出
    • error {Error}
    • stdout {Buffer}
    • stderr {Buffer}
    • 返回:ChildProcess物件

可以看出,execfilespawn在形式上的主要區別在於execfile提供了一個回撥函數,通過這個回撥函數可以獲得子程序的標準輸出/錯誤流。

使用 shell 進行跨程序呼叫長久以來被認為是不穩定的,這大概源於人們對控制檯不友好的互動體驗的恐懼(輸入命令後,很可能長時間看不到一個輸出,儘管後臺可能在一直運算,但在使用者看來和宕機無異)。

在 Linux下執行exec命令後,原有程序會被替換成新的程序,進而失去對新程序的控制,這代表著新程序的狀態也沒辦法獲取了,此外還有 shell 本身執行出現錯誤,或者因為各種原因出現長時間卡頓甚至失去響應等情況。

Node.js 提供了比較好的解決方案,timeout解決了長時間卡頓的問題,stdoutstderr則提供了標準輸出和錯誤輸出,使得子程序的狀態可以被獲取。

5. 各方法之間的比較

5.1 spawn 和 execFile

為了更好地說明,我們先寫一段簡單的 C 語言程式碼,並將其命名為 example.c:

#include<stdio.h>
int main() {
    printf("%s", "Hello World!");
    return 5;
}

使用 gcc 編譯該檔案:

gcc example.c -o example

生成名為example的可執行檔案,然後將這個可執行檔案放到系統環境變數中,然後開啟控制檯,輸入example,看到最後輸出"Hello World"

確保這個可執行檔案在任意路徑下都能存取。

我們分別用spawnexecfile來呼叫example檔案。

首先是spawn

const spawn = require('child_process').spawn;
const ls = spawn('example');

ls.stdout.on('data', (data) => {
    console.log('stdout: ', daata.toString());
});

ls.stderr.on('data', (data) => {
    console.log('stderr: ', daata.toString());
});

ls.on('close', (code) => {
    console.log('child process exited with code', code);
});

程式輸出:

stdout: Hello World!
child process exited with code 5

程式正確列印出了Hello World,此外還可以看到example最後的return 5會被作為子程序結束的code被返回。

然後是execFile

const exec = require('child_process').exec;
const child = exec('example', (error, stdout, stderr) => {
    if (error) {
        throw error;
    }
    console.log(stdout);
});

同樣列印出Hello World,可見除了呼叫形式不同,二者相差不大。

5.2 execFile 和 spawn

在子程序的資訊互動方面,spawn使用了流式處理的方式,當子程序產生資料時,主程序可以通過監聽事件來獲取訊息;而exec是將所有返回的資訊放在stdout裡面一次性返回的,也就是該方法的maxBuffer引數,當子程序的輸出超過這個大小時,會產生一個錯誤。

此外,spawn有一個名為shell的引數:

其型別為一個布林值或者字串,如果這個值被設定為true,,就會啟動一個 shell 來執行命令,這個 shell 在 UNIX上是 bin/sh,,在Windows上則是cmd.exe。

5.3 exec 和 execFile

exec在內部也是通過呼叫execFile來實現的,我們可以從原始碼中驗證這一點,在早期的Node原始碼中,exec命令會根據當前環境來初始化一個 shell,,例如 cmd.exe 或者 bin/sh,然後在shell中呼叫作為引數的命令。

通常execFile的效率要高於exec,這是因為execFile沒有啟動一個 shell,而是直接呼叫 spawn來實現的。

6. 程序間通訊

前面介紹的幾個用於建立程序的方法,都是屬於child_process的類方法,此外childProcess類繼承了EventEmitter,在childProcess中引入事件給程序間通訊帶來很大的便利。

childProcess中定義瞭如下事件。

  • Event:'close':程序的輸入輸出流關閉時會觸發該事件。
  • Event:'disconnect':通常childProcess.disconnect呼叫後會觸發這一事件。
  • Event:'exit':程序退出時觸發。
  • Event:'message':呼叫child_process.send會觸發這一事件
  • Event:'error':該事件的觸發分為幾種情況:
    • 該程序無法建立子程序。
    • 該程序無法通過kill方法關閉。
    • 無法傳送訊息給子程序。

Event:'error'事件無法保證一定會被觸發,因為可能會遇到一些極端情況,例如伺服器斷電等。

上面也提到,childProcess模組定義了send方法,用於程序間通訊,該方法的宣告如下:

child.send(message[, sendHandle[, options]][, callback])

通過send方法傳送的訊息,可以通過監聽message事件來獲取。

// master.js 父程序向子程序傳送訊息
const child_process = require('child_process');
const worker = child_process.fork('worker.js', ['args1']);
worker.on('exit', () => {
  console.log('child process exit');
});
worker.send({ msg: 'hello child' });
worker.on('message', msg => {
  console.log('from child: ', msg);
});


// worker.js 子程序接收父程序訊息
const begin = process.argv[2];
console.log('I am worker ' + begin);
process.on('message', msg => {
  console.log('from parent ', msg);
  process.exit();
});
process.send({ msg: 'hello parent' });

send方法的第一個引數型別通常為一個json物件或者原始型別,第二個引數是一個控制程式碼,該控制程式碼可以是一個net.Socket或者net.Server物件。下面是一個例子:

//master.js 父程序傳送一個 Socket 物件
const child = require('child_process').fork('worker.js');
// Open up the server object and send the handle.
const server = require('net').createServer();
server.on('connection', socket => {
  socket.end('handled by parent');
});
server.listen(1337, () => {
  child.send('server', server);
});


//worker.js 子程序接收 Socket 物件
process.on('message', (m, server) => {
  if (m === 'server') {
    server.on('connection', socket => {
      socket.end('handled by child');
    });
  }
});

7. Cluster

前面已經介紹了child_process的使用,child_process的一個重要使用場景是建立多程序服務來保證服務穩定執行。

為了統一 Node 建立多程序服務的方式,Node 在之後的版本中增加了Cluster模組,Cluster可以看作是做了封裝的child_Process模組。

Cluster模組的一個顯著優點是可以共用同一個socket連線,這代表可以使用Cluster模組實現簡單的負載均衡。

下面是Cluster的簡單栗子:

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

if (cluster.isMaster) {
  console.log('Master process id is', process.pid);
  // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker, code, signal) => {
    console.log('worker process died, id ', worker.process.pid);
  });
} else {
  // Worker 可以共用同一個 TCP 連線
  // 這裡的例子是一個 http 伺服器
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello worldn');
  }).listen(8000);

  console.log('Worker started, process id', process.pid);
}

上面是使用Cluster模組的一個簡單的例子,為了充分利用多核CPU,先呼叫OS模組的cpus()方法來獲得CPU的核心數,假設主機裝有兩個 CPU,每個CPU有4個核,那麼總核數就是8。

在上面的程式碼中,Cluster模組呼叫fork方法來建立子程序,該方法和child_process中的fork是同一個方法。

Cluster模組採用的是經典的主從模型,由master程序來管理所有的子程序,可以使用cluster.isMaster屬性判斷當前程序是master還是worker,其中主程序不負責具體的任務處理,其主要工作是負責排程和管理,上面的程式碼中,所有的子程序都監聽8000埠。

通常情況下,如果多個 Node 程序監聽同一個埠時會出現Error: listen EADDRINUS的錯誤,而Cluster模組能夠讓多個子程序監聽同一個埠的原因是master程序內部啟動了一個 TCP 伺服器,而真正監聽埠的只有這個伺服器,當來自前端的請求觸發伺服器的connection事件後,master會將對應的socket控制程式碼傳送給子程序。

到此這篇關於深入瞭解 Node的多程序服務實現的文章就介紹到這了,更多相關Node 多程序服務內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com