理解NodeJS多程序

語言: CN / TW / HK

序言

一次面試中,我提到自己用過pm2,面試接著問:「那你知道pm2父子程序通訊方式嗎」。我大概聽說pm2有cluster模式,但不清楚父子程序如何通訊。面試結束後把NodeJS的多程序重新整理了一下。

對於前端開發同學,一定很清楚js是單執行緒非阻塞的,這決定了NodeJS能夠支援高效能的服務的開發。 JavaScript的單執行緒非阻塞特性讓NodeJS適合IO密集型應用,因為JavaScript在訪問磁碟/資料庫/RPC等時候不需要阻塞等待結果,而是可以非同步監聽結果,同時繼續向下執行。

但js不適合計算密集型應用,因為當JavaScript遇到耗費計算效能的任務時候,單執行緒的缺點就暴露出來了。後面的任務都要被阻塞,直到耗時任務執行完畢。

為了優化NodeJS不適合計算密集型任務的問題,NodeJS提供了多執行緒和多程序的支援。

多程序和多執行緒從兩個方面對計算密集型任務進行了優化,非同步和併發

  1. 非同步,對於耗時任務,可以新建一個執行緒或者程序來執行,執行完畢再通知主執行緒/程序。

看下面例子,這是一個koa介面,裡面有耗時任務,會阻塞其他任務執行。

```javascript const Koa = require('koa'); const app = new Koa();

app.use(async ctx => { const url = ctx.request.url; if (url === '/') { ctx.body = 'hello'; }

if (url === '/compute') {
    let sum = 0;
    for (let i = 0; i < 1e20; i++) {
        sum += i;    
    }
    ctx.body = `${sum}`;
}

});

app.listen(3000, () => { console.log('http://localhost:300/ start') });

```

可以通過多執行緒和多程序來解決這個問題。

NodeJS提供多執行緒模組worker_threads,其中Woker模組用來建立執行緒,parentPort用在子執行緒中,可以獲取主執行緒引用,子執行緒通過parentPort.postMessage傳送資料給主執行緒,主執行緒通過worker.on接受資料。

```javascript //api.js const Koa = require('koa'); const app = new Koa();

const {Worker} = require('worker_threads');

app.use(async (ctx) => { const url = ctx.request.url; if (url === '/') { ctx.body = 'hello'; }

if (url === '/compute') {
    const sum = await new Promise(resolve => {
        const worker = new Worker(__dirname + '/compute.js');
        //接收資訊
        worker.on('message', data => {
            resolve(data);
        })

    });
    ctx.body = `${sum}`;
}

})

app.listen(3000, () => { console.log('http://localhost:3000/ start') });

//computer.js const {parentPort} = require('worker_threads') let sum = 0; for (let i = 0; i < 1e20; i++) { sum += i; }

//傳送資訊 parentPort.postMessage(sum);

```

下面是使用多程序解決耗時任務的方法,多程序模組child_process提供了fork方法(後面會介紹更多建立子程序的方法),可以用來建立子程序,主程序通過fork返回值(worker)持有子程序的引用,並通過worker.on監聽子程序傳送的資料,子程序通過process.send給父程序傳送資料。

```javascript //api.js const Koa = require('koa'); const app = new Koa();

const {fork} = require('child_process');

app.use(async ctx => { const url = ctx.request.url; if (url === '/') { ctx.body = 'hello'; }

if (url === '/compute') {
    const sum = await new Promise(resolve => {
        const worker = fork(__dirname + '/compute.js');
        worker.on('message', data => {
            resolve(data);
        });
    });
    ctx.body = `${sum}`;
}

});

app.listen(300, () => { console.log('http://localhost:300/ start'); });

//computer.js let sum = 0; for (let i = 0; i < 1e20; i++) { sum += i; } process.send(sum);

```

  1. 併發,為了可以更好地利用多核能力,通常會對同一個指令碼建立多程序和多執行緒,數量和CPU核數相同,這樣可以讓任務併發執行,最大程度提升了任務執行效率。

