理解NodeJS多程序
序言
一次面試中,我提到自己用過pm2,面試接著問:「那你知道pm2父子程序通訊方式嗎」。我大概聽說pm2有cluster模式,但不清楚父子程序如何通訊。面試結束後把NodeJS的多程序重新整理了一下。
對於前端開發同學,一定很清楚js是單執行緒非阻塞的,這決定了NodeJS能夠支援高效能的服務的開發。 JavaScript的單執行緒非阻塞特性讓NodeJS適合IO密集型應用,因為JavaScript在訪問磁碟/資料庫/RPC等時候不需要阻塞等待結果,而是可以非同步監聽結果,同時繼續向下執行。
但js不適合計算密集型應用,因為當JavaScript遇到耗費計算效能的任務時候,單執行緒的缺點就暴露出來了。後面的任務都要被阻塞,直到耗時任務執行完畢。
為了優化NodeJS不適合計算密集型任務的問題,NodeJS提供了多執行緒和多程序的支援。
多程序和多執行緒從兩個方面對計算密集型任務進行了優化,非同步和併發:
- 非同步,對於耗時任務,可以新建一個執行緒或者程序來執行,執行完畢再通知主執行緒/程序。
看下面例子,這是一個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);
```
- 併發,為了可以更好地利用多核能力,通常會對同一個指令碼建立多程序和多執行緒,數量和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流stdin
、stdout
、stderr
;spawn
返回一個子程序的引用,通過這個引用可以監聽子程序狀態,並接收子程序的輸入流。
```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}
);
});
```
fork
、exec
和execFile
都是基於spawn
擴充套件的。
exec
與spawn
不同,它接收一個回撥作為引數,回撥中會傳入報錯和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}`);
});
```
execFile
和exec
不同的是,它不會建立一個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'});
```
對於上面幾個建立子程序的方法,有對應的同步版本。
spawnSync
、execSync
、execFileSync
。
程序間通訊
程序間通訊分為父子程序通訊和兄弟程序通訊,當然也可能涉及遠端程序通訊,這個會在後面提到,本文主要關注本地程序的通訊。
父子程序通訊可以通過標準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的方法可以分為兩種:
- TCP/UDP socket,原本用於進行網路通訊,實際就是兩個遠端程序間的通訊,但兩個程序既可以是遠端也可以是本地,使用socket進行通訊的方式就是一個程序建立server,另一個程序建立client,然後通過socket提供的能力進行通訊。
- 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提供的能力:
- 建立子程序
- 解決多子程序監聽同一個埠導致衝突的問題
- 負載均衡
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_POLICY
為rr/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程序管理工具,直觀地看,它提供了幾個非常好用的能力:
- 後臺執行。
- 自動重啟。
- 叢集管理,支援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_mode
為cluster
時候,pm2就會自動使用cluster建立多個程序,也就有了負載均衡的能力。
egg-cluster
egg-cluster是egg專案開源的一個程序管理工具,它的作用和pm2類似,但兩者也有很大的區別,比如pm2的程序模型是master-worker,master負責管理worker,worker負責執行具體任務。egg-cluster的程序模型是master-agent-worker,其中多出來的agent有什麼作用呢?
有些工作其實不需要每個 Worker 都去做,如果都做,一來是浪費資源,更重要的是可能會導致多程序間資源訪問衝突
既然有了pm2,為什麼egg要自己開發一個程序管理工具呢?可以參考作者的回答
- PM2 的理念跟我們不一致,它的大部分功能我們用不上,用得上的部分卻又做的不夠極致。
- PM2 是AGPL 協議的,對企業應用不友好。
pm2雖然很強大,但還不能說完美,比如pm2並不支援master-agent-worker模型,而這個是實際專案中很常見的一個需求。因此egg-cluster基於實際的場景實現了程序管理的一系列功能。
答案
通過上面的介紹,我們知道了pm2使用cluster做叢集管理,cluster又是使用child_process.fork來建立子程序,所以父子程序通訊使用的是內建預設的IPC通道。