本文重點講解多程序的使用。

從實際應用角度,如果我們希望使用多程序,讓我們的應用支援併發執行,提升應用效能,那麼首先要建立多程序,然後程序執行的過程中難免涉及到程序之間的通訊,包括父子程序通訊和兄弟程序之間的通訊,另外還有很重要的一點是程序的管理,因為建立了多個程序,那麼來了一個任務應該交給哪個程序去執行呢?程序必然要支援後臺執行(守護程序),這個又怎麼實現呢?程序崩潰如何重啟?重啟過於頻繁的不穩定程序又如何限制?如何操作程序的啟動、停止、重啟?

這一系列的程序管理工作都有相關的工具支援。

接下來就按照上面說明的建立程序、程序間通訊、程序管理(cluster叢集管理、程序管理工具:pm2和egg-cluster)。

建立多程序

child_process模組用來建立子程序,該模組提供了4個方法用於建立子程序

```perl const {spawn, fork, exec, execFile} = require('child_process');

```

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

child_process.fork(modulePath[, args][, options])

child_process.exec(command[, options][, callback])

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

spawn會啟動一個shell,並在shell上執行命令;spawn會在父子程序間建立IO流stdinstdoutstderrspawn返回一個子程序的引用,通過這個引用可以監聽子程序狀態,並接收子程序的輸入流。

```javascript const { spawn } = require('child_process'); const ls = spawn('ls', ['-lh', '/usr']);

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

ls.stderr.on('data', (data) => { console.error(stderr: ${data}); });

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

```

forkexecexecFile都是基於spawn擴充套件的。

execspawn不同,它接收一個回撥作為引數,回撥中會傳入報錯和IO流

``lua const { exec } = require('child_process'); exec('cat ./test.txt', (error, stdout, stderr) => { if (error) { console.error(exec error: ${error}); return; } console.log(stdout: ${stdout}); console.error(stderr: ${stderr}`); });

```

execFileexec不同的是,它不會建立一個shell,而是直接執行可執行檔案,因此效率比exec稍高一些,另外,它傳入的第一個引數是可執行檔案,第二個引數是執行可執行檔案的引數。參考nodejs進階視訊講解:進入學習

``lua const { execFile } = require('child_process'); execFile('cat', ['./test.txt'], (error, stdout, stderr) => { if (error) { console.error(exec error: ${error}`); return; } console.log(stdout); });

```

fork支援傳入一個NodeJS模組路徑,而非shell命令,返回一個子程序引用,這個子程序的引用和父程序建立了一個內建的IPC通道,可以讓父子程序通訊。

```javascript // parent.js

var child_process = require('child_process');

var child = child_process.fork('./child.js');

child.on('message', function(m){ console.log('message from child: ' + JSON.stringify(m)); });

child.send({from: 'parent'});

// child.js

process.on('message', function(m){ console.log('message from parent: ' + JSON.stringify(m)); });

process.send({from: 'child'});

```

對於上面幾個建立子程序的方法,有對應的同步版本。

spawnSyncexecSyncexecFileSync

程序間通訊

程序間通訊分為父子程序通訊和兄弟程序通訊,當然也可能涉及遠端程序通訊,這個會在後面提到,本文主要關注本地程序的通訊。

父子程序通訊可以通過標準IO流傳遞json

```javascript // 父程序 const { spawn } = require('child_process');

child = spawn('node', ['./stdio-child.js']); child.stdout.setEncoding('utf8'); // 父程序-發 child.stdin.write(JSON.stringify({ type: 'handshake', payload: '你好吖' })); // 父程序-收 child.stdout.on('data', function (chunk) { let data = chunk.toString(); let message = JSON.parse(data); console.log(${message.type} ${message.payload}); });

// ./stdio-child.js // 子程序-收 process.stdin.on('data', (chunk) => { let data = chunk.toString(); let message = JSON.parse(data); switch (message.type) { case 'handshake': // 子程序-發 process.stdout.write(JSON.stringify({ type: 'message', payload: message.payload + ' : hoho' })); break; default: break; } });

```

使用fork建立的子程序,父子程序之間會建立內建IPC通道(不知道該IPC通道底層是使用管道還是socket實現)。(程式碼見“建立多程序小節”)

因此父子程序通訊是NodeJS原生支援的。

下面我們看兄弟程序如何通訊。

通常程序通訊有幾種方法:共享記憶體、訊息佇列、管道、socket、訊號。

其中對於共享記憶體和訊息佇列,NodeJS並未提供原生的程序間通訊支援,需要依賴第三方實現,比如通過C++shared-memory-disruptor addon外掛實現共享記憶體的支援、通過redis、MQ實現訊息佇列的支援。

下面介紹在NodeJS中通過socket、管道、訊號實現的程序間通訊。

socket

socket是應用層與TCP/IP協議族通訊的中間抽象層,是一種作業系統提供的程序間通訊機制,是作業系統提供的,工作在傳輸層的網路操作API。

socket提供了一系列API,可以讓兩個程序之間實現客戶端-服務端模式的通訊。

通過socket實現IPC的方法可以分為兩種:

  1. TCP/UDP socket,原本用於進行網路通訊,實際就是兩個遠端程序間的通訊,但兩個程序既可以是遠端也可以是本地,使用socket進行通訊的方式就是一個程序建立server,另一個程序建立client,然後通過socket提供的能力進行通訊。
  2. UNIX Domain socket,這是一套由作業系統支援的、和socket很相近的API,但用於IPC,名字雖然是UNIX,實際Linux也支援。socket 原本是為網路通訊設計的,但後來在 socket 的框架上發展出一種 IPC 機制,就是 UNIX domain socket。雖然網路 socket 也可用於同一臺主機的程序間通訊(通過 loopback 地址 127.0.0.1),但是 UNIX domain socket 用於 IPC 更有效率:不需要經過網路協議棧,不需要打包拆包、計算校驗和、維護序號和應答等,只是將應用層資料從一個程序拷貝到另一個程序。這是因為,IPC 機制本質上是可靠的通訊,而網路協議是為不可靠的通訊設計的。

開源的node-ipc方案就是使用了socket方案

NodeJS如何使用socket進行通訊呢?答案是通過net模組實現,看下面的例子。

```javascript // server const net = require('net');

net.createServer((stream => { stream.end(hello world!\n); })).listen(3302, () => { console.log(running ...); });

// client const net = require('net');

const socket = net.createConnection({port: 3302});

socket.on('data', data => { console.log(data.toString()); });

```

UNIX Domain socket在NodeJS層面上提供的API和TCP socket類似,只是listen的是一個檔案描述符,而不是埠,相應的,client連線的也是一個檔案描述符(path)。

``javascript // 建立程序 const net = require('net') const unixSocketServer = net.createServer(stream => { stream.on('data', data => { console.log(receive data: ${data}`) }) });

unixSocketServer.listen('/tmp/test', () => { console.log('listening...'); });

// 其他程序

const net = require('net')

const socket = net.createConnection({path: '/tmp/test'})

socket.on('data', data => { console.log(data.toString()); });

socket.write('my name is vb');

// 輸出結果

listening...

```

管道

管道是一種作業系統提供的程序通訊方法,它是一種半雙工通訊,同一時間只能有一個方向的資料流。

管道本質上就是核心中的一個快取,當程序建立一個管道後,Linux會返回兩個檔案描述符,一個是寫入端的描述符(fd[1]),一個是輸出端的描述符(fd[0]),可以通過這兩個描述符往管道寫入或者讀取資料。

NodeJS中也是通過net模組實現管道通訊,與socket區別是server listen的和client connect的都是特定格式的管道名。

管道的通訊效率比較低下,一般不用它作為程序通訊方案。

下面是使用net實現程序通訊的示例。

```scss var net = require('net');

var PIPE_NAME = "mypipe"; var PIPE_PATH = "\.\pipe\" + PIPE_NAME;

var L = console.log;

var server = net.createServer(function(stream) { L('Server: on connection')

stream.on('data', function(c) {
    L('Server: on data:', c.toString());
});

stream.on('end', function() {
    L('Server: on end')
    server.close();
});

stream.write('Take it easy!');

});

server.on('close',function(){ L('Server: on close'); })

server.listen(PIPE_PATH,function(){ L('Server: on listening'); })

// == Client part == // var client = net.connect(PIPE_PATH, function() { L('Client: on connection'); })

client.on('data', function(data) { L('Client: on data:', data.toString()); client.end('Thanks!'); });

client.on('end', function() { L('Client: on end'); })

// Server: on listening // Client: on connection // Server: on connection // Client: on data: Take it easy! // Server: on data: Thanks! // Client: on end // Server: on end // Server: on close

```

訊號

作為完整健壯的程式,需要支援常見的中斷退出訊號,使得程式能夠正確的響應使用者和正確的清理退出。

訊號是作業系統殺掉程序時候給程序傳送的訊息,如果程序中沒有監聽訊號並做處理,則作業系統一般會預設直接粗暴地殺死程序,如果程序監聽訊號,則作業系統不預設處理。

這種程序通訊方式比較侷限,只用在一個程序殺死另一個程序的情況。

在NodeJS中,一個程序可以殺掉另一個程序,通過制定要被殺掉的程序的id來實現:process.kill(pid, signal)/child_process.kill(pid, signal)

程序可以監聽訊號:

```arduino process.on('SIGINT', () => { console.log('ctl + c has pressed'); });

```

cluster

現在設想我們有了一個啟動server的腳步,我們希望能更好地利用多核能力,啟動多個程序來執行server指令碼,另外我們還要考慮如何給多個程序分配請求。

上面的場景是一個很常見的需求:多程序管理,即一個指令碼執行時候建立多個程序,那麼如何對多個程序進行管理?

實際上,不僅是在server的場景有這種需求,只要是多程序都會遇到這種需求。而server的多程序還會遇到另一個問題:同一個server指令碼監聽的埠肯定相同,那啟動多個程序時候,埠一定會衝突。

為了解決多程序的問題,並解決server場景的埠衝突問題,NodeJS提供了cluster模組。

這種同樣一份程式碼在多個例項中執行的架構叫做叢集,cluster就是一個NodeJS程序叢集管理的工具。

cluster提供的能力:

  1. 建立子程序
  2. 解決多子程序監聽同一個埠導致衝突的問題
  3. 負載均衡

cluster主要用於server場景,當然也支援非server場景。

先來看下cluster的使用

```javascript import cluster from 'cluster'; import http from 'http'; import { cpus } from 'os'; import process from 'process';

const numCPUs = cpus().length;

if (cluster.isPrimary) { console.log(Primary ${process.pid} is running);

// Fork workers. for (let i = 0; i < numCPUs; i++) { cluster.fork(); }

cluster.on('exit', (worker, code, signal) => { console.log(worker ${worker.process.pid} died); }); } else { // Workers can share any TCP connection // In this case it is an HTTP server http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n'); }).listen(8000);

console.log(Worker ${process.pid} started); }

```

可以看到使用cluster.fork建立了子程序,實際上cluster.fork呼叫了child_process.fork來建立子程序。建立好後,cluster會自動進行負載均衡。

cluster支援設定負載均衡策略,有兩種策略:輪詢和作業系統預設策略。可以通過設定cluster.schedulingPolicy = cluster.SCHED_RR;指定輪詢策略,設定cluster.schedulingPolicy = cluster.SCHED_NONE;指定用作業系統預設策略。也可以設定環境變數NODE_CLUSTER_SCHED_POLICYrr/none來實現。

讓人比較在意的是,cluster是如何解決埠衝突問題的呢?

我們看到程式碼中使用了http.createServer,並監聽了埠8000,但實際上子程序並未監聽8000,net模組的server.listen方法(http繼承自net)判斷在cluster子程序中不監聽埠,而是建立一個socket併發送到父程序,以此將自己註冊到父程序,所以只有父程序監聽了埠,子程序通過socket和父程序通訊,當一個請求到來後,父程序會根據輪詢策略選中一個子程序,然後將請求的控制代碼(其實就是一個socket)通過程序通訊傳送給子程序,子程序拿到socket後使用這個socket和客戶端通訊,響應請求。

那麼net中又是如何判斷是否是在cluster子程序中的呢?cluster.fork對程序做了標識,因此net可以區分出來。

cluster是一個典型的master-worker架構,一個master負責管理worker,而worker才是實際工作的程序。

程序管理:pm2與egg-cluster

除了叢集管理,在實際應用執行時候,還有很多程序管理的工作,比如:程序的啟動、暫停、重啟、記錄當前有哪些程序、程序的後臺執行、守護程序監聽程序崩潰重啟、終止不穩定程序(頻繁崩潰重啟)等等。

社群也有比較成熟的工具做程序管理,比如pm2和egg-cluster

pm2

pm2是一個社群很流行的NodeJS程序管理工具,直觀地看,它提供了幾個非常好用的能力:

  1. 後臺執行。
  2. 自動重啟。
  3. 叢集管理,支援cluster多程序模式。

其他的功能還包括0s reload、日誌管理、終端監控、開發除錯等等。

pm2的大概原理是,建立一個守護程序(daemon),用來管理機器上通過pm2啟動的應用。當用戶通過命令列執行pm2命令對應用進行操作時候,其實是在和daemon通訊,daemon接收到指令後進行相應的操作。這時一種C/S架構,命令列相當於客戶端(client),守護程序daemon相當於伺服器(server),這種模式和docker的執行模式相同,docker也是有一個守護程序接收命令列的指令,再執行對應的操作。

客戶端和daemon通過rpc進行通訊,daemon是真正的“程序管理者”。

由於有守護程序,在啟動應用時候,命令列使用pm2客戶端通過rpc向daemon傳送資訊,daemon建立程序,這樣程序不是由客戶端建立的,而是daemon建立的,因此客戶端退出也不會收到影響,這就是pm2啟動的應用可以後臺執行的原因。

daemon還會監控程序的狀態,崩潰會自動重啟(當然頻繁重啟的程序被認為是不穩定的程序,存在問題,不會一直重啟),這樣就實現了程序的自動重啟。

pm2利用NodeJS的cluster模組實現了叢集能力,當配置exec_modecluster時候,pm2就會自動使用cluster建立多個程序,也就有了負載均衡的能力。

egg-cluster

egg-cluster是egg專案開源的一個程序管理工具,它的作用和pm2類似,但兩者也有很大的區別,比如pm2的程序模型是master-worker,master負責管理worker,worker負責執行具體任務。egg-cluster的程序模型是master-agent-worker,其中多出來的agent有什麼作用呢?

有些工作其實不需要每個 Worker 都去做,如果都做,一來是浪費資源,更重要的是可能會導致多程序間資源訪問衝突

既然有了pm2,為什麼egg要自己開發一個程序管理工具呢?可以參考作者的回答

  1. PM2 的理念跟我們不一致,它的大部分功能我們用不上,用得上的部分卻又做的不夠極致。
  2. PM2 是AGPL 協議的,對企業應用不友好。

pm2雖然很強大,但還不能說完美,比如pm2並不支援master-agent-worker模型,而這個是實際專案中很常見的一個需求。因此egg-cluster基於實際的場景實現了程序管理的一系列功能。

答案

通過上面的介紹,我們知道了pm2使用cluster做叢集管理,cluster又是使用child_process.fork來建立子程序,所以父子程序通訊使用的是內建預設的IPC通道。